From 5ce171da234b42cedc82fb05902b1631f27ca61d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 19:23:08 +0000 Subject: [PATCH 001/191] Docking: the workspace, its commands and their parser come from craft-ui (craft-libs 16fb3d4) craft-ui and craft-docpath are pinned to craft-libs 16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3. The saved workspace is craft_ui::docking::Workspace; settings written before it had a version load as version 1. ui.dock params parse as the shared DockCommand and apply through Workspace::apply_all. LightCraft keeps its panels, permissions, default layout, photo-view checks, the fallback beside the photo view for a panel with no recorded place, and its settings file. The photo view takes side splits (accepts_splits on PROTECTED), so panels still open and move beside it. Tests: a version 1 workspace file loads and reopens its hidden panel; a drag of a group by its bar moves both of its panels. --- Cargo.lock | 8 +- Cargo.toml | 4 +- apps/lightcraft/src/main.rs | 6 +- crates/ui-egui/src/docking.rs | 418 +++++++----------- .../ui-egui/src/tests_docking_acceptance.rs | 10 +- crates/ui-egui/tests/it/docking_workflows.rs | 36 +- docs/control-protocol.md | 4 +- 7 files changed, 221 insertions(+), 265 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78de058c3..5d17222c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,14 +1084,18 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" dependencies = [ "egui", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index c22395b8a..3564f8491 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 7d30e69cd..d89a71ef8 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -265,7 +265,7 @@ fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> { let fields = saved.as_object_mut().ok_or("settings must be an object")?; let recovered = fields.get("docking").is_some_and(|value| match serde_json::from_value::>(value.clone()) { - Ok(Some(workspace)) => workspace.validate().is_err(), + Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(), Ok(None) => false, Err(_) => true, }); @@ -376,7 +376,7 @@ impl PrefsWriter { } let Some(path) = self.path.clone() else { return Ok(()) }; if let Some(workspace) = &ui.docking { - workspace.validate()?; + lightcraft_ui_egui::docking::validate(workspace)?; } let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?; if bytes == self.written { @@ -1245,7 +1245,7 @@ mod tests { restored_app.ui = restored.unwrap(); restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap(); assert_eq!( - restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, + restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect, [70.0, 80.0, 310.0, 500.0] ); std::fs::remove_dir_all(d).unwrap(); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index e9f51a56f..4128b1628 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -1,7 +1,10 @@ //! Application panel identities and persistence around the shared docking renderer. //! Document contents remain owned by their existing view implementations. -use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; +use craft_ui::docking::{ + Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Layout, Node, PanelLimits, PanelState, Permissions, Placement, + UiDock, Zone, +}; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -41,13 +44,11 @@ impl Panel { Self::Presets => Zone::Right, } } - fn parse(id: &str) -> Result { - Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) - } } fn permissions(panel: &Panel) -> Permissions { - if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } + // Panels open and split beside the photo view, but never join it as tabs. + if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() } } fn tabs(panel: Panel) -> Node { @@ -58,233 +59,119 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node, - /// Closed panels retain their most recent group, side, size or floating rectangle. - pub hidden: Vec<(Panel, Location)>, +/// The saved arrangement. Closed panels keep their last group, side, size or floating rect. +pub type Workspace = craft_ui::docking::Workspace; + +pub fn default_workspace() -> Workspace { + Workspace::new(Layout { + root: Some(split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default), + tabs(Panel::Library), + split( + SplitAxis::Horizontal, + SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default), + split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), + tabs(Panel::Tools), + ), + )), + floating: Vec::new(), + }) } -impl Default for Workspace { - fn default() -> Self { - Self { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default), - tabs(Panel::Library), - split( - SplitAxis::Horizontal, - SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default), - split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), - tabs(Panel::Tools), - ), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), - } - } +fn error(error: DockError) -> String { + error.to_string() } -impl Workspace { - pub fn validate(&self) -> Result<(), String> { - self.layout.validate().map_err(|error| error.to_string())?; - if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { - return Err("The panel layout must contain its document view and bounded hidden panels".into()); - } - // Saved settings cannot turn the protected document view into a floating or closable tab. - if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { - return Err("The document view cannot float".into()); - } - let mut nodes: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { - return Err("The document view cannot share a tab group".into()); - } - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { - return Err("The document view cannot be collapsed".into()); - } - _ => {} +/// The shared checks plus LightCraft's own: saved settings cannot float, hide, share or +/// collapse the photo view, nor send a hidden panel back onto it. +pub fn validate(workspace: &Workspace) -> Result<(), String> { + workspace.validate().map_err(error)?; + let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); + let mut alone = false; + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); } + Node::Tabs { panels, .. } => alone |= panels.as_slice() == [Panel::Canvas], + _ => {} } - let mut seen = std::collections::HashSet::new(); - for (panel, location) in &self.hidden { - if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { - return Err("A hidden panel has an invalid or duplicate identity".into()); - } - if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { - return Err("A hidden panel cannot restore over the document view".into()); - } - let mut probe = self.layout.clone(); - if let Err(error) = probe.restore(*panel, location) - && error != craft_ui::docking::DockError::MissingTarget - { - return Err(error.to_string()); - } - } - Ok(()) } + if !alone { + return Err("The document view must stay docked in a group of its own".into()); + } + if workspace.hidden.iter().any(|(_, location)| { + location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) + }) { + return Err("A hidden panel cannot restore over the document view".into()); + } + Ok(()) +} - pub fn apply(&mut self, action: Action) -> Result<(), String> { - self.validate()?; - let mut next = self.clone(); - if let Action::Close { panel } = &action { - let location = next.layout.location(panel).map_err(|error| error.to_string())?; - next.hidden.retain(|(id, _)| id != panel); - next.hidden.push((*panel, location)); - } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { - next.hidden.retain(|(id, _)| id != panel); - } - next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; - next.validate()?; - *self = next; - Ok(()) - } +fn apply(workspace: &mut Workspace, action: Action) -> Result<(), String> { + let mut next = workspace.clone(); + next.apply(action, permissions).map_err(error)?; + validate(&next)?; + *workspace = next; + Ok(()) +} - pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { - if self.layout.contains(&panel) == visible { - return Ok(()); - } - if !visible { - return self.apply(Action::Close { panel }); - } - self.validate()?; - let mut next = self.clone(); - let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); - next.hidden.retain(|(id, _)| *id != panel); - if let Some(saved) = &mut location - && saved.floating.is_some() - && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor))) - { - // The other members of a floating group may have been closed too. - saved.anchor = None; - saved.placement = Placement::Tab { before: None }; - } - let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); - if !restored { - next.layout - .apply_with_permissions( - Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, - permissions, - ) - .map_err(|error| error.to_string())?; - } - next.validate()?; - *self = next; - Ok(()) +/// Shows a panel at its recorded place, else beside the photo on its usual side. A panel that +/// already has the asked visibility keeps its tab selection. +fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { + if workspace.layout.contains(&panel) == visible { + return Ok(()); } + if !visible { + return apply(workspace, Action::Close { panel }); + } + validate(workspace)?; + let mut next = workspace.clone(); + if next.set_visible(panel, true, None, permissions).is_ok() && validate(&next).is_ok() { + *workspace = next; + return Ok(()); + } + apply(workspace, Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }) +} - /// Membership is passive state; an explicit command also exposes the panel's body. - pub fn reveal(&mut self, panel: Panel) -> Result<(), String> { - self.set_visible(panel, true)?; - self.apply(Action::Activate { panel }) +/// Membership is passive state; an explicit command also exposes the panel's body. +fn reveal(workspace: &mut Workspace, panel: Panel) -> Result<(), String> { + set_visible(workspace, panel, true)?; + apply(workspace, Action::Activate { panel })?; + if workspace.layout.panel_state(&panel) != PanelState::Front { + // A minimized window or group opens again; where nothing is minimized the action is + // refused and the layout stays as it is. + let _ = apply(workspace, Action::SetMinimized { panel, minimized: false }); } + Ok(()) +} - pub fn exposed(&self, panel: Panel) -> bool { - if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) { - return true; - } - let mut nodes = self.layout.root.as_ref().map(|node| vec![node]).unwrap_or_default(); - while let Some(node) = nodes.pop() { - match node { - Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true, - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true, - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - _ => {} - } - } - false - } +pub fn exposed_in(workspace: &Workspace, panel: Panel) -> bool { + workspace.layout.panel_state(&panel) == PanelState::Front +} - fn command(&mut self, params: &Value) -> Result<(), String> { - self.validate()?; - if let Some(action) = params.get("action") { - let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; - return self.apply(action); - } - let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; - if operation == "resizeSplit" { - let path: Vec = - serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; - let size: SplitSize = - serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; - return self.apply(Action::ResizeSplit { path, size }); - } - let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; - let action = match operation { - "open" => return self.reveal(panel), - "close" => Action::Close { panel }, - "activate" => Action::Activate { panel }, - "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, - "resizeStack" => { - let value = params.get("height").ok_or("height is required")?; - let height = if value.is_null() { - None - } else { - Some( - value - .as_f64() - .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) - .ok_or("height must be finite and bounded")? as f32, - ) - }; - Action::ResizeStack { panel, height } - } - "float" | "moveFloating" => { - let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; - let mut values = [0.0f32; 4]; - for (output, value) in values.iter_mut().zip(rect) { - *output = value - .as_f64() - .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) - .ok_or("rect values must be finite and bounded")? as f32; - } - if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } - } - "move" => { - let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; - let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { - "center" => Placement::Split(Zone::Center), - "left" => Placement::Split(Zone::Left), - "right" => Placement::Split(Zone::Right), - "top" => Placement::Split(Zone::Top), - "bottom" => Placement::Split(Zone::Bottom), - _ => return Err("zone must be center, left, right, top or bottom".into()), - }; - let placement = if let Some(before) = params.get("before") { - if !matches!(placement, Placement::Split(Zone::Center)) { - return Err("before only applies to a center move".into()); - } - Placement::Tab { - before: if before.is_null() { - None - } else { - Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) - }, - } - } else { - placement - }; - Action::Move { panel, anchor, placement } - } - _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), - }; - self.apply(action) +/// `ui.dock` params, parsed by the shared command vocabulary. +fn run(workspace: &mut Workspace, params: &Value) -> Result<(), String> { + validate(workspace)?; + let mut tagged = params.clone(); + let fields = tagged.as_object_mut().ok_or("ui.dock params must be an object")?; + fields.insert("command".into(), json!("ui.dock")); + let command: DockCommand = serde_json::from_value(tagged).map_err(|error| error.to_string())?; + if let DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel })) = command { + return reveal(workspace, panel); } + let mut next = workspace.clone(); + // `ui.dock` operations carry their own rects; only `window.panel.float` reads the space. + command.apply(&mut next, false, &FloatSpace::new(egui::Rect::ZERO), permissions).map_err(error)?; + validate(&next)?; + *workspace = next; + Ok(()) } fn from_ui(app: &crate::LightcraftApp) -> Workspace { - let mut workspace = Workspace::default(); + let mut workspace = default_workspace(); if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() { *size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width)); if let Node::Split { size, .. } = second.as_mut() { @@ -295,9 +182,9 @@ fn from_ui(app: &crate::LightcraftApp) -> Workspace { } fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> { - workspace.set_visible(Panel::Library, app.ui.left_panel)?; - workspace.set_visible(Panel::Tools, app.ui.right != crate::state::RightPanel::None)?; - workspace.set_visible(Panel::Presets, app.ui.presets) + set_visible(workspace, Panel::Library, app.ui.left_panel)?; + set_visible(workspace, Panel::Tools, app.ui.right != crate::state::RightPanel::None)?; + set_visible(workspace, Panel::Presets, app.ui.presets) } fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { @@ -328,7 +215,7 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { } pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool { - app.ui.docking.as_ref().is_none_or(|workspace| workspace.exposed(panel)) + app.ui.docking.as_ref().is_none_or(|workspace| exposed_in(workspace, panel)) } pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> { @@ -349,7 +236,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result if let Some(panel) = panel { let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); sync(&mut workspace, app)?; - workspace.reveal(panel)?; + reveal(&mut workspace, panel)?; cancel_replaced_gesture(app, Some(&workspace)); app.ui.docking = Some(workspace); } @@ -381,13 +268,13 @@ pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> R fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result { let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); if reset { - workspace = Workspace::default(); + workspace = default_workspace(); // The usual LightCraft starting workspace shows its current tool beside the photo. - workspace.set_visible(Panel::Library, false)?; - workspace.set_visible(Panel::Presets, false)?; + set_visible(&mut workspace, Panel::Library, false)?; + set_visible(&mut workspace, Panel::Presets, false)?; } else { sync(&mut workspace, app)?; - workspace.command(params)?; + run(&mut workspace, params)?; } if !renderer_action { cancel_replaced_gesture(app, Some(&workspace)); @@ -400,7 +287,7 @@ fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, ren pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); - if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) { + if let Err(error) = validate(&workspace).and_then(|()| sync(&mut workspace, app)) { app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), ui.input(|input| input.time) + 8.0, None)); workspace = from_ui(app); let _ = sync(&mut workspace, app); @@ -413,7 +300,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.min_pane = 96.0; style.float_label = crate::i18n::tr("Float panel").into(); style.close_label = crate::i18n::tr("Close panel").into(); - style.move_label = crate::i18n::tr("Move to group").into(); + style.move_panel_to_label = crate::i18n::tr("Move to group").into(); style.panels_label = crate::i18n::tr("Panels").into(); style.resize_label = crate::i18n::tr("Resize panels").into(); style.resize_window_label = crate::i18n::tr("Resize panel window").into(); @@ -427,7 +314,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.font = t.font(12.0); // Tool and preset panels publish hover previews before the photo consumes them. let mut canvas = None; - let output = DockArea::new(egui::Id::new("lightcraft-docking")).show_with_limits( + let output = DockArea::new(egui::Id::new("lightcraft-docking")).locked(workspace.locked).show_with_limits( ui, &workspace.layout, &style, @@ -500,7 +387,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( let Some(workspace) = state.docking.as_mut() else { return Ok(()); }; - workspace.validate()?; + validate(workspace)?; for (key, panel, width) in [("leftWidth", Panel::Library, state.left_width), ("rightWidth", Panel::Tools, state.right_width)] { if params.get(key).is_none() { continue; @@ -516,10 +403,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( } continue; } - if let Some(group) = workspace.layout.floating.iter().find(|group| group.panels.contains(&panel)) { + if let Some(group) = workspace.layout.floating.iter().find(|group| group.contains(&panel)) { let mut rect = group.rect; rect[2] = width; - workspace.apply(Action::MoveFloating { panel, rect })?; + apply(workspace, Action::MoveFloating { panel, rect })?; continue; } let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); @@ -529,7 +416,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( let contains = |node: &Node| match node { Node::Tabs { panels, .. } => panels.contains(&panel), Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), - Node::Split { .. } => false, + _ => false, }; if *axis == SplitAxis::Horizontal { if contains(first) { @@ -548,7 +435,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( } } if let Some(action) = change { - workspace.apply(action)?; + apply(workspace, action)?; } } Ok(()) @@ -583,7 +470,7 @@ mod tests { split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), ); let root = if first { split(axis, size, tabs(Panel::Tools), rest) } else { split(axis, size, rest, tabs(Panel::Tools)) }; - Workspace { layout: Layout { root: Some(root), floating: Vec::new() }, hidden: Vec::new() } + Workspace::new(Layout { root: Some(root), floating: Vec::new() }) } #[test] @@ -594,23 +481,20 @@ mod tests { .into_iter() .map(|panel| craft_ui::docking::StackEntry { panel, open: panel != Panel::Tools, height: Some(180.0) }) .collect(); - app.ui.docking = Some(Workspace { - layout: Layout { - root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })), - floating: Vec::new(), - }, - hidden: Vec::new(), - }); + app.ui.docking = Some(Workspace::new(Layout { + root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })), + floating: Vec::new(), + })); let undo = app.session.undo.len(); app.run("panel.crop", json!({})).unwrap(); assert_eq!(app.ui.right, crate::state::RightPanel::Crop); let workspace = app.ui.docking.as_ref().unwrap(); - assert!(workspace.exposed(Panel::Tools)); + assert!(exposed_in(workspace, Panel::Tools)); assert_eq!(app.session.undo.len(), undo); let saved = serde_json::to_string(workspace).unwrap(); let reloaded: Workspace = serde_json::from_str(&saved).unwrap(); - reloaded.validate().unwrap(); - assert!(reloaded.exposed(Panel::Tools)); + validate(&reloaded).unwrap(); + assert!(exposed_in(&reloaded, Panel::Tools)); } #[test] @@ -622,7 +506,7 @@ mod tests { (SplitAxis::Horizontal, SplitSize::FixedFirst(180.0), false), ] { let workspace = tools_split(axis, size, first); - workspace.validate().unwrap(); + validate(&workspace).unwrap(); let mut app = app_with_widths(); publish_visibility(&mut app, &workspace); assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 410.0), "{axis:?} {size:?}, first={first}"); @@ -637,8 +521,8 @@ mod tests { publish_visibility(&mut app, &workspace); assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 310.0)); } - let mut workspace = Workspace::default(); - workspace.apply(Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); + let mut workspace = default_workspace(); + apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); let mut app = app_with_widths(); publish_visibility(&mut app, &workspace); assert_eq!(app.ui.right_width, 390.0); @@ -646,12 +530,50 @@ mod tests { #[test] fn legacy_width_update_preserves_hidden_top_panel_height() { - let mut workspace = Workspace::default(); - workspace.apply(Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap(); - workspace.set_visible(Panel::Tools, false).unwrap(); + let mut workspace = default_workspace(); + apply(&mut workspace, Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap(); + set_visible(&mut workspace, Panel::Tools, false).unwrap(); let height = workspace.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size; let mut state = crate::UiState { docking: Some(workspace), right_width: 410.0, ..Default::default() }; set_widths(&mut state, &json!({"rightWidth":410.0})).unwrap(); assert_eq!(state.docking.as_ref().unwrap().hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size, height); } + + /// Settings written before workspaces had a version: `{layout, hidden}`, a floating + /// window as one group, hidden locations without dock state. + #[test] + fn version_one_workspace_file_loads_and_reopens_its_hidden_panel() { + let v1 = json!({ + "layout": { + "root": {"Split": { + "axis": "Horizontal", + "size": {"FixedFirst": 300.0}, + "first": {"Tabs": {"panels": ["library"], "active": 0}}, + "second": {"Tabs": {"panels": ["canvas"], "active": 0}} + }}, + "floating": [{"panels": ["tools"], "active": 0, "rect": [120.0, 90.0, 320.0, 600.0]}] + }, + "hidden": [["presets", { + "floating": null, + "anchor": "canvas", + "placement": {"Split": "Right"}, + "size": {"FixedSecond": 280.0}, + "stack": null, + "sibling": ["canvas"] + }]] + }); + let mut workspace: Workspace = serde_json::from_value(v1).unwrap(); + validate(&workspace).unwrap(); + assert_eq!(workspace.version, 1); + let window = workspace.layout.floating.first().unwrap(); + assert!(window.contains(&Panel::Tools)); + assert_eq!(window.rect, [120.0, 90.0, 320.0, 600.0]); + assert!(exposed_in(&workspace, Panel::Library)); + set_visible(&mut workspace, Panel::Presets, true).unwrap(); + let location = workspace.layout.location(&Panel::Presets).unwrap(); + assert_eq!((location.anchor, location.size), (Some(Panel::Canvas), Some(SplitSize::FixedSecond(280.0)))); + let saved = serde_json::to_value(&workspace).unwrap(); + assert_eq!(saved["version"], 2); + assert_eq!(saved["layout"]["floating"][0]["panels"], json!(["tools"]), "a one-group window keeps the v1 shape"); + } } diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests_docking_acceptance.rs index 1dc97892e..5e496b822 100644 --- a/crates/ui-egui/src/tests_docking_acceptance.rs +++ b/crates/ui-egui/src/tests_docking_acceptance.rs @@ -53,7 +53,7 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() { let undo = h.app.session.undo.len(); let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(); h.app.run(command, json!({})).unwrap(); - assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools), "{command}"); + assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools), "{command}"); assert_ne!(h.app.ui.right, RightPanel::None); assert_eq!(h.app.session.undo.len(), undo); assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(), photos); @@ -64,10 +64,10 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() { dock(&mut h, json!({"operation":"move","panel":"presets","target":"library","zone":"center"})); dock(&mut h, json!({"operation":"activate","panel":"library"})); h.app.run("panel.presets", json!({})).unwrap(); - assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets)); + assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Presets)); dock(&mut h, json!({"operation":"activate","panel":"library"})); dock(&mut h, json!({"operation":"open","panel":"presets"})); - assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets)); + assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Presets)); } #[test] @@ -81,7 +81,7 @@ fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() { dock(&mut h, json!({"operation":"move","panel":"tools","target":"presets","zone":"center"})); dock(&mut h, json!({"operation":"activate","panel":"presets"})); assert_eq!(h.app.ui.right, RightPanel::Crop); - assert!(!h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools)); + assert!(!crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools)); let library = h.app.ui.docking.as_ref().unwrap().layout.location(&Panel::Library).unwrap(); let id = h.app.session.active().unwrap(); let photo = serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap(); @@ -94,7 +94,7 @@ fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() { } assert_eq!(h.app.ui.right, RightPanel::Edit, "the real Canvas double-click completes Crop"); let workspace = h.app.ui.docking.as_ref().unwrap(); - assert!(workspace.exposed(Panel::Tools), "tool.done reveals Tools after the Canvas callback"); + assert!(crate::docking::exposed_in(workspace, Panel::Tools), "tool.done reveals Tools after the Canvas callback"); assert_eq!(workspace.layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Presets)); assert_eq!(workspace.layout.location(&Panel::Library).unwrap(), library); assert_eq!(h.app.session.active(), Some(id)); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index aa80e8301..7538946b1 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -1,4 +1,5 @@ //! Real library/tool panel moves preserve the selected tool and saved arrangement. +use craft_ui::docking::{DockArea, Hit, Node}; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel}; @@ -63,7 +64,7 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() { h.run_steps(3); h.get_by_label("Float panel").click(); h.run_steps(5); - let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect; + let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect; h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary); h.run_steps(3); h.get_by_label("Close panel").click(); @@ -71,7 +72,7 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() { assert_eq!(h.state().ui.right, RightPanel::None); h.state_mut().ui.right = RightPanel::Crop; h.run_steps(4); - assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, rect); + assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect, rect); let saved = serde_json::to_string(&h.state().ui).unwrap(); let expected = serde_json::to_value(&h.state().ui.docking).unwrap(); let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0); @@ -86,6 +87,35 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() { assert_eq!(reloaded.state().ui.right, RightPanel::Crop); } +#[test] +fn dragging_a_group_by_its_bar_moves_the_whole_group() { + let mut h = harness(None, 1400.0, 1.0); + h.state_mut().ui.presets = true; + h.run_steps(4); + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"presets","target":"tools","zone":"center"})).unwrap(); + h.run_steps(6); + let bar = DockArea::::hit_rects(&h.ctx, egui::Id::new("lightcraft-docking")) + .into_iter() + .find_map(|(hit, rect)| matches!(hit, Hit::DragBar(Panel::Tools | Panel::Presets)).then_some(rect)) + .expect("the Tools and Presets group draws its drag bar"); + let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Library").rect().center(); + drag(&mut h, bar.center(), target); + let layout = &h.state().ui.docking.as_ref().unwrap().layout; + let mut nodes: Vec<_> = layout.root.iter().collect(); + let mut groups = Vec::new(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Tabs { panels, .. } => groups.push(panels.clone()), + _ => {} + } + } + let mut library = groups.into_iter().find(|panels| panels.contains(&Panel::Library)).unwrap(); + library.sort_by_key(|panel| panel.id()); + assert_eq!(library, [Panel::Library, Panel::Presets, Panel::Tools], "both panels of the dragged group joined Library"); + assert_eq!(h.state().ui.right, RightPanel::Edit); +} + #[test] fn invalid_docking_requests_preserve_layout_and_selected_tool() { let mut h = harness(None, 1400.0, 1.0); @@ -115,7 +145,7 @@ fn docking_renders_real_photo_panels_default_floating_and_redocked() { } h.run_steps(8); settle_photo(&mut h); - h.state().ui.docking.as_ref().unwrap().validate().unwrap(); + lightcraft_ui_egui::docking::validate(h.state().ui.docking.as_ref().unwrap()).unwrap(); if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { std::fs::create_dir_all(&directory).unwrap(); h.render().unwrap().save(format!("{directory}/light-{width}-{scale}-{state}.png")).unwrap(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7a8033aa5..5e374da64 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -364,11 +364,11 @@ the pending name. `ui.dialog.confirm` starts the same import into the selected a ## Panel docking -Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents. +Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences as a craft-ui docking workspace (`docking`: `version`, `layout`, `hidden`, `saved`, `locked`); settings written before workspaces had a `version` load as version 1. These operations do not edit document contents. Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`. From c964b7f654d930db29cdfe0c3cc75979e2d254af Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 19:44:32 +0000 Subject: [PATCH 002/191] Docking: a dragged divider beside Library or Tools keeps that panel's width craft-ui stores a moved divider as a ratio. After each frame the split beside a side panel takes the width the panel was drawn at, so the panel keeps its width when the window resizes and left_width / right_width follow the drag. --- crates/ui-egui/src/docking.rs | 50 +++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 4128b1628..52914dd91 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -314,6 +314,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.font = t.font(12.0); // Tool and preset panels publish hover previews before the photo consumes them. let mut canvas = None; + let (mut library_width, mut tools_width) = (None, None); let output = DockArea::new(egui::Id::new("lightcraft-docking")).locked(workspace.locked).show_with_limits( ui, &workspace.layout, @@ -326,10 +327,12 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); } Panel::Library => { + library_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect()); crate::panels::left::show(app, ui); } Panel::Tools => { + tools_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect()); crate::panels::right::show(app, ui); } @@ -364,6 +367,8 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; if stale { craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("lightcraft-docking")); + } else { + keep_side_widths(&mut workspace, [(Panel::Library, library_width), (Panel::Tools, tools_width)]); } publish_visibility(app, &workspace); app.ui.docking = Some(workspace); @@ -380,6 +385,45 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { } } +/// A dragged divider stores a ratio. The side panels keep the width they were drawn at instead, +/// so a window resize leaves them as they are and `left_width` / `right_width` follow them. +fn keep_side_widths(workspace: &mut Workspace, drawn: [(Panel, Option); 2]) { + let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); + let mut changes = Vec::new(); + while let Some((node, path)) = nodes.pop() { + let Node::Split { axis, size, first, second } = node else { continue }; + if *axis == SplitAxis::Horizontal && matches!(size, SplitSize::Ratio(_)) { + let fixed = drawn.iter().find_map(|(panel, width)| { + // A width drawn from a ratio carries float noise; in hundredths a panel at its + // limit stores the limit itself. + let width = width.filter(|width| width.is_finite() && *width > 0.0).map(|width| (width * 100.0).round() / 100.0)?; + if holds(first, *panel) { Some(SplitSize::FixedFirst(width)) } else { holds(second, *panel).then_some(SplitSize::FixedSecond(width)) } + }); + if let Some(size) = fixed { + changes.push(Action::ResizeSplit { path: path.clone(), size }); + } + } + for (child, side) in [(second, true), (first, false)] { + let mut path = path.clone(); + path.push(side); + nodes.push((child, path)); + } + } + for action in changes { + // A size the layout refuses leaves the ratio, which still draws the same widths. + let _ = apply(workspace, action); + } +} + +/// Whether `node` is the group (tabs or accordion) holding `panel`. +fn holds(node: &Node, panel: Panel) -> bool { + match node { + Node::Tabs { panels, .. } => panels.contains(&panel), + Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), + _ => false, + } +} + /// Keep the established ui.set width controls connected to the panel's current parent split. pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> { state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width); @@ -413,11 +457,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( let mut change = None; while let Some((node, path)) = nodes.pop() { if let Node::Split { axis, first, second, .. } = node { - let contains = |node: &Node| match node { - Node::Tabs { panels, .. } => panels.contains(&panel), - Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), - _ => false, - }; + let contains = |node: &Node| holds(node, panel); if *axis == SplitAxis::Horizontal { if contains(first) { change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedFirst(width) }); From a70d7638a07fc4ceb433ac833c0fcee79f360ab3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:45:00 +0000 Subject: [PATCH 003/191] Docking: repin craft-libs 1774443; a dragged divider keeps its panel's width without app code craft-ui and craft-docpath are pinned to craft-libs 17744430be327d0257496a6a31c8d38fe20a6756, where a divider drag keeps the split's sizing mode. LightCraft no longer turns dragged ratios back into side-panel widths. --- Cargo.lock | 4 +-- Cargo.toml | 4 +-- crates/ui-egui/src/docking.rs | 50 ++++------------------------------- 3 files changed, 9 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d17222c1..a15fe2744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 3564f8491..20118c218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 52914dd91..4128b1628 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -314,7 +314,6 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.font = t.font(12.0); // Tool and preset panels publish hover previews before the photo consumes them. let mut canvas = None; - let (mut library_width, mut tools_width) = (None, None); let output = DockArea::new(egui::Id::new("lightcraft-docking")).locked(workspace.locked).show_with_limits( ui, &workspace.layout, @@ -327,12 +326,10 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); } Panel::Library => { - library_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect()); crate::panels::left::show(app, ui); } Panel::Tools => { - tools_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect()); crate::panels::right::show(app, ui); } @@ -367,8 +364,6 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; if stale { craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("lightcraft-docking")); - } else { - keep_side_widths(&mut workspace, [(Panel::Library, library_width), (Panel::Tools, tools_width)]); } publish_visibility(app, &workspace); app.ui.docking = Some(workspace); @@ -385,45 +380,6 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { } } -/// A dragged divider stores a ratio. The side panels keep the width they were drawn at instead, -/// so a window resize leaves them as they are and `left_width` / `right_width` follow them. -fn keep_side_widths(workspace: &mut Workspace, drawn: [(Panel, Option); 2]) { - let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); - let mut changes = Vec::new(); - while let Some((node, path)) = nodes.pop() { - let Node::Split { axis, size, first, second } = node else { continue }; - if *axis == SplitAxis::Horizontal && matches!(size, SplitSize::Ratio(_)) { - let fixed = drawn.iter().find_map(|(panel, width)| { - // A width drawn from a ratio carries float noise; in hundredths a panel at its - // limit stores the limit itself. - let width = width.filter(|width| width.is_finite() && *width > 0.0).map(|width| (width * 100.0).round() / 100.0)?; - if holds(first, *panel) { Some(SplitSize::FixedFirst(width)) } else { holds(second, *panel).then_some(SplitSize::FixedSecond(width)) } - }); - if let Some(size) = fixed { - changes.push(Action::ResizeSplit { path: path.clone(), size }); - } - } - for (child, side) in [(second, true), (first, false)] { - let mut path = path.clone(); - path.push(side); - nodes.push((child, path)); - } - } - for action in changes { - // A size the layout refuses leaves the ratio, which still draws the same widths. - let _ = apply(workspace, action); - } -} - -/// Whether `node` is the group (tabs or accordion) holding `panel`. -fn holds(node: &Node, panel: Panel) -> bool { - match node { - Node::Tabs { panels, .. } => panels.contains(&panel), - Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), - _ => false, - } -} - /// Keep the established ui.set width controls connected to the panel's current parent split. pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> { state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width); @@ -457,7 +413,11 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( let mut change = None; while let Some((node, path)) = nodes.pop() { if let Node::Split { axis, first, second, .. } = node { - let contains = |node: &Node| holds(node, panel); + let contains = |node: &Node| match node { + Node::Tabs { panels, .. } => panels.contains(&panel), + Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), + _ => false, + }; if *axis == SplitAxis::Horizontal { if contains(first) { change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedFirst(width) }); From 5b7a4e9a9458b0821429663e365c61bdc36e0d3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 21:31:46 +0000 Subject: [PATCH 004/191] Docs: ui.dock move lists the tab, above and below zones --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5e374da64..c25deb98c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -368,7 +368,7 @@ Library, Tools, and Presets panels can be regrouped, split, floated within the a Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`. From a27d8c05d49d609c443f5edb20b1e2835b011a78 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:19:29 +0000 Subject: [PATCH 005/191] Docs: ui.dock above and below name the target's group --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c25deb98c..ebaebcbb6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -368,7 +368,7 @@ Library, Tools, and Presets panels can be regrouped, split, floated within the a Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`. From b2f624631425ff10949ccdbfb9bdc957ada5134a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:29:00 +0000 Subject: [PATCH 006/191] Docking: repin craft-libs 6e7f285 (the docking follow-ups) --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a15fe2744..5c90435b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 20118c218..136644fce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From ee1576682abc0ec7e6672c7cae815ba6fb4000ec Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:33:27 +0000 Subject: [PATCH 007/191] Docking: a drop at the window's edge makes no dock column The side panels are frames whose widths LightCraft tracks, so new_dock_zones is off. Test: an edge drop of Tools leaves the layout without a Node::Dock. --- crates/ui-egui/src/docking.rs | 2 ++ crates/ui-egui/tests/it/docking_workflows.rs | 23 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 4128b1628..636081967 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -298,6 +298,8 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { app.ui.docking = Some(workspace.clone()); let mut style = DockStyle::from_ui(ui); style.min_pane = 96.0; + // The side panels are frames whose widths LightCraft tracks; dock columns are not offered. + style.new_dock_zones = false; style.float_label = crate::i18n::tr("Float panel").into(); style.close_label = crate::i18n::tr("Close panel").into(); style.move_panel_to_label = crate::i18n::tr("Move to group").into(); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 7538946b1..a8e6c3d51 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -116,6 +116,29 @@ fn dragging_a_group_by_its_bar_moves_the_whole_group() { assert_eq!(h.state().ui.right, RightPanel::Edit); } +/// LightCraft's side panels live in frames; a drop at the window's edge makes no dock column. +#[test] +fn an_edge_drop_does_not_create_a_dock() { + for at_right in [false, true] { + let mut h = harness(None, 1400.0, 1.0); + let library = h.state().widgets.iter().find(|(id, _)| id == "panel:left_panel").unwrap().1; + let tools = h.state().widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; + let x = if at_right { tools.right() - 4.0 } else { library.left() + 4.0 }; + let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center(); + drag(&mut h, source, egui::pos2(x, 500.0)); + let layout = &h.state().ui.docking.as_ref().unwrap().layout; + assert!(layout.contains(&Panel::Tools)); + let mut nodes: Vec<_> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { .. } => panic!("a drop at x = {x} made a dock: {layout:?}"), + _ => {} + } + } + } +} + #[test] fn invalid_docking_requests_preserve_layout_and_selected_tool() { let mut h = harness(None, 1400.0, 1.0); From e442e3d220a8ac43460e8927da2afe56da76392f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:36:03 +0000 Subject: [PATCH 008/191] Docking: a panel with no recorded place opens beside the photo on its side Showing a panel that has no hidden entry skips craft-ui's right-edge fallback and opens it beside the photo view on the panel's default side. Test: Library opens to the left of the photo, Presets to the right. --- crates/ui-egui/src/docking.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 636081967..017ffe842 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -128,10 +128,14 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result return apply(workspace, Action::Close { panel }); } validate(workspace)?; - let mut next = workspace.clone(); - if next.set_visible(panel, true, None, permissions).is_ok() && validate(&next).is_ok() { - *workspace = next; - return Ok(()); + // Without a recorded place craft-ui opens the panel at the right edge; LightCraft puts it + // beside the photo instead. + if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) { + let mut next = workspace.clone(); + if next.set_visible(panel, true, None, permissions).is_ok() && validate(&next).is_ok() { + *workspace = next; + return Ok(()); + } } apply(workspace, Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }) } @@ -578,4 +582,15 @@ mod tests { assert_eq!(saved["version"], 2); assert_eq!(saved["layout"]["floating"][0]["panels"], json!(["tools"]), "a one-group window keeps the v1 shape"); } + + #[test] + fn a_panel_without_a_recorded_place_opens_beside_the_photo_on_its_side() { + for (panel, zone) in [(Panel::Library, Zone::Left), (Panel::Presets, Zone::Right)] { + let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools)); + let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); + set_visible(&mut workspace, panel, true).unwrap(); + let location = workspace.layout.location(&panel).unwrap(); + assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(zone)), "{panel:?}"); + } + } } From 63ab092576a8d2e52ad565f281d25ea74378cd13 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:37:41 +0000 Subject: [PATCH 009/191] View: F8 on a right panel that isn't showing brings it forward in its mode When the right panel is open but minimized or behind another tab, F8 keeps its mode instead of switching to the one it last hid, and view.rightPanel reveals Tools. Test: F8 on a minimized floating Tools window in Crop opens the window and stays in Crop. --- crates/ui-egui/src/docking.rs | 1 + crates/ui-egui/src/menus.rs | 4 +++- crates/ui-egui/src/shortcuts.rs | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 017ffe842..2fc69e0da 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -229,6 +229,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result // the sidebar it scrolls, also when it is a tab behind another panel Some(Panel::Library) } else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets" + || id == "view.rightPanel" || id.starts_with("section.") || id.starts_with("tool.") && id != "tool.none") && app.ui.right != crate::state::RightPanel::None diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a162e6ca6..baf3d4514 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -865,7 +865,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option RightPanel::Edit, p => p, @@ -878,6 +878,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 032548f6a..435936190 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -863,6 +863,24 @@ mod tests { assert_eq!(h.app.ui.right, RightPanel::Edit); } + /// F8 on a minimized Tools window opens it again in the mode it had. + #[test] + fn classic_f8_shows_a_minimized_tools_window_and_keeps_its_mode() { + use crate::{docking::Panel, state::RightPanel}; + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = RightPanel::Edit; + h.settle(std::time::Duration::from_secs(10)); + h.app.run("ui.dock", json!({"operation": "float", "panel": "tools", "rect": [400, 100, 320, 500]})).unwrap(); + h.app.run("ui.dock", json!({"action": {"SetMinimized": {"panel": "tools", "minimized": true}}})).unwrap(); + h.app.ui.right = RightPanel::Crop; + h.step(); + assert!(!crate::docking::exposed(&h.app, Panel::Tools)); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Crop); + assert!(crate::docking::exposed(&h.app, Panel::Tools), "the window opens"); + } + #[test] fn shift_culling_keys_advance_exactly_once() { use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; From 1e4cc14dfbceb3c26c3294d5a208fde9d3af776f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:41:54 +0000 Subject: [PATCH 010/191] Docking: group and window menu labels go through tr() in sentence case --- crates/ui-egui/src/docking.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 2fc69e0da..be4c9d060 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -311,6 +311,16 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.panels_label = crate::i18n::tr("Panels").into(); style.resize_label = crate::i18n::tr("Resize panels").into(); style.resize_window_label = crate::i18n::tr("Resize panel window").into(); + style.move_group_label = crate::i18n::tr("Move tab group").into(); + style.move_group_to_label = crate::i18n::tr("Move group to").into(); + style.float_group_label = crate::i18n::tr("Float tab group").into(); + style.close_group_label = crate::i18n::tr("Close tab group").into(); + style.close_others_label = crate::i18n::tr("Close other panels in group").into(); + style.minimize_label = crate::i18n::tr("Minimize").into(); + style.expand_label = crate::i18n::tr("Expand").into(); + style.move_window_label = crate::i18n::tr("Move panel window").into(); + style.close_window_label = crate::i18n::tr("Close panel window").into(); + style.float_hint_label = crate::i18n::tr("Hold {key} to undock").into(); style.background = t.chrome; style.tab_background = t.chrome; style.active_background = t.canvas; From 02aa1e0f59e80f2ee271afa64fa5212fd2d0347a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:41:54 +0000 Subject: [PATCH 011/191] Docs: only the five split zones of ui.dock move have PascalCase names --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ebaebcbb6..e4c03a9d7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -368,7 +368,7 @@ Library, Tools, and Presets panels can be regrouped, split, floated within the a Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; `Center`, `Left`, `Right`, `Top` and `Bottom` also parse, while `tab`, `above` and `below` are lowercase only). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`. From 8e16c05cbb1e3cf609cef990bf4f94ccd359a20c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 00:34:42 +0000 Subject: [PATCH 012/191] Docking: a panel whose saved place is gone opens beside the photo on its side When a hidden panel's anchor is neither shown nor hidden, craft-ui falls back to the given anchor's tabs. The photo view is that anchor and takes no tabs, so the panel opens beside it on its default side instead of at the right edge. Test: Library anchored on an absent Presets opens to the left of the photo. --- crates/ui-egui/src/docking.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index be4c9d060..6feebe5cb 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -132,7 +132,9 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result // beside the photo instead. if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) { let mut next = workspace.clone(); - if next.set_visible(panel, true, None, permissions).is_ok() && validate(&next).is_ok() { + // A saved place that can't be rebuilt falls back to joining the fallback's tabs. The + // photo view takes no tabs, so that is refused and the panel opens beside it below. + if next.set_visible(panel, true, Some(Panel::Canvas), permissions).is_ok() && validate(&next).is_ok() { *workspace = next; return Ok(()); } @@ -604,4 +606,25 @@ mod tests { assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(zone)), "{panel:?}"); } } + + #[test] + fn a_panel_whose_saved_place_is_gone_opens_beside_the_photo_on_its_side() { + let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools)); + let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); + // Presets, the panel Library went back beside, is neither shown nor hidden. + let location: craft_ui::docking::Location = serde_json::from_value(json!({ + "floating": null, + "anchor": "presets", + "placement": {"Split": "Left"}, + "size": {"FixedFirst": 280.0}, + "stack": null, + "sibling": ["presets"] + })) + .unwrap(); + workspace.hidden.push((Panel::Library, location)); + validate(&workspace).unwrap(); + set_visible(&mut workspace, Panel::Library, true).unwrap(); + let location = workspace.layout.location(&Panel::Library).unwrap(); + assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left))); + } } From e8588b925b477043e5947584884d9f0c5fda7acb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:59:13 +0000 Subject: [PATCH 013/191] Docking: repin craft-ui and craft-docpath to craft-libs 8a503cf --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c90435b2..d1d45b277 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 136644fce..e9d36bd16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 4e6c210a5f4cd728d8001f2701f1320c05f95927 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:57:50 +0000 Subject: [PATCH 014/191] Docking: repin craft-ui and craft-docpath to craft-libs 4dc543c --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1d45b277..eb0c8168a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index e9d36bd16..70b4db861 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 8ae15d4f5e891605394f977920e1d6e847219bd3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:17:28 +0000 Subject: [PATCH 015/191] Docking: side panels in dock columns; saved split-frame layouts reset once --- apps/lightcraft/src/main.rs | 4 +- crates/ui-egui/src/docking.rs | 285 +++++++++++++++--- crates/ui-egui/src/state.rs | 4 + .../ui-egui/src/tests_docking_acceptance.rs | 30 +- crates/ui-egui/tests/it/docking_workflows.rs | 59 ++-- 5 files changed, 302 insertions(+), 80 deletions(-) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 0cab9e189..b971e5519 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -259,10 +259,12 @@ fn load_prefs(in_memory: bool) -> (Option, Option, bool) { load_prefs_at(&path, !in_memory) } -/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. +/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. An +/// arrangement from an older docking version starts again from the default without a warning. fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> { let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; let fields = saved.as_object_mut().ok_or("settings must be an object")?; + lightcraft_ui_egui::docking::drop_outdated(fields); let recovered = fields.get("docking").is_some_and(|value| match serde_json::from_value::>(value.clone()) { Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(), diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 6feebe5cb..59c00b0f6 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -2,8 +2,8 @@ //! Document contents remain owned by their existing view implementations. use craft_ui::docking::{ - Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Layout, Node, PanelLimits, PanelState, Permissions, Placement, - UiDock, Zone, + Action, Column, ColumnLocation, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Group, GroupLocation, Layout, Location, + Node, PanelLimits, PanelState, Permissions, Placement, Side, UiDock, Zone, }; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; @@ -36,16 +36,38 @@ impl Panel { Self::Presets => "Presets", } } - fn default_side(self) -> Zone { + /// The dock the panel opens in when it has no recorded place. + fn home_side(self) -> Side { + if self == Self::Library { Side::Start } else { Side::End } + } + /// The width of the panel's own column in the default workspace. + fn home_width(self) -> f32 { match self { - Self::Canvas => Zone::Right, - Self::Library => Zone::Left, - Self::Tools => Zone::Right, - Self::Presets => Zone::Right, + Self::Canvas => crate::state::MIN_PHOTO_WIDTH, + Self::Library => crate::state::LEFT_WIDTH.default, + Self::Tools => crate::state::RIGHT_WIDTH.default, + Self::Presets => PRESETS_WIDTH, } } } +const PRESETS_WIDTH: f32 = 300.0; + +/// The arrangement generation saved as `UiState::docking_version`. Settings without it hold the +/// side panels in split frames; they start again from [`default_workspace`]. +pub const DOCKING_VERSION: u32 = 1; + +/// Drops saved settings' arrangement from before [`DOCKING_VERSION`], with the places of its +/// closed panels, so the app starts from the default workspace; every other setting stays. +/// Returns true when an arrangement was dropped. +pub fn drop_outdated(settings: &mut serde_json::Map) -> bool { + if settings.get("dockingVersion").and_then(Value::as_u64).is_some_and(|version| version >= u64::from(DOCKING_VERSION)) { + return false; + } + settings.insert("dockingVersion".into(), json!(DOCKING_VERSION)); + settings.remove("docking").is_some_and(|docking| !docking.is_null()) +} + fn permissions(panel: &Panel) -> Permissions { // Panels open and split beside the photo view, but never join it as tabs. if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() } @@ -62,23 +84,49 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node; -pub fn default_workspace() -> Workspace { +fn column(panel: Panel, width: f32) -> Column { + Column::new(vec![Group::new(vec![panel], 0)], width) +} + +/// Library in a start dock and Tools in an end dock beside the photo. Presets has a column of +/// its own on Tools' photo side rather than a group under Tools: both are tall lists that are +/// read side by side while trying presets, and stacking them would halve both. +fn docked(left: f32, right: f32) -> Workspace { + let start = Node::Dock { side: Side::Start, columns: vec![column(Panel::Library, left)] }; + let end = Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, right), column(Panel::Presets, PRESETS_WIDTH)] }; Workspace::new(Layout { root: Some(split( SplitAxis::Horizontal, - SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default), - tabs(Panel::Library), - split( - SplitAxis::Horizontal, - SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default), - split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), - tabs(Panel::Tools), - ), + SplitSize::FixedFirst(left), + start, + split(SplitAxis::Horizontal, SplitSize::FixedSecond(right + PRESETS_WIDTH), tabs(Panel::Canvas), end), )), floating: Vec::new(), }) } +pub fn default_workspace() -> Workspace { + docked(crate::state::LEFT_WIDTH.default, crate::state::RIGHT_WIDTH.default) +} + +/// Where a panel without a usable recorded place opens: its own column at its dock's window +/// edge, or Presets on the photo side of Tools while Tools is in the end dock. +fn home(workspace: &Workspace, panel: Panel) -> Location { + let column = ColumnLocation { side: panel.home_side(), width: panel.home_width(), iconic: false, iconic_width: None }; + let docked_at = + |other: Panel| workspace.layout.location(&other).ok().and_then(|location| location.group).and_then(|group| group.column).map(|c| c.side); + let beside = (panel == Panel::Presets && docked_at(Panel::Tools) == Some(Side::End)).then_some(Panel::Tools); + Location { + floating: None, + anchor: beside, + placement: if beside.is_some() { Placement::Column { after: true } } else { Placement::Tab { before: None } }, + size: None, + stack: None, + sibling: Vec::new(), + group: Some(GroupLocation { minimized: false, height: None, column: Some(column) }), + } +} + fn error(error: DockError) -> String { error.to_string() } @@ -118,7 +166,7 @@ fn apply(workspace: &mut Workspace, action: Action) -> Result<(), String> Ok(()) } -/// Shows a panel at its recorded place, else beside the photo on its usual side. A panel that +/// Shows a panel at its recorded place, else in its dock column ([`home`]). A panel that /// already has the asked visibility keeps its tab selection. fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { if workspace.layout.contains(&panel) == visible { @@ -128,18 +176,23 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result return apply(workspace, Action::Close { panel }); } validate(workspace)?; - // Without a recorded place craft-ui opens the panel at the right edge; LightCraft puts it - // beside the photo instead. + // A recorded place that can't be rebuilt falls back to joining the fallback's tabs. The + // photo view takes no tabs, so that is refused and the panel goes to its home column below. if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) { let mut next = workspace.clone(); - // A saved place that can't be rebuilt falls back to joining the fallback's tabs. The - // photo view takes no tabs, so that is refused and the panel opens beside it below. if next.set_visible(panel, true, Some(Panel::Canvas), permissions).is_ok() && validate(&next).is_ok() { *workspace = next; return Ok(()); } } - apply(workspace, Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }) + let mut next = workspace.clone(); + next.hidden.retain(|(hidden, _)| *hidden != panel); + let location = home(&next, panel); + next.hidden.push((panel, location)); + next.set_visible(panel, true, Some(Panel::Canvas), permissions).map_err(error)?; + validate(&next)?; + *workspace = next; + Ok(()) } /// Membership is passive state; an explicit command also exposes the panel's body. @@ -177,14 +230,7 @@ fn run(workspace: &mut Workspace, params: &Value) -> Result<(), String> { } fn from_ui(app: &crate::LightcraftApp) -> Workspace { - let mut workspace = default_workspace(); - if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() { - *size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width)); - if let Node::Split { size, .. } = second.as_mut() { - *size = SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.clamp(app.ui.right_width)); - } - } - workspace + docked(crate::state::LEFT_WIDTH.clamp(app.ui.left_width), crate::state::RIGHT_WIDTH.clamp(app.ui.right_width)) } fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> { @@ -207,10 +253,12 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { let Ok(location) = workspace.layout.location(&panel) else { continue; }; - let value = match (location.floating, location.placement, location.size) { - (Some([_, _, width, _]), _, _) - | (None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width))) - | (None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width), + let column = location.group.and_then(|group| group.column); + let value = match (location.floating, column, location.placement, location.size) { + (Some([_, _, width, _]), ..) => Some(width), + (None, Some(column), ..) => Some(column.width), + (None, None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width))) + | (None, None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width), // A vertical split stores height, and the opposite fixed side sizes a sibling. _ => None, }; @@ -305,8 +353,10 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { app.ui.docking = Some(workspace.clone()); let mut style = DockStyle::from_ui(ui); style.min_pane = 96.0; - // The side panels are frames whose widths LightCraft tracks; dock columns are not offered. - style.new_dock_zones = false; + // Free docking: a panel dropped at an empty window edge makes a dock, and the photo takes + // drops on all four sides. + style.new_dock_zones = true; + style.side_edge_zones = false; style.float_label = crate::i18n::tr("Float panel").into(); style.close_label = crate::i18n::tr("Close panel").into(); style.move_panel_to_label = crate::i18n::tr("Move to group").into(); @@ -320,6 +370,12 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.close_others_label = crate::i18n::tr("Close other panels in group").into(); style.minimize_label = crate::i18n::tr("Minimize").into(); style.expand_label = crate::i18n::tr("Expand").into(); + style.move_column_label = crate::i18n::tr("Move panel column").into(); + style.collapse_to_icons_label = crate::i18n::tr("Collapse to Icons").into(); + style.expand_panels_label = crate::i18n::tr("Expand Panels").into(); + style.collapse_column_label = crate::i18n::tr("Collapse column").into(); + style.expand_column_label = crate::i18n::tr("Expand column").into(); + style.auto_collapse_label = crate::i18n::tr("Auto-Collapse Iconic Panels").into(); style.move_window_label = crate::i18n::tr("Move panel window").into(); style.close_window_label = crate::i18n::tr("Close panel window").into(); style.float_hint_label = crate::i18n::tr("Hold {key} to undock").into(); @@ -399,7 +455,8 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { } } -/// Keep the established ui.set width controls connected to the panel's current parent split. +/// Keep the established ui.set width controls connected to the panel's dock column, floating +/// window or parent split. pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> { state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width); state.right_width = crate::state::RIGHT_WIDTH.clamp(state.right_width); @@ -415,6 +472,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( if let Some(rect) = &mut location.floating { rect[2] = width; } + if let Some(column) = location.group.as_mut().and_then(|group| group.column.as_mut()) { + column.width = width; + continue; + } match location.placement { Placement::Split(Zone::Left) => location.size = Some(SplitSize::FixedFirst(width)), Placement::Split(Zone::Right) => location.size = Some(SplitSize::FixedSecond(width)), @@ -428,6 +489,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( apply(workspace, Action::MoveFloating { panel, rect })?; continue; } + if workspace.layout.location(&panel).is_ok_and(|location| location.group.is_some_and(|group| group.column.is_some())) { + apply(workspace, Action::ResizeColumn { panel, width })?; + continue; + } let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); let mut change = None; while let Some((node, path)) = nodes.pop() { @@ -470,6 +535,23 @@ fn panel_limits(panel: &Panel) -> PanelLimits { PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } } +/// The columns of the dock on `side`, from the window edge inwards: width and the panels of +/// each group. +#[cfg(test)] +pub(crate) fn columns(workspace: &Workspace, side: Side) -> Vec<(f32, Vec>)> { + let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: dock, columns } if *dock == side => { + return columns.iter().map(|column| (column.width, column.groups.iter().map(|group| group.panels.clone()).collect())).collect(); + } + _ => {} + } + } + Vec::new() +} + #[cfg(test)] mod tests { use super::*; @@ -533,7 +615,7 @@ mod tests { } #[test] - fn legacy_width_publication_reads_own_horizontal_and_floating_widths() { + fn legacy_width_publication_reads_own_split_and_floating_widths() { for (size, first) in [(SplitSize::FixedFirst(310.0), true), (SplitSize::FixedSecond(310.0), false)] { let workspace = tools_split(SplitAxis::Horizontal, size, first); let mut app = app_with_widths(); @@ -597,18 +679,125 @@ mod tests { } #[test] - fn a_panel_without_a_recorded_place_opens_beside_the_photo_on_its_side() { - for (panel, zone) in [(Panel::Library, Zone::Left), (Panel::Presets, Zone::Right)] { - let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools)); - let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); - set_visible(&mut workspace, panel, true).unwrap(); - let location = workspace.layout.location(&panel).unwrap(); - assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(zone)), "{panel:?}"); + fn default_workspace_docks_library_at_the_start_and_tools_and_presets_at_the_end() { + let workspace = default_workspace(); + validate(&workspace).unwrap(); + assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); + assert_eq!( + columns(&workspace, Side::End), + [(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])], + "Presets has its own column on Tools' photo side" + ); + let Some(Node::Split { second, .. }) = &workspace.layout.root else { panic!("{:?}", workspace.layout.root) }; + let Node::Split { first, .. } = &**second else { panic!("{second:?}") }; + assert_eq!(**first, tabs(Panel::Canvas), "the photo sits between the docks"); + assert!(workspace.layout.floating.is_empty()); + let app = app_with_widths(); + let workspace = from_ui(&app); + assert_eq!(columns(&workspace, Side::Start).first().map(|column| column.0), Some(333.0)); + assert_eq!(columns(&workspace, Side::End).first().map(|column| column.0), Some(410.0)); + } + + #[test] + fn dock_column_widths_publish_and_follow_width_controls() { + let mut app = app_with_widths(); + let workspace = docked(300.0, 310.0); + publish_visibility(&mut app, &workspace); + assert_eq!((app.ui.left_width, app.ui.right_width), (300.0, 310.0)); + let mut state = crate::UiState { docking: Some(workspace), left_width: 350.0, right_width: 400.0, ..Default::default() }; + set_visible(state.docking.as_mut().unwrap(), Panel::Library, false).unwrap(); + set_widths(&mut state, &json!({"leftWidth":350.0, "rightWidth":400.0})).unwrap(); + let workspace = state.docking.as_mut().unwrap(); + assert_eq!(columns(workspace, Side::End).first().map(|column| column.0), Some(400.0), "Tools' column"); + set_visible(workspace, Panel::Library, true).unwrap(); + assert_eq!(columns(workspace, Side::Start), [(350.0, vec![vec![Panel::Library]])], "the closed Library's column"); + } + + #[test] + fn a_panel_without_a_recorded_place_opens_in_its_dock() { + let root = split( + SplitAxis::Horizontal, + SplitSize::FixedSecond(300.0), + tabs(Panel::Canvas), + Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, 300.0)] }, + ); + let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); + set_visible(&mut workspace, Panel::Presets, true).unwrap(); + set_visible(&mut workspace, Panel::Library, true).unwrap(); + validate(&workspace).unwrap(); + assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); + assert_eq!(columns(&workspace, Side::End), [(300.0, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])]); + // Without Tools in the end dock, Presets takes the dock's window edge. + let mut workspace = default_workspace(); + set_visible(&mut workspace, Panel::Presets, false).unwrap(); + workspace.hidden.clear(); + apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); + set_visible(&mut workspace, Panel::Presets, true).unwrap(); + assert_eq!(columns(&workspace, Side::End), [(PRESETS_WIDTH, vec![vec![Panel::Presets]])]); + } + + fn saved_settings(docking: Value, version: Option) -> serde_json::Map { + let ui = crate::UiState { thumb_size: 210.0, filmstrip: false, presets: true, ..Default::default() }; + let mut saved = serde_json::to_value(&ui).unwrap(); + let fields = saved.as_object_mut().unwrap(); + fields.insert("docking".into(), docking); + match version { + Some(version) => fields.insert("dockingVersion".into(), json!(version)), + None => fields.remove("dockingVersion"), + }; + fields.clone() + } + + #[test] + fn a_saved_split_frame_layout_without_a_docking_version_loads_as_the_default_docks() { + let frames = split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(333.0), + tabs(Panel::Library), + split(SplitAxis::Horizontal, SplitSize::FixedSecond(410.0), tabs(Panel::Canvas), tabs(Panel::Tools)), + ); + let frames = Workspace::new(Layout { root: Some(frames), floating: Vec::new() }); + validate(&frames).unwrap(); + for version in [None, Some(0)] { + let mut fields = saved_settings(serde_json::to_value(&frames).unwrap(), version); + assert!(drop_outdated(&mut fields)); + let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap(); + assert!(ui.docking.is_none()); + assert_eq!(ui.docking_version, DOCKING_VERSION); + assert_eq!((ui.thumb_size, ui.filmstrip, ui.presets), (210.0, false, true), "other settings stay"); + let mut app = app_with_widths(); + app.ui = ui; + command(&mut app, false, &json!({"operation":"activate","panel":"tools"})).unwrap(); + let workspace = app.ui.docking.as_ref().unwrap(); + assert!(!workspace.layout.contains(&Panel::Library), "the Library panel stays closed"); + assert_eq!( + columns(workspace, Side::End), + [(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])] + ); } } #[test] - fn a_panel_whose_saved_place_is_gone_opens_beside_the_photo_on_its_side() { + fn a_saved_dock_layout_round_trips() { + let mut workspace = default_workspace(); + apply(&mut workspace, Action::ResizeColumn { panel: Panel::Tools, width: 333.0 }).unwrap(); + apply( + &mut workspace, + Action::Move { panel: Panel::Presets, anchor: Panel::Library, placement: Placement::Group { below: true, height: None } }, + ) + .unwrap(); + set_visible(&mut workspace, Panel::Library, false).unwrap(); + let mut fields = saved_settings(serde_json::to_value(&workspace).unwrap(), Some(DOCKING_VERSION)); + assert!(!drop_outdated(&mut fields)); + let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap(); + let loaded = ui.docking.unwrap(); + validate(&loaded).unwrap(); + assert_eq!(loaded, workspace); + assert_eq!(serde_json::to_value(crate::UiState::default()).unwrap()["dockingVersion"], DOCKING_VERSION, "saves write the version"); + } + + #[test] + fn a_panel_whose_saved_place_is_gone_opens_in_its_dock() { let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools)); let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); // Presets, the panel Library went back beside, is neither shown nor hidden. @@ -624,7 +813,7 @@ mod tests { workspace.hidden.push((Panel::Library, location)); validate(&workspace).unwrap(); set_visible(&mut workspace, Panel::Library, true).unwrap(); - let location = workspace.layout.location(&Panel::Library).unwrap(); - assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left))); + validate(&workspace).unwrap(); + assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0bbd619d8..f9da46444 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -782,6 +782,9 @@ pub struct UiState { pub window: Option, /// Shared panel arrangement; absent in legacy settings until their first layout. pub docking: Option, + /// The arrangement generation `docking` was saved in ([`crate::docking::DOCKING_VERSION`]); + /// settings files are read through [`crate::docking::drop_outdated`]. + pub docking_version: u32, #[serde(default = "crate::i18n::default_language")] pub language: crate::i18n::Locale, /// The Build Previews run last announced (its identity, finished?). @@ -1482,6 +1485,7 @@ impl Default for UiState { UiState { window: None, docking: None, + docking_version: crate::docking::DOCKING_VERSION, language: crate::i18n::default_language(), preview_build_seen: None, unsaved_seen: false, diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests_docking_acceptance.rs index 5e496b822..940451f19 100644 --- a/crates/ui-egui/src/tests_docking_acceptance.rs +++ b/crates/ui-egui/src/tests_docking_acceptance.rs @@ -44,6 +44,30 @@ fn release(h: &mut Headless, pos: egui::Pos2) { h.step(); } +#[test] +fn window_menu_reopens_closed_panels_in_their_dock_columns() { + use craft_ui::docking::Side; + let mut h = app(); + let columns = |h: &Headless, side| { + crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), side).into_iter().map(|(_, groups)| groups).collect::>() + }; + let end = vec![vec![vec![Panel::Tools]], vec![vec![Panel::Presets]]]; + h.app.run("panel.presets", json!({})).unwrap(); + h.step(); + assert_eq!(columns(&h, Side::End), end, "Presets opens on Tools' photo side"); + dock(&mut h, json!({"operation":"close","panel":"tools"})); + assert_eq!(h.app.ui.right, RightPanel::None); + h.app.run("panel.edit", json!({})).unwrap(); + h.step(); + assert_eq!(columns(&h, Side::End), end, "Tools reopens at the window edge"); + let width = crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), Side::Start).first().unwrap().0; + dock(&mut h, json!({"operation":"close","panel":"library"})); + assert!(!h.app.ui.left_panel); + h.app.run("view.leftPanel", json!({})).unwrap(); + h.step(); + assert_eq!(crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), Side::Start), [(width, vec![vec![Panel::Library]])]); +} + #[test] fn inactive_same_mode_commands_reveal_without_closing_or_editing() { let mut h = app(); @@ -271,8 +295,8 @@ fn covered_keyword_rejects_hierarchy_move_and_uncovered_target_moves_exact_keywo #[test] fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() { let mut h = app(); - let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::::new())); - let from = h.view.ctx.read_response(divider).expect("the custom workspace root divider is rendered").rect.center(); + let divider = egui::Id::new("lightcraft-docking").with(("column-resize", Some(Panel::Library))); + let from = h.view.ctx.read_response(divider).expect("the Library column's divider is rendered").rect.center(); let before = h.app.ui.docking.as_ref().unwrap().layout.clone(); h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); h.step(); @@ -303,7 +327,7 @@ fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() { fn held_divider_replacement_route(route: &str) { let mut h = app(); - let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::::new())); + let divider = egui::Id::new("lightcraft-docking").with(("column-resize", Some(Panel::Library))); let from = h.view.ctx.read_response(divider).unwrap().rect.center(); let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(); h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index a8e6c3d51..1d360a6b2 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -1,5 +1,5 @@ //! Real library/tool panel moves preserve the selected tool and saved arrangement. -use craft_ui::docking::{DockArea, Hit, Node}; +use craft_ui::docking::{DockArea, Hit, Node, Side}; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel}; @@ -100,42 +100,45 @@ fn dragging_a_group_by_its_bar_moves_the_whole_group() { .expect("the Tools and Presets group draws its drag bar"); let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Library").rect().center(); drag(&mut h, bar.center(), target); - let layout = &h.state().ui.docking.as_ref().unwrap().layout; - let mut nodes: Vec<_> = layout.root.iter().collect(); - let mut groups = Vec::new(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), - Node::Tabs { panels, .. } => groups.push(panels.clone()), - _ => {} - } - } - let mut library = groups.into_iter().find(|panels| panels.contains(&Panel::Library)).unwrap(); + let mut library = start_dock(&h).into_iter().flatten().find(|panels| panels.contains(&Panel::Library)).unwrap(); library.sort_by_key(|panel| panel.id()); assert_eq!(library, [Panel::Library, Panel::Presets, Panel::Tools], "both panels of the dragged group joined Library"); assert_eq!(h.state().ui.right, RightPanel::Edit); } -/// LightCraft's side panels live in frames; a drop at the window's edge makes no dock column. +/// The start dock's columns from the window edge inwards, each as its groups' panels. +fn start_dock(h: &Harness<'_, LightcraftApp>) -> Vec>> { + let layout = &h.state().ui.docking.as_ref().unwrap().layout; + let mut nodes: Vec<_> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: Side::Start, columns } => { + return columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect(); + } + _ => {} + } + } + Vec::new() +} + +/// In a dock column, a group's top third stacks above it, its photo-side third adds a column +/// there and its middle joins its tabs. #[test] -fn an_edge_drop_does_not_create_a_dock() { - for at_right in [false, true] { +fn a_tab_dropped_on_a_dock_group_stacks_adds_a_column_or_joins_by_where_it_lands() { + let cases: [(fn(egui::Rect) -> egui::Pos2, Vec>>); 3] = [ + (|r| egui::pos2(r.center().x, r.top() + r.height() / 6.0), vec![vec![vec![Panel::Tools], vec![Panel::Library]]]), + (|r| egui::pos2(r.right() - r.width() / 6.0, r.center().y), vec![vec![vec![Panel::Library]], vec![vec![Panel::Tools]]]), + (|r| r.center(), vec![vec![vec![Panel::Library, Panel::Tools]]]), + ]; + for (at, expected) in cases { let mut h = harness(None, 1400.0, 1.0); let library = h.state().widgets.iter().find(|(id, _)| id == "panel:left_panel").unwrap().1; - let tools = h.state().widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; - let x = if at_right { tools.right() - 4.0 } else { library.left() + 4.0 }; let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center(); - drag(&mut h, source, egui::pos2(x, 500.0)); - let layout = &h.state().ui.docking.as_ref().unwrap().layout; - assert!(layout.contains(&Panel::Tools)); - let mut nodes: Vec<_> = layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), - Node::Dock { .. } => panic!("a drop at x = {x} made a dock: {layout:?}"), - _ => {} - } - } + drag(&mut h, source, at(library)); + assert_eq!(start_dock(&h), expected, "dropped at {:?} of {library:?}", at(library)); + lightcraft_ui_egui::docking::validate(h.state().ui.docking.as_ref().unwrap()).unwrap(); + assert_eq!(h.state().ui.right, RightPanel::Edit); } } From b477efd3da21c815c14069f78c2ba350dacde21e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:17:31 +0000 Subject: [PATCH 016/191] Docking: repin craft-ui and craft-docpath to craft-libs b6d35e2 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb0c8168a..c5d73c571 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" +source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" +source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 70b4db861..8a87d0e24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 4bc5d2b587fc2f64de333f59e62ed3945a3f8c4c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:17:42 +0000 Subject: [PATCH 017/191] Docking: the web app resets a saved split-frame layout once too --- apps/lightcraft-web/src/web.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 8cdb32462..fe77ce1e1 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -472,7 +472,12 @@ impl WebApp { let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone())) .with_chinese_font(chinese_font); let ui_written = files.get("ui.json").unwrap_or_default(); - if let Ok(ui) = serde_json::from_slice::(&ui_written) { + // an arrangement from an older docking version starts again from the default + let mut saved = serde_json::from_slice::(&ui_written).unwrap_or_default(); + if let Some(fields) = saved.as_object_mut() { + lightcraft_ui_egui::docking::drop_outdated(fields); + } + if let Ok(ui) = serde_json::from_value::(saved) { app.ui = ui; } app.ui = app.ui.sanitized(); From 1291d391d981b7a39059b181f5781e0c39bbccda Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 16:25:25 +0000 Subject: [PATCH 018/191] Docking: repin craft-ui and craft-docpath to craft-libs main 5bbe9f6 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7f385f62..c6bb9c9d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" +source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" +source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 12cfba073..72d732c6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From df05e59474d9a1afb42904c788a543a7154985ac Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 17:08:39 +0000 Subject: [PATCH 019/191] Docking: the Info fields test window is tall enough for the place fields in the dock column --- crates/ui-egui/src/headless.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8686dbdf5..1d71e0b88 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2860,7 +2860,8 @@ mod tests { /// accessibility and place fields reach the photo. #[test] fn info_fields_keep_typing_and_save() { - let mut h = demo([1300.0, 1000.0]); + // (tall enough for the place fields to show without scrolling) + let mut h = demo([1300.0, 1200.0]); let t = Duration::from_secs(10); h.request("ui.set", json!({"view": "detail", "right": "info"}), t); h.settle(SETTLE); From 7ebbb17a8252291229386a4e1d31214467a10c10 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 15:31:09 -0700 Subject: [PATCH 020/191] Originals: on Windows a hard link to an original counts as that original safe_file::same_file compared length and canonical paths on every platform other than Unix. On Windows canonicalize returns a hard link's own path, so a hard link to a library original under another name was not the same file, and pin_write_target accepted it as a screenshot destination. For this reason the engine test write_targets_pin_absent_paths_and_recheck_the_current_library failed on Windows. On Windows same_file now first compares the volume serial number and file index through the same-file crate (already in Cargo.lock through walkdir), then falls back to the resolved-path comparison. The catalog crate takes same-file as a Windows-only dependency. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/catalog/Cargo.toml | 3 +++ crates/catalog/src/safe_file.rs | 26 +++++++++++++++++++++++++- 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index b69533958..455758fb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3220,6 +3220,7 @@ dependencies = [ "lightcraft-raw", "log", "proptest", + "same-file", "serde", "serde_json", "thiserror 2.0.21", diff --git a/Cargo.toml b/Cargo.toml index 5c0418d30..fc2d90d2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" +same-file = "1" # lightcraft-catalog reads a file's volume serial number and file index with it on Windows proptest = "1" bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 27273d5c4..e9df63166 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -20,6 +20,9 @@ log = { workspace = true } chrono = { workspace = true } web-time = { workspace = true } +[target.'cfg(windows)'.dependencies] +same-file = { workspace = true } + [dev-dependencies] lightcraft-geom = { workspace = true } proptest = { workspace = true } diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index fa067de0f..83139756c 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -23,6 +23,7 @@ //! replacing anything** (a hard link, which fails on a taken name; where the volume has no hard //! links, an exclusive create that claims the name, then a rename over that placeholder). //! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix, +//! volume serial number + file index on Windows, //! resolved paths elsewhere), for "is this target an original?" checks. use std::cell::Cell; @@ -267,7 +268,8 @@ fn same_content(path: &Path, data: &[u8]) -> io::Result { } } -/// Do `a` and `b` name the same existing file (through links, `..`, case-insensitive names)? +/// Do `a` and `b` name the same existing file (through hard links, symlinks, `..`, +/// case-insensitive names)? pub fn same_file(a: &Path, b: &Path) -> bool { let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false }; #[cfg(unix)] @@ -277,6 +279,14 @@ pub fn same_file(a: &Path, b: &Path) -> bool { } #[cfg(not(unix))] { + // A hard link has its own path but shares the volume serial number and file index with + // its original. A volume that does not report them is compared by resolved path below. + #[cfg(windows)] + { + if ::same_file::is_same_file(a, b).unwrap_or(false) { + return true; + } + } if ma.len() != mb.len() { return false; } @@ -397,6 +407,20 @@ mod tests { let _ = fs::remove_dir_all(&d); } + #[test] + fn same_file_sees_a_hard_link_under_another_name() { + let d = temp("same-link"); + let p = d.join("IMG_1.jpg"); + fs::write(&p, b"original").unwrap(); + let linked = d.join("linked.png"); + fs::hard_link(&p, &linked).unwrap(); + fs::write(d.join("copy.png"), b"original").unwrap(); + assert!(same_file(&p, &linked), "a hard link is the same file"); + assert!(same_file(&linked, &p)); + assert!(!same_file(&p, &d.join("copy.png")), "a copy with the same content and length is another file"); + let _ = fs::remove_dir_all(&d); + } + #[test] fn same_file_sees_through_other_spellings() { let d = temp("same"); From a97f19605c9fbfa9e52379faf5ff339742b71e41 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 22:32:37 -0700 Subject: [PATCH 021/191] Originals: compare lengths before the Windows file identity On ReFS two files can share the 64-bit file index that same_file compares, so same_file now rejects files of different lengths before it checks the volume serial number and file index. The hard-link test skips with a recorded reason when the temporary folder's volume has no hard links (FAT/exFAT, some shares), the same errors publish_new treats as a volume without hard links. --- Cargo.lock | 1 + crates/catalog/Cargo.toml | 1 + crates/catalog/src/safe_file.rs | 18 ++++++++++++++---- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 455758fb0..3e9acf98c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3218,6 +3218,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-meta", "lightcraft-raw", + "lightcraft-testkit", "log", "proptest", "same-file", diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index e9df63166..7a9c80439 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -25,4 +25,5 @@ same-file = { workspace = true } [dev-dependencies] lightcraft-geom = { workspace = true } +lightcraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index 83139756c..f7737bf78 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -279,17 +279,18 @@ pub fn same_file(a: &Path, b: &Path) -> bool { } #[cfg(not(unix))] { + if ma.len() != mb.len() { + return false; + } // A hard link has its own path but shares the volume serial number and file index with // its original. A volume that does not report them is compared by resolved path below. + // On ReFS two files can share the 64-bit file index, so the lengths are compared first. #[cfg(windows)] { if ::same_file::is_same_file(a, b).unwrap_or(false) { return true; } } - if ma.len() != mb.len() { - return false; - } match (fs::canonicalize(a), fs::canonicalize(b)) { (Ok(x), Ok(y)) => x == y || x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(), _ => false, @@ -413,7 +414,16 @@ mod tests { let p = d.join("IMG_1.jpg"); fs::write(&p, b"original").unwrap(); let linked = d.join("linked.png"); - fs::hard_link(&p, &linked).unwrap(); + match fs::hard_link(&p, &linked) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => panic!("{}: {e}", linked.display()), + // no hard links on this volume (FAT/exFAT, some shares), as in `publish_new` + Err(e) => { + let _ = fs::remove_dir_all(&d); + lightcraft_testkit::skip!(System, "no hard links in {}: {e}", d.display()); + return; + } + } fs::write(d.join("copy.png"), b"original").unwrap(); assert!(same_file(&p, &linked), "a hard link is the same file"); assert!(same_file(&linked, &p)); From 1bb11ff321a1385647cecce8058f43da4dd94dfc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 03:03:39 -0700 Subject: [PATCH 022/191] Local: on Windows the folder trees leave out hidden folders such as AppData The Local trees left out folders whose names start with a dot, but on Windows a folder is hidden by its attribute. Home's tree listed AppData, and browsing a folder inside the temp folder opened Home's tree down through AppData\Local\Temp, drawing one row for every folder there on each frame. On a machine with 148,400 folders in Temp each frame took about 4 s, and tests_panels::local_photos_cannot_be_added_to_albums_until_they_join_my_photos timed out on its ui.drag. list_subfolders now also skips folders with FILE_ATTRIBUTE_HIDDEN set. Test: panels::left::tests::subfolders_leave_out_hidden_folders. --- crates/ui-egui/src/panels/left.rs | 37 ++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 5a81edfab..3304b4b8f 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1040,7 +1040,7 @@ fn list_subfolders(path: &str) -> Vec<(String, String)> { let mut v: Vec<(String, String)> = std::fs::read_dir(path) .map(|rd| { rd.flatten() - .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !hidden_attribute(e)) .filter_map(|e| { let name = e.file_name().to_string_lossy().to_string(); (!name.starts_with('.')).then(|| (name, e.path().to_string_lossy().to_string())) @@ -1052,6 +1052,20 @@ fn list_subfolders(path: &str) -> Vec<(String, String)> { v } +/// On Windows a folder is hidden by an attribute (AppData in the home folder is), not by a +/// leading dot. +#[cfg(windows)] +fn hidden_attribute(e: &std::fs::DirEntry) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; + e.metadata().is_ok_and(|m| m.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0) +} + +#[cfg(not(windows))] +fn hidden_attribute(_: &std::fs::DirEntry) -> bool { + false +} + /// How many [`fs_cached`] answers for `ctx` are being worked out right now. Rows appear (and /// the sidebar below them moves) when they land, so the headless driver counts them as pending /// work and waits for them before acting on widget positions. @@ -2370,6 +2384,27 @@ mod tests { assert_eq!((names(&l.places), l.owner), (vec!["Pictures"], Some(0))); } + /// A folder tree leaves out hidden subfolders: a leading dot everywhere, and on Windows the + /// hidden attribute, so Home's tree does not list AppData and open down into its temp folder. + #[test] + fn subfolders_leave_out_hidden_folders() { + let dir = std::env::temp_dir().join(format!("lc-ui-hidden-subfolders-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for name in ["Shown", ".dot", "Hidden"] { + std::fs::create_dir_all(dir.join(name)).unwrap(); + } + let expected = if cfg!(windows) { + let status = std::process::Command::new("attrib").arg("+h").arg(dir.join("Hidden")).status().unwrap(); + assert!(status.success(), "attrib +h: {status}"); + vec!["Shown"] + } else { + vec!["Hidden", "Shown"] + }; + let listed = super::list_subfolders(&dir.to_string_lossy()); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(listed.iter().map(|(n, _)| n.as_str()).collect::>(), expected); + } + #[test] fn a_local_folder_lines_up_with_the_rows_above_and_its_triangle_stays_left_of_the_icon() { use crate::Services; From 545ed92d6a1b28ed29117b43b7b8f7f1e0094cd1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 07:16:41 -0700 Subject: [PATCH 023/191] Originals: refuse a screenshot target whose file identity cannot be read On Windows the identity check opens both files to read the volume serial number and file index. When that open failed (another process holds the file without sharing it, or the file cannot be read), the hard link was treated as an ordinary file. The screenshot target check now refuses the target in that case. A missing original is still not a match, and other callers of same_file keep the current comparison. --- crates/catalog/src/safe_file.rs | 30 ++++++++++++---- crates/engine/src/originals.rs | 63 ++++++++++++++++++++++++++------- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index f7737bf78..b0974961f 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -271,30 +271,46 @@ fn same_content(path: &Path, data: &[u8]) -> io::Result { /// Do `a` and `b` name the same existing file (through hard links, symlinks, `..`, /// case-insensitive names)? pub fn same_file(a: &Path, b: &Path) -> bool { - let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false }; + compare_files(a, b, false).unwrap_or(false) +} + +/// [`same_file`] for a check that must not let an unknown identity pass: `Err` when both paths +/// exist with the same length but the file identity of either cannot be read (on Windows, when +/// either file cannot be opened). A path that does not exist is still `Ok(false)`. +pub fn same_file_strict(a: &Path, b: &Path) -> io::Result { + compare_files(a, b, true) +} + +fn compare_files(a: &Path, b: &Path, strict: bool) -> io::Result { + #[cfg(not(windows))] + let _ = strict; + let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return Ok(false) }; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; - ma.dev() == mb.dev() && ma.ino() == mb.ino() + Ok(ma.dev() == mb.dev() && ma.ino() == mb.ino()) } #[cfg(not(unix))] { if ma.len() != mb.len() { - return false; + return Ok(false); } // A hard link has its own path but shares the volume serial number and file index with // its original. A volume that does not report them is compared by resolved path below. // On ReFS two files can share the 64-bit file index, so the lengths are compared first. #[cfg(windows)] { - if ::same_file::is_same_file(a, b).unwrap_or(false) { - return true; + match ::same_file::is_same_file(a, b) { + Ok(true) => return Ok(true), + Ok(false) => {} + Err(e) if strict => return Err(e), + Err(_) => {} } } - match (fs::canonicalize(a), fs::canonicalize(b)) { + Ok(match (fs::canonicalize(a), fs::canonicalize(b)) { (Ok(x), Ok(y)) => x == y || x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(), _ => false, - } + }) } } diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index 890e98f81..e20a9378b 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; #[cfg(not(any(unix, windows)))] use std::time::SystemTime; -use lightcraft_catalog::safe_file::same_file; +use lightcraft_catalog::safe_file::{same_file, same_file_strict}; use lightcraft_catalog::{Catalog, Source}; /// The directory entry a screenshot is about to write, pinned at request admission. @@ -147,14 +147,30 @@ impl OriginalGuard { if let Some(n) = std::fs::canonicalize(target).ok().and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_lowercase())) { names.push(n); } + // The identity check refuses a target whose file identity cannot be read, so an unreadable + // hard link to an original is not taken for an ordinary file. + let refuse_if_same = |p: &Path, what: &str| -> Result<(), String> { + let same = if hard_links { + same_file_strict(p, target).map_err(|e| { + format!( + "{}: LightCraft cannot tell whether this is {what} in the library ({e}) and never writes over an original (choose another folder or file name)", + target.display() + ) + })? + } else { + same_file(p, target) + }; + if same { + return Err(format!( + "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", + target.display() + )); + } + Ok(()) + }; for n in &names { for (p, what) in self.by_name.get(n).into_iter().flatten() { - if same_file(p, target) { - return Err(format!( - "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", - target.display() - )); - } + refuse_if_same(p, what)?; } } // An imported hard link or symlink can have a different name from its original. A write @@ -166,12 +182,7 @@ impl OriginalGuard { if !hard_links && std::fs::read_link(p).is_err() { continue; } - if same_file(p, target) { - return Err(format!( - "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", - target.display() - )); - } + refuse_if_same(p, what)?; } Ok(()) } @@ -339,4 +350,30 @@ mod tests { } let _ = std::fs::remove_dir_all(&d); } + + #[cfg(windows)] + #[test] + fn a_hard_link_whose_identity_cannot_be_read_is_refused() { + use std::os::windows::fs::OpenOptionsExt; + let d = std::env::temp_dir().join(format!("lc-unreadable-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let mut s = crate::Session::new(); + let original = d.join("IMG_1.jpg"); + std::fs::write(&original, b"original").unwrap(); + let photo = Photo::new(PhotoId(1), Source::File { path: original.to_string_lossy().to_string() }, "IMG_1.jpg", "JPEG", 10, 10, "2026-01-01"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + let linked = d.join("shot.png"); + std::fs::hard_link(&original, &linked).unwrap(); + + // A handle that shares nothing makes every other open of the file fail, so the file + // identity of either name cannot be read while it is held. + let held = std::fs::OpenOptions::new().read(true).share_mode(0).open(&original).unwrap(); + let refused = s.pin_write_target(&linked.to_string_lossy()).unwrap_err(); + assert!(refused.contains("original"), "{refused}"); + drop(held); + + assert!(s.pin_write_target(&linked.to_string_lossy()).unwrap_err().contains("original")); + let _ = std::fs::remove_dir_all(&d); + } } From 567e7f11262ee961501280a05f8c4aea6e0e9172 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 17:42:48 +0000 Subject: [PATCH 024/191] test: scan source directories recursively xtask parity reads the UI command tables from menus.rs and every file under menus/, and the desktop app's dialog-filter test checks every source file under src/, so both keep finding their items after files move into subdirectories. --- apps/lightcraft/src/dialog_filter.rs | 18 +++++++++++++---- xtask/src/parity.rs | 30 +++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/lightcraft/src/dialog_filter.rs b/apps/lightcraft/src/dialog_filter.rs index 87760f799..27c3fd704 100644 --- a/apps/lightcraft/src/dialog_filter.rs +++ b/apps/lightcraft/src/dialog_filter.rs @@ -133,13 +133,23 @@ mod tests { } } - /// Every dialog filter goes through the helper: `main.rs` must not call rfd's own + /// Every dialog filter goes through the helper: no source file in `src/` calls rfd's own /// `add_filter`. #[test] fn no_dialog_bypasses_the_helper() { - for (file, src) in [("main.rs", include_str!("main.rs")), ("control_server.rs", include_str!("control_server.rs"))] { - assert!(!src.contains(".add_filter("), "{file} calls rfd's add_filter; use add_filter_nocase"); + let mut dirs = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; + let mut helper_used = false; + while let Some(dir) = dirs.pop() { + for path in std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()) { + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|ext| ext == "rs") && !path.ends_with("dialog_filter.rs") { + let src = std::fs::read_to_string(&path).unwrap(); + assert!(!src.contains(".add_filter("), "{} calls rfd's add_filter; use add_filter_nocase", path.display()); + helper_used |= src.contains(".add_filter_nocase("); + } + } } - assert!(include_str!("main.rs").contains(".add_filter_nocase(")); + assert!(helper_used); } } diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index dcc940b92..538d93973 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -153,6 +153,30 @@ pub fn ui_command_ids(menus_rs: &str) -> Vec { ids } +/// The source of the UI menu module: `menus.rs` and every `.rs` file under `menus/`, so the +/// tables are found in whichever file of the module holds them. +pub fn menu_sources(ui_src: &Path) -> Result { + let mut text = std::fs::read_to_string(ui_src.join("menus.rs")).map_err(|e| format!("menus.rs: {e}"))?; + let mut dirs = vec![ui_src.join("menus")]; + let mut files = Vec::new(); + while let Some(dir) = dirs.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for path in entries.flatten().map(|e| e.path()) { + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } + } + files.sort(); + for path in files { + text.push('\n'); + text.push_str(&std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?); + } + Ok(text) +} + fn known(set: &BTreeSet, id: &str) -> bool { match id.strip_suffix('*') { Some(prefix) => set.iter().any(|s| s.starts_with(prefix)), @@ -314,7 +338,7 @@ pub fn run(root: &Path, write: bool) -> Result<(), String> { let path = root.join(DOC); let md = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; let doc = parse(&md); - let menus = std::fs::read_to_string(root.join("crates/ui-egui/src/menus.rs")).map_err(|e| format!("menus.rs: {e}"))?; + let menus = menu_sources(&root.join("crates/ui-egui/src"))?; let mut commands = registry("commands")?; commands.extend(ui_command_ids(&menus)); let controls = registry("controls")?; @@ -510,8 +534,8 @@ old /// The real menu tables: both are found, so every language command is a known id. #[test] fn reads_the_language_commands_from_menus_rs() { - let menus = include_str!("../../crates/ui-egui/src/menus.rs"); - let ids = ui_command_ids(menus); + let ui_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../crates/ui-egui/src"); + let ids = ui_command_ids(&menu_sources(&ui_src).unwrap()); for id in ["view.detail", "app.language.english", "app.language.japanese", "app.language.simplifiedChinese", "app.language.portuguese"] { assert!(ids.iter().any(|known| known == id), "{id} not found"); } From 86f2ba81eb5fe5fec35606dca597ce116ad77927 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 11:01:51 -0700 Subject: [PATCH 025/191] Originals: refuse a screenshot target when an original cannot be queried same_file_strict returned false when the metadata of either path could not be read, so a hard link to an original in a folder the process cannot enter was accepted as a screenshot target. It now returns the error unless the path does not exist. --- crates/catalog/src/safe_file.rs | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index b0974961f..9b8bf5bcd 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -274,17 +274,22 @@ pub fn same_file(a: &Path, b: &Path) -> bool { compare_files(a, b, false).unwrap_or(false) } -/// [`same_file`] for a check that must not let an unknown identity pass: `Err` when both paths -/// exist with the same length but the file identity of either cannot be read (on Windows, when -/// either file cannot be opened). A path that does not exist is still `Ok(false)`. +/// [`same_file`] for a check that must not let an unknown identity pass: `Err` when the metadata +/// of either path cannot be read for a reason other than the path not existing (for example a +/// folder it cannot enter), or when both paths exist with the same length but the file identity +/// of either cannot be read (on Windows, when either file cannot be opened). A path that does not +/// exist is still `Ok(false)`. pub fn same_file_strict(a: &Path, b: &Path) -> io::Result { compare_files(a, b, true) } fn compare_files(a: &Path, b: &Path, strict: bool) -> io::Result { - #[cfg(not(windows))] - let _ = strict; - let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return Ok(false) }; + let missing = |e: &io::Error| matches!(e.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory); + let (ma, mb) = match (fs::metadata(a), fs::metadata(b)) { + (Ok(ma), Ok(mb)) => (ma, mb), + (Err(e), _) | (_, Err(e)) if strict && !missing(&e) => return Err(e), + _ => return Ok(false), + }; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; @@ -459,4 +464,20 @@ mod tests { assert!(!same_file(&p, &d.join("missing.jpg"))); let _ = fs::remove_dir_all(&d); } + + #[test] + fn strict_comparison_reports_a_path_it_cannot_query() { + let d = temp("strict-query"); + let p = d.join("x.jpg"); + fs::write(&p, b"x").unwrap(); + // A NUL byte makes the metadata lookup fail with an error other than "not found" on + // every platform, as a folder the process cannot enter does. + let unreadable = Path::new("IMG_1\0.jpg"); + assert!(same_file_strict(unreadable, &p).is_err()); + assert!(same_file_strict(&p, unreadable).is_err()); + assert!(!same_file(unreadable, &p), "the lenient comparison still answers false"); + assert!(!same_file_strict(&d.join("missing.jpg"), &p).unwrap(), "a missing original is another file"); + assert!(!same_file_strict(&p.join("under-a-file.jpg"), &p).unwrap()); + let _ = fs::remove_dir_all(&d); + } } From 486cc1db17df8bf74ca0e448e705ff6608d7936f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:07 +0000 Subject: [PATCH 026/191] refactor(lightcraft-ui-egui): move crates/ui-egui/src/panels/mod.rs to crates/ui-egui/src/panels.rs --- crates/ui-egui/src/{panels/mod.rs => panels.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/ui-egui/src/{panels/mod.rs => panels.rs} (100%) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels.rs similarity index 100% rename from crates/ui-egui/src/panels/mod.rs rename to crates/ui-egui/src/panels.rs From 1ebee9735354dcacc4d093a4e03d5d88f263a431 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:07 +0000 Subject: [PATCH 027/191] refactor(lightcraft-engine): move crates/engine/src/cmd/mod.rs to crates/engine/src/cmd.rs --- crates/engine/src/{cmd/mod.rs => cmd.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/engine/src/{cmd/mod.rs => cmd.rs} (100%) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs From 15030abbb509746cd4928c29fc428f6e10ab1564 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:08 +0000 Subject: [PATCH 028/191] refactor(lightcraft-engine): move crates/engine/src/print/mod.rs to crates/engine/src/print.rs --- crates/engine/src/{print/mod.rs => print.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/engine/src/{print/mod.rs => print.rs} (100%) diff --git a/crates/engine/src/print/mod.rs b/crates/engine/src/print.rs similarity index 100% rename from crates/engine/src/print/mod.rs rename to crates/engine/src/print.rs From 728f223ee59c879fcb8546046ad68f9a605e8cfb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:08 +0000 Subject: [PATCH 029/191] refactor(lightcraft-engine): move crates/engine/src/segment/mod.rs to crates/engine/src/segment.rs --- crates/engine/src/{segment/mod.rs => segment.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/engine/src/{segment/mod.rs => segment.rs} (100%) diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment.rs similarity index 100% rename from crates/engine/src/segment/mod.rs rename to crates/engine/src/segment.rs From bb7289ced47e3775d43aa6dee84ec1952c0e75ea Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:08 +0000 Subject: [PATCH 030/191] refactor(lightcraft-segment): move crates/segment/src/fetch/mod.rs to crates/segment/src/fetch.rs --- crates/segment/src/{fetch/mod.rs => fetch.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/segment/src/{fetch/mod.rs => fetch.rs} (100%) diff --git a/crates/segment/src/fetch/mod.rs b/crates/segment/src/fetch.rs similarity index 100% rename from crates/segment/src/fetch/mod.rs rename to crates/segment/src/fetch.rs From 44680949f716e5ceb92710642f038ad9878cd7d2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:08 +0000 Subject: [PATCH 031/191] refactor(lightcraft-merge): move crates/merge/src/pano/mod.rs to crates/merge/src/pano.rs --- crates/merge/src/{pano/mod.rs => pano.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/merge/src/{pano/mod.rs => pano.rs} (100%) diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano.rs similarity index 100% rename from crates/merge/src/pano/mod.rs rename to crates/merge/src/pano.rs From 62514e34b8f508697c20e659e9c92cceedbcb340 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:09 +0000 Subject: [PATCH 032/191] refactor(lightcraft-raw): move crates/raw/src/vendor/mod.rs to crates/raw/src/vendor.rs --- crates/raw/src/{vendor/mod.rs => vendor.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/raw/src/{vendor/mod.rs => vendor.rs} (100%) diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor.rs similarity index 100% rename from crates/raw/src/vendor/mod.rs rename to crates/raw/src/vendor.rs From 5699489a3e43d056694b70c4d226329a7f707e14 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:09 +0000 Subject: [PATCH 033/191] refactor(lightcraft-raw): move crates/raw/src/demosaic/mod.rs to crates/raw/src/demosaic.rs --- crates/raw/src/{demosaic/mod.rs => demosaic.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/raw/src/{demosaic/mod.rs => demosaic.rs} (100%) diff --git a/crates/raw/src/demosaic/mod.rs b/crates/raw/src/demosaic.rs similarity index 100% rename from crates/raw/src/demosaic/mod.rs rename to crates/raw/src/demosaic.rs From 03e3b947dc36409a6b41c6236a76cc0aee26b0aa Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:50 +0000 Subject: [PATCH 034/191] refactor(lightcraft-meta): move xmp.rs tests to crates/meta/src/xmp/tests.rs --- crates/meta/src/xmp.rs | 488 +---------------------------------- crates/meta/src/xmp/tests.rs | 485 ++++++++++++++++++++++++++++++++++ 2 files changed, 486 insertions(+), 487 deletions(-) create mode 100644 crates/meta/src/xmp/tests.rs diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index ac0a8808a..8ca82da01 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -738,490 +738,4 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn full() -> Metadata { - Metadata { - // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the - // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. - regions: Vec::new(), - focus: None, - make: Some("Maker & Sons".into()), - model: Some("X <1>".into()), - serial_number: Some("SN1".into()), - software: Some("LightCraft".into()), - lens_make: Some("L".into()), - lens_model: Some("24-70mm".into()), - lens_serial_number: Some("LS".into()), - lens_spec: Some([24.0, 70.0, 2.8, 2.8]), - capture_time: DateTime::parse_iso("2022-05-06T07:08:09.123-04:00"), - exposure_time: Some(1.0 / 125.0), - f_number: Some(5.6), - iso: Some(800), - focal_length: Some(35.0), - focal_length_35mm: Some(52.0), - flash: Some(Flash { fired: true, raw: 0x59 }), - exposure_bias: Some(-1.0 / 3.0), - exposure_program: Some(2), - metering_mode: Some(3), - white_balance: Some(1), - orientation: Some(Orientation::Rotate270), - gps: Some(Gps { latitude: 48.858222, longitude: -2.2945, altitude: Some(-3.5) }), - width: Some(8000), - height: Some(6000), - artist: Some("Ann \"Photo\" Lee".into()), - copyright: Some("© 2022".into()), - copyright_marked: Some(true), - usage_terms: Some("Editorial use only; no ".into()), - copyright_url: Some("https://example.com/licence?a=1&b=2".into()), - title: Some("Tïtle".into()), - caption: Some("Line one\nline two".into()), - alt_text: Some("A tree by the sea".into()), - extended_description: Some("Long ".into()), - sublocation: Some("Champ de Mars".into()), - city: Some("Paris".into()), - state: Some("Île-de-France".into()), - country: Some("France".into()), - keywords: vec!["tree".into(), "sky & sea".into()], - hierarchical_keywords: vec!["Places|France|Paris".into()], - rating: Some(-1), - label: Some("Green".into()), - } - } - - #[test] - fn roundtrip_all_fields() { - let m = full(); - let json = r#"{"version":1,"exposure":0.5,"note":"<&>"}"#; - let x = write_xmp(&m, Some(json)); - let d = parse_xmp(&x).unwrap(); - assert_eq!(d.lc_settings.as_deref(), Some(json)); - let mut got = d.metadata.clone(); - // floats go through rationals - assert!((got.exposure_bias.unwrap() + 1.0 / 3.0).abs() < 1e-5); - got.exposure_bias = m.exposure_bias; - let g = got.gps.unwrap(); - assert!((g.latitude - 48.858222).abs() < 1e-6 && (g.longitude + 2.2945).abs() < 1e-6, "{g:?}"); - got.gps = m.gps; - assert_eq!(got, m); - } - - #[test] - fn empty_metadata_roundtrip() { - let x = write_xmp(&Metadata::default(), None); - let d = parse_xmp(&x).unwrap(); - assert_eq!(d.metadata, Metadata::default()); - assert_eq!(d.lc_settings, None); - } - - #[test] - fn foreign_namespace_cannot_impersonate_known_metadata() { - for body in [ - r#"xmp:Rating="2" lc:settings="foreign settings"/>"#, - r#">2foreign settings"#, - ] { - let packet = format!( - r#""# - ); - let d = parse_xmp(&packet).unwrap(); - assert_eq!(d.metadata.rating, None, "foreign rating: {packet}"); - assert_eq!(d.lc_settings, None, "foreign settings: {packet}"); - assert!(!d.properties.contains_key("xmp:Rating")); - assert!(!d.values.contains_key("lc:settings")); - assert!(d.properties.values().any(|v| v == &["foreign settings"])); - assert!(d.properties.keys().all(|key| !key.contains('/')), "opaque names must not create struct paths"); - } - } - - #[test] - fn foreign_namespace_names_are_distinct_and_scoped() { - let packet = r#" - - 1 - 2 - 4 - "#; - let d = parse_xmp(packet).unwrap(); - assert_eq!(d.metadata.rating, Some(4), "a local rebinding must not escape its scope"); - assert_eq!(d.properties.len(), 3, "different foreign URIs must not collide"); - assert_eq!(d.values.len(), 3); - assert_eq!(d.properties["xmp:Rating"], vec!["4"]); - assert!(d.properties.values().any(|v| v == &["1"])); - assert!(d.properties.values().any(|v| v == &["2"])); - } - - #[test] - fn attribute_form_foreign_prefixes_and_structs() { - let x = r#" - - TitelTitle & more é - - k1k2 - "#; - let d = parse_xmp(x).unwrap(); - let m = d.metadata; - assert_eq!(m.rating, Some(4)); - assert_eq!(m.label.as_deref(), Some("Blue")); - assert_eq!(m.f_number, Some(4.0)); - assert_eq!(m.title.as_deref(), Some("Title & more é")); - assert_eq!(m.keywords, vec!["k1".to_string(), "k2".to_string()]); - assert_eq!(m.flash, Some(Flash { fired: false, raw: 0x10 })); - let g = m.gps.unwrap(); - assert!((g.latitude - 10.5).abs() < 1e-9); - assert!((g.longitude + 20.26).abs() < 1e-9); - assert_eq!(d.properties["dc:title"], vec!["Title & more é".to_string(), "Titel".to_string()]); - } - - /// Copyright status, usage terms and info URL (XMP Rights Management schema): written as - /// `xmpRights:Marked` (Boolean), `xmpRights:UsageTerms` (Lang Alt) and `xmpRights:WebStatement` (URI). - #[test] - fn rights_management_fields() { - let m = Metadata { copyright_marked: Some(false), usage_terms: Some("CC0".into()), ..Default::default() }; - let x = write_xmp(&m, None); - assert!(x.contains("False"), "{x}"); - assert!(x.contains("\n \n CC0"), "{x}"); - assert!(!x.contains("WebStatement"), "unset fields are left out"); - assert_eq!(parse_xmp(&x).unwrap().metadata, m); - // attribute form, other prefixes; anything but True / False is "unknown" - let read = |marked: &str| { - let x = format!( - r#" - - "# - ); - parse_xmp(&x).unwrap().metadata - }; - assert_eq!(read("True").copyright_marked, Some(true)); - assert_eq!(read("false").copyright_marked, Some(false)); - assert_eq!(read("").copyright_marked, None); - assert_eq!(read("maybe").copyright_marked, None); - assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); - } - - /// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and - /// must not hide a valid `exifEX:PhotographicSensitivity`. - #[test] - fn non_positive_iso_is_ignored() { - let read = |attrs: &str| { - let x = format!( - r#" - - "# - ); - parse_xmp(&x).unwrap().metadata.iso - }; - assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400)); - assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None); - assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None); - assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); - } - - #[test] - fn malformed_inputs() { - assert!(parse_xmp("").is_ok() || parse_xmp("").is_err()); - assert_eq!(parse_xmp("").unwrap().metadata, Metadata::default()); - assert_eq!(parse_xmp("not xml at all").unwrap().metadata, Metadata::default()); - let deep = "".repeat(1000); - assert_eq!(parse_xmp(&deep), Err(XmpError::TooComplex)); - let x = write_xmp(&full(), Some("{}")); - for n in (0..x.len()).step_by(7) { - if x.is_char_boundary(n) { - let _ = parse_xmp(&x[..n]); - } - } - } - - #[test] - fn lc_extras_and_crs_prefix() { - let x = write_xmp_lc(&Metadata::default(), &[("settings", "{\"a\":1}"), ("flag", "pick"), ("bad name", "x")]); - assert!(!x.contains("xmlns:crs"), "we never write crs:"); - let d = parse_xmp(&x).unwrap(); - assert_eq!(d.lc_settings.as_deref(), Some("{\"a\":1}")); - assert_eq!(d.properties["lc:flag"], vec!["pick".to_string()]); - assert!(!x.contains("bad name")); - let foreign = r#""#; - assert_eq!(parse_xmp(foreign).unwrap().properties["crs:Exposure2012"], vec!["+0.5".to_string()]); - } - - #[test] - fn gps_coords() { - assert_eq!(parse_gps_coord("45,30N"), Some(45.5)); - assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51)); - assert_eq!(parse_gps_coord("45X"), None); - assert_eq!(parse_gps_coord(""), None); - assert_eq!(parse_gps_coord("é"), None); - assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); - assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N"); - assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W"); - } - - /// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. - #[test] - fn xmp_gps_out_of_range_rejected() { - let gps = |lat: &str, lon: &str| { - let x = format!( - r#""# - ); - parse_xmp(&x).unwrap().metadata.gps - }; - assert_eq!(gps("95,0N", "200,30E"), None); - assert_eq!(gps("95,0N", "10,0E"), None); - assert_eq!(gps("10,0S", "200,30W"), None); - assert_eq!(gps("90,0,1N", "0,0E"), None); - assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); - assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); - } - - /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how - /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one - /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas - /// (the default, and the only unit real files use) don't need it. - #[test] - fn mwg_regions_element_form() { - let x = r#" - - - - - - - - Jane Doe - Face - - - - - - - Pet - Rex - Good boy - - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 3, "{regions:?}"); - - assert_eq!(regions[0].kind, RegionKind::Face); - assert_eq!(regions[0].name.as_deref(), Some("Jane Doe")); - assert_eq!(regions[0].description, None); - let r = regions[0].rect; - assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}"); - assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}"); - - // No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`. - assert_eq!(regions[1].kind, RegionKind::Other(String::new())); - assert_eq!(regions[1].name, None); - - assert_eq!(regions[2].kind, RegionKind::Pet); - assert_eq!(regions[2].name.as_deref(), Some("Rex")); - assert_eq!(regions[2].description.as_deref(), Some("Good boy")); - } - - /// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame - /// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the - /// upright frame. - #[test] - fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() { - let x = r#" - - - - - - - - - - - - - - - - - - - - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 5, "{regions:?}"); - let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9); - assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect); - assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect); - assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect); - assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect); - assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect); - } - - /// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an - /// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an - /// unrecognised `mwg-rs:Type` value kept verbatim. - #[test] - fn mwg_regions_attribute_form_and_unknown_type() { - let x = r#" - - - - - - - - - - - - - - - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 2, "{regions:?}"); - assert_eq!(regions[0].kind, RegionKind::BarCode); - assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string())); - } - - /// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of - /// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area - /// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction). - #[test] - fn mwg_regions_hostile_inputs_are_dropped_not_panicking() { - let x = r#" - - - - - No Area - - - - - - Face - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}"); - assert_eq!(regions[0].kind, RegionKind::Face); - } - - /// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a - /// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view - /// would then draw or crop far outside the image. - #[test] - fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() { - let x = r#" - - - - - Far right - Far left - Touching edge - Huge - Corner - - - - "#; - let regions = parse_xmp(x).unwrap().metadata.regions; - let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect(); - assert_eq!(names, ["Huge", "Corner"], "{regions:?}"); - for r in ®ions { - let q = r.rect; - assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}"); - } - assert_eq!(regions[0].rect, Rect::UNIT); - let c = regions[1].rect; - assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}"); - } - - /// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). - #[test] - fn regions_merge_like_keywords() { - let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None }; - let mut a = Metadata::default(); - let b = Metadata { regions: vec![with_region.clone()], ..Default::default() }; - a.fill_missing(&b); - assert_eq!(a.regions, vec![with_region.clone()]); - - let mut a = Metadata::default(); - a.overlay_user_fields(&b); - assert_eq!(a.regions, vec![with_region]); - } - - #[test] - fn writer_drops_characters_xml_1_0_forbids() { - let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh"; - let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() }; - let x = write_xmp_lc(&m, &[("settings", bad)]); - let illegal: Vec = - x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); - assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); - assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); - } - - #[test] - fn numeric_references_to_forbidden_characters_are_not_decoded() { - assert_eq!(entity("#1"), None); - assert_eq!(entity("#0"), None); - assert_eq!(entity("#xFFFE"), None); - assert_eq!(entity("#x41"), Some('A')); - assert_eq!(entity("#9"), Some('\t')); - } - - /// Flattened keys repeat their whole ancestor path, so a long nested name with a wide child - /// fan-out would otherwise retain its prefix once per leaf: the cumulative key budget stops it - /// (a 65 KB document with 3000 leaves must not retain ~2.4 MB of key text). - #[test] - fn flattened_keys_stay_within_the_cumulative_budget() { - let level = "n".repeat(200); - let mut doc = String::from( - "", - ); - for _ in 0..4 { - doc.push_str(&format!("")); - } - // each leaf has a name of its own, so each one adds a key - for i in 0..3000 { - doc.push_str(&format!("")); - } - doc.push_str(""); - for _ in 0..4 { - doc.push_str(&format!("")); - } - doc.push_str(""); - let d = parse_xmp(&doc).unwrap(); - let total: usize = d.properties.keys().map(String::len).sum(); - assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget"); - assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent"); - assert!(!d.properties.is_empty(), "properties parsed before the budget are kept"); - } -} +mod tests; diff --git a/crates/meta/src/xmp/tests.rs b/crates/meta/src/xmp/tests.rs new file mode 100644 index 000000000..3939d2753 --- /dev/null +++ b/crates/meta/src/xmp/tests.rs @@ -0,0 +1,485 @@ +use super::*; + +fn full() -> Metadata { + Metadata { + // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the + // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. + regions: Vec::new(), + focus: None, + make: Some("Maker & Sons".into()), + model: Some("X <1>".into()), + serial_number: Some("SN1".into()), + software: Some("LightCraft".into()), + lens_make: Some("L".into()), + lens_model: Some("24-70mm".into()), + lens_serial_number: Some("LS".into()), + lens_spec: Some([24.0, 70.0, 2.8, 2.8]), + capture_time: DateTime::parse_iso("2022-05-06T07:08:09.123-04:00"), + exposure_time: Some(1.0 / 125.0), + f_number: Some(5.6), + iso: Some(800), + focal_length: Some(35.0), + focal_length_35mm: Some(52.0), + flash: Some(Flash { fired: true, raw: 0x59 }), + exposure_bias: Some(-1.0 / 3.0), + exposure_program: Some(2), + metering_mode: Some(3), + white_balance: Some(1), + orientation: Some(Orientation::Rotate270), + gps: Some(Gps { latitude: 48.858222, longitude: -2.2945, altitude: Some(-3.5) }), + width: Some(8000), + height: Some(6000), + artist: Some("Ann \"Photo\" Lee".into()), + copyright: Some("© 2022".into()), + copyright_marked: Some(true), + usage_terms: Some("Editorial use only; no ".into()), + copyright_url: Some("https://example.com/licence?a=1&b=2".into()), + title: Some("Tïtle".into()), + caption: Some("Line one\nline two".into()), + alt_text: Some("A tree by the sea".into()), + extended_description: Some("Long ".into()), + sublocation: Some("Champ de Mars".into()), + city: Some("Paris".into()), + state: Some("Île-de-France".into()), + country: Some("France".into()), + keywords: vec!["tree".into(), "sky & sea".into()], + hierarchical_keywords: vec!["Places|France|Paris".into()], + rating: Some(-1), + label: Some("Green".into()), + } +} + +#[test] +fn roundtrip_all_fields() { + let m = full(); + let json = r#"{"version":1,"exposure":0.5,"note":"<&>"}"#; + let x = write_xmp(&m, Some(json)); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.lc_settings.as_deref(), Some(json)); + let mut got = d.metadata.clone(); + // floats go through rationals + assert!((got.exposure_bias.unwrap() + 1.0 / 3.0).abs() < 1e-5); + got.exposure_bias = m.exposure_bias; + let g = got.gps.unwrap(); + assert!((g.latitude - 48.858222).abs() < 1e-6 && (g.longitude + 2.2945).abs() < 1e-6, "{g:?}"); + got.gps = m.gps; + assert_eq!(got, m); +} + +#[test] +fn empty_metadata_roundtrip() { + let x = write_xmp(&Metadata::default(), None); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.metadata, Metadata::default()); + assert_eq!(d.lc_settings, None); +} + +#[test] +fn foreign_namespace_cannot_impersonate_known_metadata() { + for body in [ + r#"xmp:Rating="2" lc:settings="foreign settings"/>"#, + r#">2foreign settings"#, + ] { + let packet = format!( + r#""# + ); + let d = parse_xmp(&packet).unwrap(); + assert_eq!(d.metadata.rating, None, "foreign rating: {packet}"); + assert_eq!(d.lc_settings, None, "foreign settings: {packet}"); + assert!(!d.properties.contains_key("xmp:Rating")); + assert!(!d.values.contains_key("lc:settings")); + assert!(d.properties.values().any(|v| v == &["foreign settings"])); + assert!(d.properties.keys().all(|key| !key.contains('/')), "opaque names must not create struct paths"); + } +} + +#[test] +fn foreign_namespace_names_are_distinct_and_scoped() { + let packet = r#" + + 1 + 2 + 4 + "#; + let d = parse_xmp(packet).unwrap(); + assert_eq!(d.metadata.rating, Some(4), "a local rebinding must not escape its scope"); + assert_eq!(d.properties.len(), 3, "different foreign URIs must not collide"); + assert_eq!(d.values.len(), 3); + assert_eq!(d.properties["xmp:Rating"], vec!["4"]); + assert!(d.properties.values().any(|v| v == &["1"])); + assert!(d.properties.values().any(|v| v == &["2"])); +} + +#[test] +fn attribute_form_foreign_prefixes_and_structs() { + let x = r#" + + TitelTitle & more é + + k1k2 + "#; + let d = parse_xmp(x).unwrap(); + let m = d.metadata; + assert_eq!(m.rating, Some(4)); + assert_eq!(m.label.as_deref(), Some("Blue")); + assert_eq!(m.f_number, Some(4.0)); + assert_eq!(m.title.as_deref(), Some("Title & more é")); + assert_eq!(m.keywords, vec!["k1".to_string(), "k2".to_string()]); + assert_eq!(m.flash, Some(Flash { fired: false, raw: 0x10 })); + let g = m.gps.unwrap(); + assert!((g.latitude - 10.5).abs() < 1e-9); + assert!((g.longitude + 20.26).abs() < 1e-9); + assert_eq!(d.properties["dc:title"], vec!["Title & more é".to_string(), "Titel".to_string()]); +} + +/// Copyright status, usage terms and info URL (XMP Rights Management schema): written as +/// `xmpRights:Marked` (Boolean), `xmpRights:UsageTerms` (Lang Alt) and `xmpRights:WebStatement` (URI). +#[test] +fn rights_management_fields() { + let m = Metadata { copyright_marked: Some(false), usage_terms: Some("CC0".into()), ..Default::default() }; + let x = write_xmp(&m, None); + assert!(x.contains("False"), "{x}"); + assert!(x.contains("\n \n CC0"), "{x}"); + assert!(!x.contains("WebStatement"), "unset fields are left out"); + assert_eq!(parse_xmp(&x).unwrap().metadata, m); + // attribute form, other prefixes; anything but True / False is "unknown" + let read = |marked: &str| { + let x = format!( + r#" + + "# + ); + parse_xmp(&x).unwrap().metadata + }; + assert_eq!(read("True").copyright_marked, Some(true)); + assert_eq!(read("false").copyright_marked, Some(false)); + assert_eq!(read("").copyright_marked, None); + assert_eq!(read("maybe").copyright_marked, None); + assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); +} + +/// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and +/// must not hide a valid `exifEX:PhotographicSensitivity`. +#[test] +fn non_positive_iso_is_ignored() { + let read = |attrs: &str| { + let x = format!( + r#" + + "# + ); + parse_xmp(&x).unwrap().metadata.iso + }; + assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400)); + assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); +} + +#[test] +fn malformed_inputs() { + assert!(parse_xmp("").is_ok() || parse_xmp("").is_err()); + assert_eq!(parse_xmp("").unwrap().metadata, Metadata::default()); + assert_eq!(parse_xmp("not xml at all").unwrap().metadata, Metadata::default()); + let deep = "".repeat(1000); + assert_eq!(parse_xmp(&deep), Err(XmpError::TooComplex)); + let x = write_xmp(&full(), Some("{}")); + for n in (0..x.len()).step_by(7) { + if x.is_char_boundary(n) { + let _ = parse_xmp(&x[..n]); + } + } +} + +#[test] +fn lc_extras_and_crs_prefix() { + let x = write_xmp_lc(&Metadata::default(), &[("settings", "{\"a\":1}"), ("flag", "pick"), ("bad name", "x")]); + assert!(!x.contains("xmlns:crs"), "we never write crs:"); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.lc_settings.as_deref(), Some("{\"a\":1}")); + assert_eq!(d.properties["lc:flag"], vec!["pick".to_string()]); + assert!(!x.contains("bad name")); + let foreign = r#""#; + assert_eq!(parse_xmp(foreign).unwrap().properties["crs:Exposure2012"], vec!["+0.5".to_string()]); +} + +#[test] +fn gps_coords() { + assert_eq!(parse_gps_coord("45,30N"), Some(45.5)); + assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51)); + assert_eq!(parse_gps_coord("45X"), None); + assert_eq!(parse_gps_coord(""), None); + assert_eq!(parse_gps_coord("é"), None); + assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); + assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N"); + assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W"); +} + +/// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. +#[test] +fn xmp_gps_out_of_range_rejected() { + let gps = |lat: &str, lon: &str| { + let x = format!( + r#""# + ); + parse_xmp(&x).unwrap().metadata.gps + }; + assert_eq!(gps("95,0N", "200,30E"), None); + assert_eq!(gps("95,0N", "10,0E"), None); + assert_eq!(gps("10,0S", "200,30W"), None); + assert_eq!(gps("90,0,1N", "0,0E"), None); + assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); + assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); +} + +/// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how +/// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one +/// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas +/// (the default, and the only unit real files use) don't need it. +#[test] +fn mwg_regions_element_form() { + let x = r#" + + + + + + + + Jane Doe + Face + + + + + + + Pet + Rex + Good boy + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 3, "{regions:?}"); + + assert_eq!(regions[0].kind, RegionKind::Face); + assert_eq!(regions[0].name.as_deref(), Some("Jane Doe")); + assert_eq!(regions[0].description, None); + let r = regions[0].rect; + assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}"); + assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}"); + + // No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`. + assert_eq!(regions[1].kind, RegionKind::Other(String::new())); + assert_eq!(regions[1].name, None); + + assert_eq!(regions[2].kind, RegionKind::Pet); + assert_eq!(regions[2].name.as_deref(), Some("Rex")); + assert_eq!(regions[2].description.as_deref(), Some("Good boy")); +} + +/// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame +/// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the +/// upright frame. +#[test] +fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() { + let x = r#" + + + + + + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 5, "{regions:?}"); + let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9); + assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect); + assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect); + assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect); + assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect); + assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect); +} + +/// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an +/// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an +/// unrecognised `mwg-rs:Type` value kept verbatim. +#[test] +fn mwg_regions_attribute_form_and_unknown_type() { + let x = r#" + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 2, "{regions:?}"); + assert_eq!(regions[0].kind, RegionKind::BarCode); + assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string())); +} + +/// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of +/// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area +/// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction). +#[test] +fn mwg_regions_hostile_inputs_are_dropped_not_panicking() { + let x = r#" + + + + + No Area + + + + + + Face + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}"); + assert_eq!(regions[0].kind, RegionKind::Face); +} + +/// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a +/// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view +/// would then draw or crop far outside the image. +#[test] +fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() { + let x = r#" + + + + + Far right + Far left + Touching edge + Huge + Corner + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect(); + assert_eq!(names, ["Huge", "Corner"], "{regions:?}"); + for r in ®ions { + let q = r.rect; + assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}"); + } + assert_eq!(regions[0].rect, Rect::UNIT); + let c = regions[1].rect; + assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}"); +} + +/// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). +#[test] +fn regions_merge_like_keywords() { + let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None }; + let mut a = Metadata::default(); + let b = Metadata { regions: vec![with_region.clone()], ..Default::default() }; + a.fill_missing(&b); + assert_eq!(a.regions, vec![with_region.clone()]); + + let mut a = Metadata::default(); + a.overlay_user_fields(&b); + assert_eq!(a.regions, vec![with_region]); +} + +#[test] +fn writer_drops_characters_xml_1_0_forbids() { + let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh"; + let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() }; + let x = write_xmp_lc(&m, &[("settings", bad)]); + let illegal: Vec = + x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); + assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); + assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); +} + +#[test] +fn numeric_references_to_forbidden_characters_are_not_decoded() { + assert_eq!(entity("#1"), None); + assert_eq!(entity("#0"), None); + assert_eq!(entity("#xFFFE"), None); + assert_eq!(entity("#x41"), Some('A')); + assert_eq!(entity("#9"), Some('\t')); +} + +/// Flattened keys repeat their whole ancestor path, so a long nested name with a wide child +/// fan-out would otherwise retain its prefix once per leaf: the cumulative key budget stops it +/// (a 65 KB document with 3000 leaves must not retain ~2.4 MB of key text). +#[test] +fn flattened_keys_stay_within_the_cumulative_budget() { + let level = "n".repeat(200); + let mut doc = String::from( + "", + ); + for _ in 0..4 { + doc.push_str(&format!("")); + } + // each leaf has a name of its own, so each one adds a key + for i in 0..3000 { + doc.push_str(&format!("")); + } + doc.push_str(""); + for _ in 0..4 { + doc.push_str(&format!("")); + } + doc.push_str(""); + let d = parse_xmp(&doc).unwrap(); + let total: usize = d.properties.keys().map(String::len).sum(); + assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget"); + assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent"); + assert!(!d.properties.is_empty(), "properties parsed before the budget are kept"); +} From 55c9eac7c79b1112d92385effb111ee9626e2953 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:50 +0000 Subject: [PATCH 035/191] refactor(lightcraft-meta): move focus.rs tests to crates/meta/src/focus/tests.rs --- crates/meta/src/focus.rs | 467 +-------------------------------- crates/meta/src/focus/tests.rs | 464 ++++++++++++++++++++++++++++++++ 2 files changed, 465 insertions(+), 466 deletions(-) create mode 100644 crates/meta/src/focus/tests.rs diff --git a/crates/meta/src/focus.rs b/crates/meta/src/focus.rs index e1c84f296..510782998 100644 --- a/crates/meta/src/focus.rs +++ b/crates/meta/src/focus.rs @@ -281,469 +281,4 @@ fn area_used_name(v: u8) -> Option<&'static str> { } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value}; - - fn encipher(p: u8) -> u8 { - if p >= 249 { p } else { ((u32::from(p).pow(3)) % 249) as u8 } - } - - fn entry(tag: u16, value: Value) -> Entry { - Entry { tag, value, offset: 0 } - } - - /// The α1 sample `_DSC6954.ARW`: AF-C, Wide, Face tracking, Animal Eye Tracking, 8640×5760 at (3226, 1980), 175×180. - fn alpha1() -> Ifd { - let mut block = vec![0u8; 0x40]; - block[0x16] = encipher(0x83); - block[0x17] = encipher(20); - Ifd { - entries: vec![ - entry(FOCUS_MODE, Value::Byte(vec![3])), - entry(AF_AREA_SETTING, Value::Byte(vec![0])), - entry(AF_TRACKING, Value::Byte(vec![1])), - entry(FOCUS_LOCATION, Value::Short(vec![8640, 5760, 3226, 1980])), - entry(FOCUS_FRAME_SIZE, Value::Undefined(vec![0xaf, 0, 0xb4, 0, 1, 1])), - entry(TAG_9402, Value::Undefined(block)), - ], - ..Default::default() - } - } - - fn set(ifd: &mut Ifd, tag: u16, value: Value) { - ifd.entries.retain(|e| e.tag != tag); - ifd.entries.push(entry(tag, value)); - // IFD entries are kept sorted by tag (lookups rely on it) - ifd.entries.sort_by_key(|e| e.tag); - } - - #[test] - fn the_cipher_matches_the_bytes_seen_in_files() { - // stored → plain, as read from an α1 file's 0x9402 block next to ExifTool's decoded values - assert_eq!(decipher(0x20), 20); - assert_eq!(decipher(0x77), 0x83); - assert_eq!(decipher(0xff), 0xff); - for p in 0..=255u8 { - assert_eq!(decipher(encipher(p)), p); - } - } - - #[test] - fn reads_the_alpha1_sample() { - let f = sony(&alpha1(), ByteOrder::Little, Orientation::Normal).unwrap(); - assert!((f.rect.x0 - (3226.0 - 87.5) / 8640.0).abs() < 1e-12); - assert!((f.rect.y1 - (1980.0 + 90.0) / 5760.0).abs() < 1e-12); - assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); - let (cx, cy) = f.center(); - assert!((cx - 3226.0 / 8640.0).abs() < 1e-12 && (cy - 1980.0 / 5760.0).abs() < 1e-12); - } - - #[test] - fn big_endian_frame_size() { - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0, 0xaf, 0, 0xb4, 1, 1])); - let f = sony(&ifd, ByteOrder::Big, Orientation::Normal).unwrap(); - assert!(((f.rect.x1 - f.rect.x0) * 8640.0 - 175.0).abs() < 1e-9); - } - - #[test] - fn follows_the_exif_orientation() { - // a portrait shot (Rotate 270 CW, EXIF 8) still records landscape sensor coordinates - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 864, 576])); - let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(8)).unwrap(); - let (cx, cy) = f.center(); - // left of the sensor (x 0.1) is the bottom of the upright photo; its top (y 0.1) the left - assert!((cx - 0.1).abs() < 1e-9 && (cy - 0.9).abs() < 1e-9, "{cx} {cy}"); - let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(6)).unwrap(); - let (cx, cy) = f.center(); - assert!((cx - 0.9).abs() < 1e-9 && (cy - 0.1).abs() < 1e-9, "{cx} {cy}"); - } - - #[test] - fn no_real_point() { - // manual focus - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_MODE, Value::Byte(vec![0])); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); - // the block says Manual (its focus mode agreeing) - let mut ifd = alpha1(); - let mut block = vec![0u8; 0x18]; - block[0x16] = encipher(3); - block[0x17] = encipher(255); - set(&mut ifd, TAG_9402, Value::Undefined(block)); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); - // Multi at the exact centre - let mut ifd = alpha1(); - let mut block = vec![0u8; 0x18]; - block[0x16] = encipher(3); - block[0x17] = encipher(0); - set(&mut ifd, TAG_9402, Value::Undefined(block)); - set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); - // …and without the block: Wide, tracking off - ifd.entries.retain(|e| e.tag != TAG_9402); - set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); - // missing location, zero frame, centre outside it - let mut ifd = alpha1(); - ifd.entries.retain(|e| e.tag != FOCUS_LOCATION); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); - for loc in [vec![0, 5760, 0, 0], vec![8640, 0, 1, 0], vec![8640, 5760, 9000, 10], vec![8640, 5760, 10, 6000], vec![8640, 5760, 10]] { - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_LOCATION, Value::Short(loc.clone())); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none(), "{loc:?}"); - } - } - - #[test] - fn a_centred_spot_is_a_real_point() { - let mut ifd = alpha1(); - set(&mut ifd, AF_AREA_SETTING, Value::Byte(vec![3])); - set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); - let mut block = vec![0u8; 0x18]; - block[0x16] = encipher(2); - block[0x17] = encipher(3); - set(&mut ifd, TAG_9402, Value::Undefined(block)); - set(&mut ifd, FOCUS_MODE, Value::Byte(vec![2])); - set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); - let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); - assert_eq!(f.words(), vec!["AF-S", "Flexible Spot"]); - let (cx, cy) = f.center(); - assert!((cx - 0.5).abs() < 1e-12 && (cy - 0.5).abs() < 1e-12); - } - - #[test] - fn a_block_that_disagrees_is_ignored() { - // another body's block layout: its "focus mode" is not the plain tag's, so its area isn't trusted - let mut ifd = alpha1(); - let mut block = vec![0u8; 0x18]; - block[0x16] = encipher(7); - block[0x17] = encipher(255); - set(&mut ifd, TAG_9402, Value::Undefined(block)); - let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); - assert_eq!(f.words(), vec!["AF-C", "Wide", "Face tracking"]); - // a block too short to hold the bytes is ignored the same way - set(&mut ifd, TAG_9402, Value::Undefined(vec![1, 2, 3])); - assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_some()); - } - - #[test] - fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() { - for fs in [ - Value::Undefined(vec![0, 0, 0, 0, 0, 0]), - Value::Undefined(vec![1]), - Value::Short(vec![5]), - Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0]), - ] { - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_FRAME_SIZE, fs); - let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); - assert!(((f.rect.y1 - f.rect.y0) - DEFAULT_SIDE).abs() < 1e-9); - } - // a frame reaching past the corner is clipped to the photo - let mut ifd = alpha1(); - set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 0, 5760])); - set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0xff, 0xff, 0xff, 0xff, 1, 1])); - let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); - assert_eq!(f.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }); - let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); - assert!(f.rect.x0 >= 0.0 && f.rect.y1 <= 1.0); - } - - #[test] - fn wrong_types_and_extreme_values_never_panic() { - let values = [ - Value::Ascii("x".into()), - Value::Byte(vec![]), - Value::SShort(vec![-1, -1, -1, -1]), - Value::Long(vec![u32::MAX; 4]), - Value::Double(vec![f64::NAN; 4]), - Value::Undefined(vec![0xff; 300]), - ]; - for v in &values { - for tag in [FOCUS_MODE, AF_AREA_SETTING, AF_TRACKING, FOCUS_LOCATION, FOCUS_FRAME_SIZE, TAG_9402] { - let mut ifd = alpha1(); - set(&mut ifd, tag, v.clone()); - if let Some(f) = sony(&ifd, ByteOrder::Little, Orientation::Normal) { - assert!(f.rect.x0 >= 0.0 && f.rect.x1 <= 1.0 && f.rect.x0 < f.rect.x1); - } - } - } - assert!(FocusPoint { rect: Rect { x0: f64::NAN, y0: 0.0, x1: 1.0, y1: 1.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); - assert!(FocusPoint { rect: Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); - } - - /// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. - fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec { - sony_file_with(make, model, orientation, note_entries, None) - } - - /// [`sony_file`], with the `0x9402` block's value placed `far` bytes past the end of the TIFF - /// (the gap filled with "sample data") when `far` is given. - fn sony_file_with(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)], far: Option) -> Vec { - let order = ByteOrder::Little; - // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, - // which we only know after writing; write once to learn where the note lands, then rebuild - let build = |note_at: u64, end: u64| -> (Vec, Vec) { - let mut note = b"SONY DSC \0\0\0".to_vec(); - let ifd_len = 2 + note_entries.len() * 12 + 4; - let mut tail: Vec = Vec::new(); - let mut outside: Vec = Vec::new(); - order.put_u16(&mut note, note_entries.len() as u16); - for (tag, v) in note_entries { - let raw = lightcraft_tiff::writer::encode(order, v); - order.put_u16(&mut note, *tag); - order.put_u16(&mut note, v.field_type() as u16); - order.put_u32(&mut note, v.count() as u32); - if raw.len() <= 4 { - let mut r = raw.clone(); - r.resize(4, 0); - note.extend(r); - } else if let (Some(gap), TAG_9402) = (far, *tag) { - order.put_u32(&mut note, (end + gap as u64) as u32); - outside = raw; - } else { - order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); - tail.extend(raw); - if tail.len() % 2 == 1 { - tail.push(0); - } - } - } - order.put_u32(&mut note, 0); - note.extend(tail); - let mut exif = IfdBuilder::new(); - exif.set(t::MAKER_NOTE, Value::Undefined(note)); - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::MAKE, Value::Ascii(make.into())); - ifd0.set(t::MODEL, Value::Ascii(model.into())); - ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); - ifd0.set_child(t::EXIF_IFD, exif); - (TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(), outside) - }; - let (first, _) = build(0, 0); - let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; - let (mut file, outside) = build(at, first.len() as u64); - assert_eq!(file.len(), first.len()); - assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); - if let Some(gap) = far { - file.extend((0..gap).map(|i| (i * 13 % 241) as u8)); - file.extend(outside); - } - file - } - - fn alpha1_entries() -> Vec<(u16, Value)> { - alpha1().entries.into_iter().map(|e| (e.tag, e.value)).collect() - } - - #[test] - fn read_exif_finds_it_through_the_maker_note_and_orientation() { - let file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); - let m = crate::read_exif(&file); - let f = m.focus.expect("focus point"); - assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); - // EXIF 8: sensor x becomes 1 - upright y - let (cx, cy) = f.center(); - assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); - // an unconfirmed body or another maker: nothing - assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); - assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); - } - - #[test] - fn truncated_and_garbage_maker_notes_never_panic() { - let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); - let note_at = Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; - for cut in (0..file.len()).step_by(7) { - let _ = crate::read_exif(&file[..cut]); - } - let mut seed = 0x2545_f491_u32; - for _ in 0..300 { - let mut f = file.clone(); - for _ in 0..8 { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - let i = note_at + (seed as usize % (file.len() - note_at)); - f[i] = (seed >> 8) as u8; - } - if let Some(fp) = crate::read_exif(&f).focus { - assert!(fp.rect.x0 >= 0.0 && fp.rect.x1 <= 1.0 && fp.rect.y0 >= 0.0 && fp.rect.y1 <= 1.0); - } - } - } - - /// A whole-file read and a header-only read of the same file agree, and the header read reads a - /// small part of it (the sample data after the metadata is never touched). - #[test] - fn header_read_matches_the_whole_file_and_reads_little() { - let read = |file: &[u8]| { - let mut asked = 0u64; - let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { - asked += buf.len() as u64; - let s = off as usize; - buf.copy_from_slice(&file[s..s + buf.len()]); - Ok(()) - }) - .unwrap(); - assert_eq!(r.bytes_read, asked); - r - }; - // a raw: the metadata, then 8 MB of "samples" - let mut file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); - let meta_len = file.len(); - file.extend((0..8_000_000u32).map(|i| (i * 31 % 251) as u8)); - let whole = crate::extract(&file).focus; - assert!(whole.is_some()); - let r = read(&file); - assert_eq!(r.focus, whole); - assert!(r.bytes_read <= HEADER_PREFIX && r.bytes_read < file.len() as u64 / 100, "{} of {}", r.bytes_read, file.len()); - assert!(meta_len < HEADER_PREFIX as usize); - // the area-mode block placed 3 MB further on: found in a further round, still a small part - let mut far = sony_file_with("SONY", "ILCE-1", 1, &alpha1_entries(), Some(3_000_000)); - far.extend((0..4_000_000u32).map(|i| (i * 7 % 253) as u8)); - let whole = crate::extract(&far).focus; - assert!(whole.is_some()); - let r = read(&far); - assert_eq!(r.focus, whole); - assert!(r.bytes_read < 64 * 1024, "{}", r.bytes_read); - // a JPEG: the Exif segment after another segment - let exif = sony_file("SONY", "ILCE-1", 6, &alpha1_entries()); - let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16]; - jpeg.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"); - jpeg.extend_from_slice(&[0xff, 0xe1]); - jpeg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); - jpeg.extend_from_slice(b"Exif\0\0"); - jpeg.extend_from_slice(&exif); - jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); - jpeg.extend(std::iter::repeat_n(0x55u8, 3_000_000)); - let whole = crate::extract(&jpeg).focus; - assert!(whole.is_some()); - let r = read(&jpeg); - assert_eq!(r.focus, whole); - assert!(r.bytes_read <= HEADER_PREFIX); - // unsupported bodies agree too (no point either way) - let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); - assert_eq!(read(&other).focus, None); - } - - /// Every truncation and corruption of a file gives the header read the whole-file read's answer - /// (or an error when the file is shorter than it said), never a panic. - #[test] - fn header_read_of_damaged_files_agrees_or_fails_cleanly() { - let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); - for cut in (0..file.len()).step_by(5) { - let part = &file[..cut]; - let r = read_header(part.len() as u64, &mut |off, buf: &mut [u8]| { - let s = off as usize; - buf.copy_from_slice(part.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); - Ok(()) - }) - .unwrap(); - assert_eq!(r.focus, crate::extract(part).focus, "cut at {cut}"); - } - let mut seed = 0x9e37_79b9_u32; - for _ in 0..400 { - let mut f = file.clone(); - for _ in 0..6 { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - let i = seed as usize % f.len(); - f[i] = (seed >> 8) as u8; - } - let r = read_header(f.len() as u64, &mut |off, buf: &mut [u8]| { - let s = off as usize; - buf.copy_from_slice(&f[s..s + buf.len()]); - Ok(()) - }) - .unwrap(); - assert_eq!(r.focus, crate::extract(&f).focus); - } - // a reader told the file is longer than it is (replaced since it was measured): an error - let r = read_header(file.len() as u64 + 100_000, &mut |off, buf: &mut [u8]| { - let s = off as usize; - buf.copy_from_slice(file.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); - Ok(()) - }); - assert!(r.is_err()); - } - - #[test] - fn the_synthetic_arw_is_laid_out_as_described() { - let f = synthetic_arw(3, 10); - assert_eq!(f.len(), 260); - let p = crate::extract(&f).focus.expect("a point"); - assert_eq!(p.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); - assert!(crate::extract(&synthetic_arw(0, 0)).focus.is_none(), "manual focus"); - } - - #[test] - fn cameras_that_can_have_a_point() { - assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); - for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { - assert!(!camera_supported(c), "{c}"); - } - } - - /// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) - /// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=` is the photo folder the paths below are relative - /// to; skipped without it. No user file is part of the repository. - #[test] - fn real_sony_files_match_exiftool() { - let Ok(dir) = std::env::var("LIGHTCRAFT_FOCUS_SAMPLES") else { return }; - let dir = std::path::Path::new(&dir); - type Want = Option<([f64; 4], [f64; 2], &'static [&'static str])>; - // (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point) - let cases: [(&str, u16, Want); 8] = [ - // α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking - ( - "2023/11-november/17th/_DSC6954.ARW", - 1, - Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"])), - ), - ( - "2023/11-november/17th/_DSC6894.ARW", - 1, - Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"])), - ), - // α1, manual focus (the location 4441 3060 is where the frame was parked) - ("2023/11-november/15th/_DSC6613.ARW", 1, None), - // α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880 - ("2022/01-january/9th/_DSC6079.ARW", 8, None), - // α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking - ("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))), - // A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking - ( - "2020/10-october/31st/DSC00065.ARW", - 8, - Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"])), - ), - // A7S III upside down, AF-C, a centred Flexible Spot: a real point - ("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))), - // A7S III, manual focus - ("2021/06-june/12th/DSC06806.ARW", 1, None), - ]; - for (name, exif_orientation, want) in cases { - let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); - let got = crate::extract(&bytes).focus; - match (want, got) { - (None, None) => {} - (Some((loc, size, words)), Some(f)) => { - let [w, h, x, y] = loc; - let sensor = - Rect { x0: (x - size[0] / 2.0) / w, y0: (y - size[1] / 2.0) / h, x1: (x + size[0] / 2.0) / w, y1: (y + size[1] / 2.0) / h }; - let r = Orientation::from_exif(exif_orientation).map_norm_rect(sensor); - for (a, b) in [(f.rect.x0, r.x0), (f.rect.y0, r.y0), (f.rect.x1, r.x1), (f.rect.y1, r.y1)] { - assert!((a - b).abs() < 1e-9, "{name}: {:?} vs {r:?}", f.rect); - } - assert_eq!(f.words(), words, "{name}"); - } - (w, g) => panic!("{name}: want {w:?}, got {g:?}"), - } - } - } -} +mod tests; diff --git a/crates/meta/src/focus/tests.rs b/crates/meta/src/focus/tests.rs new file mode 100644 index 000000000..7c1df95db --- /dev/null +++ b/crates/meta/src/focus/tests.rs @@ -0,0 +1,464 @@ +use super::*; +use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value}; + +fn encipher(p: u8) -> u8 { + if p >= 249 { p } else { ((u32::from(p).pow(3)) % 249) as u8 } +} + +fn entry(tag: u16, value: Value) -> Entry { + Entry { tag, value, offset: 0 } +} + +/// The α1 sample `_DSC6954.ARW`: AF-C, Wide, Face tracking, Animal Eye Tracking, 8640×5760 at (3226, 1980), 175×180. +fn alpha1() -> Ifd { + let mut block = vec![0u8; 0x40]; + block[0x16] = encipher(0x83); + block[0x17] = encipher(20); + Ifd { + entries: vec![ + entry(FOCUS_MODE, Value::Byte(vec![3])), + entry(AF_AREA_SETTING, Value::Byte(vec![0])), + entry(AF_TRACKING, Value::Byte(vec![1])), + entry(FOCUS_LOCATION, Value::Short(vec![8640, 5760, 3226, 1980])), + entry(FOCUS_FRAME_SIZE, Value::Undefined(vec![0xaf, 0, 0xb4, 0, 1, 1])), + entry(TAG_9402, Value::Undefined(block)), + ], + ..Default::default() + } +} + +fn set(ifd: &mut Ifd, tag: u16, value: Value) { + ifd.entries.retain(|e| e.tag != tag); + ifd.entries.push(entry(tag, value)); + // IFD entries are kept sorted by tag (lookups rely on it) + ifd.entries.sort_by_key(|e| e.tag); +} + +#[test] +fn the_cipher_matches_the_bytes_seen_in_files() { + // stored → plain, as read from an α1 file's 0x9402 block next to ExifTool's decoded values + assert_eq!(decipher(0x20), 20); + assert_eq!(decipher(0x77), 0x83); + assert_eq!(decipher(0xff), 0xff); + for p in 0..=255u8 { + assert_eq!(decipher(encipher(p)), p); + } +} + +#[test] +fn reads_the_alpha1_sample() { + let f = sony(&alpha1(), ByteOrder::Little, Orientation::Normal).unwrap(); + assert!((f.rect.x0 - (3226.0 - 87.5) / 8640.0).abs() < 1e-12); + assert!((f.rect.y1 - (1980.0 + 90.0) / 5760.0).abs() < 1e-12); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + let (cx, cy) = f.center(); + assert!((cx - 3226.0 / 8640.0).abs() < 1e-12 && (cy - 1980.0 / 5760.0).abs() < 1e-12); +} + +#[test] +fn big_endian_frame_size() { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0, 0xaf, 0, 0xb4, 1, 1])); + let f = sony(&ifd, ByteOrder::Big, Orientation::Normal).unwrap(); + assert!(((f.rect.x1 - f.rect.x0) * 8640.0 - 175.0).abs() < 1e-9); +} + +#[test] +fn follows_the_exif_orientation() { + // a portrait shot (Rotate 270 CW, EXIF 8) still records landscape sensor coordinates + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 864, 576])); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(8)).unwrap(); + let (cx, cy) = f.center(); + // left of the sensor (x 0.1) is the bottom of the upright photo; its top (y 0.1) the left + assert!((cx - 0.1).abs() < 1e-9 && (cy - 0.9).abs() < 1e-9, "{cx} {cy}"); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(6)).unwrap(); + let (cx, cy) = f.center(); + assert!((cx - 0.9).abs() < 1e-9 && (cy - 0.1).abs() < 1e-9, "{cx} {cy}"); +} + +#[test] +fn no_real_point() { + // manual focus + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // the block says Manual (its focus mode agreeing) + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // Multi at the exact centre + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(0); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // …and without the block: Wide, tracking off + ifd.entries.retain(|e| e.tag != TAG_9402); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // missing location, zero frame, centre outside it + let mut ifd = alpha1(); + ifd.entries.retain(|e| e.tag != FOCUS_LOCATION); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + for loc in [vec![0, 5760, 0, 0], vec![8640, 0, 1, 0], vec![8640, 5760, 9000, 10], vec![8640, 5760, 10, 6000], vec![8640, 5760, 10]] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(loc.clone())); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none(), "{loc:?}"); + } +} + +#[test] +fn a_centred_spot_is_a_real_point() { + let mut ifd = alpha1(); + set(&mut ifd, AF_AREA_SETTING, Value::Byte(vec![3])); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(2); + block[0x17] = encipher(3); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![2])); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-S", "Flexible Spot"]); + let (cx, cy) = f.center(); + assert!((cx - 0.5).abs() < 1e-12 && (cy - 0.5).abs() < 1e-12); +} + +#[test] +fn a_block_that_disagrees_is_ignored() { + // another body's block layout: its "focus mode" is not the plain tag's, so its area isn't trusted + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(7); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Face tracking"]); + // a block too short to hold the bytes is ignored the same way + set(&mut ifd, TAG_9402, Value::Undefined(vec![1, 2, 3])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_some()); +} + +#[test] +fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() { + for fs in [ + Value::Undefined(vec![0, 0, 0, 0, 0, 0]), + Value::Undefined(vec![1]), + Value::Short(vec![5]), + Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0]), + ] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, fs); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(((f.rect.y1 - f.rect.y0) - DEFAULT_SIDE).abs() < 1e-9); + } + // a frame reaching past the corner is clipped to the photo + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 0, 5760])); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0xff, 0xff, 0xff, 0xff, 1, 1])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(f.rect.x0 >= 0.0 && f.rect.y1 <= 1.0); +} + +#[test] +fn wrong_types_and_extreme_values_never_panic() { + let values = [ + Value::Ascii("x".into()), + Value::Byte(vec![]), + Value::SShort(vec![-1, -1, -1, -1]), + Value::Long(vec![u32::MAX; 4]), + Value::Double(vec![f64::NAN; 4]), + Value::Undefined(vec![0xff; 300]), + ]; + for v in &values { + for tag in [FOCUS_MODE, AF_AREA_SETTING, AF_TRACKING, FOCUS_LOCATION, FOCUS_FRAME_SIZE, TAG_9402] { + let mut ifd = alpha1(); + set(&mut ifd, tag, v.clone()); + if let Some(f) = sony(&ifd, ByteOrder::Little, Orientation::Normal) { + assert!(f.rect.x0 >= 0.0 && f.rect.x1 <= 1.0 && f.rect.x0 < f.rect.x1); + } + } + } + assert!(FocusPoint { rect: Rect { x0: f64::NAN, y0: 0.0, x1: 1.0, y1: 1.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + assert!(FocusPoint { rect: Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); +} + +/// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. +fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec { + sony_file_with(make, model, orientation, note_entries, None) +} + +/// [`sony_file`], with the `0x9402` block's value placed `far` bytes past the end of the TIFF +/// (the gap filled with "sample data") when `far` is given. +fn sony_file_with(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)], far: Option) -> Vec { + let order = ByteOrder::Little; + // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, + // which we only know after writing; write once to learn where the note lands, then rebuild + let build = |note_at: u64, end: u64| -> (Vec, Vec) { + let mut note = b"SONY DSC \0\0\0".to_vec(); + let ifd_len = 2 + note_entries.len() * 12 + 4; + let mut tail: Vec = Vec::new(); + let mut outside: Vec = Vec::new(); + order.put_u16(&mut note, note_entries.len() as u16); + for (tag, v) in note_entries { + let raw = lightcraft_tiff::writer::encode(order, v); + order.put_u16(&mut note, *tag); + order.put_u16(&mut note, v.field_type() as u16); + order.put_u32(&mut note, v.count() as u32); + if raw.len() <= 4 { + let mut r = raw.clone(); + r.resize(4, 0); + note.extend(r); + } else if let (Some(gap), TAG_9402) = (far, *tag) { + order.put_u32(&mut note, (end + gap as u64) as u32); + outside = raw; + } else { + order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); + tail.extend(raw); + if tail.len() % 2 == 1 { + tail.push(0); + } + } + } + order.put_u32(&mut note, 0); + note.extend(tail); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note)); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); + ifd0.set_child(t::EXIF_IFD, exif); + (TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(), outside) + }; + let (first, _) = build(0, 0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; + let (mut file, outside) = build(at, first.len() as u64); + assert_eq!(file.len(), first.len()); + assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); + if let Some(gap) = far { + file.extend((0..gap).map(|i| (i * 13 % 241) as u8)); + file.extend(outside); + } + file +} + +fn alpha1_entries() -> Vec<(u16, Value)> { + alpha1().entries.into_iter().map(|e| (e.tag, e.value)).collect() +} + +#[test] +fn read_exif_finds_it_through_the_maker_note_and_orientation() { + let file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let m = crate::read_exif(&file); + let f = m.focus.expect("focus point"); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + // EXIF 8: sensor x becomes 1 - upright y + let (cx, cy) = f.center(); + assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); + // an unconfirmed body or another maker: nothing + assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); + assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); +} + +#[test] +fn truncated_and_garbage_maker_notes_never_panic() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + let note_at = Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; + for cut in (0..file.len()).step_by(7) { + let _ = crate::read_exif(&file[..cut]); + } + let mut seed = 0x2545_f491_u32; + for _ in 0..300 { + let mut f = file.clone(); + for _ in 0..8 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = note_at + (seed as usize % (file.len() - note_at)); + f[i] = (seed >> 8) as u8; + } + if let Some(fp) = crate::read_exif(&f).focus { + assert!(fp.rect.x0 >= 0.0 && fp.rect.x1 <= 1.0 && fp.rect.y0 >= 0.0 && fp.rect.y1 <= 1.0); + } + } +} + +/// A whole-file read and a header-only read of the same file agree, and the header read reads a +/// small part of it (the sample data after the metadata is never touched). +#[test] +fn header_read_matches_the_whole_file_and_reads_little() { + let read = |file: &[u8]| { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let s = off as usize; + buf.copy_from_slice(&file[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.bytes_read, asked); + r + }; + // a raw: the metadata, then 8 MB of "samples" + let mut file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let meta_len = file.len(); + file.extend((0..8_000_000u32).map(|i| (i * 31 % 251) as u8)); + let whole = crate::extract(&file).focus; + assert!(whole.is_some()); + let r = read(&file); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX && r.bytes_read < file.len() as u64 / 100, "{} of {}", r.bytes_read, file.len()); + assert!(meta_len < HEADER_PREFIX as usize); + // the area-mode block placed 3 MB further on: found in a further round, still a small part + let mut far = sony_file_with("SONY", "ILCE-1", 1, &alpha1_entries(), Some(3_000_000)); + far.extend((0..4_000_000u32).map(|i| (i * 7 % 253) as u8)); + let whole = crate::extract(&far).focus; + assert!(whole.is_some()); + let r = read(&far); + assert_eq!(r.focus, whole); + assert!(r.bytes_read < 64 * 1024, "{}", r.bytes_read); + // a JPEG: the Exif segment after another segment + let exif = sony_file("SONY", "ILCE-1", 6, &alpha1_entries()); + let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16]; + jpeg.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"); + jpeg.extend_from_slice(&[0xff, 0xe1]); + jpeg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpeg.extend_from_slice(b"Exif\0\0"); + jpeg.extend_from_slice(&exif); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg.extend(std::iter::repeat_n(0x55u8, 3_000_000)); + let whole = crate::extract(&jpeg).focus; + assert!(whole.is_some()); + let r = read(&jpeg); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX); + // unsupported bodies agree too (no point either way) + let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); + assert_eq!(read(&other).focus, None); +} + +/// Every truncation and corruption of a file gives the header read the whole-file read's answer +/// (or an error when the file is shorter than it said), never a panic. +#[test] +fn header_read_of_damaged_files_agrees_or_fails_cleanly() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + for cut in (0..file.len()).step_by(5) { + let part = &file[..cut]; + let r = read_header(part.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(part.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(part).focus, "cut at {cut}"); + } + let mut seed = 0x9e37_79b9_u32; + for _ in 0..400 { + let mut f = file.clone(); + for _ in 0..6 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = seed as usize % f.len(); + f[i] = (seed >> 8) as u8; + } + let r = read_header(f.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(&f[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(&f).focus); + } + // a reader told the file is longer than it is (replaced since it was measured): an error + let r = read_header(file.len() as u64 + 100_000, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(file.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }); + assert!(r.is_err()); +} + +#[test] +fn the_synthetic_arw_is_laid_out_as_described() { + let f = synthetic_arw(3, 10); + assert_eq!(f.len(), 260); + let p = crate::extract(&f).focus.expect("a point"); + assert_eq!(p.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + assert!(crate::extract(&synthetic_arw(0, 0)).focus.is_none(), "manual focus"); +} + +#[test] +fn cameras_that_can_have_a_point() { + assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); + for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { + assert!(!camera_supported(c), "{c}"); + } +} + +/// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) +/// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=` is the photo folder the paths below are relative +/// to; skipped without it. No user file is part of the repository. +#[test] +fn real_sony_files_match_exiftool() { + let Ok(dir) = std::env::var("LIGHTCRAFT_FOCUS_SAMPLES") else { return }; + let dir = std::path::Path::new(&dir); + type Want = Option<([f64; 4], [f64; 2], &'static [&'static str])>; + // (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point) + let cases: [(&str, u16, Want); 8] = [ + // α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking + ( + "2023/11-november/17th/_DSC6954.ARW", + 1, + Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"])), + ), + ( + "2023/11-november/17th/_DSC6894.ARW", + 1, + Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"])), + ), + // α1, manual focus (the location 4441 3060 is where the frame was parked) + ("2023/11-november/15th/_DSC6613.ARW", 1, None), + // α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880 + ("2022/01-january/9th/_DSC6079.ARW", 8, None), + // α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking + ("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))), + // A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking + ( + "2020/10-october/31st/DSC00065.ARW", + 8, + Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"])), + ), + // A7S III upside down, AF-C, a centred Flexible Spot: a real point + ("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))), + // A7S III, manual focus + ("2021/06-june/12th/DSC06806.ARW", 1, None), + ]; + for (name, exif_orientation, want) in cases { + let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); + let got = crate::extract(&bytes).focus; + match (want, got) { + (None, None) => {} + (Some((loc, size, words)), Some(f)) => { + let [w, h, x, y] = loc; + let sensor = + Rect { x0: (x - size[0] / 2.0) / w, y0: (y - size[1] / 2.0) / h, x1: (x + size[0] / 2.0) / w, y1: (y + size[1] / 2.0) / h }; + let r = Orientation::from_exif(exif_orientation).map_norm_rect(sensor); + for (a, b) in [(f.rect.x0, r.x0), (f.rect.y0, r.y0), (f.rect.x1, r.x1), (f.rect.y1, r.y1)] { + assert!((a - b).abs() < 1e-9, "{name}: {:?} vs {r:?}", f.rect); + } + assert_eq!(f.words(), words, "{name}"); + } + (w, g) => panic!("{name}: want {w:?}, got {g:?}"), + } + } +} From 858046cc4f09629e37035bfb520bd13f4f41b03c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:50 +0000 Subject: [PATCH 036/191] refactor(lightcraft-engine): move media.rs tests to crates/engine/src/media/tests.rs --- crates/engine/src/media.rs | 525 +------------------------------ crates/engine/src/media/tests.rs | 522 ++++++++++++++++++++++++++++++ 2 files changed, 523 insertions(+), 524 deletions(-) create mode 100644 crates/engine/src/media/tests.rs diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index e76ef8169..537f56a5e 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1872,527 +1872,4 @@ mod thumbnail_hash_tests { } #[cfg(test)] -mod tests { - use super::*; - - /// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept; - /// a draft, and a render no larger than the drawn size, are left as rendered. - #[test] - fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() { - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image; - let long = plain.width.max(plain.height); - assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}"); - let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image; - assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height); - let aspect = |i: &Rgba8| i.width as f32 / i.height as f32; - assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept"); - let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image; - assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk"); - let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image; - assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered"); - let a = s.loupe_job(id, 1024, 1024, true).unwrap(); - let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400); - assert_ne!(a.key, b.key, "the fit is part of the result key"); - } - - #[test] - fn renders_know_the_original_size_whichever_level_they_come_from() { - // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the - // 512-px thumbnail level denoises like a scaled-down export - let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, ""); - assert_eq!(source_info(&p).native_long, Some(6000)); - let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() }; - let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone())); - let info = thumb.info_or(source_info(&p)); - assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size"); - let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, ""); - assert_eq!(source_info(&unknown).native_long, None); - assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None); - } - - #[test] - fn denoise_mix_is_reserved_before_the_source_enters_the_cache() { - let mut plain = Rgb32f::new(8, 8); - plain.data.fill([1.0; 3]); - let mut source = DecodedSource::new(Arc::new(plain), None); - source.denoised = Some(Arc::new(Twin::new(Arc::new(Rgb32f::new(8, 8))))); - let reserved = source.bytes(); - assert!(reserved >= 3 * 8 * 8 * 12); - assert_eq!(source.image_for(0.5).data[0], [0.5; 3]); - assert_eq!(source.bytes(), reserved, "creating a mix cannot evade the LRU's recorded cost"); - let _ = source.image_for(0.25); - assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix"); - } - - #[test] - fn hidden_grading_wheels_render_neutral_without_touching_the_photo() { - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap(); - let job = s.loupe_job(id, 64, 64, true).unwrap(); - let same = job.clone().with_hidden_grading([false; 3]); - assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged"); - assert!(same.view_cache.is_some()); - let hidden = job.clone().with_hidden_grading([true, false, false]); - assert_eq!(hidden.settings.grading.shadows.sat, 0.0); - assert_eq!(hidden.settings.grading.highlights.sat, 40.0); - assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key"); - assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview"); - assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched"); - } - - #[test] - fn decoder_info_survives_render_jobs_cache_and_eviction() { - let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| { - let x = 0.01 * (i + 1) as f32; - [x, (x * 2.0).min(0.9)] - })) - .unwrap(); - let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() }; - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let mut job = s.render_job(id, 64, 64, false, true).unwrap(); - let loaded_info = info.clone(); - job.source = SourceRef::File { - path: "synthetic.arw".into(), - max_edge: 64, - loader: Some(Arc::new(move |_, _| { - let mut image = Rgb32f::new(64, 64); - image.data.fill([0.1; 3]); - Ok((image, loaded_info.clone())) - })), - fallback: None, - denoise: None, - reduced: None, - }; - job.info = SourceInfo::default(); // Header facts cannot override decoder facts. - job.settings = Arc::new(DevelopSettings::default()); - let r = job.clone().run(); - assert_eq!(r.loaded.as_ref().unwrap().info.as_ref(), Some(&info)); - let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request); - assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data); - s.accept(&r); - job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level); - let again = job.run(); - assert!(again.loaded.is_none()); - assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data); - s.media.forget(id); - assert!(s.media.get_source(id, r.level).is_none()); - } - - /// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as - /// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices - /// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step), - /// and the panel then reads As Shot in Kelvin while the stored settings keep their look. - #[test] - fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() { - use lightcraft_develop::{WbMode, WbScale}; - let mut s = crate::Session::new(); - let id = s.catalog.alloc_photo_id(); - let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00"); - p.kind = MediaKind::Raw; - p.meta.camera = "SONY ILCE-7M3".into(); - p.as_shot_wb = Some((6500.0, 0.0)); - let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0); - (d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0); - assert_eq!(d.wb.scale, WbScale::Legacy); - p.develop = Arc::new(d.clone()); - s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); - s.selection.ids = vec![id]; - s.selection.active = Some(id); - // the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known - let header = s.source_info(id); - assert!(header.raw && !header.relative_wb && header.legacy_relative_wb); - assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0)); - // the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices) - let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() }; - let mut pixels = Rgb32f::new(64, 64); - for (i, px) in pixels.data.iter_mut().enumerate() { - let v = (i % 64) as f32 / 64.0; - *px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v]; - } - let mut job = s.render_job(id, 64, 64, false, true).unwrap(); - let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone()); - job.source = SourceRef::File { - path: "/photos/DSC00001.ARW".into(), - max_edge: 64, - loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))), - fallback: None, - denoise: None, - reduced: None, - }; - let undo_before = s.undo.len(); - let request = job.request; - let r = job.run(); - let new = r.rendered.as_ref().unwrap().image.data.clone(); - // the old pipeline: the same settings against the relative-scale source facts of an older build - let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() }; - let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data; - assert_eq!(new, old, "the look of an existing edit changed"); - assert_ne!( - new, - lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data - ); - // accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched - s.accept(&r); - let p = s.catalog.photo(id).unwrap(); - assert_eq!(p.as_shot_wb, Some((4986.0, -2.0))); - assert_eq!(*p.develop, d); - assert_eq!(s.undo.len(), undo_before); - assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id))); - assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom); - // the panel reads the white balance in effect in Kelvin: the value the relative edit renders as - let info = s.source_info(id); - assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0)); - let shown = lightcraft_pipeline::local::effective_wb(&info, &d); - assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0))); - assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}"); - // accepting again changes nothing more - let log = s.pending_log.len(); - s.accept(&r); - assert_eq!(s.pending_log.len(), log); - // a catalog-only (proxy) render of the same settings before the decode gave the same pixels too - let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data; - assert_eq!(proxy, old, "a smart preview of an existing edit changed"); - } - - #[test] - fn budget_evicts_least_recently_used_across_caches() { - let img = |w: usize| Arc::new(Rgb32f::new(w, w)); - let mut m = MediaCache::default(); - let mb = 1 << 20; - m.set_budget(30 * mb); - // 3 previews of 12 MB (1000² × 12 B) don't fit 30 MB: the least recently used goes - m.insert(PhotoId(1), SourceLevel::Preview, img(1000)); - m.insert(PhotoId(2), SourceLevel::Preview, img(1000)); - assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some()); // 1 is now more recent than 2 - m.insert(PhotoId(3), SourceLevel::Preview, img(1000)); - assert!(m.get(PhotoId(2), SourceLevel::Preview).is_none()); - assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some() && m.get(PhotoId(3), SourceLevel::Preview).is_some()); - // a thumbnail source and a rendered preview compete in the same budget - m.insert(PhotoId(4), SourceLevel::Thumb, img(500)); - m.rendered.put(Hash128(7), Arc::new(lightcraft_raster::Rgba8::new(1000, 1000))); - assert!(m.held() <= 30 * mb, "{}", m.held()); - // the newest preview (the one on screen) is never evicted, even alone over budget - m.set_budget(mb); - assert!(m.get(PhotoId(3), SourceLevel::Preview).is_some()); - assert_eq!(m.usage().1.count, 1); - assert_eq!(m.usage().0.count, 0); - assert_eq!(m.rendered.mem_usage().0, 0); - } - - // Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view, - // the least recently used goes, and the budget still evicts them like any source - #[test] - fn full_size_originals_are_kept_up_to_the_capacity() { - let img = |w: usize| Arc::new(Rgb32f::new(w, w)); - let mut m = MediaCache::default(); - let mb = 1 << 20; - m.set_budget(100 * mb); - m.insert(PhotoId(1), SourceLevel::Full, img(1000)); - m.insert(PhotoId(2), SourceLevel::Full, img(1000)); - assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default"); - m.set_full_capacity(2); - m.insert(PhotoId(1), SourceLevel::Full, img(1000)); - assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); - assert_eq!(m.usage().2.count, 2); - // re-inserting a held photo replaces it instead of taking a second place - m.insert(PhotoId(2), SourceLevel::Full, img(1000)); - assert_eq!(m.usage().2.count, 2); - // 1 is the least recently used now - m.set_full_capacity(1); - assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); - m.set_full_capacity(0); - assert_eq!(m.usage().2.count, 1, "never less than one"); - m.set_full_capacity(2); - m.insert(PhotoId(3), SourceLevel::Full, img(1000)); - m.set_budget(15 * mb); - assert_eq!(m.usage().2.count, 1, "the budget evicts the older original"); - assert!(m.has_source(PhotoId(3), SourceLevel::Full)); - } - - // Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay - // (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render) - #[test] - fn the_on_screen_original_does_not_evict_the_other_sources() { - let img = |w: usize| Arc::new(Rgb32f::new(w, w)); - let mut m = MediaCache::default(); - let mb = 1 << 20; - m.set_budget(20 * mb); - m.set_on_screen(Some(PhotoId(1))); - m.insert(PhotoId(1), SourceLevel::Preview, img(500)); - m.insert(PhotoId(1), SourceLevel::Thumb, img(200)); - m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB - m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour - for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] { - assert!(m.has_source(PhotoId(id), level), "{id} {level:?}"); - } - } - - // A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB): - // evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo - // on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown. - #[test] - fn the_original_on_screen_stays_even_over_budget() { - let img = |w: usize| Arc::new(Rgb32f::new(w, w)); - let mut m = MediaCache::default(); - let mb = 1 << 20; - m.set_budget(20 * mb); - m.set_on_screen(Some(PhotoId(1))); - m.insert(PhotoId(1), SourceLevel::Preview, img(500)); - m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB - assert!(m.has_source(PhotoId(1), SourceLevel::Full)); - m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour - m.insert(PhotoId(3), SourceLevel::Thumb, img(100)); - assert!(m.has_source(PhotoId(1), SourceLevel::Full)); - m.set_on_screen(Some(PhotoId(2))); - assert!(!m.has_source(PhotoId(1), SourceLevel::Full)); - assert!(m.held() <= 20 * mb, "{}", m.held()); - } - - #[test] - fn work_gate_waits_for_room() { - use crate::memory::WorkGate; - let g = Arc::new(WorkGate::new(100)); - let a = g.acquire(80); - // more than fits waits until `a` is released; urgent work never waits - let urgent = g.acquire_urgent(500); - assert_eq!(g.usage().0, 580); - drop(urgent); - let g2 = g.clone(); - let t = std::thread::spawn(move || { - let _b = g2.acquire(50); - g2.usage().0 - }); - std::thread::sleep(std::time::Duration::from_millis(50)); - assert_eq!(g.usage().0, 80, "the second holder is still waiting"); - drop(a); - assert_eq!(t.join().unwrap(), 50); - assert_eq!(g.usage().0, 0); - // a single holder may exceed the limit - drop(g.acquire(1000)); - } - - #[test] - fn levels_cover_sizes_and_large_exports_use_the_original() { - assert_eq!(SourceLevel::for_size(256), SourceLevel::Thumb); - assert_eq!(SourceLevel::for_size(2560), SourceLevel::Preview); - assert_eq!(SourceLevel::for_size(2561), SourceLevel::Full); - let mut s = crate::Session::with_demo(); - let p = s.catalog.photos().next().unwrap().clone(); - let long = p.width.max(p.height) as usize; - assert!(long > 2560, "demo photos are camera-sized"); - // a full-size export reads the source at its native size, not an upscaled preview - let job = s.render_job(p.id, long, long, false, true).unwrap(); - assert_eq!(job.level, SourceLevel::Full); - match &job.source { - SourceRef::Demo { max_edge, .. } => assert_eq!(*max_edge, long), - _ => panic!("demo source expected"), - } - let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap(); - assert_eq!(loupe.level, SourceLevel::Preview); - assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key); - // two photos with identical settings and size never share a result key - let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect(); - let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap()); - assert_ne!(a.key, b.key); - } - - // Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale, - // from the original, with a key of its own - #[test] - fn region_jobs_render_a_window_of_the_zoomed_frame() { - use lightcraft_pipeline::PixelWindow; - let mut s = crate::Session::with_demo(); - let p = s.catalog.photos().next().unwrap().clone(); - let (w, h) = (p.width as usize, p.height as usize); - assert!(w.max(h) > 2560, "demo photos are camera-sized"); - let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 }; - let job = s.region_job(p.id, w, h, win, true).unwrap(); - assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original"); - let img = job.run().rendered.unwrap().image; - assert_eq!((img.width, img.height), (300, 200)); - // other windows, and the whole-frame job, never share a key - let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap(); - let whole = s.render_job(p.id, w, h, false, true).unwrap(); - let again = s.region_job(p.id, w, h, win, true).unwrap(); - assert_ne!(other.key, again.key); - assert_ne!(whole.key, again.key); - assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key"); - // a window never writes the photo's view preview or the thumbnail cache - assert!(again.view_cache.is_none() && again.cache.is_none()); - } - - // Issue #323: a tight crop shown at its own pixels needs the original, not the 2560 px preview - #[test] - fn a_tight_crop_reads_the_source_level_its_pixels_need() { - let mut s = crate::Session::with_demo(); - let p = s.catalog.photos().next().unwrap().clone(); - let long = p.width.max(p.height) as usize; - assert!(long > 2560); - let id = p.id; - let mut d = (*s.develop_of(id).unwrap()).clone(); - // the uncropped photo at 1600 px: the preview is plenty - assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview); - // a crop to 30 % of each side shown 1600 px long needs 1600 / 0.3 px of the source - d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.2, y0: 0.2, x1: 0.5, y1: 0.5 }; - s.set_develop(id, d, "Crop").unwrap(); - assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Full); - // …but with the crop tool open (the whole photo shown) it does not - assert_eq!(s.render_job(id, 1600, 1600, false, false).unwrap().level, SourceLevel::Preview); - // and a crop shown small enough still reads the preview - assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview); - } - - // a window whose spot reads from further away than a render can hold is refused - #[test] - fn a_region_job_is_refused_when_a_spot_reads_beyond_what_fits() { - use lightcraft_pipeline::PixelWindow; - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let p = s.catalog.photo(id).unwrap().clone(); - let (w, h) = (p.width as usize * 8, p.height as usize * 8); - let mut d = (*s.develop_of(id).unwrap()).clone(); - d.spots.push(lightcraft_develop::Spot { - points: vec![lightcraft_geom::Point::new(0.2, 0.5)], - size: 0.01, - source_offset: Some(lightcraft_geom::Point::new(0.6, 0.0)), - ..Default::default() - }); - s.set_develop(id, d, "Spot").unwrap(); - let win = PixelWindow { x: (w as f64 * 0.2) as usize - 100, y: h / 2 - 100, w: 400, h: 300 }; - assert!(s.region_job(id, w, h, win, true).is_none()); - // a window elsewhere is fine - assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some()); - } - - // Issue #323: the Before side of a Before/After view at 1:1 is a window too, of the photo - // without its edits (the crop is kept), with a key of its own - #[test] - fn a_before_window_shows_the_unedited_look() { - use lightcraft_pipeline::PixelWindow; - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let p = s.catalog.photo(id).unwrap().clone(); - let (w, h) = (p.width as usize, p.height as usize); - let mut d = (*s.develop_of(id).unwrap()).clone(); - d.light.exposure = 2.0; - s.set_develop(id, d, "Exposure").unwrap(); - let win = PixelWindow { x: w / 3, y: h / 3, w: 200, h: 150 }; - let after = s.region_job(id, w, h, win, true).unwrap(); - let before = s.region_job_before(id, w, h, win, true).unwrap(); - assert_ne!(after.key, before.key); - let (a, b) = (after.run().rendered.unwrap().image, before.run().rendered.unwrap().image); - assert_eq!((a.width, a.height), (b.width, b.height)); - let mean = |i: &Rgba8| i.data.iter().map(|p| p[1] as f64).sum::() / i.data.len() as f64; - assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); - } - - // LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of - // the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and - // at a corner, with tone, colour and detail edits on a camera-sized photo - #[test] - fn one_to_one_window_pixels_are_export_pixels() { - use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow}; - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let p = s.catalog.photo(id).unwrap().clone(); - let (w, h) = (p.width as usize, p.height as usize); - assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}"); - let mut d = (*s.develop_of(id).unwrap()).clone(); - d.light.exposure = 0.7; - d.light.contrast = 25.0; - d.color.saturation = 20.0; - s.set_develop(id, d, "Edits").unwrap(); - let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image; - assert_eq!((export.width, export.height), (w, h), "a full-size export"); - for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] { - let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image; - assert_eq!((tile.width, tile.height), (win.w, win.h)); - let mut worst = 0u8; - for y in 0..win.h { - for x in 0..win.w { - let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]); - for k in 0..3 { - worst = worst.max(a[k].abs_diff(b[k])); - } - } - } - assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels"); - } - } - - /// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here - /// one nothing can decode) is not read again. - #[test] - fn a_thumbnail_is_cut_from_the_decoded_preview() { - let mut s = crate::Session::new(); - let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, ""); - s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); - s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25]))); - let job = s.thumb_job(p.id, 512).unwrap(); - assert!(matches!(job.source, SourceRef::Fit { .. })); - let r = job.run(); - let thumb = r.rendered.as_ref().unwrap(); - assert_eq!((thumb.image.width, thumb.image.height), (512, 341)); - s.accept(&r); - let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap(); - assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview"); - } - - #[test] - fn has_source_follows_what_the_session_accepted() { - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let p = s.catalog.photo(id).unwrap().clone(); - let long = p.width.max(p.height) as usize; - assert!(!s.media.has_source(id, SourceLevel::Full)); - let r = s.render_job(id, long, long, false, true).unwrap().run(); - s.accept(&r); - assert!(s.media.has_source(id, SourceLevel::Full)); - assert!(!s.media.has_source(id, SourceLevel::Preview)); - assert!(!s.media.has_source(PhotoId(id.0 + 1), SourceLevel::Full)); - } - - #[test] - fn variant_jobs_have_their_own_keys_and_hit_the_cache() { - let mut s = crate::Session::with_demo(); - let id = s.active().unwrap(); - let mine = (*s.develop_of(id).unwrap()).clone(); - let with = |pid: &str| { - let mut d = mine.clone(); - d.profile.id = pid.into(); - d - }; - let (a, b) = (with("lc.vivid"), with("lc.bw.sepia")); - let ja = s.variant_job(id, &a, 128).unwrap(); - let jb = s.variant_job(id, &b, 128).unwrap(); - assert_ne!(ja.key, jb.key, "keys differ per settings"); - assert_eq!(ja.key, s.variant_job(id, &a, 128).unwrap().key, "stable"); - assert_ne!(ja.key, s.variant_job(id, &a, 256).unwrap().key, "and per size"); - assert_ne!(ja.key, s.thumb_job(id, 128).unwrap().key, "not the photo's own thumbnail"); - assert_eq!(ja.level, SourceLevel::Thumb); - let (cache, ck) = ja.cache.clone().unwrap(); - assert!(cache.get(ck).is_none()); - let r = ja.run(); - s.accept(&r); - let first = r.rendered.unwrap().image; - assert!(first.width.max(first.height) <= 128); - assert!(cache.get(ck).is_some(), "rendered variants are cached"); - assert!(cache.get(jb.cache.as_ref().unwrap().1).is_none()); - // a second job for the same variant is served from the cache, pixel for pixel - let again = s.variant_job(id, &a, 128).unwrap().run(); - assert!(again.loaded.is_none()); - assert_eq!(again.rendered.unwrap().image.as_bytes(), first.as_bytes()); - let other = jb.run().rendered.unwrap().image; - assert_ne!(other.as_bytes(), first.as_bytes()); - // the photo's own settings never changed - assert_eq!(*s.develop_of(id).unwrap(), mine); - // hover previews: a loupe-sized job with other settings, its own key - let pv = s.preview_job(id, 800, 600, true, &a).unwrap(); - assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key); - assert_eq!(*pv.settings, a); - } -} +mod tests; diff --git a/crates/engine/src/media/tests.rs b/crates/engine/src/media/tests.rs new file mode 100644 index 000000000..95a2c2964 --- /dev/null +++ b/crates/engine/src/media/tests.rs @@ -0,0 +1,522 @@ +use super::*; + +/// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept; +/// a draft, and a render no larger than the drawn size, are left as rendered. +#[test] +fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image; + let long = plain.width.max(plain.height); + assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}"); + let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height); + let aspect = |i: &Rgba8| i.width as f32 / i.height as f32; + assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept"); + let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk"); + let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image; + assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered"); + let a = s.loupe_job(id, 1024, 1024, true).unwrap(); + let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400); + assert_ne!(a.key, b.key, "the fit is part of the result key"); +} + +#[test] +fn renders_know_the_original_size_whichever_level_they_come_from() { + // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the + // 512-px thumbnail level denoises like a scaled-down export + let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, ""); + assert_eq!(source_info(&p).native_long, Some(6000)); + let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() }; + let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone())); + let info = thumb.info_or(source_info(&p)); + assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size"); + let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, ""); + assert_eq!(source_info(&unknown).native_long, None); + assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None); +} + +#[test] +fn denoise_mix_is_reserved_before_the_source_enters_the_cache() { + let mut plain = Rgb32f::new(8, 8); + plain.data.fill([1.0; 3]); + let mut source = DecodedSource::new(Arc::new(plain), None); + source.denoised = Some(Arc::new(Twin::new(Arc::new(Rgb32f::new(8, 8))))); + let reserved = source.bytes(); + assert!(reserved >= 3 * 8 * 8 * 12); + assert_eq!(source.image_for(0.5).data[0], [0.5; 3]); + assert_eq!(source.bytes(), reserved, "creating a mix cannot evade the LRU's recorded cost"); + let _ = source.image_for(0.25); + assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix"); +} + +#[test] +fn hidden_grading_wheels_render_neutral_without_touching_the_photo() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap(); + let job = s.loupe_job(id, 64, 64, true).unwrap(); + let same = job.clone().with_hidden_grading([false; 3]); + assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged"); + assert!(same.view_cache.is_some()); + let hidden = job.clone().with_hidden_grading([true, false, false]); + assert_eq!(hidden.settings.grading.shadows.sat, 0.0); + assert_eq!(hidden.settings.grading.highlights.sat, 40.0); + assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key"); + assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview"); + assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched"); +} + +#[test] +fn decoder_info_survives_render_jobs_cache_and_eviction() { + let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| { + let x = 0.01 * (i + 1) as f32; + [x, (x * 2.0).min(0.9)] + })) + .unwrap(); + let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() }; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let mut job = s.render_job(id, 64, 64, false, true).unwrap(); + let loaded_info = info.clone(); + job.source = SourceRef::File { + path: "synthetic.arw".into(), + max_edge: 64, + loader: Some(Arc::new(move |_, _| { + let mut image = Rgb32f::new(64, 64); + image.data.fill([0.1; 3]); + Ok((image, loaded_info.clone())) + })), + fallback: None, + denoise: None, + reduced: None, + }; + job.info = SourceInfo::default(); // Header facts cannot override decoder facts. + job.settings = Arc::new(DevelopSettings::default()); + let r = job.clone().run(); + assert_eq!(r.loaded.as_ref().unwrap().info.as_ref(), Some(&info)); + let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request); + assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data); + s.accept(&r); + job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level); + let again = job.run(); + assert!(again.loaded.is_none()); + assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data); + s.media.forget(id); + assert!(s.media.get_source(id, r.level).is_none()); +} + +/// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as +/// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices +/// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step), +/// and the panel then reads As Shot in Kelvin while the stored settings keep their look. +#[test] +fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() { + use lightcraft_develop::{WbMode, WbScale}; + let mut s = crate::Session::new(); + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00"); + p.kind = MediaKind::Raw; + p.meta.camera = "SONY ILCE-7M3".into(); + p.as_shot_wb = Some((6500.0, 0.0)); + let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0); + (d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0); + assert_eq!(d.wb.scale, WbScale::Legacy); + p.develop = Arc::new(d.clone()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.selection.ids = vec![id]; + s.selection.active = Some(id); + // the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known + let header = s.source_info(id); + assert!(header.raw && !header.relative_wb && header.legacy_relative_wb); + assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0)); + // the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices) + let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() }; + let mut pixels = Rgb32f::new(64, 64); + for (i, px) in pixels.data.iter_mut().enumerate() { + let v = (i % 64) as f32 / 64.0; + *px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v]; + } + let mut job = s.render_job(id, 64, 64, false, true).unwrap(); + let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone()); + job.source = SourceRef::File { + path: "/photos/DSC00001.ARW".into(), + max_edge: 64, + loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))), + fallback: None, + denoise: None, + reduced: None, + }; + let undo_before = s.undo.len(); + let request = job.request; + let r = job.run(); + let new = r.rendered.as_ref().unwrap().image.data.clone(); + // the old pipeline: the same settings against the relative-scale source facts of an older build + let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() }; + let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data; + assert_eq!(new, old, "the look of an existing edit changed"); + assert_ne!( + new, + lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data + ); + // accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched + s.accept(&r); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.as_shot_wb, Some((4986.0, -2.0))); + assert_eq!(*p.develop, d); + assert_eq!(s.undo.len(), undo_before); + assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id))); + assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom); + // the panel reads the white balance in effect in Kelvin: the value the relative edit renders as + let info = s.source_info(id); + assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0)); + let shown = lightcraft_pipeline::local::effective_wb(&info, &d); + assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0))); + assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}"); + // accepting again changes nothing more + let log = s.pending_log.len(); + s.accept(&r); + assert_eq!(s.pending_log.len(), log); + // a catalog-only (proxy) render of the same settings before the decode gave the same pixels too + let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data; + assert_eq!(proxy, old, "a smart preview of an existing edit changed"); +} + +#[test] +fn budget_evicts_least_recently_used_across_caches() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(30 * mb); + // 3 previews of 12 MB (1000² × 12 B) don't fit 30 MB: the least recently used goes + m.insert(PhotoId(1), SourceLevel::Preview, img(1000)); + m.insert(PhotoId(2), SourceLevel::Preview, img(1000)); + assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some()); // 1 is now more recent than 2 + m.insert(PhotoId(3), SourceLevel::Preview, img(1000)); + assert!(m.get(PhotoId(2), SourceLevel::Preview).is_none()); + assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some() && m.get(PhotoId(3), SourceLevel::Preview).is_some()); + // a thumbnail source and a rendered preview compete in the same budget + m.insert(PhotoId(4), SourceLevel::Thumb, img(500)); + m.rendered.put(Hash128(7), Arc::new(lightcraft_raster::Rgba8::new(1000, 1000))); + assert!(m.held() <= 30 * mb, "{}", m.held()); + // the newest preview (the one on screen) is never evicted, even alone over budget + m.set_budget(mb); + assert!(m.get(PhotoId(3), SourceLevel::Preview).is_some()); + assert_eq!(m.usage().1.count, 1); + assert_eq!(m.usage().0.count, 0); + assert_eq!(m.rendered.mem_usage().0, 0); +} + +// Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view, +// the least recently used goes, and the budget still evicts them like any source +#[test] +fn full_size_originals_are_kept_up_to_the_capacity() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(100 * mb); + m.insert(PhotoId(1), SourceLevel::Full, img(1000)); + m.insert(PhotoId(2), SourceLevel::Full, img(1000)); + assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default"); + m.set_full_capacity(2); + m.insert(PhotoId(1), SourceLevel::Full, img(1000)); + assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); + assert_eq!(m.usage().2.count, 2); + // re-inserting a held photo replaces it instead of taking a second place + m.insert(PhotoId(2), SourceLevel::Full, img(1000)); + assert_eq!(m.usage().2.count, 2); + // 1 is the least recently used now + m.set_full_capacity(1); + assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); + m.set_full_capacity(0); + assert_eq!(m.usage().2.count, 1, "never less than one"); + m.set_full_capacity(2); + m.insert(PhotoId(3), SourceLevel::Full, img(1000)); + m.set_budget(15 * mb); + assert_eq!(m.usage().2.count, 1, "the budget evicts the older original"); + assert!(m.has_source(PhotoId(3), SourceLevel::Full)); +} + +// Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay +// (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render) +#[test] +fn the_on_screen_original_does_not_evict_the_other_sources() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(20 * mb); + m.set_on_screen(Some(PhotoId(1))); + m.insert(PhotoId(1), SourceLevel::Preview, img(500)); + m.insert(PhotoId(1), SourceLevel::Thumb, img(200)); + m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB + m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour + for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] { + assert!(m.has_source(PhotoId(id), level), "{id} {level:?}"); + } +} + +// A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB): +// evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo +// on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown. +#[test] +fn the_original_on_screen_stays_even_over_budget() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(20 * mb); + m.set_on_screen(Some(PhotoId(1))); + m.insert(PhotoId(1), SourceLevel::Preview, img(500)); + m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB + assert!(m.has_source(PhotoId(1), SourceLevel::Full)); + m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour + m.insert(PhotoId(3), SourceLevel::Thumb, img(100)); + assert!(m.has_source(PhotoId(1), SourceLevel::Full)); + m.set_on_screen(Some(PhotoId(2))); + assert!(!m.has_source(PhotoId(1), SourceLevel::Full)); + assert!(m.held() <= 20 * mb, "{}", m.held()); +} + +#[test] +fn work_gate_waits_for_room() { + use crate::memory::WorkGate; + let g = Arc::new(WorkGate::new(100)); + let a = g.acquire(80); + // more than fits waits until `a` is released; urgent work never waits + let urgent = g.acquire_urgent(500); + assert_eq!(g.usage().0, 580); + drop(urgent); + let g2 = g.clone(); + let t = std::thread::spawn(move || { + let _b = g2.acquire(50); + g2.usage().0 + }); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert_eq!(g.usage().0, 80, "the second holder is still waiting"); + drop(a); + assert_eq!(t.join().unwrap(), 50); + assert_eq!(g.usage().0, 0); + // a single holder may exceed the limit + drop(g.acquire(1000)); +} + +#[test] +fn levels_cover_sizes_and_large_exports_use_the_original() { + assert_eq!(SourceLevel::for_size(256), SourceLevel::Thumb); + assert_eq!(SourceLevel::for_size(2560), SourceLevel::Preview); + assert_eq!(SourceLevel::for_size(2561), SourceLevel::Full); + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(long > 2560, "demo photos are camera-sized"); + // a full-size export reads the source at its native size, not an upscaled preview + let job = s.render_job(p.id, long, long, false, true).unwrap(); + assert_eq!(job.level, SourceLevel::Full); + match &job.source { + SourceRef::Demo { max_edge, .. } => assert_eq!(*max_edge, long), + _ => panic!("demo source expected"), + } + let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap(); + assert_eq!(loupe.level, SourceLevel::Preview); + assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key); + // two photos with identical settings and size never share a result key + let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect(); + let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap()); + assert_ne!(a.key, b.key); +} + +// Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale, +// from the original, with a key of its own +#[test] +fn region_jobs_render_a_window_of_the_zoomed_frame() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + assert!(w.max(h) > 2560, "demo photos are camera-sized"); + let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 }; + let job = s.region_job(p.id, w, h, win, true).unwrap(); + assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original"); + let img = job.run().rendered.unwrap().image; + assert_eq!((img.width, img.height), (300, 200)); + // other windows, and the whole-frame job, never share a key + let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap(); + let whole = s.render_job(p.id, w, h, false, true).unwrap(); + let again = s.region_job(p.id, w, h, win, true).unwrap(); + assert_ne!(other.key, again.key); + assert_ne!(whole.key, again.key); + assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key"); + // a window never writes the photo's view preview or the thumbnail cache + assert!(again.view_cache.is_none() && again.cache.is_none()); +} + +// Issue #323: a tight crop shown at its own pixels needs the original, not the 2560 px preview +#[test] +fn a_tight_crop_reads_the_source_level_its_pixels_need() { + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(long > 2560); + let id = p.id; + let mut d = (*s.develop_of(id).unwrap()).clone(); + // the uncropped photo at 1600 px: the preview is plenty + assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview); + // a crop to 30 % of each side shown 1600 px long needs 1600 / 0.3 px of the source + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.2, y0: 0.2, x1: 0.5, y1: 0.5 }; + s.set_develop(id, d, "Crop").unwrap(); + assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Full); + // …but with the crop tool open (the whole photo shown) it does not + assert_eq!(s.render_job(id, 1600, 1600, false, false).unwrap().level, SourceLevel::Preview); + // and a crop shown small enough still reads the preview + assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview); +} + +// a window whose spot reads from further away than a render can hold is refused +#[test] +fn a_region_job_is_refused_when_a_spot_reads_beyond_what_fits() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize * 8, p.height as usize * 8); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.spots.push(lightcraft_develop::Spot { + points: vec![lightcraft_geom::Point::new(0.2, 0.5)], + size: 0.01, + source_offset: Some(lightcraft_geom::Point::new(0.6, 0.0)), + ..Default::default() + }); + s.set_develop(id, d, "Spot").unwrap(); + let win = PixelWindow { x: (w as f64 * 0.2) as usize - 100, y: h / 2 - 100, w: 400, h: 300 }; + assert!(s.region_job(id, w, h, win, true).is_none()); + // a window elsewhere is fine + assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some()); +} + +// Issue #323: the Before side of a Before/After view at 1:1 is a window too, of the photo +// without its edits (the crop is kept), with a key of its own +#[test] +fn a_before_window_shows_the_unedited_look() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.light.exposure = 2.0; + s.set_develop(id, d, "Exposure").unwrap(); + let win = PixelWindow { x: w / 3, y: h / 3, w: 200, h: 150 }; + let after = s.region_job(id, w, h, win, true).unwrap(); + let before = s.region_job_before(id, w, h, win, true).unwrap(); + assert_ne!(after.key, before.key); + let (a, b) = (after.run().rendered.unwrap().image, before.run().rendered.unwrap().image); + assert_eq!((a.width, a.height), (b.width, b.height)); + let mean = |i: &Rgba8| i.data.iter().map(|p| p[1] as f64).sum::() / i.data.len() as f64; + assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); +} + +// LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of +// the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and +// at a corner, with tone, colour and detail edits on a camera-sized photo +#[test] +fn one_to_one_window_pixels_are_export_pixels() { + use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow}; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}"); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.light.exposure = 0.7; + d.light.contrast = 25.0; + d.color.saturation = 20.0; + s.set_develop(id, d, "Edits").unwrap(); + let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image; + assert_eq!((export.width, export.height), (w, h), "a full-size export"); + for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] { + let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image; + assert_eq!((tile.width, tile.height), (win.w, win.h)); + let mut worst = 0u8; + for y in 0..win.h { + for x in 0..win.w { + let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]); + for k in 0..3 { + worst = worst.max(a[k].abs_diff(b[k])); + } + } + } + assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels"); + } +} + +/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here +/// one nothing can decode) is not read again. +#[test] +fn a_thumbnail_is_cut_from_the_decoded_preview() { + let mut s = crate::Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, ""); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); + s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25]))); + let job = s.thumb_job(p.id, 512).unwrap(); + assert!(matches!(job.source, SourceRef::Fit { .. })); + let r = job.run(); + let thumb = r.rendered.as_ref().unwrap(); + assert_eq!((thumb.image.width, thumb.image.height), (512, 341)); + s.accept(&r); + let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap(); + assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview"); +} + +#[test] +fn has_source_follows_what_the_session_accepted() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(!s.media.has_source(id, SourceLevel::Full)); + let r = s.render_job(id, long, long, false, true).unwrap().run(); + s.accept(&r); + assert!(s.media.has_source(id, SourceLevel::Full)); + assert!(!s.media.has_source(id, SourceLevel::Preview)); + assert!(!s.media.has_source(PhotoId(id.0 + 1), SourceLevel::Full)); +} + +#[test] +fn variant_jobs_have_their_own_keys_and_hit_the_cache() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let mine = (*s.develop_of(id).unwrap()).clone(); + let with = |pid: &str| { + let mut d = mine.clone(); + d.profile.id = pid.into(); + d + }; + let (a, b) = (with("lc.vivid"), with("lc.bw.sepia")); + let ja = s.variant_job(id, &a, 128).unwrap(); + let jb = s.variant_job(id, &b, 128).unwrap(); + assert_ne!(ja.key, jb.key, "keys differ per settings"); + assert_eq!(ja.key, s.variant_job(id, &a, 128).unwrap().key, "stable"); + assert_ne!(ja.key, s.variant_job(id, &a, 256).unwrap().key, "and per size"); + assert_ne!(ja.key, s.thumb_job(id, 128).unwrap().key, "not the photo's own thumbnail"); + assert_eq!(ja.level, SourceLevel::Thumb); + let (cache, ck) = ja.cache.clone().unwrap(); + assert!(cache.get(ck).is_none()); + let r = ja.run(); + s.accept(&r); + let first = r.rendered.unwrap().image; + assert!(first.width.max(first.height) <= 128); + assert!(cache.get(ck).is_some(), "rendered variants are cached"); + assert!(cache.get(jb.cache.as_ref().unwrap().1).is_none()); + // a second job for the same variant is served from the cache, pixel for pixel + let again = s.variant_job(id, &a, 128).unwrap().run(); + assert!(again.loaded.is_none()); + assert_eq!(again.rendered.unwrap().image.as_bytes(), first.as_bytes()); + let other = jb.run().rendered.unwrap().image; + assert_ne!(other.as_bytes(), first.as_bytes()); + // the photo's own settings never changed + assert_eq!(*s.develop_of(id).unwrap(), mine); + // hover previews: a loupe-sized job with other settings, its own key + let pv = s.preview_job(id, 800, 600, true, &a).unwrap(); + assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key); + assert_eq!(*pv.settings, a); +} From 50116167a6ddc16d883a2ad5725a76368b8b38ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:51 +0000 Subject: [PATCH 037/191] refactor(lightcraft-engine): move export.rs tests to crates/engine/src/export/tests.rs --- crates/engine/src/export.rs | 1456 +---------------------------- crates/engine/src/export/tests.rs | 1453 ++++++++++++++++++++++++++++ 2 files changed, 1454 insertions(+), 1455 deletions(-) create mode 100644 crates/engine/src/export/tests.rs diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 236387b4d..1ce4a0fee 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -2378,1458 +2378,4 @@ impl<'a> Placer<'a> { } #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn test_image() -> Rgba8 { - let mut img = Rgba8::new(96, 64); - for (i, p) in img.data.iter_mut().enumerate() { - let (x, y) = (i % 96, i / 96); - *p = [(x * 2) as u8, (y * 3) as u8, ((x * y) % 255) as u8, 255]; - } - img - } - - /// Issue #1670: removing a redundant alpha channel preserves samples and metadata. - #[test] - fn opaque_eight_bit_exports_are_rgb() { - let img = test_image(); - let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() }; - for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { - for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { - let options = ExportOptions { format, tiff_compression: compression, ..Default::default() }; - let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap(); - let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); - assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB"); - assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8)); - assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels"); - assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice())); - assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test")); - if format == ExportFormat::Png { - assert_eq!(bytes[25], 2, "PNG IHDR declares RGB"); - } - if format != ExportFormat::Tiff { - break; - } - } - } - } - - #[test] - fn eight_bit_exports_preserve_real_alpha() { - let mut img = test_image(); - img.data[0][3] = 128; - for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { - let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap(); - let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); - assert!(decoded.has_alpha, "{format:?} must retain real alpha"); - assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples"); - } - } - - #[test] - fn formats_have_magic() { - let img = test_image(); - for (f, magic) in - [(ExportFormat::Jpeg, &b"\xFF\xD8"[..]), (ExportFormat::Png, b"\x89PNG"), (ExportFormat::Tiff, b"II"), (ExportFormat::Webp, b"RIFF")] - { - let b = encode_image(&img, &ExportOptions { format: f, ..Default::default() }).unwrap(); - assert!(b.starts_with(magic), "{f:?}"); - } - } - - #[test] - fn size_limit_respected() { - let img = test_image(); - let full = encode_image(&img, &ExportOptions { quality: 100, ..Default::default() }).unwrap(); - let kb = (full.len() / 1024 / 2).max(2) as u32; - let b = encode_image(&img, &ExportOptions { quality: 100, limit_kb: Some(kb), ..Default::default() }).unwrap(); - assert!(b.len() <= kb as usize * 1024); - } - - #[test] - fn hdr_jpeg_size_limit_respected() { - let img = Rgba8::from_fn(256, 192, |x, y| { - let n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663); - [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255] - }); - let hdr = DeepImage { - width: img.width, - height: img.height, - space: OutputSpace::Srgb, - samples: DeepSamples::F32( - img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect(), - ), - }; - let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() }; - let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap(); - let kb = (full.len() / 2048) as u32; - let limited = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(kb), ..opts.clone() }, None).unwrap(); - assert!(limited.len() <= kb as usize * 1024, "{} bytes exceeds {kb} KB", limited.len()); - let decoded = lightcraft_codecs::gainmap::read_jpeg(&limited).unwrap(); - assert!(decoded.meta.alternate_headroom > 1.0, "the gain map must survive size limiting"); - let impossible = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(1), ..opts }, None); - assert!(impossible.is_err_and(|e| e.contains("cannot fit within 1 KB"))); - } - - #[test] - fn graphic_watermark_honours_exif_orientation() { - let dir = std::env::temp_dir().join(format!("lc-wm-orientation-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }); - for orientation in [2, 6] { - let meta = Metadata { orientation: Some(lightcraft_geom::Orientation::from_exif(orientation)), ..Default::default() }; - let exif = lightcraft_meta::write_exif(&meta); - let bytes = lightcraft_codecs::encode_jpeg( - &EncodeImage::rgba8(&logo), - 100, - ChromaSubsampling::S444, - &EncodeMeta { exif: Some(&exif), ..Default::default() }, - ) - .unwrap(); - let path = dir.join(format!("oriented-{orientation}.jpg")); - std::fs::write(&path, bytes).unwrap(); - let wm = Watermark { - image: path.to_string_lossy().into(), - image_width: if orientation == 6 { 0.25 } else { 0.5 }, - opacity: 1.0, - inset_x: 0.0, - inset_y: 0.0, - ..Default::default() - }; - let mut img = Rgba8::from_fn(80, 80, |_, _| [0, 0, 0, 255]); - draw_watermark(&mut img, &wm); - if orientation == 6 { - assert!(img.get(70, 45)[0] > 240 && img.get(70, 75)[2] > 240, "EXIF 6: red above blue"); - } else { - assert!(img.get(45, 70)[2] > 240 && img.get(75, 70)[0] > 240, "EXIF 2: blue left of red"); - } - } - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn graphic_watermark_keeps_aspect_when_fitted_and_rotated() { - let dir = std::env::temp_dir().join(format!("lc-wm-aspect-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("tall.png"); - let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); - std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); - for (rotation, expected) in [(0, (20, 80)), (90, (120, 30)), (180, (20, 80)), (270, (120, 30))] { - let wm = Watermark { - image: path.to_string_lossy().into(), - image_width: 0.5, - opacity: 1.0, - inset_x: 0.0, - inset_y: 0.0, - rotation, - ..Default::default() - }; - let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); - draw_watermark(&mut img, &wm); - let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); - let bounds = ( - lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap() + 1, - lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap() + 1, - ); - assert_eq!(bounds, expected, "{rotation} degrees: fitting must preserve the logo's aspect ratio"); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn graphic_watermark_fit_preserves_anchor_insets() { - let dir = std::env::temp_dir().join(format!("lc-wm-insets-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("tall.png"); - let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); - std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); - use Anchor::*; - for (anchor, rotation, expected) in [ - (BottomRight, 90, (0, 44, 111, 71)), - (TopLeft, 270, (8, 8, 119, 35)), - (Right, 90, (0, 26, 111, 53)), - (BottomRight, 0, (94, 0, 111, 71)), - (TopLeft, 180, (8, 8, 25, 79)), - (Bottom, 0, (51, 0, 68, 71)), - // Centered axes ignore their inset rather than shrinking the image unnecessarily. - (Center, 90, (0, 25, 119, 54)), - (Top, 90, (0, 8, 119, 37)), - ] { - let wm = Watermark { - image: path.to_string_lossy().into(), - image_width: 0.5, - opacity: 1.0, - inset_x: 0.1, - inset_y: 0.1, - rotation, - anchor, - ..Default::default() - }; - let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); - draw_watermark(&mut img, &wm); - let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); - let bounds = ( - lit.iter().map(|p| p.0).min().unwrap(), - lit.iter().map(|p| p.1).min().unwrap(), - lit.iter().map(|p| p.0).max().unwrap(), - lit.iter().map(|p| p.1).max().unwrap(), - ); - assert_eq!(bounds, expected, "{anchor:?}, {rotation} degrees"); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn sharpen_increases_edge_contrast() { - let mut img = Rgba8::new(8, 8); - for (i, p) in img.data.iter_mut().enumerate() { - let v = if i % 8 < 4 { 80 } else { 170 }; - *p = [v, v, v, 255]; - } - output_sharpen(&mut img, SharpenFor::Matte, SharpenAmount::High); - assert!(img.data[3][0] < 80 && img.data[4][0] > 170); - } - - /// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject` - /// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of - /// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`. - #[test] - fn exported_keywords_follow_their_options() { - use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source, keywords::KeywordInfo}; - let mut c = Catalog::new(); - c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); - c.apply(Op::SetKeyword { path: "draft".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); - let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); - p.meta.keywords = vec!["Places|Lisbon".into(), "travel|Italy".into(), "draft".into()]; - let m = export_metadata(&p, &c, &ExportOptions::default()).unwrap(); - assert_eq!(m.keywords, ["Lisbon", "Italy", "travel"]); - assert_eq!(m.hierarchical_keywords, ["Places|Lisbon", "travel|Italy"]); - let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&m)).unwrap(); - let back = lightcraft_meta::extract(&jpg); - assert_eq!((back.keywords, back.hierarchical_keywords), (m.keywords.clone(), m.hierarchical_keywords.clone())); - } - - #[test] - fn metadata_policies() { - use lightcraft_catalog::{Catalog, Photo, PhotoId, Source}; - let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); - p.meta.camera = "Synthetic X2".into(); - p.meta.copyright = "(c) Me".into(); - p.meta.copyright_status = lightcraft_catalog::CopyrightStatus::Copyrighted; - p.meta.usage_terms = "Editorial use only".into(); - p.meta.copyright_url = "https://example.com/rights".into(); - p.meta.shutter = "1/250".into(); - p.meta.gps = Some((43.0, -110.0)); - let all = export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap(); - assert_eq!(all.model.as_deref(), Some("Synthetic X2")); - assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9); - assert!(all.gps.is_some()); - let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() }; - let m = export_metadata(&p, &Catalog::new(), &o).unwrap(); - assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some()); - let c = export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap(); - assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me")); - // "copyright only" keeps all the copyright info: status, usage terms, info URL - assert_eq!( - (c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()), - (Some(true), Some("Editorial use only"), Some("https://example.com/rights")) - ); - assert!(export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none()); - // embedded and readable back from the JPEG - let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap(); - let back = lightcraft_meta::extract(&jpg); - assert_eq!(back.model.as_deref(), Some("Synthetic X2")); - assert_eq!(back.copyright.as_deref(), Some("(c) Me")); - assert_eq!((back.copyright_marked, back.usage_terms.as_deref()), (Some(true), Some("Editorial use only"))); - assert_eq!(back.copyright_url.as_deref(), Some("https://example.com/rights")); - assert!(back.gps.is_some()); - } - - #[test] - fn watermark_draws_in_the_anchored_corner_only() { - let mut img = Rgba8::new(400, 300); - for p in img.data.iter_mut() { - *p = [0, 0, 0, 255]; - } - let o = ExportOptions::from_json(&serde_json::json!({"watermark": {"text": "LightCraft", "size": 0.08, "opacity": 1.0, "shadow": false}})); - let wm = o.watermark.clone().unwrap(); - draw_watermark(&mut img, &wm); - let lit = |x0: usize, x1: usize, y0: usize, y1: usize| { - (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).count() - }; - assert!(lit(200, 400, 225, 300) > 100, "bottom-right has text"); - assert!(lit(0, 200, 0, 150) == 0, "top-left untouched"); - // string shorthand - assert_eq!(ExportOptions::from_json(&serde_json::json!({"watermark": "© Me"})).watermark.unwrap().text, "© Me"); - assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); - } - - /// Horizontal and vertical margins move a text watermark separately; settings saved with one - /// `inset` (before the two margins) read as both, and either new key overrides it. - #[test] - fn watermark_insets_are_horizontal_and_vertical() { - // gaps between the drawn text and the right and bottom edges of a 400 × 300 image - let gaps = |wm: &Watermark| { - let mut img = Rgba8::new(400, 300); - draw_watermark(&mut img, wm); - let lit: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); - let right = lit.iter().map(|p| p.0).max().unwrap(); - let bottom = lit.iter().map(|p| p.1).max().unwrap(); - (399 - right as i64, 299 - bottom as i64) - }; - let base = Watermark { text: "LC".into(), size: 0.1, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; - let (r0, b0) = gaps(&base); - // 0.2 and 0.1 of the 300 px short edge - let (r1, b1) = gaps(&Watermark { inset_x: 0.2, ..base.clone() }); - assert!((r1 - r0 - 60).abs() <= 1 && (b1 - b0).abs() <= 1, "horizontal: {r0},{b0} → {r1},{b1}"); - let (r2, b2) = gaps(&Watermark { inset_y: 0.1, ..base.clone() }); - assert!((r2 - r0).abs() <= 1 && (b2 - b0 - 30).abs() <= 1, "vertical: {r0},{b0} → {r2},{b2}"); - // the old single margin - let wm = |w: serde_json::Value| ExportOptions::from_json(&json!({"watermark": w})).watermark.unwrap(); - let old = wm(json!({"text": "LC", "inset": 0.1})); - assert_eq!((old.inset_x, old.inset_y), (0.1, 0.1)); - let mixed = wm(json!({"text": "LC", "inset": 0.1, "insetY": 0.3})); - assert_eq!((mixed.inset_x, mixed.inset_y), (0.1, 0.3)); - assert!(ExportOptions::from_params(&json!({"watermark": {"text": "LC", "inset": 0.1}})).is_ok()); - // saved and read back as the two margins - let o = ExportOptions { watermark: Some(Watermark { inset_x: 0.05, inset_y: 0.15, ..base }), ..Default::default() }; - let saved = o.to_json(); - assert!(saved["watermark"].get("inset").is_none(), "{saved}"); - assert_eq!(ExportOptions::from_json(&saved).watermark.map(|w| (w.inset_x, w.inset_y)), Some((0.05, 0.15))); - } - - /// A turned text watermark is placed by its turned box: taller than wide at 90° and 270°, the - /// same corner and insets, and an unknown angle draws it unturned. - #[test] - fn watermark_rotation_turns_the_text_in_its_corner() { - let lit = |wm: &Watermark| { - let mut img = Rgba8::new(400, 300); - draw_watermark(&mut img, wm); - let on: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); - let (x0, x1) = (on.iter().map(|p| p.0).min().unwrap(), on.iter().map(|p| p.0).max().unwrap()); - let (y0, y1) = (on.iter().map(|p| p.1).min().unwrap(), on.iter().map(|p| p.1).max().unwrap()); - (x0, x1, y0, y1, img) - }; - let base = Watermark { text: "LightCraft".into(), size: 0.08, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; - let (a0, a1, b0, b1, flat) = lit(&base); - assert!(a1 - a0 > 2 * (b1 - b0), "unturned text is wide"); - for rotation in [90, 270] { - let (x0, x1, y0, y1, _) = lit(&Watermark { rotation, ..base.clone() }); - assert!(y1 - y0 > 2 * (x1 - x0), "{rotation}°: tall"); - assert!(x1 >= 390 && y1 >= 290, "{rotation}°: still in the bottom-right corner ({x1}, {y1})"); - // 0.1 × 300 px further from the right edge - let (_, moved, ..) = lit(&Watermark { rotation, inset_x: 0.1, ..base.clone() }); - assert!((x1 as i64 - moved as i64 - 30).abs() <= 1, "{rotation}°: {x1} → {moved}"); - } - let (x0, x1, y0, y1, _) = lit(&Watermark { rotation: 180, ..base.clone() }); - assert!(((x1 - x0) as i64 - (a1 - a0) as i64).abs() <= 2 && ((y1 - y0) as i64 - (b1 - b0) as i64).abs() <= 2, "180°: same size"); - assert!(x1 >= 390 && y1 >= 290, "180°: same corner"); - let (.., odd) = lit(&Watermark { rotation: 45, ..base.clone() }); - assert_eq!(odd.data, flat.data, "an angle that isn't a quarter turn draws unturned"); - // saved and read back; older settings have none - let o = ExportOptions { watermark: Some(Watermark { rotation: 270, ..base }), ..Default::default() }; - assert_eq!(ExportOptions::from_json(&o.to_json()).watermark.map(|w| w.rotation), Some(270)); - assert_eq!(ExportOptions::from_json(&json!({"watermark": {"text": "x"}})).watermark.map(|w| w.rotation), Some(0)); - } - - /// A preview keeps the export's 6 px minimum text size as a share of the photo: a watermark - /// clamped to 6 px in a 150 px export covers the same share of a 600 px preview. - #[test] - fn watermark_preview_scales_the_minimum_text_size() { - let wm = Watermark { text: "LightCraft".into(), size: 0.01, opacity: 1.0, shadow: false, ..Default::default() }; - let rows = |img: &Rgba8| (0..img.height).filter(|&y| (0..img.width).any(|x| img.get(x, y)[0] > 64)).count() as f32; - let mut export = Rgba8::new(200, 150); - draw_watermark(&mut export, &wm); - let mut preview = Rgba8::new(800, 600); - draw_watermark_preview(&mut preview, &wm, 150); - let mut unscaled = Rgba8::new(800, 600); - draw_watermark(&mut unscaled, &wm); - let (export_share, preview_share) = (rows(&export) / 150.0, rows(&preview) / 600.0); - assert!((preview_share - export_share).abs() < 0.02, "export {export_share}, preview {preview_share}"); - assert!(rows(&unscaled) < rows(&preview) / 2.0, "the unscaled 6 px floor would draw it too small"); - // drawn at the export's own size, the preview is the export - let mut same = Rgba8::new(200, 150); - draw_watermark_preview(&mut same, &wm, 150); - assert_eq!(same.data, export.data); - } - - fn vertical_japanese() -> Watermark { - let options = - ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); - options.watermark.unwrap() - } - - #[test] - fn japanese_watermark_options_and_legacy_defaults() { - let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}})); - assert!(!old.watermark.unwrap().vertical); - let wm = vertical_japanese(); - assert!(wm.vertical); - let round: Watermark = serde_json::from_value(serde_json::to_value(&wm).unwrap()).unwrap(); - assert!(round.vertical); - } - - /// Built without craft-fonts, a Japanese watermark still lays out and draws (Inter's - /// missing-glyph boxes), and Latin text is unaffected. - #[test] - fn watermarks_work_without_craft_fonts() { - assert_eq!(watermark_fonts(&[]).len(), 1, "Inter only"); - for wm in [vertical_japanese(), Watermark { text: "LightCraft 日本語".into(), ..Watermark::default() }] { - let mut covered = 0usize; - watermark_coverage(400, 300, &wm, &[], 1.0, |_, _, k, _| covered += usize::from(k > 0.0)); - assert!(covered > 0, "{:?} draws something", wm.text); - } - } - - #[test] - fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() { - use ab_glyph::Font; - let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else { - lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts"); - return; - }; - let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); - // BIZ UDMincho has no Unicode presentation-form characters: the old fallback drew 、。. - assert_eq!(font.glyph_id('︑').0, 0); - assert_eq!(font.glyph_id('︒').0, 0); - let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap()); - for ch in ['、', '。'] { - let glyph = watermark_cell_glyphs(&font, &data, &ch.to_string(), true, 100.0, 0.0, 0.0).unwrap().remove(0); - assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}"); - let rect = font.outline_glyph(glyph).unwrap().px_bounds(); - assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}"); - } - let craft = Box::leak( - vec![crate::fonts::CraftFont { family: entry.family, style: entry.style, scripts: entry.scripts, bytes: entry.bytes }].into_boxed_slice(), - ); - let wm = Watermark { - text: "、。".into(), - vertical: true, - size: 0.1, - anchor: Anchor::TopLeft, - inset_x: 0.0, - inset_y: 0.0, - shadow: false, - opacity: 1.0, - ..Default::default() - }; - let mut pixels = Vec::new(); - watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| { - if k > 0.5 { - pixels.push((x, y)); - } - }); - assert!(!pixels.is_empty()); - for (x, y) in pixels { - assert!(x > 15 && y % 30 < 15, "actual export coverage at the upper right of the 30 px cell: {x}, {y}"); - } - } - - #[test] - fn vertical_watermarks_keep_combining_marks_in_one_cell() { - let coverage = |text: &str, craft| { - let wm = Watermark { - text: text.into(), - vertical: true, - size: 0.1, - anchor: Anchor::Center, - inset_x: 0.0, - inset_y: 0.0, - shadow: false, - opacity: 1.0, - ..Default::default() - }; - let mut pixels = vec![0.0f32; 400 * 300]; - watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| pixels[y * 400 + x] = k); - assert!(pixels.iter().any(|&k| k > 0.0)); - pixels - }; - let compare = |a: &str, b: &str, craft| { - let (a_pixels, b_pixels) = (coverage(a, craft), coverage(b, craft)); - let difference = a_pixels.iter().zip(&b_pixels).position(|(a, b)| a != b); - assert!(difference.is_none(), "{a:?} vs {b:?}: first differing pixel {difference:?}"); - }; - for (composed, decomposed) in [("éA", "e\u{301}A"), ("Å\nA", "A\u{30a}\nA"), ("A\nB", "A\r\nB"), ("日A", "日\u{e0100}A")] { - compare(composed, decomposed, &[]); - } - if crate::fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDMincho") { - for (composed, decomposed) in [("が日", "か\u{3099}日"), ("ぱ\n日", "は\u{309a}\n日")] { - compare(composed, decomposed, crate::fonts::CRAFT_FONTS); - } - } else { - lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts"); - } - } - - #[test] - fn upright_watermark_cell_retains_multiple_glyphs() { - use ab_glyph::{Font, ScaleFont}; - let font = ab_glyph::FontRef::try_from_slice(WATERMARK_FONT).unwrap(); - let data = harfrust::ShaperData::new(&harfrust::FontRef::new(WATERMARK_FONT).unwrap()); - // There is no precomposed A with a combining long solidus overlay. - let glyphs = watermark_cell_glyphs(&font, &data, "A\u{338}", false, 30.0, 0.0, 0.0).unwrap(); - assert_eq!(glyphs.len(), 2, "retain the base and the separately positioned combining glyph"); - let baseline = font.as_scaled(30.0).ascent(); - for glyph in glyphs { - assert!((glyph.position.y - baseline).abs() < 1.0, "no extra vertical cell for the mark"); - assert!(font.outline_glyph(glyph).is_some(), "both glyphs have ink"); - } - } - - #[test] - fn japanese_watermarks_support_vertical_columns() { - use ab_glyph::Font; - let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS); - if fonts.len() < 2 { - lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); - return; - } - for c in "日本語の文字".chars() { - assert!(fonts[1..].iter().any(|f| f.glyph_id(c).0 != 0), "a craft-fonts face has {c}"); - } - let wm = vertical_japanese(); - let mut img = Rgba8::new(400, 300); - img.data.fill([0, 0, 0, 255]); - draw_watermark(&mut img, &wm); - let lit: Vec<_> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 64).collect(); - assert!(lit.len() > 100); - let w = lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap(); - let h = lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap(); - assert!(h > w * 2, "vertical Japanese must extend down the column"); - let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset_x: 0.0, inset_y: 0.0, ..wm }; - img.data.fill([0, 0, 0, 255]); - draw_watermark(&mut img, &two); - for (text, dx) in [("本", 0), ("日", 30)] { - let mut single = Rgba8::new(400, 300); - single.data.fill([0, 0, 0, 255]); - draw_watermark(&mut single, &Watermark { text: text.into(), ..two.clone() }); - for y in 0..30 { - for x in 0..30 { - assert_eq!(img.get(x + dx, y), single.get(x, y), "newlines move columns left"); - } - } - } - } - - #[test] - fn graphic_watermark_is_placed_scaled_and_blended() { - let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let logo_path = dir.join("logo.png"); - // 40×20: left half opaque red, right half fully transparent - let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 255, 0, 0] }); - let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap(); - std::fs::write(&logo_path, png).unwrap(); - let mut img = Rgba8::new(400, 300); - img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); - let wm = Watermark { - image: logo_path.to_string_lossy().into(), - image_width: 0.25, - opacity: 1.0, - inset_x: 0.0, - inset_y: 0.0, - ..Default::default() - }; - draw_watermark(&mut img, &wm); - // 100 px wide × 50 px high in the bottom-right corner - assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280)); - assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo"); - assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo"); - assert_eq!(img.get(10, 10), [0, 0, 0, 255]); - // 30 px from the right edge and 15 px from the bottom: x 270..370, y 235..285 - let mut img = Rgba8::new(400, 300); - img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); - draw_watermark(&mut img, &Watermark { inset_x: 0.1, inset_y: 0.05, ..wm.clone() }); - assert!(img.get(272, 283)[0] > 200, "inside the moved logo: {:?}", img.get(272, 283)); - assert_eq!(img.get(267, 283), [0, 0, 0, 255], "left of the logo"); - assert_eq!(img.get(272, 288), [0, 0, 0, 255], "below the logo"); - // turned: 180° puts the opaque half on the right; 90° clockwise puts it on top of a 50 × 100 box - let turned = |rotation: u16| { - let mut img = Rgba8::new(400, 300); - img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); - draw_watermark(&mut img, &Watermark { rotation, ..wm.clone() }); - img - }; - let half = turned(180); - assert!(half.get(390, 280)[0] > 200 && half.get(310, 280) == [0, 0, 0, 255]); - let quarter = turned(90); - assert!(quarter.get(375, 220)[0] > 200, "top half of the turned logo: {:?}", quarter.get(375, 220)); - assert_eq!(quarter.get(375, 280), [0, 0, 0, 255], "its transparent half below"); - assert_eq!(quarter.get(340, 220), [0, 0, 0, 255], "left of the 50 px wide box"); - assert!(quarter.get(352, 202)[0] > 200 && quarter.get(398, 248)[0] > 200, "the whole box is covered"); - // three quarter turns: the opaque half at the bottom - let three = turned(270); - assert!(three.get(375, 280)[0] > 200 && three.get(375, 220) == [0, 0, 0, 255]); - // an image-only watermark survives the params (no text needed) - let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}})); - assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn naming_and_params() { - let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"})); - assert_eq!(o.format, ExportFormat::Jpeg); - assert_eq!(o.quality, 100); - assert_eq!(o.resize, Some(Resize { dont_enlarge: true, height: 0, ..Resize::long_edge(2048) })); - assert_eq!(o.file_name_for(&named("IMG/1.png"), 7), "IMG_1-007.jpg"); - assert_eq!(o.ppi, 240); - let orig = ExportOptions { format: ExportFormat::Original, naming: "{name}-{seq}".into(), ..Default::default() }; - assert_eq!(orig.file_name_for(&named("DSC_1.NEF"), 2), "DSC_1-002.NEF"); - assert_eq!(orig.file_name_for(&named("noext"), 1), "noext-001"); - assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for(&named("a.cr2"), 1), "a.dng"); - } - - fn named(file_name: &str) -> lightcraft_catalog::Photo { - use lightcraft_catalog::{Photo, PhotoId, Source}; - Photo::new(PhotoId(1), Source::Demo { scene: 0 }, file_name, "", 1, 1, "2026-01-01T00:00:00") - } - - /// Export naming takes the same tokens as Rename Photos and import renaming. - #[test] - fn naming_uses_the_rename_tokens() { - let mut p = named("DSC_0815.NEF"); - p.captured = Some("2026-09-30T14:05:09".into()); - p.meta.camera = "Model X/2".into(); - p.meta.title = "Harbour".into(); - p.rating = 5; - let o = |naming: &str| ExportOptions { naming: naming.into(), start_number: 9, ..Default::default() }; - assert_eq!(o("{date:%Y-%m-%d}_{title}_{seq:2}").file_name_for(&p, 1), "2026-09-30_Harbour_09.jpg"); - assert_eq!(o("{camera}-{num}-{rating}").file_name_for(&p, 1), "Model X_2-0815-5.jpg", "path separators become _"); - assert_eq!(o("{name}-{seq}").file_name_for(&p, 3), "DSC_0815-011.jpg", "a bare {{seq}} keeps its 3 digits"); - assert_eq!(o("{ext}_{name}").file_name_for(&p, 1), "NEF_DSC_0815.jpg"); - // nothing left after expanding: the original name - p.meta.title.clear(); - assert_eq!(o("{title}").file_name_for(&p, 1), "DSC_0815.jpg"); - } - - #[test] - fn params_round_trip() { - let o = ExportOptions { - format: ExportFormat::Tiff, - quality: 70, - resize: Some(Resize { mode: ResizeMode::Megapixels, value: 12.5, height: 0, dont_enlarge: false }), - ppi: 300, - limit_kb: Some(500), - sharpen: SharpenFor::Glossy, - naming: "{date}-{name}-{seq}".into(), - start_number: 42, - subfolder: "Client".into(), - conflict: Conflict::Skip, - tiff_compression: TiffCompression::Lzw, - metadata: MetadataPolicy::Copyright, - remove_location: true, - watermark: Some(Watermark { text: "© LC".into(), ..Default::default() }), - color_space: OutputSpace::ProPhoto, - bit_depth: Some(16), - ..Default::default() - }; - assert_eq!(ExportOptions::from_json(&o.to_json()), o); - let full = ExportOptions::default(); - assert_eq!(ExportOptions::from_json(&full.to_json()), full); - assert!(ExportOptions::has_size_param(&full.to_json()), "full size is explicit"); - let mut p = named("IMG.png"); - p.captured = Some("2026-09-30T10:00:00".into()); - assert_eq!(o.file_name_for(&p, 2), "20260930-IMG-043.tif"); - } - - #[test] - fn resize_modes() { - let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)); - // a 6000 × 4000 landscape and a 4000 × 6000 portrait - assert_eq!(size(json!({"longEdge": 1500}), 6000.0, 4000.0), Some((1500, 1000))); - assert_eq!(size(json!({"longEdge": 1500}), 4000.0, 6000.0), Some((1000, 1500))); - assert_eq!(size(json!({"shortEdge": 1080}), 6000.0, 4000.0), Some((1620, 1080))); - assert_eq!(size(json!({"width": 3000}), 4000.0, 6000.0), Some((3000, 4500))); - assert_eq!(size(json!({"height": 1000}), 6000.0, 4000.0), Some((1500, 1000))); - // W × H fits either orientation: the long edge gets the larger number - assert_eq!(size(json!({"width": 1000, "height": 800}), 6000.0, 4000.0), Some((1000, 667))); - assert_eq!(size(json!({"width": 1000, "height": 800}), 4000.0, 6000.0), Some((667, 1000))); - assert_eq!(size(json!({"width": 1000, "height": 500}), 6000.0, 4000.0), Some((750, 500))); - assert_eq!(size(json!({"width": 270, "height": 337, "fit": "dimensions"}), 480.0, 320.0), Some((337, 225))); - // Width & Height (`fit: widthHeight`) binds each edge to its own number: W limits the width, H the height (#1620) - assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 480.0, 320.0), Some((270, 180))); - assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 320.0, 480.0), Some((225, 337))); - assert_eq!(size(json!({"width": 240, "height": 300, "fit": "widthHeight"}), 480.0, 320.0), Some((240, 160))); - // `fit` only matters with both numbers - assert_eq!(size(json!({"width": 1000, "fit": "widthHeight"}), 6000.0, 4000.0), Some((1000, 667))); - let (w, h) = size(json!({"megapixels": 6}), 6000.0, 4000.0).unwrap(); - assert!(((w * h) as f64 - 6e6).abs() < 6e6 * 0.002, "{w}×{h}"); - assert_eq!(size(json!({"percent": 25}), 6000.0, 4000.0), Some((1500, 1000))); - // don't enlarge (default) vs enlarge - assert_eq!(size(json!({"longEdge": 8000}), 6000.0, 4000.0), Some((6000, 4000))); - assert_eq!(size(json!({"longEdge": 9000, "dontEnlarge": false}), 6000.0, 4000.0), Some((9000, 6000))); - // full size / absent - assert_eq!(size(json!({"longEdge": 0}), 6000.0, 4000.0), None); - assert_eq!(size(json!({}), 6000.0, 4000.0), None); - assert!(ExportOptions::has_size_param(&json!({"longEdge": 0})) && !ExportOptions::has_size_param(&json!({"quality": 3}))); - // the `resize` object form round-trips (what the dialog and prefs store) - let r = Resize { mode: ResizeMode::ShortEdge, value: 720.0, height: 0, dont_enlarge: false }; - let back = ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize; - assert_eq!(back, Some(r)); - let r = Resize { mode: ResizeMode::WidthHeight, value: 270.0, height: 337, dont_enlarge: true }; - assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize, Some(r)); - assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None); - assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300); - // never larger than the encoders' limit, never empty - assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((27713, 18475)), "512 MP at most"); - assert_eq!(size(json!({"width": 70000, "dontEnlarge": false}), 60000.0, 100.0), Some((65535, 109)), "65535 px an edge"); - assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1)); - } - - /// Typing 20000 for 2000 (or percent 10000) with Don't Enlarge off asked for gigapixels: the - /// render's first plane alone (34 GB at 65535 × 43690) failed to allocate, which aborts the - /// process. An enlargement stops at 512 megapixels; full-size and smaller exports of photos - /// larger than that are untouched. - #[test] - fn enlargements_stop_at_512_megapixels() { - let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)).unwrap(); - for p in [json!({"longEdge": 60000, "dontEnlarge": false}), json!({"megapixels": 5000}), json!({"percent": 10000, "dontEnlarge": false})] { - let (w, h) = size(p.clone(), 6000.0, 4000.0); - assert!((w * h) as f64 <= MAX_ENLARGED_PIXELS * 1.001, "{p}: {w}×{h}"); - assert!((w as f64 / h as f64 - 1.5).abs() < 0.001, "{p}: keeps the shape"); - } - // below the ceiling an enlargement is as asked - assert_eq!(size(json!({"longEdge": 12000, "dontEnlarge": false}), 6000.0, 4000.0), (12000, 8000)); - // a 600 MP panorama exports at full size, and smaller, as before; it is never enlarged - assert_eq!(size(json!({"percent": 100, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000)); - assert_eq!(size(json!({"percent": 50}), 30000.0, 20000.0), (15000, 10000)); - assert_eq!(size(json!({"percent": 200, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000)); - } - - /// An enlarged export that needs more memory than the computer has is refused with a message, - /// before it starts; everything else goes ahead. - #[test] - fn enlarged_exports_must_fit_the_computers_memory() { - let gb = 1usize << 30; - let why = too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(16 * gb)).unwrap(); - assert!(why.contains("27713 × 18475") && why.contains("20 GB") && why.contains("16 GB") && why.contains("smaller size"), "{why}"); - assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(32 * gb)), None, "fits"); - assert_eq!(too_big_for_memory((6000, 4000), 24_000_000, 20 * gb, Some(gb)), None, "full size is never refused"); - assert_eq!(too_big_for_memory((3000, 2000), 24_000_000, 20 * gb, Some(gb)), None, "nor a smaller export"); - assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, None), None, "unknown memory"); - } - - #[test] - fn resize_size_survives_the_render() { - // the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312) - let cropped = |r: Resize, w: u32, h: u32, x1: f64| { - let mut p = named("IMG.ARW"); - (p.width, p.height) = (w, h); - std::sync::Arc::make_mut(&mut p.develop).crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, x1, 1.0); - let (bw, bh) = output_size(&p, &ExportOptions { resize: Some(r), ..Default::default() }); - lightcraft_pipeline::output_size(w as usize, h as usize, &p.develop, &lightcraft_pipeline::RenderRequest::fit(bw, bh)) - }; - let rendered = |r: Resize, w: u32, h: u32| cropped(r, w, h, 1.0); - let mode = |mode: ResizeMode, value: f32, height: u32| Resize { mode, value, height, dont_enlarge: true }; - // a frame a hair narrower than 3:2 (a raw's default crop in floats) puts 1333 × 1.5 just under 1999.5 - let hair = 1.0 - 1e-9; - assert_eq!(cropped(Resize::long_edge(2000), 6000, 4000, hair), (2000, 1333)); - assert_eq!(cropped(Resize::long_edge(2048), 6000, 4000, hair), (2048, 1365)); - assert_eq!(cropped(mode(ResizeMode::Width, 2000.0, 0), 6000, 4000, hair), (2000, 1333)); - assert_eq!(cropped(mode(ResizeMode::ShortEdge, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); - assert_eq!(cropped(mode(ResizeMode::Height, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); - assert_eq!(rendered(Resize::long_edge(2000), 4000, 6000), (1333, 2000)); - // 3:2, 4:3, 16:9, 1:1 and their portraits, every mode: the render keeps what `apply` says - for (w, h) in [(6000u32, 4000u32), (6240, 4160), (4032, 3024), (5184, 3888), (3840, 2160), (6000, 3375), (4000, 4000)] { - for (w, h) in [(w, h), (h, w)] { - for value in [1000.0, 1080.0, 1333.0, 2000.0, 2048.0, 2500.0, 3000.0] { - for r in [ - Resize::long_edge(value as u32), - mode(ResizeMode::ShortEdge, value, 0), - mode(ResizeMode::Width, value, 0), - mode(ResizeMode::Height, value, 0), - mode(ResizeMode::Dimensions, value, 1365), - mode(ResizeMode::WidthHeight, value, 1365), - mode(ResizeMode::Megapixels, value / 1000.0, 0), - mode(ResizeMode::Percent, value / 100.0, 0), - ] { - let want = r.apply(w.into(), h.into()); - assert_eq!(rendered(r, w, h), want, "{r:?} on {w}×{h}"); - } - } - } - } - } - - /// A photo more than 65535 times as long as it is short (a 70000 × 1 strip) made `scale` - /// clamp with its lower bound above its upper one, which panics, in the export and in the - /// Export dialog's preview on the UI thread. The encoders' limit wins, the short edge keeps a - /// pixel. - #[test] - fn resize_survives_photos_too_long_for_the_encoders() { - let modes = [ - json!({"longEdge": 2000}), - json!({"shortEdge": 1}), - json!({"width": 500, "height": 500}), - json!({"megapixels": 1}), - json!({"percent": 50}), - json!({"percent": 1000, "dontEnlarge": false}), - ]; - for p in modes { - for (w, h) in [(70_000.0, 1.0), (1.0, 70_000.0), (1e9, 3.0), (65_536.0, 1.0)] { - let r = ExportOptions::from_json(&p).resize.unwrap(); - let (ow, oh) = r.apply(w, h); - assert!((1..=65_535).contains(&ow) && (1..=65_535).contains(&oh), "{p} on {w}×{h}: {ow}×{oh}"); - assert!(r.scale(w, h).is_finite()); - } - } - // (a short edge under a pixel would be scaled up to one, which the limit then caps) - assert_eq!(Resize::long_edge(2000).apply(70_000.0, 1.0), (65_535, 1)); - assert_eq!(Resize::long_edge(2000).apply(1.0, 70_000.0), (1, 65_535)); - } - - /// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`. - fn p3_green(space: OutputSpace) -> Rgba8 { - use lightcraft_color::{DISPLAY_P3, REC2020}; - let c = DISPLAY_P3.to_space(&REC2020).apply_f32([0.04, 0.45, 0.04]); - let src = lightcraft_raster::Rgb32f::filled(16, 16, c); - let req = lightcraft_pipeline::RenderRequest { space, ..lightcraft_pipeline::RenderRequest::fit(16, 16) }; - lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req).image - } - - /// Decode `bytes` and return the centre pixel in linear sRGB primaries (unclamped), plus the - /// recognised space of the embedded profile. - fn decoded_in_srgb(bytes: &[u8]) -> ([f32; 3], Option) { - let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); - let m = d.space.to_space(&lightcraft_color::SRGB); - (m.apply_f32(d.image.get(8, 8)), d.space.named) - } - - #[test] - fn p3_colour_survives_a_p3_export_and_is_clipped_in_srgb() { - let o = |space| ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() }; - let (p3, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::DisplayP3), &o(OutputSpace::DisplayP3)).unwrap()); - assert_eq!(named, Some(NamedSpace::DisplayP3)); - assert!(p3[0] < -0.03 || p3[2] < -0.03, "outside sRGB after a P3 round trip: {p3:?}"); - let (s, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::Srgb), &o(OutputSpace::Srgb)).unwrap()); - assert_eq!(named, Some(NamedSpace::Srgb)); - assert!(s.iter().all(|v| *v > -0.002), "{s:?}"); - // the green itself is about the same brightness either way - assert!((p3[1] - s[1]).abs() < 0.15, "{p3:?} {s:?}"); - } - - #[test] - fn every_space_embeds_its_own_profile_and_round_trips() { - let grey = { - let src = lightcraft_raster::Rgb32f::filled(16, 16, [0.18; 3]); - let base = lightcraft_pipeline::RenderRequest::fit(16, 16); - move |space| { - lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base }) - .image - } - }; - let reference = - decoded_in_srgb(&encode_image(&grey(OutputSpace::Srgb), &ExportOptions { format: ExportFormat::Tiff, ..Default::default() }).unwrap()).0; - for space in OutputSpace::ALL { - for format in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { - let o = ExportOptions { format, color_space: space, ..Default::default() }; - let bytes = encode_image(&grey(space), &o).unwrap(); - let (px, named) = decoded_in_srgb(&bytes); - assert_eq!(named, Some(named_space(space)), "{space:?} {format:?}"); - // a neutral grey decodes to the same linear value whatever the output space - for c in 0..3 { - assert!((px[c] - reference[c]).abs() < 0.01, "{space:?} {format:?}: {px:?} vs {reference:?}"); - } - } - let icc = icc::write_named(named_space(space)); - let info = icc::parse(&icc).expect("our profile parses"); - assert_eq!(info.named, Some(named_space(space))); - assert!(info.description.as_deref().is_some_and(|d| !d.is_empty())); - } - let o = ExportOptions { format: ExportFormat::Avif, color_space: OutputSpace::ProPhoto, ..Default::default() }; - assert_eq!(o.effective_space(), OutputSpace::Srgb); - let o = ExportOptions::from_json(&serde_json::json!({"colorSpace": "displayP3"})); - assert_eq!(o.color_space, OutputSpace::DisplayP3); - assert_eq!(ExportOptions::from_json(&serde_json::json!({"colorSpace": "Adobe RGB (1998) compatible"})).color_space, OutputSpace::AdobeRgb); - } - - #[test] - fn srgb_watermark_colour_is_converted() { - assert_eq!(srgb8_in(OutputSpace::Srgb, [10, 200, 30]), [10, 200, 30]); - for s in OutputSpace::ALL { - assert_eq!(srgb8_in(s, [255, 255, 255]), [255, 255, 255], "{s:?}"); - assert_eq!(srgb8_in(s, [0, 0, 0]), [0, 0, 0], "{s:?}"); - } - let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]); - assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}"); - } - - /// A shallow horizontal grey ramp (few 8-bit levels), rendered at `depth`. - fn ramp(depth: OutputDepth, space: OutputSpace) -> lightcraft_pipeline::Rendered { - let src = lightcraft_raster::Rgb32f::from_fn(1024, 4, |x, _| [0.10 + 0.03 * x as f32 / 1023.0; 3]); - let req = lightcraft_pipeline::RenderRequest { depth, space, ..lightcraft_pipeline::RenderRequest::fit(1024, 4) }; - lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req) - } - - fn distinct_levels(bytes: &[u8]) -> (usize, lightcraft_codecs::Decoded) { - let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); - let mut v: Vec = (0..d.image.width).map(|x| d.image.get(x, 1)[1].to_bits()).collect(); - v.dedup(); - (v.len(), d) - } - - #[test] - fn sixteen_bit_tiff_and_png_have_no_banding() { - let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() }; - assert_eq!(tiff(None).effective_depth(), OutputDepth::U16, "TIFF defaults to 16-bit"); - let r16 = ramp(OutputDepth::U16, OutputSpace::Srgb); - assert!(matches!(r16.deep.as_ref().unwrap().samples, DeepSamples::U16(_))); - let (levels16, d16) = distinct_levels(&encode_rendered(&r16, &tiff(None), None).unwrap()); - let r8 = ramp(OutputDepth::U8, OutputSpace::Srgb); - let (levels8, _) = distinct_levels(&encode_rendered(&r8, &tiff(Some(8)), None).unwrap()); - eprintln!("gradient levels: 16-bit TIFF {levels16}, 8-bit TIFF {levels8}"); - assert!(levels8 < 40, "{levels8}"); - assert!(levels16 > 10 * levels8, "16-bit: {levels16} levels vs 8-bit: {levels8}"); - assert_eq!(d16.space.named, Some(NamedSpace::Srgb)); - // the 8-bit preview of a deep render matches the 8-bit render - for (a, b) in r16.image.data.iter().zip(&r8.image.data) { - assert!(a[1].abs_diff(b[1]) <= 1, "{a:?} {b:?}"); - } - let png = ExportOptions { format: ExportFormat::Png, bit_depth: Some(16), color_space: OutputSpace::DisplayP3, ..Default::default() }; - let rp = ramp(OutputDepth::U16, OutputSpace::DisplayP3); - let bytes = encode_rendered(&rp, &png, None).unwrap(); - assert_eq!(bytes[24], 16, "PNG IHDR bit depth"); - let (levels, d) = distinct_levels(&bytes); - assert!(levels > 10 * levels8, "{levels}"); - assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); - // sharpening and watermarking work on deep images too - let o = ExportOptions { - sharpen: SharpenFor::Matte, - watermark: Some(Watermark { text: "LC".into(), size: 0.5, ..Default::default() }), - ..tiff(None) - }; - assert!(encode_rendered(&r16, &o, None).is_ok()); - } - - #[test] - fn float_tiff_is_linear_with_a_linear_profile() { - let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(32), color_space: OutputSpace::ProPhoto, ..Default::default() }; - assert_eq!(o.effective_depth(), OutputDepth::F32Linear); - let rf = ramp(OutputDepth::F32Linear, OutputSpace::ProPhoto); - let (levels, df) = distinct_levels(&encode_rendered(&rf, &o, None).unwrap()); - assert!(levels > 500, "{levels}"); - assert_eq!(df.space.named, Some(NamedSpace::ProPhoto)); - assert!(df.space.trc.as_ref().is_some_and(|t| t[0].is_linear()), "{:?}", df.space.trc); - // decodes to the same light as the 16-bit gamma-encoded export - let o16 = ExportOptions { bit_depth: Some(16), ..o.clone() }; - let (_, d16) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::ProPhoto), &o16, None).unwrap()); - for x in [0, 300, 700, 1023] { - let (a, b) = (df.image.get(x, 1)[1], d16.image.get(x, 1)[1]); - assert!((a - b).abs() < 2e-4, "{x}: {a} vs {b}"); - } - } - - #[test] - fn jxl_options() { - assert_eq!(ExportFormat::parse("JPEG-XL"), Some(ExportFormat::Jxl)); - let o = ExportOptions::from_params(&json!({"format": "jxl", "jxlLossless": true, "jxlEffort": "fast", "jxlProgressive": true})).unwrap(); - assert_eq!((o.format, o.jxl_lossless, o.jxl_effort, o.jxl_progressive), (ExportFormat::Jxl, true, JxlEffort::Fast, true)); - assert_eq!(ExportOptions::from_json(&o.to_json()), o, "saved presets keep the JPEG XL settings"); - assert!(ExportOptions::from_params(&json!({"jxlEffort": "best"})).is_err()); - assert!(ExportOptions::from_params(&json!({"jxlLossless": "yes"})).is_err()); - let space = |lossless, color_space, hdr| { - ExportOptions { format: ExportFormat::Jxl, jxl_lossless: lossless, color_space, hdr, ..Default::default() }.effective_space() - }; - assert_eq!(space(false, OutputSpace::DisplayP3, false), OutputSpace::DisplayP3); - assert_eq!(space(false, OutputSpace::ProPhoto, false), OutputSpace::Rec2020, "lossy has no ProPhoto enum"); - assert_eq!(space(true, OutputSpace::ProPhoto, false), OutputSpace::ProPhoto, "lossless keeps the profile"); - assert_eq!(space(true, OutputSpace::Srgb, true), OutputSpace::Rec2020); - let o = ExportOptions { format: ExportFormat::Jxl, hdr: true, ..Default::default() }; - assert!(o.hdr_output()); - assert_eq!(o.effective_depth(), OutputDepth::F32Hdr); - assert_eq!(ExportFormat::Jxl.extension(), "jxl"); - } - - #[test] - fn jxl_every_space_round_trips() { - // wide-gamut green stays outside sRGB in P3, Rec. 2020 and (lossless) ProPhoto files - for (lossless, space) in [ - (false, OutputSpace::Srgb), - (false, OutputSpace::DisplayP3), - (false, OutputSpace::Rec2020), - (true, OutputSpace::DisplayP3), - (true, OutputSpace::AdobeRgb), - (true, OutputSpace::ProPhoto), - ] { - let o = ExportOptions { format: ExportFormat::Jxl, color_space: space, jxl_lossless: lossless, quality: 95, ..Default::default() }; - let png = ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() }; - let img = p3_green(space); - let bytes = encode_image(&img, &o).unwrap(); - assert_eq!(lightcraft_codecs::sniff(&bytes), Some(lightcraft_codecs::Format::Jxl)); - let (got, _) = decoded_in_srgb(&bytes); - let (want, _) = decoded_in_srgb(&encode_image(&img, &png).unwrap()); - for c in 0..3 { - assert!((got[c] - want[c]).abs() < 0.01, "{space:?} lossless={lossless}: {got:?} vs PNG {want:?}"); - } - if space != OutputSpace::Srgb { - assert!(got[0] < -0.03 || got[2] < -0.03, "{space:?}: outside sRGB after the round trip: {got:?}"); - } - } - } - - #[test] - fn jxl_sixteen_bit() { - let o = ExportOptions { - format: ExportFormat::Jxl, - bit_depth: Some(16), - jxl_lossless: true, - color_space: OutputSpace::DisplayP3, - ..Default::default() - }; - assert_eq!(o.effective_depth(), OutputDepth::U16); - let (levels8, _) = distinct_levels( - &encode_rendered(&ramp(OutputDepth::U8, OutputSpace::DisplayP3), &ExportOptions { bit_depth: Some(8), ..o.clone() }, None).unwrap(), - ); - let (levels, d) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &o, None).unwrap()); - assert_eq!(d.bit_depth, 16); - assert!(levels > 10 * levels8, "{levels} vs {levels8}"); - // lossy 16-bit encodes too - let lossy = ExportOptions { jxl_lossless: false, ..o }; - assert!(encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &lossy, None).is_ok()); - } - - #[test] - fn bit_depth_options_per_format() { - let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth(); - assert_eq!(d(ExportFormat::Jpeg, Some(16)), OutputDepth::U8); - assert_eq!(d(ExportFormat::Webp, None), OutputDepth::U8); - assert_eq!(d(ExportFormat::Png, None), OutputDepth::U8); - assert_eq!(d(ExportFormat::Png, Some(16)), OutputDepth::U16); - assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16); - assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16)); - assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None); - assert_eq!(d(ExportFormat::Jxl, Some(16)), OutputDepth::U16); - assert_eq!(d(ExportFormat::Jxl, None), OutputDepth::U8); - for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif, ExportFormat::Jxl] { - let first = ExportOptions::bit_depths(f)[0].0; - let def = d(f, None); - assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default"); - } - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn avif_ten_bit() { - let o = ExportOptions { format: ExportFormat::Avif, bit_depth: Some(10), quality: 60, ..Default::default() }; - let r = ramp(OutputDepth::U16, OutputSpace::Srgb); - match encode_rendered(&r, &o, None) { - Ok(b) => assert_eq!(&b[4..8], b"ftyp"), - Err(e) => assert!(e.contains("not available"), "{e}"), - } - } - - /// Issue #181: a command about to export refuses what `from_json` would silently default — - /// unknown keys (naming the nearest known one) and values of the wrong kind or range. - #[test] - fn export_params_are_validated() { - use serde_json::json; - let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); - let e = err(json!({"longEdgee": 400})); - assert!(e.starts_with("invalid parameters for `app.export`: unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}"); - assert!(err(json!({"qualty": 5})).contains("did you mean `quality`")); - assert!(err(json!({"longEdge": "banana"})).contains("`longEdge` must be a number")); - assert!(err(json!({"quality": 0})).contains("`quality` must be an integer 1..100")); - assert!(err(json!({"quality": "5"})).contains("`quality` must be an integer")); - assert!(err(json!({"quality": 92.5})).contains("`quality` must be an integer")); - assert!(err(json!({"sharpen": "lots"})).contains("`sharpen` must be one of none|screen|matte|glossy")); - assert!(err(json!({"format": "bmp"})).contains("`format` must be one of")); - assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32")); - assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of")); - assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false")); - assert!(err(json!({"fit": "sideways"})).contains("`fit` must be one of dimensions|widthHeight")); - assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`")); - assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:")); - assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids")); - assert!(err(json!("jpeg")).contains("params must be an object")); - assert!( - ExportOptions::validate("export.savePreset", &json!({"qualty": 5})) - .unwrap_err() - .to_string() - .starts_with("invalid parameters for `export.savePreset`") - ); - // everything the presets, the dialog, Export with Previous and `render --opt` send passes - for p in builtin_presets() { - ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name)); - } - let full = ExportOptions { - resize: Some(Resize::default()), - limit_kb: Some(500), - watermark: Some(Watermark { text: "©".into(), ..Default::default() }), - format: ExportFormat::Png, - bit_depth: Some(16), - ..Default::default() - }; - let mut j = full.to_json(); - for (k, v) in [ - ("dir", json!("/tmp/out")), - ("background", json!(true)), - ("preset", json!("x")), - ("ids", json!([1, 2])), - ("id", json!(1)), - ("path", json!("a.jpg")), - ] { - j[k] = v; - } - assert_eq!(ExportOptions::from_params(&j).unwrap(), full); - // Add to Apple Photos (issue #236): a switch and an album name; on only where Photos is - assert!(err(json!({"addToPhotos": "yes"})).contains("`addToPhotos` must be true or false")); - assert!(err(json!({"photosAlbum": 3})).contains("`photosAlbum` must be a string")); - assert!(err(json!({"photosAlbum": "a\0b"})).contains("NUL")); - assert!(err(json!({"photosAlbm": "x"})).contains("did you mean `photosAlbum`")); - let album = ExportOptions { photos_album: "Trip “2026” \\ \"x\"".into(), ..Default::default() }; - assert_eq!(ExportOptions::from_params(&album.to_json()).unwrap(), album); - let on = ExportOptions { add_to_photos: true, ..album }; - if cfg!(target_os = "macos") { - assert_eq!(ExportOptions::from_params(&on.to_json()).unwrap(), on); - } else { - assert!(err(on.to_json()).contains("Apple Photos is only available on macOS")); - // stored settings (a preset or Export with Previous made on a Mac) still read - assert!(ExportOptions::from_json(&on.to_json()).add_to_photos); - } - assert_eq!(ExportOptions::from_json(&json!({"photosAlbum": " Trip "})).photos_album, "Trip"); - assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default()); - // null is "not given", as a dropped key is - ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); - } - - /// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers - /// it), not written at the format's own depth; the error names the depths the format takes. - #[test] - fn bit_depth_must_suit_the_format() { - use serde_json::json; - let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); - assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg")); - assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png")); - assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff")); - // no `format`: the one `path` names, else the default (JPEG) - assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png")); - assert!(err(json!({"bitDepth": 16})).contains("for jpeg")); - assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok()); - // a path naming no format, or a `null` format, exports the default too - assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg")); - assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg")); - assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg")); - // or the caller's own format for such a path, which is the one returned - let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png); - assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png")); - assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png); - assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff); - // stored settings (a preset, Export with Previous) lose such a depth instead of failing - let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16})); - assert_eq!(stored.get("bitDepth"), None); - assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32); - for f in [ - ExportFormat::Jpeg, - ExportFormat::Png, - ExportFormat::Tiff, - ExportFormat::Psd, - ExportFormat::Webp, - ExportFormat::Avif, - ExportFormat::Jxl, - ExportFormat::Original, - ExportFormat::Dng, - ExportFormat::RenderedDng, - ] { - for &(b, _) in ExportOptions::bit_depths(f) { - let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() }; - assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}"); - } - } - } - - /// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own - /// folder; it is checked like the other params and kept in presets and Export with Previous. - #[test] - fn destination_mode_param() { - use serde_json::json; - assert_eq!(ExportOptions::default().destination_mode, DestinationMode::SpecificFolder); - for (v, want) in [ - ("sameAsOriginal", DestinationMode::SameAsOriginal), - ("SameAsOriginal", DestinationMode::SameAsOriginal), - ("specificFolder", DestinationMode::SpecificFolder), - ] { - assert_eq!(ExportOptions::from_params(&json!({"destinationMode": v})).unwrap().destination_mode, want, "{v}"); - } - let e = ExportOptions::from_params(&json!({"destinationMode": "nextToIt"})).unwrap_err().to_string(); - assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}"); - assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err()); - assert!( - ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"})) - .unwrap_err() - .to_string() - .contains("did you mean `destinationMode`") - ); - // saved (a preset, `lastExport`) and read back - let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; - let j = o.to_json(); - assert_eq!(j["destinationMode"], "sameAsOriginal"); - assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o); - } - - /// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the - /// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and - /// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and - /// `conflict` had the same gap. - #[test] - fn accepted_choices_are_the_ones_exported() { - use serde_json::json; - let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string()); - let metadata = [ - ("all", MetadataPolicy::All), - ("allExceptCamera", MetadataPolicy::AllExceptCamera), - ("copyright", MetadataPolicy::Copyright), - ("none", MetadataPolicy::None), - ]; - for (name, want) in metadata { - for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] { - assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}"); - } - } - for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)] - { - assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want); - } - for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] { - assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want); - } - for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] { - assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want); - } - // an explicit value nothing reads as is an error, never the permissive default - for v in ["", " none", "nothing", "all-except-camera"] { - let e = read("metadata", v).unwrap_err(); - assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}"); - } - assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high")); - assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip")); - // absent (or null) keeps the defaults - let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap(); - assert_eq!( - (d.metadata, d.sharpen, d.sharpen_amount, d.conflict), - (MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique) - ); - // `removeLocation` stays independent of the policy's spelling - let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap(); - assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true)); - // stored settings (lenient `from_json`) read the same spellings - assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None); - // and `to_json` still writes what the MCP schema lists - let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json(); - assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip"))); - } - - /// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in - /// the `app.export` param string), instead of unknown keys passing and sizes clamping silently. - #[test] - fn watermark_params_are_validated() { - use serde_json::json; - let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default(); - assert!(err(json!(5)).contains( - "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}" - )); - assert!(err(json!({"text": "x", "bogus": 1})).contains( - "unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth)" - )); - assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`")); - for size in [json!(3), json!(0.5001), json!(0), json!("big")] { - let e = err(json!({"text": "x", "size": size})); - assert!(e.contains("`watermark.size` must be a number 0.005..0.5 (fraction of the short edge)"), "{size}: {e}"); - } - assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1")); - assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4")); - assert!(err(json!({"text": "x", "insetX": 0.5})).contains("`watermark.insetX` must be a number 0..0.4")); - assert!(err(json!({"text": "x", "insetY": "far"})).contains("`watermark.insetY` must be a number 0..0.4")); - for rotation in [json!(45), json!(-90), json!(360), json!("90"), json!(90.5)] { - let e = err(json!({"text": "x", "rotation": rotation})); - assert!(e.contains("`watermark.rotation` must be 0, 90, 180 or 270 (degrees clockwise)"), "{rotation}: {e}"); - } - assert!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "rotation": 270}})).is_ok()); - assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1")); - assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of")); - assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]")); - assert!(err(json!({"text": "x", "color": [255, 255, 256]})).contains("`watermark.color`")); - assert!(err(json!({"text": "x", "vertical": "yes"})).contains("`watermark.vertical` must be true or false")); - assert!(err(json!({"text": 7})).contains("`watermark.text` must be a string")); - // the documented shapes pass and read as before - let o = ExportOptions::from_params(&json!({"watermark": "© me"})).unwrap(); - assert_eq!(o.watermark.map(|w| w.text).as_deref(), Some("© me")); - let w = json!({"text": "日本語", "vertical": true, "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "inset": 0.05, "color": [0, 0, 0], "shadow": false, "image": "", "imageWidth": 0.3}); - let wm = ExportOptions::from_params(&json!({"watermark": w})).unwrap().watermark.unwrap(); - assert!((wm.size - 0.1).abs() < 1e-6 && wm.vertical && wm.anchor == Anchor::TopLeft && wm.color == [0, 0, 0] && !wm.shadow); - assert_eq!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "size": 0.5}})).unwrap().watermark.map(|w| w.size), Some(0.5)); - // the Export dialog's sliders (1–15 % size, 5–100 % opacity, 2–100 % width) stay inside the accepted ranges - assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15); - assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0); - } - - /// A batch with `lanes` photos in flight into a fake folder `out` (nothing exists there): - /// results, writes (path, bytes) and progress calls; `progress` returns false at `cancel_at`. - #[allow(clippy::type_complexity)] - fn lanes_batch( - s: &mut crate::Session, - ids: &[lightcraft_catalog::PhotoId], - o: &ExportOptions, - lanes: usize, - stop_on_error: bool, - cancel_at: usize, - ) -> (Result, String>, Vec<(String, Vec)>, Vec<(usize, String)>) { - let items = prepare_batch(s, ids, o).unwrap(); - let to = Destination { dir: "out".into(), exact: None }; - let mut written = Vec::new(); - let mut write = |p: &str, b: &[u8]| { - written.push((p.to_string(), b.to_vec())); - Ok(()) - }; - let mut seen = Vec::new(); - let mut progress = |i: usize, name: &str| { - seen.push((i, name.to_string())); - i < cancel_at - }; - let files = run_batch_with(items, &mut Placer::new(o, &to, &mut write, &|_| false, stop_on_error), &mut progress, lanes); - (files, written, seen) - } - - /// The files of a batch without their byte counts: a GPU render (when another test turns the - /// GPU off mid-run) may differ from a CPU one by an LSB, never in name, size or order. - fn shape(files: &[serde_json::Value]) -> Vec { - files.iter().map(|f| json!({"path": f["path"], "width": f["width"], "height": f["height"], "error": f["error"]})).collect() - } - - /// Issue #675: a batch reports a render that fell back from the GPU to the CPU on that file's - /// entry (`gpuFallback`, the render's own reason) and says nothing on the others. - #[test] - fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { - if !lightcraft_gpu::available() { - lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); - return; - } - let mut s = crate::Session::with_demo(); - let ids: Vec<_> = s.visible().iter().copied().take(2).collect(); - let o = ExportOptions::from_json(&json!({"format": "png", "width": 64})); - // one lane: both photos render on this thread, and the fault hits the first render here - lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::Limit(1 << 10)); - let (files, _, _) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); - let files = files.unwrap(); - assert_eq!(files.len(), 2, "{files:?}"); - let why: Vec<_> = files.iter().filter_map(|f| f.get("gpuFallback").and_then(serde_json::Value::as_str)).collect(); - assert_eq!(why.len(), 1, "exactly one file fell back: {files:?}"); - assert!(why[0].contains("limit"), "{files:?}"); - assert!(files[1].get("gpuFallback").is_none(), "the second render was fine: {files:?}"); - assert!(lightcraft_gpu::available(), "a limit is no device failure"); - } - - // Issue #496: photos rendered side by side give the same files, names, order and progress as - // Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture - #[test] - fn rendered_dng_matches_the_rendered_tiff() { - let mut s = crate::Session::with_demo(); - let id = s.visible()[0]; - let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap(); - assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name); - let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap(); - let raw = lightcraft_raw::decode(&dng.bytes).unwrap(); - assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3)); - assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one"); - let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); - let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image; - assert_eq!((reference.width, reference.height), (back.width, back.height)); - // mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB - let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::() / back.data.len() as f32; - let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::() / reference.data.len() as f32; - assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}"); - } - - // one at a time, and a cancel stops at the same photo - #[test] - fn side_by_side_batches_match_one_at_a_time() { - let mut s = crate::Session::with_demo(); - let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); - // one name for all: the Unique numbering follows the order the files are placed in - let o = ExportOptions::from_json(&json!({"format": "png", "width": 40, "naming": "same"})); - let (files, written, seen) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); - let files = files.unwrap(); - let paths: Vec<_> = written.iter().map(|(p, _)| p.clone()).collect(); - assert_eq!(paths, ["out/same.png", "out/same-2.png", "out/same-3.png", "out/same-4.png", "out/same-5.png"]); - let heights: std::collections::HashSet<_> = files.iter().map(|f| f["height"].as_u64()).collect(); - assert!(heights.len() > 1, "photos of different shapes show the order: {files:?}"); - for lanes in [2, 3, 8] { - let (f, w, sn) = lanes_batch(&mut s, &ids, &o, lanes, true, usize::MAX); - assert_eq!(shape(&f.unwrap()), shape(&files), "{lanes} lanes"); - assert_eq!(w.iter().map(|(p, _)| p.clone()).collect::>(), paths, "{lanes} lanes"); - assert_eq!(sn, seen, "{lanes} lanes"); - } - let (f, w, sn) = lanes_batch(&mut s, &ids, &o, 3, true, 2); - assert_eq!(shape(&f.unwrap()), shape(&files[..2])); - assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written"); - } - - // A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it - #[test] - fn a_panic_in_progress_does_not_hang_the_lanes() { - let mut s = crate::Session::with_demo(); - let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); - let o = ExportOptions::from_json(&json!({"format": "png", "width": 40})); - let items = prepare_batch(&mut s, &ids, &o).unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let to = Destination { dir: "out".into(), exact: None }; - let mut write = |_: &str, _: &[u8]| Ok(()); - let mut calls = 0; - let mut progress = |_: usize, _: &str| { - calls += 1; - assert!(calls < 2, "progress panics on its second call"); - true - }; - let r = crate::guard::catch("test batch", || { - run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2) - }); - tx.send(r.and_then(|r| r)).ok(); - }); - let r = rx.recv_timeout(std::time::Duration::from_secs(60)); - assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}"); - } - - // A photo that fails is reported in its place while the others are exported; with - // stop_on_error the batch ends there and nothing after it is written - #[test] - fn side_by_side_batches_report_failures_in_order() { - use lightcraft_catalog::{Op, Photo, PhotoId, Source}; - let mut s = crate::Session::with_demo(); - let mut ids: Vec<_> = s.visible().iter().copied().take(3).collect(); - let gone = - Photo::new(PhotoId(9_496), Source::File { path: "no/such/dir/gone.jpg".into() }, "gone.jpg", "JPEG", 600, 400, "2026-10-09T00:00:00"); - s.catalog.apply(Op::AddPhoto { photo: Box::new(gone) }).unwrap(); - ids.insert(1, PhotoId(9_496)); - let o = ExportOptions::from_json(&json!({"format": "jpeg", "width": 40})); - let (files, written, _) = lanes_batch(&mut s, &ids, &o, 3, false, usize::MAX); - let files = files.unwrap(); - assert_eq!(files.len(), 4); - assert_eq!((files[1]["photo"].as_u64(), files[1]["file"].as_str()), (Some(9_496), Some("gone.jpg")), "{files:?}"); - assert!(files[1]["error"].is_string()); - assert!([0, 2, 3].iter().all(|&i| files[i]["path"].is_string()), "{files:?}"); - assert_eq!(written.len(), 3); - let one = lanes_batch(&mut s, &ids, &o, 1, false, usize::MAX).0.unwrap(); - assert_eq!(shape(&files), shape(&one)); - - let (r, written, _) = lanes_batch(&mut s, &ids, &o, 3, true, usize::MAX); - assert!(r.is_err()); - assert_eq!(written.len(), 1, "only the photo before the failure"); - } - - #[test] - fn export_weights_follow_the_source_and_output_sizes() { - use crate::media::SourceLevel; - let mb = |b: usize| b >> 20; - // a full-size 24 MP export (measured peak ~1.2 GB) - assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Full, 6000 * 4000)), 1098); - // 2048 px from the 2560 px preview (measured ~150 MB) - assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Preview, 2048 * 1365)), 145); - // a photo smaller than the preview level is its own source - assert_eq!(render_weight(1000 * 500, 1000, SourceLevel::Preview, 0), 1000 * 500 * 12); - assert_eq!(render_weight(0, 0, SourceLevel::Full, 0), 0); - assert!(render_weight(usize::MAX, 1, SourceLevel::Full, usize::MAX) > 0, "no overflow"); - } -} +mod tests; diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs new file mode 100644 index 000000000..ca3fba3f8 --- /dev/null +++ b/crates/engine/src/export/tests.rs @@ -0,0 +1,1453 @@ +use super::*; +use serde_json::json; + +fn test_image() -> Rgba8 { + let mut img = Rgba8::new(96, 64); + for (i, p) in img.data.iter_mut().enumerate() { + let (x, y) = (i % 96, i / 96); + *p = [(x * 2) as u8, (y * 3) as u8, ((x * y) % 255) as u8, 255]; + } + img +} + +/// Issue #1670: removing a redundant alpha channel preserves samples and metadata. +#[test] +fn opaque_eight_bit_exports_are_rgb() { + let img = test_image(); + let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() }; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let options = ExportOptions { format, tiff_compression: compression, ..Default::default() }; + let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB"); + assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8)); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels"); + assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice())); + assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test")); + if format == ExportFormat::Png { + assert_eq!(bytes[25], 2, "PNG IHDR declares RGB"); + } + if format != ExportFormat::Tiff { + break; + } + } + } +} + +#[test] +fn eight_bit_exports_preserve_real_alpha() { + let mut img = test_image(); + img.data[0][3] = 128; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(decoded.has_alpha, "{format:?} must retain real alpha"); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples"); + } +} + +#[test] +fn formats_have_magic() { + let img = test_image(); + for (f, magic) in + [(ExportFormat::Jpeg, &b"\xFF\xD8"[..]), (ExportFormat::Png, b"\x89PNG"), (ExportFormat::Tiff, b"II"), (ExportFormat::Webp, b"RIFF")] + { + let b = encode_image(&img, &ExportOptions { format: f, ..Default::default() }).unwrap(); + assert!(b.starts_with(magic), "{f:?}"); + } +} + +#[test] +fn size_limit_respected() { + let img = test_image(); + let full = encode_image(&img, &ExportOptions { quality: 100, ..Default::default() }).unwrap(); + let kb = (full.len() / 1024 / 2).max(2) as u32; + let b = encode_image(&img, &ExportOptions { quality: 100, limit_kb: Some(kb), ..Default::default() }).unwrap(); + assert!(b.len() <= kb as usize * 1024); +} + +#[test] +fn hdr_jpeg_size_limit_respected() { + let img = Rgba8::from_fn(256, 192, |x, y| { + let n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663); + [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255] + }); + let hdr = DeepImage { + width: img.width, + height: img.height, + space: OutputSpace::Srgb, + samples: DeepSamples::F32( + img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect(), + ), + }; + let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() }; + let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap(); + let kb = (full.len() / 2048) as u32; + let limited = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(kb), ..opts.clone() }, None).unwrap(); + assert!(limited.len() <= kb as usize * 1024, "{} bytes exceeds {kb} KB", limited.len()); + let decoded = lightcraft_codecs::gainmap::read_jpeg(&limited).unwrap(); + assert!(decoded.meta.alternate_headroom > 1.0, "the gain map must survive size limiting"); + let impossible = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(1), ..opts }, None); + assert!(impossible.is_err_and(|e| e.contains("cannot fit within 1 KB"))); +} + +#[test] +fn graphic_watermark_honours_exif_orientation() { + let dir = std::env::temp_dir().join(format!("lc-wm-orientation-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }); + for orientation in [2, 6] { + let meta = Metadata { orientation: Some(lightcraft_geom::Orientation::from_exif(orientation)), ..Default::default() }; + let exif = lightcraft_meta::write_exif(&meta); + let bytes = lightcraft_codecs::encode_jpeg( + &EncodeImage::rgba8(&logo), + 100, + ChromaSubsampling::S444, + &EncodeMeta { exif: Some(&exif), ..Default::default() }, + ) + .unwrap(); + let path = dir.join(format!("oriented-{orientation}.jpg")); + std::fs::write(&path, bytes).unwrap(); + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: if orientation == 6 { 0.25 } else { 0.5 }, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + ..Default::default() + }; + let mut img = Rgba8::from_fn(80, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + if orientation == 6 { + assert!(img.get(70, 45)[0] > 240 && img.get(70, 75)[2] > 240, "EXIF 6: red above blue"); + } else { + assert!(img.get(45, 70)[2] > 240 && img.get(75, 70)[0] > 240, "EXIF 2: blue left of red"); + } + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn graphic_watermark_keeps_aspect_when_fitted_and_rotated() { + let dir = std::env::temp_dir().join(format!("lc-wm-aspect-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tall.png"); + let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); + std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); + for (rotation, expected) in [(0, (20, 80)), (90, (120, 30)), (180, (20, 80)), (270, (120, 30))] { + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: 0.5, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + rotation, + ..Default::default() + }; + let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); + let bounds = ( + lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap() + 1, + lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap() + 1, + ); + assert_eq!(bounds, expected, "{rotation} degrees: fitting must preserve the logo's aspect ratio"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn graphic_watermark_fit_preserves_anchor_insets() { + let dir = std::env::temp_dir().join(format!("lc-wm-insets-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tall.png"); + let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); + std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); + use Anchor::*; + for (anchor, rotation, expected) in [ + (BottomRight, 90, (0, 44, 111, 71)), + (TopLeft, 270, (8, 8, 119, 35)), + (Right, 90, (0, 26, 111, 53)), + (BottomRight, 0, (94, 0, 111, 71)), + (TopLeft, 180, (8, 8, 25, 79)), + (Bottom, 0, (51, 0, 68, 71)), + // Centered axes ignore their inset rather than shrinking the image unnecessarily. + (Center, 90, (0, 25, 119, 54)), + (Top, 90, (0, 8, 119, 37)), + ] { + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: 0.5, + opacity: 1.0, + inset_x: 0.1, + inset_y: 0.1, + rotation, + anchor, + ..Default::default() + }; + let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); + let bounds = ( + lit.iter().map(|p| p.0).min().unwrap(), + lit.iter().map(|p| p.1).min().unwrap(), + lit.iter().map(|p| p.0).max().unwrap(), + lit.iter().map(|p| p.1).max().unwrap(), + ); + assert_eq!(bounds, expected, "{anchor:?}, {rotation} degrees"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn sharpen_increases_edge_contrast() { + let mut img = Rgba8::new(8, 8); + for (i, p) in img.data.iter_mut().enumerate() { + let v = if i % 8 < 4 { 80 } else { 170 }; + *p = [v, v, v, 255]; + } + output_sharpen(&mut img, SharpenFor::Matte, SharpenAmount::High); + assert!(img.data[3][0] < 80 && img.data[4][0] > 170); +} + +/// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject` +/// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of +/// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`. +#[test] +fn exported_keywords_follow_their_options() { + use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source, keywords::KeywordInfo}; + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + c.apply(Op::SetKeyword { path: "draft".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); + p.meta.keywords = vec!["Places|Lisbon".into(), "travel|Italy".into(), "draft".into()]; + let m = export_metadata(&p, &c, &ExportOptions::default()).unwrap(); + assert_eq!(m.keywords, ["Lisbon", "Italy", "travel"]); + assert_eq!(m.hierarchical_keywords, ["Places|Lisbon", "travel|Italy"]); + let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&m)).unwrap(); + let back = lightcraft_meta::extract(&jpg); + assert_eq!((back.keywords, back.hierarchical_keywords), (m.keywords.clone(), m.hierarchical_keywords.clone())); +} + +#[test] +fn metadata_policies() { + use lightcraft_catalog::{Catalog, Photo, PhotoId, Source}; + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); + p.meta.camera = "Synthetic X2".into(); + p.meta.copyright = "(c) Me".into(); + p.meta.copyright_status = lightcraft_catalog::CopyrightStatus::Copyrighted; + p.meta.usage_terms = "Editorial use only".into(); + p.meta.copyright_url = "https://example.com/rights".into(); + p.meta.shutter = "1/250".into(); + p.meta.gps = Some((43.0, -110.0)); + let all = export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap(); + assert_eq!(all.model.as_deref(), Some("Synthetic X2")); + assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9); + assert!(all.gps.is_some()); + let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() }; + let m = export_metadata(&p, &Catalog::new(), &o).unwrap(); + assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some()); + let c = export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap(); + assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me")); + // "copyright only" keeps all the copyright info: status, usage terms, info URL + assert_eq!( + (c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()), + (Some(true), Some("Editorial use only"), Some("https://example.com/rights")) + ); + assert!(export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none()); + // embedded and readable back from the JPEG + let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap(); + let back = lightcraft_meta::extract(&jpg); + assert_eq!(back.model.as_deref(), Some("Synthetic X2")); + assert_eq!(back.copyright.as_deref(), Some("(c) Me")); + assert_eq!((back.copyright_marked, back.usage_terms.as_deref()), (Some(true), Some("Editorial use only"))); + assert_eq!(back.copyright_url.as_deref(), Some("https://example.com/rights")); + assert!(back.gps.is_some()); +} + +#[test] +fn watermark_draws_in_the_anchored_corner_only() { + let mut img = Rgba8::new(400, 300); + for p in img.data.iter_mut() { + *p = [0, 0, 0, 255]; + } + let o = ExportOptions::from_json(&serde_json::json!({"watermark": {"text": "LightCraft", "size": 0.08, "opacity": 1.0, "shadow": false}})); + let wm = o.watermark.clone().unwrap(); + draw_watermark(&mut img, &wm); + let lit = |x0: usize, x1: usize, y0: usize, y1: usize| { + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).count() + }; + assert!(lit(200, 400, 225, 300) > 100, "bottom-right has text"); + assert!(lit(0, 200, 0, 150) == 0, "top-left untouched"); + // string shorthand + assert_eq!(ExportOptions::from_json(&serde_json::json!({"watermark": "© Me"})).watermark.unwrap().text, "© Me"); + assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); +} + +/// Horizontal and vertical margins move a text watermark separately; settings saved with one +/// `inset` (before the two margins) read as both, and either new key overrides it. +#[test] +fn watermark_insets_are_horizontal_and_vertical() { + // gaps between the drawn text and the right and bottom edges of a 400 × 300 image + let gaps = |wm: &Watermark| { + let mut img = Rgba8::new(400, 300); + draw_watermark(&mut img, wm); + let lit: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); + let right = lit.iter().map(|p| p.0).max().unwrap(); + let bottom = lit.iter().map(|p| p.1).max().unwrap(); + (399 - right as i64, 299 - bottom as i64) + }; + let base = Watermark { text: "LC".into(), size: 0.1, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; + let (r0, b0) = gaps(&base); + // 0.2 and 0.1 of the 300 px short edge + let (r1, b1) = gaps(&Watermark { inset_x: 0.2, ..base.clone() }); + assert!((r1 - r0 - 60).abs() <= 1 && (b1 - b0).abs() <= 1, "horizontal: {r0},{b0} → {r1},{b1}"); + let (r2, b2) = gaps(&Watermark { inset_y: 0.1, ..base.clone() }); + assert!((r2 - r0).abs() <= 1 && (b2 - b0 - 30).abs() <= 1, "vertical: {r0},{b0} → {r2},{b2}"); + // the old single margin + let wm = |w: serde_json::Value| ExportOptions::from_json(&json!({"watermark": w})).watermark.unwrap(); + let old = wm(json!({"text": "LC", "inset": 0.1})); + assert_eq!((old.inset_x, old.inset_y), (0.1, 0.1)); + let mixed = wm(json!({"text": "LC", "inset": 0.1, "insetY": 0.3})); + assert_eq!((mixed.inset_x, mixed.inset_y), (0.1, 0.3)); + assert!(ExportOptions::from_params(&json!({"watermark": {"text": "LC", "inset": 0.1}})).is_ok()); + // saved and read back as the two margins + let o = ExportOptions { watermark: Some(Watermark { inset_x: 0.05, inset_y: 0.15, ..base }), ..Default::default() }; + let saved = o.to_json(); + assert!(saved["watermark"].get("inset").is_none(), "{saved}"); + assert_eq!(ExportOptions::from_json(&saved).watermark.map(|w| (w.inset_x, w.inset_y)), Some((0.05, 0.15))); +} + +/// A turned text watermark is placed by its turned box: taller than wide at 90° and 270°, the +/// same corner and insets, and an unknown angle draws it unturned. +#[test] +fn watermark_rotation_turns_the_text_in_its_corner() { + let lit = |wm: &Watermark| { + let mut img = Rgba8::new(400, 300); + draw_watermark(&mut img, wm); + let on: Vec<(usize, usize)> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).collect(); + let (x0, x1) = (on.iter().map(|p| p.0).min().unwrap(), on.iter().map(|p| p.0).max().unwrap()); + let (y0, y1) = (on.iter().map(|p| p.1).min().unwrap(), on.iter().map(|p| p.1).max().unwrap()); + (x0, x1, y0, y1, img) + }; + let base = Watermark { text: "LightCraft".into(), size: 0.08, opacity: 1.0, shadow: false, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; + let (a0, a1, b0, b1, flat) = lit(&base); + assert!(a1 - a0 > 2 * (b1 - b0), "unturned text is wide"); + for rotation in [90, 270] { + let (x0, x1, y0, y1, _) = lit(&Watermark { rotation, ..base.clone() }); + assert!(y1 - y0 > 2 * (x1 - x0), "{rotation}°: tall"); + assert!(x1 >= 390 && y1 >= 290, "{rotation}°: still in the bottom-right corner ({x1}, {y1})"); + // 0.1 × 300 px further from the right edge + let (_, moved, ..) = lit(&Watermark { rotation, inset_x: 0.1, ..base.clone() }); + assert!((x1 as i64 - moved as i64 - 30).abs() <= 1, "{rotation}°: {x1} → {moved}"); + } + let (x0, x1, y0, y1, _) = lit(&Watermark { rotation: 180, ..base.clone() }); + assert!(((x1 - x0) as i64 - (a1 - a0) as i64).abs() <= 2 && ((y1 - y0) as i64 - (b1 - b0) as i64).abs() <= 2, "180°: same size"); + assert!(x1 >= 390 && y1 >= 290, "180°: same corner"); + let (.., odd) = lit(&Watermark { rotation: 45, ..base.clone() }); + assert_eq!(odd.data, flat.data, "an angle that isn't a quarter turn draws unturned"); + // saved and read back; older settings have none + let o = ExportOptions { watermark: Some(Watermark { rotation: 270, ..base }), ..Default::default() }; + assert_eq!(ExportOptions::from_json(&o.to_json()).watermark.map(|w| w.rotation), Some(270)); + assert_eq!(ExportOptions::from_json(&json!({"watermark": {"text": "x"}})).watermark.map(|w| w.rotation), Some(0)); +} + +/// A preview keeps the export's 6 px minimum text size as a share of the photo: a watermark +/// clamped to 6 px in a 150 px export covers the same share of a 600 px preview. +#[test] +fn watermark_preview_scales_the_minimum_text_size() { + let wm = Watermark { text: "LightCraft".into(), size: 0.01, opacity: 1.0, shadow: false, ..Default::default() }; + let rows = |img: &Rgba8| (0..img.height).filter(|&y| (0..img.width).any(|x| img.get(x, y)[0] > 64)).count() as f32; + let mut export = Rgba8::new(200, 150); + draw_watermark(&mut export, &wm); + let mut preview = Rgba8::new(800, 600); + draw_watermark_preview(&mut preview, &wm, 150); + let mut unscaled = Rgba8::new(800, 600); + draw_watermark(&mut unscaled, &wm); + let (export_share, preview_share) = (rows(&export) / 150.0, rows(&preview) / 600.0); + assert!((preview_share - export_share).abs() < 0.02, "export {export_share}, preview {preview_share}"); + assert!(rows(&unscaled) < rows(&preview) / 2.0, "the unscaled 6 px floor would draw it too small"); + // drawn at the export's own size, the preview is the export + let mut same = Rgba8::new(200, 150); + draw_watermark_preview(&mut same, &wm, 150); + assert_eq!(same.data, export.data); +} + +fn vertical_japanese() -> Watermark { + let options = + ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); + options.watermark.unwrap() +} + +#[test] +fn japanese_watermark_options_and_legacy_defaults() { + let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}})); + assert!(!old.watermark.unwrap().vertical); + let wm = vertical_japanese(); + assert!(wm.vertical); + let round: Watermark = serde_json::from_value(serde_json::to_value(&wm).unwrap()).unwrap(); + assert!(round.vertical); +} + +/// Built without craft-fonts, a Japanese watermark still lays out and draws (Inter's +/// missing-glyph boxes), and Latin text is unaffected. +#[test] +fn watermarks_work_without_craft_fonts() { + assert_eq!(watermark_fonts(&[]).len(), 1, "Inter only"); + for wm in [vertical_japanese(), Watermark { text: "LightCraft 日本語".into(), ..Watermark::default() }] { + let mut covered = 0usize; + watermark_coverage(400, 300, &wm, &[], 1.0, |_, _, k, _| covered += usize::from(k > 0.0)); + assert!(covered > 0, "{:?} draws something", wm.text); + } +} + +#[test] +fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() { + use ab_glyph::Font; + let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else { + lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts"); + return; + }; + let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); + // BIZ UDMincho has no Unicode presentation-form characters: the old fallback drew 、。. + assert_eq!(font.glyph_id('︑').0, 0); + assert_eq!(font.glyph_id('︒').0, 0); + let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap()); + for ch in ['、', '。'] { + let glyph = watermark_cell_glyphs(&font, &data, &ch.to_string(), true, 100.0, 0.0, 0.0).unwrap().remove(0); + assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}"); + let rect = font.outline_glyph(glyph).unwrap().px_bounds(); + assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}"); + } + let craft = Box::leak( + vec![crate::fonts::CraftFont { family: entry.family, style: entry.style, scripts: entry.scripts, bytes: entry.bytes }].into_boxed_slice(), + ); + let wm = Watermark { + text: "、。".into(), + vertical: true, + size: 0.1, + anchor: Anchor::TopLeft, + inset_x: 0.0, + inset_y: 0.0, + shadow: false, + opacity: 1.0, + ..Default::default() + }; + let mut pixels = Vec::new(); + watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| { + if k > 0.5 { + pixels.push((x, y)); + } + }); + assert!(!pixels.is_empty()); + for (x, y) in pixels { + assert!(x > 15 && y % 30 < 15, "actual export coverage at the upper right of the 30 px cell: {x}, {y}"); + } +} + +#[test] +fn vertical_watermarks_keep_combining_marks_in_one_cell() { + let coverage = |text: &str, craft| { + let wm = Watermark { + text: text.into(), + vertical: true, + size: 0.1, + anchor: Anchor::Center, + inset_x: 0.0, + inset_y: 0.0, + shadow: false, + opacity: 1.0, + ..Default::default() + }; + let mut pixels = vec![0.0f32; 400 * 300]; + watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| pixels[y * 400 + x] = k); + assert!(pixels.iter().any(|&k| k > 0.0)); + pixels + }; + let compare = |a: &str, b: &str, craft| { + let (a_pixels, b_pixels) = (coverage(a, craft), coverage(b, craft)); + let difference = a_pixels.iter().zip(&b_pixels).position(|(a, b)| a != b); + assert!(difference.is_none(), "{a:?} vs {b:?}: first differing pixel {difference:?}"); + }; + for (composed, decomposed) in [("éA", "e\u{301}A"), ("Å\nA", "A\u{30a}\nA"), ("A\nB", "A\r\nB"), ("日A", "日\u{e0100}A")] { + compare(composed, decomposed, &[]); + } + if crate::fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDMincho") { + for (composed, decomposed) in [("が日", "か\u{3099}日"), ("ぱ\n日", "は\u{309a}\n日")] { + compare(composed, decomposed, crate::fonts::CRAFT_FONTS); + } + } else { + lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts"); + } +} + +#[test] +fn upright_watermark_cell_retains_multiple_glyphs() { + use ab_glyph::{Font, ScaleFont}; + let font = ab_glyph::FontRef::try_from_slice(WATERMARK_FONT).unwrap(); + let data = harfrust::ShaperData::new(&harfrust::FontRef::new(WATERMARK_FONT).unwrap()); + // There is no precomposed A with a combining long solidus overlay. + let glyphs = watermark_cell_glyphs(&font, &data, "A\u{338}", false, 30.0, 0.0, 0.0).unwrap(); + assert_eq!(glyphs.len(), 2, "retain the base and the separately positioned combining glyph"); + let baseline = font.as_scaled(30.0).ascent(); + for glyph in glyphs { + assert!((glyph.position.y - baseline).abs() < 1.0, "no extra vertical cell for the mark"); + assert!(font.outline_glyph(glyph).is_some(), "both glyphs have ink"); + } +} + +#[test] +fn japanese_watermarks_support_vertical_columns() { + use ab_glyph::Font; + let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS); + if fonts.len() < 2 { + lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); + return; + } + for c in "日本語の文字".chars() { + assert!(fonts[1..].iter().any(|f| f.glyph_id(c).0 != 0), "a craft-fonts face has {c}"); + } + let wm = vertical_japanese(); + let mut img = Rgba8::new(400, 300); + img.data.fill([0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 64).collect(); + assert!(lit.len() > 100); + let w = lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap(); + let h = lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap(); + assert!(h > w * 2, "vertical Japanese must extend down the column"); + let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset_x: 0.0, inset_y: 0.0, ..wm }; + img.data.fill([0, 0, 0, 255]); + draw_watermark(&mut img, &two); + for (text, dx) in [("本", 0), ("日", 30)] { + let mut single = Rgba8::new(400, 300); + single.data.fill([0, 0, 0, 255]); + draw_watermark(&mut single, &Watermark { text: text.into(), ..two.clone() }); + for y in 0..30 { + for x in 0..30 { + assert_eq!(img.get(x + dx, y), single.get(x, y), "newlines move columns left"); + } + } + } +} + +#[test] +fn graphic_watermark_is_placed_scaled_and_blended() { + let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let logo_path = dir.join("logo.png"); + // 40×20: left half opaque red, right half fully transparent + let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 255, 0, 0] }); + let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap(); + std::fs::write(&logo_path, png).unwrap(); + let mut img = Rgba8::new(400, 300); + img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); + let wm = Watermark { + image: logo_path.to_string_lossy().into(), + image_width: 0.25, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + ..Default::default() + }; + draw_watermark(&mut img, &wm); + // 100 px wide × 50 px high in the bottom-right corner + assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280)); + assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo"); + assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo"); + assert_eq!(img.get(10, 10), [0, 0, 0, 255]); + // 30 px from the right edge and 15 px from the bottom: x 270..370, y 235..285 + let mut img = Rgba8::new(400, 300); + img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); + draw_watermark(&mut img, &Watermark { inset_x: 0.1, inset_y: 0.05, ..wm.clone() }); + assert!(img.get(272, 283)[0] > 200, "inside the moved logo: {:?}", img.get(272, 283)); + assert_eq!(img.get(267, 283), [0, 0, 0, 255], "left of the logo"); + assert_eq!(img.get(272, 288), [0, 0, 0, 255], "below the logo"); + // turned: 180° puts the opaque half on the right; 90° clockwise puts it on top of a 50 × 100 box + let turned = |rotation: u16| { + let mut img = Rgba8::new(400, 300); + img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); + draw_watermark(&mut img, &Watermark { rotation, ..wm.clone() }); + img + }; + let half = turned(180); + assert!(half.get(390, 280)[0] > 200 && half.get(310, 280) == [0, 0, 0, 255]); + let quarter = turned(90); + assert!(quarter.get(375, 220)[0] > 200, "top half of the turned logo: {:?}", quarter.get(375, 220)); + assert_eq!(quarter.get(375, 280), [0, 0, 0, 255], "its transparent half below"); + assert_eq!(quarter.get(340, 220), [0, 0, 0, 255], "left of the 50 px wide box"); + assert!(quarter.get(352, 202)[0] > 200 && quarter.get(398, 248)[0] > 200, "the whole box is covered"); + // three quarter turns: the opaque half at the bottom + let three = turned(270); + assert!(three.get(375, 280)[0] > 200 && three.get(375, 220) == [0, 0, 0, 255]); + // an image-only watermark survives the params (no text needed) + let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}})); + assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn naming_and_params() { + let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"})); + assert_eq!(o.format, ExportFormat::Jpeg); + assert_eq!(o.quality, 100); + assert_eq!(o.resize, Some(Resize { dont_enlarge: true, height: 0, ..Resize::long_edge(2048) })); + assert_eq!(o.file_name_for(&named("IMG/1.png"), 7), "IMG_1-007.jpg"); + assert_eq!(o.ppi, 240); + let orig = ExportOptions { format: ExportFormat::Original, naming: "{name}-{seq}".into(), ..Default::default() }; + assert_eq!(orig.file_name_for(&named("DSC_1.NEF"), 2), "DSC_1-002.NEF"); + assert_eq!(orig.file_name_for(&named("noext"), 1), "noext-001"); + assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for(&named("a.cr2"), 1), "a.dng"); +} + +fn named(file_name: &str) -> lightcraft_catalog::Photo { + use lightcraft_catalog::{Photo, PhotoId, Source}; + Photo::new(PhotoId(1), Source::Demo { scene: 0 }, file_name, "", 1, 1, "2026-01-01T00:00:00") +} + +/// Export naming takes the same tokens as Rename Photos and import renaming. +#[test] +fn naming_uses_the_rename_tokens() { + let mut p = named("DSC_0815.NEF"); + p.captured = Some("2026-09-30T14:05:09".into()); + p.meta.camera = "Model X/2".into(); + p.meta.title = "Harbour".into(); + p.rating = 5; + let o = |naming: &str| ExportOptions { naming: naming.into(), start_number: 9, ..Default::default() }; + assert_eq!(o("{date:%Y-%m-%d}_{title}_{seq:2}").file_name_for(&p, 1), "2026-09-30_Harbour_09.jpg"); + assert_eq!(o("{camera}-{num}-{rating}").file_name_for(&p, 1), "Model X_2-0815-5.jpg", "path separators become _"); + assert_eq!(o("{name}-{seq}").file_name_for(&p, 3), "DSC_0815-011.jpg", "a bare {{seq}} keeps its 3 digits"); + assert_eq!(o("{ext}_{name}").file_name_for(&p, 1), "NEF_DSC_0815.jpg"); + // nothing left after expanding: the original name + p.meta.title.clear(); + assert_eq!(o("{title}").file_name_for(&p, 1), "DSC_0815.jpg"); +} + +#[test] +fn params_round_trip() { + let o = ExportOptions { + format: ExportFormat::Tiff, + quality: 70, + resize: Some(Resize { mode: ResizeMode::Megapixels, value: 12.5, height: 0, dont_enlarge: false }), + ppi: 300, + limit_kb: Some(500), + sharpen: SharpenFor::Glossy, + naming: "{date}-{name}-{seq}".into(), + start_number: 42, + subfolder: "Client".into(), + conflict: Conflict::Skip, + tiff_compression: TiffCompression::Lzw, + metadata: MetadataPolicy::Copyright, + remove_location: true, + watermark: Some(Watermark { text: "© LC".into(), ..Default::default() }), + color_space: OutputSpace::ProPhoto, + bit_depth: Some(16), + ..Default::default() + }; + assert_eq!(ExportOptions::from_json(&o.to_json()), o); + let full = ExportOptions::default(); + assert_eq!(ExportOptions::from_json(&full.to_json()), full); + assert!(ExportOptions::has_size_param(&full.to_json()), "full size is explicit"); + let mut p = named("IMG.png"); + p.captured = Some("2026-09-30T10:00:00".into()); + assert_eq!(o.file_name_for(&p, 2), "20260930-IMG-043.tif"); +} + +#[test] +fn resize_modes() { + let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)); + // a 6000 × 4000 landscape and a 4000 × 6000 portrait + assert_eq!(size(json!({"longEdge": 1500}), 6000.0, 4000.0), Some((1500, 1000))); + assert_eq!(size(json!({"longEdge": 1500}), 4000.0, 6000.0), Some((1000, 1500))); + assert_eq!(size(json!({"shortEdge": 1080}), 6000.0, 4000.0), Some((1620, 1080))); + assert_eq!(size(json!({"width": 3000}), 4000.0, 6000.0), Some((3000, 4500))); + assert_eq!(size(json!({"height": 1000}), 6000.0, 4000.0), Some((1500, 1000))); + // W × H fits either orientation: the long edge gets the larger number + assert_eq!(size(json!({"width": 1000, "height": 800}), 6000.0, 4000.0), Some((1000, 667))); + assert_eq!(size(json!({"width": 1000, "height": 800}), 4000.0, 6000.0), Some((667, 1000))); + assert_eq!(size(json!({"width": 1000, "height": 500}), 6000.0, 4000.0), Some((750, 500))); + assert_eq!(size(json!({"width": 270, "height": 337, "fit": "dimensions"}), 480.0, 320.0), Some((337, 225))); + // Width & Height (`fit: widthHeight`) binds each edge to its own number: W limits the width, H the height (#1620) + assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 480.0, 320.0), Some((270, 180))); + assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 320.0, 480.0), Some((225, 337))); + assert_eq!(size(json!({"width": 240, "height": 300, "fit": "widthHeight"}), 480.0, 320.0), Some((240, 160))); + // `fit` only matters with both numbers + assert_eq!(size(json!({"width": 1000, "fit": "widthHeight"}), 6000.0, 4000.0), Some((1000, 667))); + let (w, h) = size(json!({"megapixels": 6}), 6000.0, 4000.0).unwrap(); + assert!(((w * h) as f64 - 6e6).abs() < 6e6 * 0.002, "{w}×{h}"); + assert_eq!(size(json!({"percent": 25}), 6000.0, 4000.0), Some((1500, 1000))); + // don't enlarge (default) vs enlarge + assert_eq!(size(json!({"longEdge": 8000}), 6000.0, 4000.0), Some((6000, 4000))); + assert_eq!(size(json!({"longEdge": 9000, "dontEnlarge": false}), 6000.0, 4000.0), Some((9000, 6000))); + // full size / absent + assert_eq!(size(json!({"longEdge": 0}), 6000.0, 4000.0), None); + assert_eq!(size(json!({}), 6000.0, 4000.0), None); + assert!(ExportOptions::has_size_param(&json!({"longEdge": 0})) && !ExportOptions::has_size_param(&json!({"quality": 3}))); + // the `resize` object form round-trips (what the dialog and prefs store) + let r = Resize { mode: ResizeMode::ShortEdge, value: 720.0, height: 0, dont_enlarge: false }; + let back = ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize; + assert_eq!(back, Some(r)); + let r = Resize { mode: ResizeMode::WidthHeight, value: 270.0, height: 337, dont_enlarge: true }; + assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize, Some(r)); + assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None); + assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300); + // never larger than the encoders' limit, never empty + assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((27713, 18475)), "512 MP at most"); + assert_eq!(size(json!({"width": 70000, "dontEnlarge": false}), 60000.0, 100.0), Some((65535, 109)), "65535 px an edge"); + assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1)); +} + +/// Typing 20000 for 2000 (or percent 10000) with Don't Enlarge off asked for gigapixels: the +/// render's first plane alone (34 GB at 65535 × 43690) failed to allocate, which aborts the +/// process. An enlargement stops at 512 megapixels; full-size and smaller exports of photos +/// larger than that are untouched. +#[test] +fn enlargements_stop_at_512_megapixels() { + let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)).unwrap(); + for p in [json!({"longEdge": 60000, "dontEnlarge": false}), json!({"megapixels": 5000}), json!({"percent": 10000, "dontEnlarge": false})] { + let (w, h) = size(p.clone(), 6000.0, 4000.0); + assert!((w * h) as f64 <= MAX_ENLARGED_PIXELS * 1.001, "{p}: {w}×{h}"); + assert!((w as f64 / h as f64 - 1.5).abs() < 0.001, "{p}: keeps the shape"); + } + // below the ceiling an enlargement is as asked + assert_eq!(size(json!({"longEdge": 12000, "dontEnlarge": false}), 6000.0, 4000.0), (12000, 8000)); + // a 600 MP panorama exports at full size, and smaller, as before; it is never enlarged + assert_eq!(size(json!({"percent": 100, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000)); + assert_eq!(size(json!({"percent": 50}), 30000.0, 20000.0), (15000, 10000)); + assert_eq!(size(json!({"percent": 200, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000)); +} + +/// An enlarged export that needs more memory than the computer has is refused with a message, +/// before it starts; everything else goes ahead. +#[test] +fn enlarged_exports_must_fit_the_computers_memory() { + let gb = 1usize << 30; + let why = too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(16 * gb)).unwrap(); + assert!(why.contains("27713 × 18475") && why.contains("20 GB") && why.contains("16 GB") && why.contains("smaller size"), "{why}"); + assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(32 * gb)), None, "fits"); + assert_eq!(too_big_for_memory((6000, 4000), 24_000_000, 20 * gb, Some(gb)), None, "full size is never refused"); + assert_eq!(too_big_for_memory((3000, 2000), 24_000_000, 20 * gb, Some(gb)), None, "nor a smaller export"); + assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, None), None, "unknown memory"); +} + +#[test] +fn resize_size_survives_the_render() { + // the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312) + let cropped = |r: Resize, w: u32, h: u32, x1: f64| { + let mut p = named("IMG.ARW"); + (p.width, p.height) = (w, h); + std::sync::Arc::make_mut(&mut p.develop).crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, x1, 1.0); + let (bw, bh) = output_size(&p, &ExportOptions { resize: Some(r), ..Default::default() }); + lightcraft_pipeline::output_size(w as usize, h as usize, &p.develop, &lightcraft_pipeline::RenderRequest::fit(bw, bh)) + }; + let rendered = |r: Resize, w: u32, h: u32| cropped(r, w, h, 1.0); + let mode = |mode: ResizeMode, value: f32, height: u32| Resize { mode, value, height, dont_enlarge: true }; + // a frame a hair narrower than 3:2 (a raw's default crop in floats) puts 1333 × 1.5 just under 1999.5 + let hair = 1.0 - 1e-9; + assert_eq!(cropped(Resize::long_edge(2000), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(Resize::long_edge(2048), 6000, 4000, hair), (2048, 1365)); + assert_eq!(cropped(mode(ResizeMode::Width, 2000.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(mode(ResizeMode::ShortEdge, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(mode(ResizeMode::Height, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(rendered(Resize::long_edge(2000), 4000, 6000), (1333, 2000)); + // 3:2, 4:3, 16:9, 1:1 and their portraits, every mode: the render keeps what `apply` says + for (w, h) in [(6000u32, 4000u32), (6240, 4160), (4032, 3024), (5184, 3888), (3840, 2160), (6000, 3375), (4000, 4000)] { + for (w, h) in [(w, h), (h, w)] { + for value in [1000.0, 1080.0, 1333.0, 2000.0, 2048.0, 2500.0, 3000.0] { + for r in [ + Resize::long_edge(value as u32), + mode(ResizeMode::ShortEdge, value, 0), + mode(ResizeMode::Width, value, 0), + mode(ResizeMode::Height, value, 0), + mode(ResizeMode::Dimensions, value, 1365), + mode(ResizeMode::WidthHeight, value, 1365), + mode(ResizeMode::Megapixels, value / 1000.0, 0), + mode(ResizeMode::Percent, value / 100.0, 0), + ] { + let want = r.apply(w.into(), h.into()); + assert_eq!(rendered(r, w, h), want, "{r:?} on {w}×{h}"); + } + } + } + } +} + +/// A photo more than 65535 times as long as it is short (a 70000 × 1 strip) made `scale` +/// clamp with its lower bound above its upper one, which panics, in the export and in the +/// Export dialog's preview on the UI thread. The encoders' limit wins, the short edge keeps a +/// pixel. +#[test] +fn resize_survives_photos_too_long_for_the_encoders() { + let modes = [ + json!({"longEdge": 2000}), + json!({"shortEdge": 1}), + json!({"width": 500, "height": 500}), + json!({"megapixels": 1}), + json!({"percent": 50}), + json!({"percent": 1000, "dontEnlarge": false}), + ]; + for p in modes { + for (w, h) in [(70_000.0, 1.0), (1.0, 70_000.0), (1e9, 3.0), (65_536.0, 1.0)] { + let r = ExportOptions::from_json(&p).resize.unwrap(); + let (ow, oh) = r.apply(w, h); + assert!((1..=65_535).contains(&ow) && (1..=65_535).contains(&oh), "{p} on {w}×{h}: {ow}×{oh}"); + assert!(r.scale(w, h).is_finite()); + } + } + // (a short edge under a pixel would be scaled up to one, which the limit then caps) + assert_eq!(Resize::long_edge(2000).apply(70_000.0, 1.0), (65_535, 1)); + assert_eq!(Resize::long_edge(2000).apply(1.0, 70_000.0), (1, 65_535)); +} + +/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`. +fn p3_green(space: OutputSpace) -> Rgba8 { + use lightcraft_color::{DISPLAY_P3, REC2020}; + let c = DISPLAY_P3.to_space(&REC2020).apply_f32([0.04, 0.45, 0.04]); + let src = lightcraft_raster::Rgb32f::filled(16, 16, c); + let req = lightcraft_pipeline::RenderRequest { space, ..lightcraft_pipeline::RenderRequest::fit(16, 16) }; + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req).image +} + +/// Decode `bytes` and return the centre pixel in linear sRGB primaries (unclamped), plus the +/// recognised space of the embedded profile. +fn decoded_in_srgb(bytes: &[u8]) -> ([f32; 3], Option) { + let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); + let m = d.space.to_space(&lightcraft_color::SRGB); + (m.apply_f32(d.image.get(8, 8)), d.space.named) +} + +#[test] +fn p3_colour_survives_a_p3_export_and_is_clipped_in_srgb() { + let o = |space| ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() }; + let (p3, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::DisplayP3), &o(OutputSpace::DisplayP3)).unwrap()); + assert_eq!(named, Some(NamedSpace::DisplayP3)); + assert!(p3[0] < -0.03 || p3[2] < -0.03, "outside sRGB after a P3 round trip: {p3:?}"); + let (s, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::Srgb), &o(OutputSpace::Srgb)).unwrap()); + assert_eq!(named, Some(NamedSpace::Srgb)); + assert!(s.iter().all(|v| *v > -0.002), "{s:?}"); + // the green itself is about the same brightness either way + assert!((p3[1] - s[1]).abs() < 0.15, "{p3:?} {s:?}"); +} + +#[test] +fn every_space_embeds_its_own_profile_and_round_trips() { + let grey = { + let src = lightcraft_raster::Rgb32f::filled(16, 16, [0.18; 3]); + let base = lightcraft_pipeline::RenderRequest::fit(16, 16); + move |space| { + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base }) + .image + } + }; + let reference = + decoded_in_srgb(&encode_image(&grey(OutputSpace::Srgb), &ExportOptions { format: ExportFormat::Tiff, ..Default::default() }).unwrap()).0; + for space in OutputSpace::ALL { + for format in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + let o = ExportOptions { format, color_space: space, ..Default::default() }; + let bytes = encode_image(&grey(space), &o).unwrap(); + let (px, named) = decoded_in_srgb(&bytes); + assert_eq!(named, Some(named_space(space)), "{space:?} {format:?}"); + // a neutral grey decodes to the same linear value whatever the output space + for c in 0..3 { + assert!((px[c] - reference[c]).abs() < 0.01, "{space:?} {format:?}: {px:?} vs {reference:?}"); + } + } + let icc = icc::write_named(named_space(space)); + let info = icc::parse(&icc).expect("our profile parses"); + assert_eq!(info.named, Some(named_space(space))); + assert!(info.description.as_deref().is_some_and(|d| !d.is_empty())); + } + let o = ExportOptions { format: ExportFormat::Avif, color_space: OutputSpace::ProPhoto, ..Default::default() }; + assert_eq!(o.effective_space(), OutputSpace::Srgb); + let o = ExportOptions::from_json(&serde_json::json!({"colorSpace": "displayP3"})); + assert_eq!(o.color_space, OutputSpace::DisplayP3); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"colorSpace": "Adobe RGB (1998) compatible"})).color_space, OutputSpace::AdobeRgb); +} + +#[test] +fn srgb_watermark_colour_is_converted() { + assert_eq!(srgb8_in(OutputSpace::Srgb, [10, 200, 30]), [10, 200, 30]); + for s in OutputSpace::ALL { + assert_eq!(srgb8_in(s, [255, 255, 255]), [255, 255, 255], "{s:?}"); + assert_eq!(srgb8_in(s, [0, 0, 0]), [0, 0, 0], "{s:?}"); + } + let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]); + assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}"); +} + +/// A shallow horizontal grey ramp (few 8-bit levels), rendered at `depth`. +fn ramp(depth: OutputDepth, space: OutputSpace) -> lightcraft_pipeline::Rendered { + let src = lightcraft_raster::Rgb32f::from_fn(1024, 4, |x, _| [0.10 + 0.03 * x as f32 / 1023.0; 3]); + let req = lightcraft_pipeline::RenderRequest { depth, space, ..lightcraft_pipeline::RenderRequest::fit(1024, 4) }; + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req) +} + +fn distinct_levels(bytes: &[u8]) -> (usize, lightcraft_codecs::Decoded) { + let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); + let mut v: Vec = (0..d.image.width).map(|x| d.image.get(x, 1)[1].to_bits()).collect(); + v.dedup(); + (v.len(), d) +} + +#[test] +fn sixteen_bit_tiff_and_png_have_no_banding() { + let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() }; + assert_eq!(tiff(None).effective_depth(), OutputDepth::U16, "TIFF defaults to 16-bit"); + let r16 = ramp(OutputDepth::U16, OutputSpace::Srgb); + assert!(matches!(r16.deep.as_ref().unwrap().samples, DeepSamples::U16(_))); + let (levels16, d16) = distinct_levels(&encode_rendered(&r16, &tiff(None), None).unwrap()); + let r8 = ramp(OutputDepth::U8, OutputSpace::Srgb); + let (levels8, _) = distinct_levels(&encode_rendered(&r8, &tiff(Some(8)), None).unwrap()); + eprintln!("gradient levels: 16-bit TIFF {levels16}, 8-bit TIFF {levels8}"); + assert!(levels8 < 40, "{levels8}"); + assert!(levels16 > 10 * levels8, "16-bit: {levels16} levels vs 8-bit: {levels8}"); + assert_eq!(d16.space.named, Some(NamedSpace::Srgb)); + // the 8-bit preview of a deep render matches the 8-bit render + for (a, b) in r16.image.data.iter().zip(&r8.image.data) { + assert!(a[1].abs_diff(b[1]) <= 1, "{a:?} {b:?}"); + } + let png = ExportOptions { format: ExportFormat::Png, bit_depth: Some(16), color_space: OutputSpace::DisplayP3, ..Default::default() }; + let rp = ramp(OutputDepth::U16, OutputSpace::DisplayP3); + let bytes = encode_rendered(&rp, &png, None).unwrap(); + assert_eq!(bytes[24], 16, "PNG IHDR bit depth"); + let (levels, d) = distinct_levels(&bytes); + assert!(levels > 10 * levels8, "{levels}"); + assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); + // sharpening and watermarking work on deep images too + let o = ExportOptions { + sharpen: SharpenFor::Matte, + watermark: Some(Watermark { text: "LC".into(), size: 0.5, ..Default::default() }), + ..tiff(None) + }; + assert!(encode_rendered(&r16, &o, None).is_ok()); +} + +#[test] +fn float_tiff_is_linear_with_a_linear_profile() { + let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(32), color_space: OutputSpace::ProPhoto, ..Default::default() }; + assert_eq!(o.effective_depth(), OutputDepth::F32Linear); + let rf = ramp(OutputDepth::F32Linear, OutputSpace::ProPhoto); + let (levels, df) = distinct_levels(&encode_rendered(&rf, &o, None).unwrap()); + assert!(levels > 500, "{levels}"); + assert_eq!(df.space.named, Some(NamedSpace::ProPhoto)); + assert!(df.space.trc.as_ref().is_some_and(|t| t[0].is_linear()), "{:?}", df.space.trc); + // decodes to the same light as the 16-bit gamma-encoded export + let o16 = ExportOptions { bit_depth: Some(16), ..o.clone() }; + let (_, d16) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::ProPhoto), &o16, None).unwrap()); + for x in [0, 300, 700, 1023] { + let (a, b) = (df.image.get(x, 1)[1], d16.image.get(x, 1)[1]); + assert!((a - b).abs() < 2e-4, "{x}: {a} vs {b}"); + } +} + +#[test] +fn jxl_options() { + assert_eq!(ExportFormat::parse("JPEG-XL"), Some(ExportFormat::Jxl)); + let o = ExportOptions::from_params(&json!({"format": "jxl", "jxlLossless": true, "jxlEffort": "fast", "jxlProgressive": true})).unwrap(); + assert_eq!((o.format, o.jxl_lossless, o.jxl_effort, o.jxl_progressive), (ExportFormat::Jxl, true, JxlEffort::Fast, true)); + assert_eq!(ExportOptions::from_json(&o.to_json()), o, "saved presets keep the JPEG XL settings"); + assert!(ExportOptions::from_params(&json!({"jxlEffort": "best"})).is_err()); + assert!(ExportOptions::from_params(&json!({"jxlLossless": "yes"})).is_err()); + let space = |lossless, color_space, hdr| { + ExportOptions { format: ExportFormat::Jxl, jxl_lossless: lossless, color_space, hdr, ..Default::default() }.effective_space() + }; + assert_eq!(space(false, OutputSpace::DisplayP3, false), OutputSpace::DisplayP3); + assert_eq!(space(false, OutputSpace::ProPhoto, false), OutputSpace::Rec2020, "lossy has no ProPhoto enum"); + assert_eq!(space(true, OutputSpace::ProPhoto, false), OutputSpace::ProPhoto, "lossless keeps the profile"); + assert_eq!(space(true, OutputSpace::Srgb, true), OutputSpace::Rec2020); + let o = ExportOptions { format: ExportFormat::Jxl, hdr: true, ..Default::default() }; + assert!(o.hdr_output()); + assert_eq!(o.effective_depth(), OutputDepth::F32Hdr); + assert_eq!(ExportFormat::Jxl.extension(), "jxl"); +} + +#[test] +fn jxl_every_space_round_trips() { + // wide-gamut green stays outside sRGB in P3, Rec. 2020 and (lossless) ProPhoto files + for (lossless, space) in [ + (false, OutputSpace::Srgb), + (false, OutputSpace::DisplayP3), + (false, OutputSpace::Rec2020), + (true, OutputSpace::DisplayP3), + (true, OutputSpace::AdobeRgb), + (true, OutputSpace::ProPhoto), + ] { + let o = ExportOptions { format: ExportFormat::Jxl, color_space: space, jxl_lossless: lossless, quality: 95, ..Default::default() }; + let png = ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() }; + let img = p3_green(space); + let bytes = encode_image(&img, &o).unwrap(); + assert_eq!(lightcraft_codecs::sniff(&bytes), Some(lightcraft_codecs::Format::Jxl)); + let (got, _) = decoded_in_srgb(&bytes); + let (want, _) = decoded_in_srgb(&encode_image(&img, &png).unwrap()); + for c in 0..3 { + assert!((got[c] - want[c]).abs() < 0.01, "{space:?} lossless={lossless}: {got:?} vs PNG {want:?}"); + } + if space != OutputSpace::Srgb { + assert!(got[0] < -0.03 || got[2] < -0.03, "{space:?}: outside sRGB after the round trip: {got:?}"); + } + } +} + +#[test] +fn jxl_sixteen_bit() { + let o = ExportOptions { + format: ExportFormat::Jxl, + bit_depth: Some(16), + jxl_lossless: true, + color_space: OutputSpace::DisplayP3, + ..Default::default() + }; + assert_eq!(o.effective_depth(), OutputDepth::U16); + let (levels8, _) = distinct_levels( + &encode_rendered(&ramp(OutputDepth::U8, OutputSpace::DisplayP3), &ExportOptions { bit_depth: Some(8), ..o.clone() }, None).unwrap(), + ); + let (levels, d) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &o, None).unwrap()); + assert_eq!(d.bit_depth, 16); + assert!(levels > 10 * levels8, "{levels} vs {levels8}"); + // lossy 16-bit encodes too + let lossy = ExportOptions { jxl_lossless: false, ..o }; + assert!(encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &lossy, None).is_ok()); +} + +#[test] +fn bit_depth_options_per_format() { + let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth(); + assert_eq!(d(ExportFormat::Jpeg, Some(16)), OutputDepth::U8); + assert_eq!(d(ExportFormat::Webp, None), OutputDepth::U8); + assert_eq!(d(ExportFormat::Png, None), OutputDepth::U8); + assert_eq!(d(ExportFormat::Png, Some(16)), OutputDepth::U16); + assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16)); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None); + assert_eq!(d(ExportFormat::Jxl, Some(16)), OutputDepth::U16); + assert_eq!(d(ExportFormat::Jxl, None), OutputDepth::U8); + for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif, ExportFormat::Jxl] { + let first = ExportOptions::bit_depths(f)[0].0; + let def = d(f, None); + assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn avif_ten_bit() { + let o = ExportOptions { format: ExportFormat::Avif, bit_depth: Some(10), quality: 60, ..Default::default() }; + let r = ramp(OutputDepth::U16, OutputSpace::Srgb); + match encode_rendered(&r, &o, None) { + Ok(b) => assert_eq!(&b[4..8], b"ftyp"), + Err(e) => assert!(e.contains("not available"), "{e}"), + } +} + +/// Issue #181: a command about to export refuses what `from_json` would silently default — +/// unknown keys (naming the nearest known one) and values of the wrong kind or range. +#[test] +fn export_params_are_validated() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); + let e = err(json!({"longEdgee": 400})); + assert!(e.starts_with("invalid parameters for `app.export`: unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}"); + assert!(err(json!({"qualty": 5})).contains("did you mean `quality`")); + assert!(err(json!({"longEdge": "banana"})).contains("`longEdge` must be a number")); + assert!(err(json!({"quality": 0})).contains("`quality` must be an integer 1..100")); + assert!(err(json!({"quality": "5"})).contains("`quality` must be an integer")); + assert!(err(json!({"quality": 92.5})).contains("`quality` must be an integer")); + assert!(err(json!({"sharpen": "lots"})).contains("`sharpen` must be one of none|screen|matte|glossy")); + assert!(err(json!({"format": "bmp"})).contains("`format` must be one of")); + assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32")); + assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of")); + assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false")); + assert!(err(json!({"fit": "sideways"})).contains("`fit` must be one of dimensions|widthHeight")); + assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`")); + assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:")); + assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids")); + assert!(err(json!("jpeg")).contains("params must be an object")); + assert!( + ExportOptions::validate("export.savePreset", &json!({"qualty": 5})) + .unwrap_err() + .to_string() + .starts_with("invalid parameters for `export.savePreset`") + ); + // everything the presets, the dialog, Export with Previous and `render --opt` send passes + for p in builtin_presets() { + ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name)); + } + let full = ExportOptions { + resize: Some(Resize::default()), + limit_kb: Some(500), + watermark: Some(Watermark { text: "©".into(), ..Default::default() }), + format: ExportFormat::Png, + bit_depth: Some(16), + ..Default::default() + }; + let mut j = full.to_json(); + for (k, v) in [ + ("dir", json!("/tmp/out")), + ("background", json!(true)), + ("preset", json!("x")), + ("ids", json!([1, 2])), + ("id", json!(1)), + ("path", json!("a.jpg")), + ] { + j[k] = v; + } + assert_eq!(ExportOptions::from_params(&j).unwrap(), full); + // Add to Apple Photos (issue #236): a switch and an album name; on only where Photos is + assert!(err(json!({"addToPhotos": "yes"})).contains("`addToPhotos` must be true or false")); + assert!(err(json!({"photosAlbum": 3})).contains("`photosAlbum` must be a string")); + assert!(err(json!({"photosAlbum": "a\0b"})).contains("NUL")); + assert!(err(json!({"photosAlbm": "x"})).contains("did you mean `photosAlbum`")); + let album = ExportOptions { photos_album: "Trip “2026” \\ \"x\"".into(), ..Default::default() }; + assert_eq!(ExportOptions::from_params(&album.to_json()).unwrap(), album); + let on = ExportOptions { add_to_photos: true, ..album }; + if cfg!(target_os = "macos") { + assert_eq!(ExportOptions::from_params(&on.to_json()).unwrap(), on); + } else { + assert!(err(on.to_json()).contains("Apple Photos is only available on macOS")); + // stored settings (a preset or Export with Previous made on a Mac) still read + assert!(ExportOptions::from_json(&on.to_json()).add_to_photos); + } + assert_eq!(ExportOptions::from_json(&json!({"photosAlbum": " Trip "})).photos_album, "Trip"); + assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default()); + // null is "not given", as a dropped key is + ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); +} + +/// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers +/// it), not written at the format's own depth; the error names the depths the format takes. +#[test] +fn bit_depth_must_suit_the_format() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg")); + assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png")); + assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff")); + // no `format`: the one `path` names, else the default (JPEG) + assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png")); + assert!(err(json!({"bitDepth": 16})).contains("for jpeg")); + assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok()); + // a path naming no format, or a `null` format, exports the default too + assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg")); + assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg")); + assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg")); + // or the caller's own format for such a path, which is the one returned + let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png); + assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png")); + assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png); + assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff); + // stored settings (a preset, Export with Previous) lose such a depth instead of failing + let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16})); + assert_eq!(stored.get("bitDepth"), None); + assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32); + for f in [ + ExportFormat::Jpeg, + ExportFormat::Png, + ExportFormat::Tiff, + ExportFormat::Psd, + ExportFormat::Webp, + ExportFormat::Avif, + ExportFormat::Jxl, + ExportFormat::Original, + ExportFormat::Dng, + ExportFormat::RenderedDng, + ] { + for &(b, _) in ExportOptions::bit_depths(f) { + let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() }; + assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}"); + } + } +} + +/// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own +/// folder; it is checked like the other params and kept in presets and Export with Previous. +#[test] +fn destination_mode_param() { + use serde_json::json; + assert_eq!(ExportOptions::default().destination_mode, DestinationMode::SpecificFolder); + for (v, want) in [ + ("sameAsOriginal", DestinationMode::SameAsOriginal), + ("SameAsOriginal", DestinationMode::SameAsOriginal), + ("specificFolder", DestinationMode::SpecificFolder), + ] { + assert_eq!(ExportOptions::from_params(&json!({"destinationMode": v})).unwrap().destination_mode, want, "{v}"); + } + let e = ExportOptions::from_params(&json!({"destinationMode": "nextToIt"})).unwrap_err().to_string(); + assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}"); + assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err()); + assert!( + ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"})) + .unwrap_err() + .to_string() + .contains("did you mean `destinationMode`") + ); + // saved (a preset, `lastExport`) and read back + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let j = o.to_json(); + assert_eq!(j["destinationMode"], "sameAsOriginal"); + assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o); +} + +/// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the +/// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and +/// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and +/// `conflict` had the same gap. +#[test] +fn accepted_choices_are_the_ones_exported() { + use serde_json::json; + let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string()); + let metadata = [ + ("all", MetadataPolicy::All), + ("allExceptCamera", MetadataPolicy::AllExceptCamera), + ("copyright", MetadataPolicy::Copyright), + ("none", MetadataPolicy::None), + ]; + for (name, want) in metadata { + for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] { + assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}"); + } + } + for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)] + { + assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want); + } + for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] { + assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want); + } + for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] { + assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want); + } + // an explicit value nothing reads as is an error, never the permissive default + for v in ["", " none", "nothing", "all-except-camera"] { + let e = read("metadata", v).unwrap_err(); + assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}"); + } + assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high")); + assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip")); + // absent (or null) keeps the defaults + let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap(); + assert_eq!( + (d.metadata, d.sharpen, d.sharpen_amount, d.conflict), + (MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique) + ); + // `removeLocation` stays independent of the policy's spelling + let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap(); + assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true)); + // stored settings (lenient `from_json`) read the same spellings + assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None); + // and `to_json` still writes what the MCP schema lists + let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json(); + assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip"))); +} + +/// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in +/// the `app.export` param string), instead of unknown keys passing and sizes clamping silently. +#[test] +fn watermark_params_are_validated() { + use serde_json::json; + let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(json!(5)).contains( + "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}" + )); + assert!(err(json!({"text": "x", "bogus": 1})).contains( + "unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth)" + )); + assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`")); + for size in [json!(3), json!(0.5001), json!(0), json!("big")] { + let e = err(json!({"text": "x", "size": size})); + assert!(e.contains("`watermark.size` must be a number 0.005..0.5 (fraction of the short edge)"), "{size}: {e}"); + } + assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1")); + assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4")); + assert!(err(json!({"text": "x", "insetX": 0.5})).contains("`watermark.insetX` must be a number 0..0.4")); + assert!(err(json!({"text": "x", "insetY": "far"})).contains("`watermark.insetY` must be a number 0..0.4")); + for rotation in [json!(45), json!(-90), json!(360), json!("90"), json!(90.5)] { + let e = err(json!({"text": "x", "rotation": rotation})); + assert!(e.contains("`watermark.rotation` must be 0, 90, 180 or 270 (degrees clockwise)"), "{rotation}: {e}"); + } + assert!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "rotation": 270}})).is_ok()); + assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1")); + assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of")); + assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]")); + assert!(err(json!({"text": "x", "color": [255, 255, 256]})).contains("`watermark.color`")); + assert!(err(json!({"text": "x", "vertical": "yes"})).contains("`watermark.vertical` must be true or false")); + assert!(err(json!({"text": 7})).contains("`watermark.text` must be a string")); + // the documented shapes pass and read as before + let o = ExportOptions::from_params(&json!({"watermark": "© me"})).unwrap(); + assert_eq!(o.watermark.map(|w| w.text).as_deref(), Some("© me")); + let w = json!({"text": "日本語", "vertical": true, "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "inset": 0.05, "color": [0, 0, 0], "shadow": false, "image": "", "imageWidth": 0.3}); + let wm = ExportOptions::from_params(&json!({"watermark": w})).unwrap().watermark.unwrap(); + assert!((wm.size - 0.1).abs() < 1e-6 && wm.vertical && wm.anchor == Anchor::TopLeft && wm.color == [0, 0, 0] && !wm.shadow); + assert_eq!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "size": 0.5}})).unwrap().watermark.map(|w| w.size), Some(0.5)); + // the Export dialog's sliders (1–15 % size, 5–100 % opacity, 2–100 % width) stay inside the accepted ranges + assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15); + assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0); +} + +/// A batch with `lanes` photos in flight into a fake folder `out` (nothing exists there): +/// results, writes (path, bytes) and progress calls; `progress` returns false at `cancel_at`. +#[allow(clippy::type_complexity)] +fn lanes_batch( + s: &mut crate::Session, + ids: &[lightcraft_catalog::PhotoId], + o: &ExportOptions, + lanes: usize, + stop_on_error: bool, + cancel_at: usize, +) -> (Result, String>, Vec<(String, Vec)>, Vec<(usize, String)>) { + let items = prepare_batch(s, ids, o).unwrap(); + let to = Destination { dir: "out".into(), exact: None }; + let mut written = Vec::new(); + let mut write = |p: &str, b: &[u8]| { + written.push((p.to_string(), b.to_vec())); + Ok(()) + }; + let mut seen = Vec::new(); + let mut progress = |i: usize, name: &str| { + seen.push((i, name.to_string())); + i < cancel_at + }; + let files = run_batch_with(items, &mut Placer::new(o, &to, &mut write, &|_| false, stop_on_error), &mut progress, lanes); + (files, written, seen) +} + +/// The files of a batch without their byte counts: a GPU render (when another test turns the +/// GPU off mid-run) may differ from a CPU one by an LSB, never in name, size or order. +fn shape(files: &[serde_json::Value]) -> Vec { + files.iter().map(|f| json!({"path": f["path"], "width": f["width"], "height": f["height"], "error": f["error"]})).collect() +} + +/// Issue #675: a batch reports a render that fell back from the GPU to the CPU on that file's +/// entry (`gpuFallback`, the render's own reason) and says nothing on the others. +#[test] +fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { + if !lightcraft_gpu::available() { + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); + return; + } + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(2).collect(); + let o = ExportOptions::from_json(&json!({"format": "png", "width": 64})); + // one lane: both photos render on this thread, and the fault hits the first render here + lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::Limit(1 << 10)); + let (files, _, _) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); + let files = files.unwrap(); + assert_eq!(files.len(), 2, "{files:?}"); + let why: Vec<_> = files.iter().filter_map(|f| f.get("gpuFallback").and_then(serde_json::Value::as_str)).collect(); + assert_eq!(why.len(), 1, "exactly one file fell back: {files:?}"); + assert!(why[0].contains("limit"), "{files:?}"); + assert!(files[1].get("gpuFallback").is_none(), "the second render was fine: {files:?}"); + assert!(lightcraft_gpu::available(), "a limit is no device failure"); +} + +// Issue #496: photos rendered side by side give the same files, names, order and progress as +// Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture +#[test] +fn rendered_dng_matches_the_rendered_tiff() { + let mut s = crate::Session::with_demo(); + let id = s.visible()[0]; + let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap(); + assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name); + let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap(); + let raw = lightcraft_raw::decode(&dng.bytes).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3)); + assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one"); + let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image; + assert_eq!((reference.width, reference.height), (back.width, back.height)); + // mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB + let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::() / back.data.len() as f32; + let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::() / reference.data.len() as f32; + assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}"); +} + +// one at a time, and a cancel stops at the same photo +#[test] +fn side_by_side_batches_match_one_at_a_time() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); + // one name for all: the Unique numbering follows the order the files are placed in + let o = ExportOptions::from_json(&json!({"format": "png", "width": 40, "naming": "same"})); + let (files, written, seen) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); + let files = files.unwrap(); + let paths: Vec<_> = written.iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(paths, ["out/same.png", "out/same-2.png", "out/same-3.png", "out/same-4.png", "out/same-5.png"]); + let heights: std::collections::HashSet<_> = files.iter().map(|f| f["height"].as_u64()).collect(); + assert!(heights.len() > 1, "photos of different shapes show the order: {files:?}"); + for lanes in [2, 3, 8] { + let (f, w, sn) = lanes_batch(&mut s, &ids, &o, lanes, true, usize::MAX); + assert_eq!(shape(&f.unwrap()), shape(&files), "{lanes} lanes"); + assert_eq!(w.iter().map(|(p, _)| p.clone()).collect::>(), paths, "{lanes} lanes"); + assert_eq!(sn, seen, "{lanes} lanes"); + } + let (f, w, sn) = lanes_batch(&mut s, &ids, &o, 3, true, 2); + assert_eq!(shape(&f.unwrap()), shape(&files[..2])); + assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written"); +} + +// A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it +#[test] +fn a_panic_in_progress_does_not_hang_the_lanes() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); + let o = ExportOptions::from_json(&json!({"format": "png", "width": 40})); + let items = prepare_batch(&mut s, &ids, &o).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let to = Destination { dir: "out".into(), exact: None }; + let mut write = |_: &str, _: &[u8]| Ok(()); + let mut calls = 0; + let mut progress = |_: usize, _: &str| { + calls += 1; + assert!(calls < 2, "progress panics on its second call"); + true + }; + let r = crate::guard::catch("test batch", || { + run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2) + }); + tx.send(r.and_then(|r| r)).ok(); + }); + let r = rx.recv_timeout(std::time::Duration::from_secs(60)); + assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}"); +} + +// A photo that fails is reported in its place while the others are exported; with +// stop_on_error the batch ends there and nothing after it is written +#[test] +fn side_by_side_batches_report_failures_in_order() { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut s = crate::Session::with_demo(); + let mut ids: Vec<_> = s.visible().iter().copied().take(3).collect(); + let gone = + Photo::new(PhotoId(9_496), Source::File { path: "no/such/dir/gone.jpg".into() }, "gone.jpg", "JPEG", 600, 400, "2026-10-09T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(gone) }).unwrap(); + ids.insert(1, PhotoId(9_496)); + let o = ExportOptions::from_json(&json!({"format": "jpeg", "width": 40})); + let (files, written, _) = lanes_batch(&mut s, &ids, &o, 3, false, usize::MAX); + let files = files.unwrap(); + assert_eq!(files.len(), 4); + assert_eq!((files[1]["photo"].as_u64(), files[1]["file"].as_str()), (Some(9_496), Some("gone.jpg")), "{files:?}"); + assert!(files[1]["error"].is_string()); + assert!([0, 2, 3].iter().all(|&i| files[i]["path"].is_string()), "{files:?}"); + assert_eq!(written.len(), 3); + let one = lanes_batch(&mut s, &ids, &o, 1, false, usize::MAX).0.unwrap(); + assert_eq!(shape(&files), shape(&one)); + + let (r, written, _) = lanes_batch(&mut s, &ids, &o, 3, true, usize::MAX); + assert!(r.is_err()); + assert_eq!(written.len(), 1, "only the photo before the failure"); +} + +#[test] +fn export_weights_follow_the_source_and_output_sizes() { + use crate::media::SourceLevel; + let mb = |b: usize| b >> 20; + // a full-size 24 MP export (measured peak ~1.2 GB) + assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Full, 6000 * 4000)), 1098); + // 2048 px from the 2560 px preview (measured ~150 MB) + assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Preview, 2048 * 1365)), 145); + // a photo smaller than the preview level is its own source + assert_eq!(render_weight(1000 * 500, 1000, SourceLevel::Preview, 0), 1000 * 500 * 12); + assert_eq!(render_weight(0, 0, SourceLevel::Full, 0), 0); + assert!(render_weight(usize::MAX, 1, SourceLevel::Full, usize::MAX) > 0, "no overflow"); +} From 47adb933db03a5b2c22d36582b97941f472fcfe4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:52 +0000 Subject: [PATCH 038/191] refactor(lightcraft-engine): move smart.rs tests to crates/engine/src/smart/tests.rs --- crates/engine/src/smart.rs | 355 +------------------------------ crates/engine/src/smart/tests.rs | 352 ++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+), 354 deletions(-) create mode 100644 crates/engine/src/smart/tests.rs diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 9f72e0f48..b936830f8 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -500,357 +500,4 @@ pub fn load(path: &Path) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - fn temp(tag: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("lc-smart-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - #[test] - fn writable_check_does_not_create_missing_parents() { - let base = temp("writable"); - assert!(check_writable(&base.join("new")).is_ok(), "one missing level is created"); - assert!(base.join("new").is_dir()); - let err = check_writable(&base.join("gone/deeper")).unwrap_err(); - assert!(err.contains("not available"), "{err}"); - assert!(!base.join("gone").exists(), "no fallback onto the current drive"); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn migrate_moves_leaves_or_discards() { - let base = temp("migrate"); - let (a, b) = (base.join("a"), base.join("b")); - std::fs::create_dir_all(&a).unwrap(); - std::fs::create_dir_all(&b).unwrap(); - std::fs::write(a.join("1.lcsp"), b"one").unwrap(); - std::fs::write(a.join("note.txt"), b"keep").unwrap(); - assert_eq!(stats(&a), (1, 3)); - assert_eq!(migrate(&a, &b, Existing::Leave).0, 0); - assert_eq!(stats(&a).0, 1); - assert_eq!(migrate(&a, &b, Existing::Move).0, 1); - assert_eq!((stats(&a).0, stats(&b).0), (0, 1)); - assert!(a.join("note.txt").exists(), "only smart previews are touched"); - assert_eq!(migrate(&b, &a, Existing::Discard).0, 1); - assert_eq!(stats(&b).0, 0); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn sharded_and_legacy_proxies_are_counted_and_moved() { - let base = temp("shards"); - let from = base.join("from"); - let to = base.join("to"); - std::fs::create_dir_all(from.join("a1")).unwrap(); - std::fs::create_dir(&to).unwrap(); - let old = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2.lcsp"; - let new = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1.lcsp"; - std::fs::write(from.join(old), b"legacy").unwrap(); - std::fs::write(from.join("a1").join(new), b"sharded").unwrap(); - std::fs::write(from.join("a1").join("notes.txt"), b"user file").unwrap(); - std::fs::write(from.join("notes.lcsp.txt"), b"keep").unwrap(); - assert_eq!(stats(&from), (2, 13)); - let (moved, failed) = migrate(&from, &to, Existing::Move); - assert_eq!((moved, failed), (2, Vec::::new())); - assert_eq!(stats(&from), (0, 0)); - assert_eq!(stats(&to), (2, 13)); - assert_eq!(std::fs::read(to.join("b2").join(old)).unwrap(), b"legacy"); - assert_eq!(std::fs::read(to.join("a1").join(new)).unwrap(), b"sharded"); - assert!(from.join("a1").join("notes.txt").exists()); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() { - let base = temp("valid"); - let (a, b) = (base.join("a"), base.join("b")); - std::fs::create_dir_all(&a).unwrap(); - std::fs::create_dir_all(&b).unwrap(); - let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let good = encode(&img, None).unwrap(); - std::fs::write(a.join("1.lcsp"), &good).unwrap(); - assert!(is_valid(&a.join("1.lcsp"))); - for cut in [good.len() - 1, good.len() / 2, 10, 0] { - std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap(); - assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}"); - } - assert!(!is_valid(&b.join("missing.lcsp"))); - // moving onto a damaged copy replaces it instead of deleting the good one - assert_eq!(migrate(&a, &b, Existing::Move).0, 1); - assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn camera_tone_travels_with_the_proxy() { - let tone = CameraTone::new(std::array::from_fn(|i| { - let x = 0.004 * 1.17f32.powi(i as i32); - [x, 0.95 * (1.0 - (-2.7 * x).exp())] - })) - .unwrap(); - let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let info = lightcraft_pipeline::SourceInfo { raw: true, camera_tone: Some(tone), ..Default::default() }; - let bytes = encode(&img, Some(&info)).unwrap(); - assert_eq!(decode(&bytes).unwrap().1, Some(tone)); - let dir = temp("tone"); - let path = dir.join("t.lcsp"); - std::fs::write(&path, &bytes).unwrap(); - // the longer header is still read by the validity check, and the curve by `load` - assert!(is_valid(&path)); - let loaded = load(&path).unwrap(); - assert_eq!(loaded.camera_tone, Some(tone)); - let header = lightcraft_pipeline::SourceInfo { raw: true, ..Default::default() }; - assert_eq!(loaded.info_or(header).camera_tone, Some(tone)); - // a hostile curve (reversing knots) is ignored, not trusted - let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); - let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); - let mut knots: Vec<[f32; 2]> = serde_json::from_value(head["tone"]["knots"].clone()).unwrap(); - knots.swap(3, 4); - head["tone"]["knots"] = serde_json::json!(knots); - let mut hostile = MAGIC.to_vec(); - hostile.extend_from_slice(head.to_string().as_bytes()); - hostile.extend_from_slice(&bytes[nl..]); - let (pixels, tone, _) = decode(&hostile).unwrap(); - assert_eq!((pixels.width, tone), (img.width, None)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn facts_and_mattes_travel_with_the_proxy() { - let img = lightcraft_scenes::demo_library()[0].render(32, 20); - let mut mattes = Mattes::default(); - mattes.push(MatteKind::Sky, Image { width: 16, height: 10, data: vec![255; 160] }); - let info = SourceInfo { - raw: true, - as_shot_temp: 4200.0, - as_shot_tint: -5.0, - legacy_relative_wb: true, - mattes: Some(std::sync::Arc::new(mattes)), - ..Default::default() - }; - let bytes = encode(&img, Some(&info)).unwrap(); - let (_, _, back) = decode(&bytes).unwrap(); - let back = back.expect("the facts are read back"); - assert_eq!((back.as_shot_temp, back.as_shot_tint, back.legacy_relative_wb), (4200.0, -5.0, true)); - assert_eq!(back.mattes.expect("the mattes are read back").bytes(), 160); - // a proxy cut inside its tail is incomplete (rebuilt, not taken for a source of facts) - let dir = temp("facts"); - let path = dir.join("f.lcsp"); - std::fs::write(&path, &bytes[..bytes.len() - 8]).unwrap(); - assert!(!is_valid(&path)); - let _ = std::fs::remove_dir_all(&dir); - // an old proxy (no tail) has no facts, and neither has one with a malformed tail - assert!(decode(&encode(&img, None).unwrap()).unwrap().2.is_none()); - let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); - let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); - head["tail"] = serde_json::json!(bytes.len()); - let mut lying = MAGIC.to_vec(); - lying.extend_from_slice(head.to_string().as_bytes()); - lying.extend_from_slice(&bytes[nl..]); - assert!(decode(&lying).map(|(_, _, i)| i.is_none()).unwrap_or(true), "an over-large tail is not trusted"); - } - - #[test] - fn roundtrip_keeps_the_picture() { - let img = lightcraft_scenes::demo_library()[0].render(160, 100); - let (back, tone, _) = decode(&encode(&img, None).unwrap()).unwrap(); - assert_eq!((back.width, back.height), (img.width, img.height)); - assert!(tone.is_none()); - let mut err = 0.0f64; - for (a, b) in img.data.iter().zip(&back.data) { - for k in 0..3 { - let (x, y) = (linear_to_srgb(a[k].clamp(0.0, 1.0)), linear_to_srgb(b[k].clamp(0.0, 1.0))); - err += ((x - y) as f64).abs(); - } - } - let mean = err / (img.data.len() * 3) as f64; - assert!(mean < 0.02, "mean encoded error {mean}"); - assert!(decode(b"nope").is_err()); - } - - /// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into - /// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never - /// enters what is measured. Built from a rendered demo scene so the dimensions and the flat - /// row-major layout are the real ones. - fn smooth_scene(w: usize, h: usize) -> Rgb32f { - let mut img = lightcraft_scenes::demo_library()[0].render(w, h); - let (w, h) = (img.width.max(1), img.height.max(1)); - for (i, px) in img.data.iter_mut().enumerate() { - let u = (i % w) as f32 / (w - 1).max(1) as f32; - let v = (i / w) as f32 / (h - 1).max(1) as f32; - let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step - let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v]; - let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)]; - *px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0)); - } - img - } - - /// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The - /// input is clamped into 0..1, the only range a proxy ever stores. - fn lab(c: [f32; 3]) -> [f64; 3] { - let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64); - let (x, y, z) = ( - (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, - 0.2126 * r + 0.7152 * g + 0.0722 * b, - (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883, - ); - let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 }; - let (fx, fy, fz) = (f(x), f(y), f(z)); - [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] - } - - /// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a - /// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders. - fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { - let n = a.data.len().min(b.data.len()); - if n == 0 { - return 0.0; - } - (0..n) - .map(|i| { - let (p, q) = (lab(a.data[i]), lab(b.data[i])); - ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() - }) - .sum::() - / n as f64 - } - - /// A deliberately frozen copy of the pixel mapping and container this module used before the - /// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the - /// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on - /// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the - /// quality raise bought instead of trusting a written-down number. - fn encode_at_q92(img: &Rgb32f) -> Vec { - let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); - let scale = if lum.is_empty() { - 1.0 - } else { - let k = ((lum.len() as f64) * 0.9995) as usize; - let k = k.min(lum.len() - 1); - let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp); - v.max(1.0) - }; - let data: Vec<[u8; 4]> = img - .data - .iter() - .map(|c| { - let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8; - [e(c[0]), e(c[1]), e(c[2]), 255] - }) - .collect(); - let rgba = Rgba8 { width: img.width, height: img.height, data }; - let jpg = lightcraft_codecs::encode_jpeg( - &lightcraft_codecs::EncodeImage::rgba8(&rgba), - 92, - lightcraft_codecs::ChromaSubsampling::S444, - &Default::default(), - ) - .unwrap(); - // the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field - let mut out = MAGIC.to_vec(); - let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); - out.extend_from_slice(head.to_string().as_bytes()); - out.push(b'\n'); - out.extend_from_slice(&jpg); - out - } - - /// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one - /// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its - /// mean, and rebuilds the whole block from it, so a proxy differs from the original by a - /// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels - /// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share - /// eightfold, which is what a per-pixel ΔE drowns in. - fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { - let w = a.width.min(b.width).max(1); - let h = a.height.min(b.height).max(1); - let (mut sum, mut n) = (0.0f64, 0usize); - for by in (0..h).step_by(8) { - for bx in (0..w).step_by(8) { - let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32); - for y in by..(by + 8).min(h) { - for x in bx..(bx + 8).min(w) { - if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) { - for k in 0..3 { - sa[k] += f64::from(ca[k]); - sb[k] += f64::from(cb[k]); - } - count += 1; - } - } - } - let d = f64::from(count.max(1)); - let (pa, pb) = ( - lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]), - lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]), - ); - sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt(); - n += 1; - } - } - sum / n.max(1) as f64 - } - - /// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit - /// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene, - /// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the - /// quality raise is reverted and never trips on the honest result (measured 0.683). - const PROXY_BLOCK_GAIN: f64 = 0.8; - - #[test] - fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() { - let img = smooth_scene(256, 160); - let ours = decode(&encode(&img, None).unwrap()).unwrap().0; - let before = decode(&encode_at_q92(&img)).unwrap().0; - let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before)); - let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before)); - println!( - "smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \ - (per-pixel {:.3} vs {:.3})", - block.0, block.1, pixel.0, pixel.1 - ); - assert!(block.1 > 0.0, "the old encoder must measure something to compare against"); - assert!( - block.0 <= PROXY_BLOCK_GAIN * block.1, - "q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \ - (the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)", - block.0, - PROXY_BLOCK_GAIN * block.1, - block.1 - ); - } - - #[test] - fn a_proxy_from_an_older_encode_is_stale() { - let dir = temp("encode-version"); - let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let bytes = encode(&img, None).unwrap(); - let path = dir.join("p.lcsp"); - std::fs::write(&path, &bytes).unwrap(); - assert!(!is_stale(&path), "this build's own proxy is up to date"); - // one written before the encode version was recorded is not - let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); - let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); - assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION)); - let mut older = head.as_object().cloned().unwrap_or_default(); - older.remove(ENC_FIELD); - let mut old = MAGIC.to_vec(); - old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes()); - old.extend_from_slice(&bytes[nl..]); - std::fs::write(&path, &old).unwrap(); - assert!(is_valid(&path), "losing the field keeps the file readable"); - assert!(is_stale(&path), "an older encoding is rebuilt"); - // the marker records both versions, and one from before the encode version forces a scan - assert!(!look_checked(&dir)); - mark_look_checked(&dir).unwrap(); - assert!(look_checked(&dir)); - std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap(); - assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild"); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/engine/src/smart/tests.rs b/crates/engine/src/smart/tests.rs new file mode 100644 index 000000000..882fad689 --- /dev/null +++ b/crates/engine/src/smart/tests.rs @@ -0,0 +1,352 @@ +use super::*; + +fn temp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-smart-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn writable_check_does_not_create_missing_parents() { + let base = temp("writable"); + assert!(check_writable(&base.join("new")).is_ok(), "one missing level is created"); + assert!(base.join("new").is_dir()); + let err = check_writable(&base.join("gone/deeper")).unwrap_err(); + assert!(err.contains("not available"), "{err}"); + assert!(!base.join("gone").exists(), "no fallback onto the current drive"); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn migrate_moves_leaves_or_discards() { + let base = temp("migrate"); + let (a, b) = (base.join("a"), base.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(a.join("1.lcsp"), b"one").unwrap(); + std::fs::write(a.join("note.txt"), b"keep").unwrap(); + assert_eq!(stats(&a), (1, 3)); + assert_eq!(migrate(&a, &b, Existing::Leave).0, 0); + assert_eq!(stats(&a).0, 1); + assert_eq!(migrate(&a, &b, Existing::Move).0, 1); + assert_eq!((stats(&a).0, stats(&b).0), (0, 1)); + assert!(a.join("note.txt").exists(), "only smart previews are touched"); + assert_eq!(migrate(&b, &a, Existing::Discard).0, 1); + assert_eq!(stats(&b).0, 0); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn sharded_and_legacy_proxies_are_counted_and_moved() { + let base = temp("shards"); + let from = base.join("from"); + let to = base.join("to"); + std::fs::create_dir_all(from.join("a1")).unwrap(); + std::fs::create_dir(&to).unwrap(); + let old = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2.lcsp"; + let new = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1.lcsp"; + std::fs::write(from.join(old), b"legacy").unwrap(); + std::fs::write(from.join("a1").join(new), b"sharded").unwrap(); + std::fs::write(from.join("a1").join("notes.txt"), b"user file").unwrap(); + std::fs::write(from.join("notes.lcsp.txt"), b"keep").unwrap(); + assert_eq!(stats(&from), (2, 13)); + let (moved, failed) = migrate(&from, &to, Existing::Move); + assert_eq!((moved, failed), (2, Vec::::new())); + assert_eq!(stats(&from), (0, 0)); + assert_eq!(stats(&to), (2, 13)); + assert_eq!(std::fs::read(to.join("b2").join(old)).unwrap(), b"legacy"); + assert_eq!(std::fs::read(to.join("a1").join(new)).unwrap(), b"sharded"); + assert!(from.join("a1").join("notes.txt").exists()); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() { + let base = temp("valid"); + let (a, b) = (base.join("a"), base.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let good = encode(&img, None).unwrap(); + std::fs::write(a.join("1.lcsp"), &good).unwrap(); + assert!(is_valid(&a.join("1.lcsp"))); + for cut in [good.len() - 1, good.len() / 2, 10, 0] { + std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap(); + assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}"); + } + assert!(!is_valid(&b.join("missing.lcsp"))); + // moving onto a damaged copy replaces it instead of deleting the good one + assert_eq!(migrate(&a, &b, Existing::Move).0, 1); + assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn camera_tone_travels_with_the_proxy() { + let tone = CameraTone::new(std::array::from_fn(|i| { + let x = 0.004 * 1.17f32.powi(i as i32); + [x, 0.95 * (1.0 - (-2.7 * x).exp())] + })) + .unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let info = lightcraft_pipeline::SourceInfo { raw: true, camera_tone: Some(tone), ..Default::default() }; + let bytes = encode(&img, Some(&info)).unwrap(); + assert_eq!(decode(&bytes).unwrap().1, Some(tone)); + let dir = temp("tone"); + let path = dir.join("t.lcsp"); + std::fs::write(&path, &bytes).unwrap(); + // the longer header is still read by the validity check, and the curve by `load` + assert!(is_valid(&path)); + let loaded = load(&path).unwrap(); + assert_eq!(loaded.camera_tone, Some(tone)); + let header = lightcraft_pipeline::SourceInfo { raw: true, ..Default::default() }; + assert_eq!(loaded.info_or(header).camera_tone, Some(tone)); + // a hostile curve (reversing knots) is ignored, not trusted + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + let mut knots: Vec<[f32; 2]> = serde_json::from_value(head["tone"]["knots"].clone()).unwrap(); + knots.swap(3, 4); + head["tone"]["knots"] = serde_json::json!(knots); + let mut hostile = MAGIC.to_vec(); + hostile.extend_from_slice(head.to_string().as_bytes()); + hostile.extend_from_slice(&bytes[nl..]); + let (pixels, tone, _) = decode(&hostile).unwrap(); + assert_eq!((pixels.width, tone), (img.width, None)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn facts_and_mattes_travel_with_the_proxy() { + let img = lightcraft_scenes::demo_library()[0].render(32, 20); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Sky, Image { width: 16, height: 10, data: vec![255; 160] }); + let info = SourceInfo { + raw: true, + as_shot_temp: 4200.0, + as_shot_tint: -5.0, + legacy_relative_wb: true, + mattes: Some(std::sync::Arc::new(mattes)), + ..Default::default() + }; + let bytes = encode(&img, Some(&info)).unwrap(); + let (_, _, back) = decode(&bytes).unwrap(); + let back = back.expect("the facts are read back"); + assert_eq!((back.as_shot_temp, back.as_shot_tint, back.legacy_relative_wb), (4200.0, -5.0, true)); + assert_eq!(back.mattes.expect("the mattes are read back").bytes(), 160); + // a proxy cut inside its tail is incomplete (rebuilt, not taken for a source of facts) + let dir = temp("facts"); + let path = dir.join("f.lcsp"); + std::fs::write(&path, &bytes[..bytes.len() - 8]).unwrap(); + assert!(!is_valid(&path)); + let _ = std::fs::remove_dir_all(&dir); + // an old proxy (no tail) has no facts, and neither has one with a malformed tail + assert!(decode(&encode(&img, None).unwrap()).unwrap().2.is_none()); + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + head["tail"] = serde_json::json!(bytes.len()); + let mut lying = MAGIC.to_vec(); + lying.extend_from_slice(head.to_string().as_bytes()); + lying.extend_from_slice(&bytes[nl..]); + assert!(decode(&lying).map(|(_, _, i)| i.is_none()).unwrap_or(true), "an over-large tail is not trusted"); +} + +#[test] +fn roundtrip_keeps_the_picture() { + let img = lightcraft_scenes::demo_library()[0].render(160, 100); + let (back, tone, _) = decode(&encode(&img, None).unwrap()).unwrap(); + assert_eq!((back.width, back.height), (img.width, img.height)); + assert!(tone.is_none()); + let mut err = 0.0f64; + for (a, b) in img.data.iter().zip(&back.data) { + for k in 0..3 { + let (x, y) = (linear_to_srgb(a[k].clamp(0.0, 1.0)), linear_to_srgb(b[k].clamp(0.0, 1.0))); + err += ((x - y) as f64).abs(); + } + } + let mean = err / (img.data.len() * 3) as f64; + assert!(mean < 0.02, "mean encoded error {mean}"); + assert!(decode(b"nope").is_err()); +} + +/// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into +/// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never +/// enters what is measured. Built from a rendered demo scene so the dimensions and the flat +/// row-major layout are the real ones. +fn smooth_scene(w: usize, h: usize) -> Rgb32f { + let mut img = lightcraft_scenes::demo_library()[0].render(w, h); + let (w, h) = (img.width.max(1), img.height.max(1)); + for (i, px) in img.data.iter_mut().enumerate() { + let u = (i % w) as f32 / (w - 1).max(1) as f32; + let v = (i / w) as f32 / (h - 1).max(1) as f32; + let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step + let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v]; + let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)]; + *px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0)); + } + img +} + +/// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The +/// input is clamped into 0..1, the only range a proxy ever stores. +fn lab(c: [f32; 3]) -> [f64; 3] { + let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64); + let (x, y, z) = ( + (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, + 0.2126 * r + 0.7152 * g + 0.0722 * b, + (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883, + ); + let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +/// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a +/// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders. +fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { + let n = a.data.len().min(b.data.len()); + if n == 0 { + return 0.0; + } + (0..n) + .map(|i| { + let (p, q) = (lab(a.data[i]), lab(b.data[i])); + ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() + }) + .sum::() + / n as f64 +} + +/// A deliberately frozen copy of the pixel mapping and container this module used before the +/// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the +/// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on +/// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the +/// quality raise bought instead of trusting a written-down number. +fn encode_at_q92(img: &Rgb32f) -> Vec { + let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); + let scale = if lum.is_empty() { + 1.0 + } else { + let k = ((lum.len() as f64) * 0.9995) as usize; + let k = k.min(lum.len() - 1); + let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp); + v.max(1.0) + }; + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let rgba = Rgba8 { width: img.width, height: img.height, data }; + let jpg = lightcraft_codecs::encode_jpeg( + &lightcraft_codecs::EncodeImage::rgba8(&rgba), + 92, + lightcraft_codecs::ChromaSubsampling::S444, + &Default::default(), + ) + .unwrap(); + // the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field + let mut out = MAGIC.to_vec(); + let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); + out.extend_from_slice(head.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(&jpg); + out +} + +/// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one +/// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its +/// mean, and rebuilds the whole block from it, so a proxy differs from the original by a +/// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels +/// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share +/// eightfold, which is what a per-pixel ΔE drowns in. +fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { + let w = a.width.min(b.width).max(1); + let h = a.height.min(b.height).max(1); + let (mut sum, mut n) = (0.0f64, 0usize); + for by in (0..h).step_by(8) { + for bx in (0..w).step_by(8) { + let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32); + for y in by..(by + 8).min(h) { + for x in bx..(bx + 8).min(w) { + if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) { + for k in 0..3 { + sa[k] += f64::from(ca[k]); + sb[k] += f64::from(cb[k]); + } + count += 1; + } + } + } + let d = f64::from(count.max(1)); + let (pa, pb) = ( + lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]), + lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]), + ); + sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt(); + n += 1; + } + } + sum / n.max(1) as f64 +} + +/// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit +/// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene, +/// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the +/// quality raise is reverted and never trips on the honest result (measured 0.683). +const PROXY_BLOCK_GAIN: f64 = 0.8; + +#[test] +fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() { + let img = smooth_scene(256, 160); + let ours = decode(&encode(&img, None).unwrap()).unwrap().0; + let before = decode(&encode_at_q92(&img)).unwrap().0; + let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before)); + let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before)); + println!( + "smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \ + (per-pixel {:.3} vs {:.3})", + block.0, block.1, pixel.0, pixel.1 + ); + assert!(block.1 > 0.0, "the old encoder must measure something to compare against"); + assert!( + block.0 <= PROXY_BLOCK_GAIN * block.1, + "q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \ + (the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)", + block.0, + PROXY_BLOCK_GAIN * block.1, + block.1 + ); +} + +#[test] +fn a_proxy_from_an_older_encode_is_stale() { + let dir = temp("encode-version"); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let bytes = encode(&img, None).unwrap(); + let path = dir.join("p.lcsp"); + std::fs::write(&path, &bytes).unwrap(); + assert!(!is_stale(&path), "this build's own proxy is up to date"); + // one written before the encode version was recorded is not + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION)); + let mut older = head.as_object().cloned().unwrap_or_default(); + older.remove(ENC_FIELD); + let mut old = MAGIC.to_vec(); + old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes()); + old.extend_from_slice(&bytes[nl..]); + std::fs::write(&path, &old).unwrap(); + assert!(is_valid(&path), "losing the field keeps the file readable"); + assert!(is_stale(&path), "an older encoding is rebuilt"); + // the marker records both versions, and one from before the encode version forces a scan + assert!(!look_checked(&dir)); + mark_look_checked(&dir).unwrap(); + assert!(look_checked(&dir)); + std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap(); + assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild"); + let _ = std::fs::remove_dir_all(&dir); +} From 6cf21f552a0d167c4d779c77abb38fd55dce712d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:52 +0000 Subject: [PATCH 039/191] refactor(lightcraft-engine): move crs_masks.rs tests to crates/engine/src/crs_masks/tests.rs --- crates/engine/src/crs_masks.rs | 335 +-------------------------- crates/engine/src/crs_masks/tests.rs | 332 ++++++++++++++++++++++++++ 2 files changed, 333 insertions(+), 334 deletions(-) create mode 100644 crates/engine/src/crs_masks/tests.rs diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 848251c65..a54bd620d 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -446,337 +446,4 @@ pub fn refit_radials(partial: &mut Value, from: f64, to: f64) { } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape}; - - /// A packet in the shape local corrections take (values written for this test). - const PACKET: &str = r#" - - - - - - - - - - - - - - - - - - - - - - - d 0.1 0.2d 0.15 0.22 - d 0.5 0.5 - - -"#; - - fn settings() -> (DevelopSettings, Vec) { - let d = lightcraft_meta::parse_xmp(PACKET).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - (DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped) - } - - #[test] - fn corrections_become_masks() { - let (s, unmapped) = settings(); - assert_eq!(s.light.exposure, 0.2, "global settings still map"); - assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks); - let sky = &s.masks[0]; - assert_eq!(sky.name, "Darker sky"); - assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0)); - assert_eq!(sky.components[0].shape, MaskShape::Sky); - assert_eq!(sky.components[1].op, MaskOp::Intersect); - let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() }; - assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point"); - let rad = &s.masks[1]; - assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0)); - let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() }; - assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9); - assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}"); - assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside"); - assert!(!rad.components[0].invert); - let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() }; - assert!(lo < hi && *lo_feather > 0.0); - let brush = &s.masks[2]; - assert_eq!(brush.adjust.clarity, 40.0); - assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component"); - let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() }; - assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0)); - assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]); - } - - #[test] - fn masks_render() { - let (s, _) = settings(); - let img = lightcraft_scenes::demo_library()[0].render(96, 64); - let render = |d: &DevelopSettings| { - lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data - }; - assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); - } - - #[test] - fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { - // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack - let x = r#" - - - - - - - - -"#; - let d = lightcraft_meta::parse_xmp(x).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).expect("valid settings"); - let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; - assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); - assert_eq!(s.masks[0].adjust.clarity, -30.0); - assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); - } - - #[test] - fn lrtemplate_corrections() { - let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { - { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = { - { What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#; - let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap(); - assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped); - let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); - assert_eq!(s.masks.len(), 1); - assert_eq!(s.masks[0].adjust.exposure, -2.0); - } - - #[test] - fn presets_add_masks_once() { - let (s, _) = settings(); - let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}}); - let p = - lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false }; - let mut mine = DevelopSettings::default(); - mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() }); - let once = p.apply(&mine, 1.0); - assert_eq!(once.masks.len(), 4, "added to the photo's own mask"); - assert_eq!(once.masks[1].id, 8, "fresh ids"); - assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing"); - let half = p.apply(&mine, 0.5); - assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks"); - } - - #[test] - fn sidecar_radials_fit_the_photo() { - let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]}); - refit_radials(&mut v, 1.5, 1.0); - let s = &v["masks"][0]["components"][0]["shape"]; - assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9); - assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height"); - } - - /// One radial correction in `container`, with `attrs` on its mask component (values written - /// for this test; the component attributes follow what Lightroom Classic writes). - fn radial_packet(container: &str, attrs: &str) -> String { - format!( - r#" - - - - - - - - -"# - ) - } - - fn radial_settings(container: &str, attrs: &str) -> DevelopSettings { - let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - assert!(unmapped.is_empty(), "{unmapped:?}"); - let s = DevelopSettings::default().merged(&partial).unwrap(); - assert_eq!(s.masks.len(), 1, "{:?}", s.masks); - s - } - - /// (the radial's `invert`, its feather, the component's `invert`). - fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) { - let c = &s.masks[0].components[0]; - let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) }; - (*invert, *feather, c.invert) - } - - /// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green). - fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) { - let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] }; - let render = - |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image; - let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() })); - let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]); - (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3)) - } - - #[test] - fn lightroom_radial_flipped_is_inside() { - // as Lightroom Classic writes an ordinary radial mask today (#1815) - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); - assert_eq!(radial_of(&s), (false, 25.0, false)); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}"); - // no MaskInverted at all reads the same - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#); - assert_eq!(radial_of(&s), (false, 25.0, false)); - } - - #[test] - fn lightroom_radial_mask_inverted_is_outside() { - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); - assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's"); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}"); - // version 2 with Flipped="false": outside, the legacy sense of the flag - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#); - assert_eq!(radial_of(&s), (true, 25.0, false)); - } - - #[test] - fn versionless_group_radial_is_inside_with_half_the_feather() { - // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2" - for flipped in ["true", "false"] { - let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#)); - assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}"); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}"); - } - } - - #[test] - fn legacy_radial_filter_is_outside_unless_flipped() { - // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true" - let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#); - assert_eq!(radial_of(&s), (true, 50.0, false)); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}"); - assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false)); - let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#); - assert_eq!(radial_of(&s), (false, 50.0, false)); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}"); - } - - #[test] - fn lrtemplate_radial_filter_reads_flipped() { - // .lrtemplate presets and catalog develop settings are the same Lua tables - let t = |flipped: &str| { - format!( - r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{ - {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{ - {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"# - ) - }; - for (flipped, outside) in [("true", false), ("false", true)] { - let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap(); - let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); - assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}"); - } - } - - /// Spot removal in the shape Lightroom Classic writes it (values written for this test): - /// a healed circle with an explicit source, a cloned circle with an automatic source, a - /// brushed removal, a circular-gradient spot, one without a mask, and the legacy list. - const SPOTS: &str = r#" - - - centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal - - - - - - - - - - - d 0.1 0.8d 0.12 0.82d 0.14 0.84 - - - - - - - - -"#; - - #[test] - fn retouch_areas_become_spots() { - use lightcraft_develop::SpotMode; - let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).expect("valid settings"); - assert_eq!(s.light.exposure, 0.1, "global settings still map"); - assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots); - let heal = &s.spots[0]; - assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0)); - assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9); - assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions"); - let src = heal.source_offset.expect("explicit source"); - assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}"); - let clone = &s.spots[1]; - assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None)); - let brushed = &s.spots[2]; - assert_eq!((brushed.points.len(), brushed.size), (3, 0.03)); - let src = brushed.source_offset.expect("explicit source"); - assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9); - let circ = &s.spots[3]; - assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9); - assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}"); - } - - #[test] - fn portrait_target_scales_spot_sizes() { - let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); - let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0); - let s = DevelopSettings::default().merged(&partial).unwrap(); - assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size); - } - - #[test] - fn legacy_retouch_info_is_read_on_its_own() { - let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored"); - let d = lightcraft_meta::parse_xmp(&packet).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).unwrap(); - assert_eq!(s.spots.len(), 1, "{:?}", s.spots); - assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal); - assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9); - assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9)); - assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}"); - assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}"); - } - - #[test] - fn spots_render() { - let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); - let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).unwrap(); - let img = lightcraft_scenes::demo_library()[0].render(96, 64); - let render = |d: &DevelopSettings| { - lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data - }; - assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture"); - } -} +mod tests; diff --git a/crates/engine/src/crs_masks/tests.rs b/crates/engine/src/crs_masks/tests.rs new file mode 100644 index 000000000..4e507df3e --- /dev/null +++ b/crates/engine/src/crs_masks/tests.rs @@ -0,0 +1,332 @@ +use super::*; +use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape}; + +/// A packet in the shape local corrections take (values written for this test). +const PACKET: &str = r#" + + + + + + + + + + + + + + + + + + + + + + + d 0.1 0.2d 0.15 0.22 + d 0.5 0.5 + + +"#; + +fn settings() -> (DevelopSettings, Vec) { + let d = lightcraft_meta::parse_xmp(PACKET).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + (DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped) +} + +#[test] +fn corrections_become_masks() { + let (s, unmapped) = settings(); + assert_eq!(s.light.exposure, 0.2, "global settings still map"); + assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks); + let sky = &s.masks[0]; + assert_eq!(sky.name, "Darker sky"); + assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0)); + assert_eq!(sky.components[0].shape, MaskShape::Sky); + assert_eq!(sky.components[1].op, MaskOp::Intersect); + let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() }; + assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point"); + let rad = &s.masks[1]; + assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0)); + let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() }; + assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9); + assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}"); + assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside"); + assert!(!rad.components[0].invert); + let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() }; + assert!(lo < hi && *lo_feather > 0.0); + let brush = &s.masks[2]; + assert_eq!(brush.adjust.clarity, 40.0); + assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component"); + let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() }; + assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0)); + assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]); +} + +#[test] +fn masks_render() { + let (s, _) = settings(); + let img = lightcraft_scenes::demo_library()[0].render(96, 64); + let render = |d: &DevelopSettings| { + lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data + }; + assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); +} + +#[test] +fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { + // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack + let x = r#" + + + + + + + + +"#; + let d = lightcraft_meta::parse_xmp(x).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; + assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); + assert_eq!(s.masks[0].adjust.clarity, -30.0); + assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); +} + +#[test] +fn lrtemplate_corrections() { + let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { + { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = { + { What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#; + let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap(); + assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped); + let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); + assert_eq!(s.masks.len(), 1); + assert_eq!(s.masks[0].adjust.exposure, -2.0); +} + +#[test] +fn presets_add_masks_once() { + let (s, _) = settings(); + let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}}); + let p = + lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false }; + let mut mine = DevelopSettings::default(); + mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() }); + let once = p.apply(&mine, 1.0); + assert_eq!(once.masks.len(), 4, "added to the photo's own mask"); + assert_eq!(once.masks[1].id, 8, "fresh ids"); + assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing"); + let half = p.apply(&mine, 0.5); + assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks"); +} + +#[test] +fn sidecar_radials_fit_the_photo() { + let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]}); + refit_radials(&mut v, 1.5, 1.0); + let s = &v["masks"][0]["components"][0]["shape"]; + assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9); + assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height"); +} + +/// One radial correction in `container`, with `attrs` on its mask component (values written +/// for this test; the component attributes follow what Lightroom Classic writes). +fn radial_packet(container: &str, attrs: &str) -> String { + format!( + r#" + + + + + + + + +"# + ) +} + +fn radial_settings(container: &str, attrs: &str) -> DevelopSettings { + let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + assert!(unmapped.is_empty(), "{unmapped:?}"); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert_eq!(s.masks.len(), 1, "{:?}", s.masks); + s +} + +/// (the radial's `invert`, its feather, the component's `invert`). +fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) { + let c = &s.masks[0].components[0]; + let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) }; + (*invert, *feather, c.invert) +} + +/// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green). +fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) { + let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] }; + let render = + |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image; + let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() })); + let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]); + (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3)) +} + +#[test] +fn lightroom_radial_flipped_is_inside() { + // as Lightroom Classic writes an ordinary radial mask today (#1815) + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}"); + // no MaskInverted at all reads the same + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); +} + +#[test] +fn lightroom_radial_mask_inverted_is_outside() { + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}"); + // version 2 with Flipped="false": outside, the legacy sense of the flag + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#); + assert_eq!(radial_of(&s), (true, 25.0, false)); +} + +#[test] +fn versionless_group_radial_is_inside_with_half_the_feather() { + // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2" + for flipped in ["true", "false"] { + let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#)); + assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}"); + } +} + +#[test] +fn legacy_radial_filter_is_outside_unless_flipped() { + // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true" + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#); + assert_eq!(radial_of(&s), (true, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}"); + assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false)); + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#); + assert_eq!(radial_of(&s), (false, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}"); +} + +#[test] +fn lrtemplate_radial_filter_reads_flipped() { + // .lrtemplate presets and catalog develop settings are the same Lua tables + let t = |flipped: &str| { + format!( + r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{ + {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{ + {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"# + ) + }; + for (flipped, outside) in [("true", false), ("false", true)] { + let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap(); + let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); + assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}"); + } +} + +/// Spot removal in the shape Lightroom Classic writes it (values written for this test): +/// a healed circle with an explicit source, a cloned circle with an automatic source, a +/// brushed removal, a circular-gradient spot, one without a mask, and the legacy list. +const SPOTS: &str = r#" + + + centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal + + + + + + + + + + + d 0.1 0.8d 0.12 0.82d 0.14 0.84 + + + + + + + + +"#; + +#[test] +fn retouch_areas_become_spots() { + use lightcraft_develop::SpotMode; + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + assert_eq!(s.light.exposure, 0.1, "global settings still map"); + assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots); + let heal = &s.spots[0]; + assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0)); + assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9); + assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions"); + let src = heal.source_offset.expect("explicit source"); + assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}"); + let clone = &s.spots[1]; + assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None)); + let brushed = &s.spots[2]; + assert_eq!((brushed.points.len(), brushed.size), (3, 0.03)); + let src = brushed.source_offset.expect("explicit source"); + assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9); + let circ = &s.spots[3]; + assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9); + assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}"); +} + +#[test] +fn portrait_target_scales_spot_sizes() { + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size); +} + +#[test] +fn legacy_retouch_info_is_read_on_its_own() { + let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored"); + let d = lightcraft_meta::parse_xmp(&packet).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert_eq!(s.spots.len(), 1, "{:?}", s.spots); + assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal); + assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9); + assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9)); + assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}"); + assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}"); +} + +#[test] +fn spots_render() { + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(96, 64); + let render = |d: &DevelopSettings| { + lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data + }; + assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture"); +} From 619f346e748ba84395f77b91d324768262291931 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:52 +0000 Subject: [PATCH 040/191] refactor(lightcraft-engine): move apple_photos.rs tests to crates/engine/src/apple_photos/tests.rs --- crates/engine/src/apple_photos.rs | 524 +----------------------- crates/engine/src/apple_photos/tests.rs | 521 +++++++++++++++++++++++ 2 files changed, 522 insertions(+), 523 deletions(-) create mode 100644 crates/engine/src/apple_photos/tests.rs diff --git a/crates/engine/src/apple_photos.rs b/crates/engine/src/apple_photos.rs index 01394cd5f..c0b562700 100644 --- a/crates/engine/src/apple_photos.rs +++ b/crates/engine/src/apple_photos.rs @@ -777,526 +777,4 @@ pub fn osascript() -> Runner { } #[cfg(test)] -mod tests { - use super::*; - - /// A runner that records the arguments it got and answers `answer`. - fn fake(answer: Result) -> (Runner, Arc>>>) { - let calls = Arc::new(Mutex::new(Vec::new())); - let c = calls.clone(); - let r: Runner = Arc::new(move |args: &[String], _| { - c.lock().unwrap().push(args.to_vec()); - answer.clone() - }); - (r, calls) - } - - fn ok(stdout: &str) -> Result { - Ok(Output { status: Some(0), stdout: stdout.into(), stderr: String::new() }) - } - - fn failed(stderr: &str) -> Output { - Output { status: Some(1), stdout: String::new(), stderr: stderr.into() } - } - - /// The script part of an argument list: everything before the marker. - fn script_of(args: &[String]) -> &[String] { - let at = args.iter().position(|a| a == MARKER).unwrap(); - &args[..at] - } - - #[test] - fn names_and_paths_are_arguments_never_script() { - let hostile = [ - "", - "Holiday \"2026\"", - "back\\slash\\", - "line\nbreak\r\nand\ttab", - "end tell\ndo shell script \"rm -rf ~\"", - "-e", - "--help", - "日本の写真 📷 Ünïcödé", - "'single' & \"double\" » «", - ]; - let base = osascript_args("", &["/tmp/a.jpg".into()]); - for album in hostile { - let paths = vec!["/tmp/with \"quotes\".jpg".to_string(), "/tmp/new\nline/é\\x.jpg".to_string(), format!("/tmp/-{album}.jpg")]; - let args = osascript_args(album, &paths); - // the script is the same whatever the input… - assert_eq!(script_of(&args), script_of(&base)); - assert!(script_of(&args).chunks(2).all(|c| c[0] == "-e" && SCRIPT.lines().any(|l| l == c[1]))); - // …and the input follows the marker as whole arguments, unchanged - let at = args.iter().position(|a| a == MARKER).unwrap(); - assert_eq!(&args[at + 1], album); - assert_eq!(&args[at + 2..], &paths[..]); - } - // the script reads them from argv only - assert!(SCRIPT.starts_with("on run argv") && SCRIPT.contains("item 2 of argv") && SCRIPT.contains("item i of argv")); - assert!(!SCRIPT.contains("do shell script")); - // it imports into the album (made when missing) without skipping Photos' duplicate check - assert!(SCRIPT.contains("make new album named albumName")); - assert!(SCRIPT.contains("import mediaFiles into targetAlbum skip check duplicates false")); - } - - /// Photos' `album` elements are every album, those in folders too, in no set order: the script - /// keeps the top-level ones of exactly that name and refuses when there are several. - #[test] - fn the_album_is_a_single_top_level_one() { - let lookup = SCRIPT.find("every album whose name is albumName").unwrap(); - let choose = SCRIPT.find("set targetAlbum to item 1 of topLevel").unwrap(); - let make = SCRIPT.find("make new album named albumName").unwrap(); - let import = SCRIPT.find("import mediaFiles into targetAlbum").unwrap(); - assert!(lookup < choose && choose < make && make < import); - let filter = &SCRIPT[lookup..choose]; - assert!(filter.contains("parent of candidate") && filter.contains("candidateParent is missing value"), "{filter}"); - assert!(filter.contains("considering case"), "exact names: {filter}"); - assert!(filter.contains(&format!( - "if (count of topLevel) > 1 then error \"LightCraft: several top-level albums have this name\" number {AMBIGUOUS_ALBUM}" - ))); - let m = explain(&failed(&format!("execution error: LightCraft: several top-level albums have this name ({AMBIGUOUS_ALBUM})")), "Trip"); - assert!(m.contains("More than one album at the top level of Photos is named “Trip”") && m.contains("nothing was added"), "{m}"); - } - - #[test] - fn ids_are_one_per_line() { - assert_eq!(parse_ids("A1B2/L0/001\nC3D4/L0/001\n"), vec!["A1B2/L0/001", "C3D4/L0/001"]); - assert_eq!(parse_ids(" X/L0/001 \r\n\r\n Y/L0/001"), vec!["X/L0/001", "Y/L0/001"]); - assert!(parse_ids("").is_empty() && parse_ids("\n \n").is_empty()); - } - - #[test] - fn errors_say_what_to_do() { - let says = |stderr: &str, part: &str| { - let m = explain(&failed(stderr), "Trip"); - assert!(m.contains(part), "{stderr:?}: {m}"); - }; - says("146:180: execution error: Not authorized to send Apple events to Photos. (-1743)", "System Settings › Privacy & Security › Automation"); - says("execution error: Photos got an error: … (-1744)", "Automation"); - says("execution error: Can’t get application \"Photos\". (-10814)", "couldn't be found"); - says("execution error: Photos got an error: Application isn’t running. (-600)", "couldn't be found or opened"); - says("execution error: User canceled. (-128)", "cancelled"); - says("execution error: Photos got an error: AppleEvent timed out. (-1712)", "didn't answer in time"); - says("execution error: File not found (-43)", "couldn't find a file"); - says("execution error: Photos got an error: Something odd happened. (-2700)", "Something odd happened"); - assert!(explain(&Output { status: Some(3), ..Default::default() }, "").contains("exited with 3")); - assert!(explain(&Output { status: None, ..Default::default() }, "").contains("stopped")); - assert!(explain_run_error(&RunError::NoPhotos).contains("couldn't be found")); - assert!(explain_run_error(&RunError::Spawn("denied".into())).contains("denied")); - assert!(explain_run_error(&RunError::Timeout(Duration::from_secs(65))).contains("65 s")); - // a stray parenthesis is not an error number - assert!(explain(&failed("execution error: (not a number)"), "").contains("not a number")); - } - - fn files(tag: &str, n: usize) -> (std::path::PathBuf, Vec) { - let dir = std::env::temp_dir().join(format!("lc-photos-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let paths = (0..n) - .map(|i| { - let p = dir.join(format!("photo {i} é.jpg")); - std::fs::write(&p, b"jpeg").unwrap(); - p.to_string_lossy().to_string() - }) - .collect(); - (dir, paths) - } - - #[test] - fn an_import_passes_the_files_and_returns_the_ids() { - let (dir, paths) = files("import", 2); - let (runner, calls) = fake(ok("ID-1/L0/001\nID-2/L0/001\n")); - let got = import(&runner, &paths, " Trip “2026” ").unwrap(); - assert_eq!(got, Imported { ids: vec!["ID-1/L0/001".into(), "ID-2/L0/001".into()], requested: 2, album: Some("Trip “2026”".into()) }); - assert_eq!(got.warning(), None); - let args = calls.lock().unwrap()[0].clone(); - let at = args.iter().position(|a| a == MARKER).unwrap(); - assert_eq!(&args[at + 1..], &[vec!["Trip “2026”".to_string()], paths.clone()].concat()[..]); - // fewer back than asked: a partial import, said so - let (runner, _) = fake(ok("ID-1/L0/001\n")); - let got = import(&runner, &paths, "").unwrap(); - assert_eq!((got.ids.len(), got.album.clone()), (1, None)); - assert!(got.warning().unwrap().contains("1 of 2")); - assert_eq!(got.to_json()["warning"], json!(got.warning().unwrap())); - // a failed run is the mapped message - let (runner, _) = fake(Ok(failed("execution error: Not authorized to send Apple events to Photos. (-1743)"))); - assert!(import(&runner, &paths, "x").unwrap_err().contains("Automation")); - // a timeout names the request's limit - let (runner, _) = fake(Err(RunError::Timeout(Duration::from_secs(70)))); - assert!(import(&runner, &paths, "x").unwrap_err().contains(&format!("{} s", timeout_for(2).as_secs()))); - let _ = std::fs::remove_dir_all(dir); - } - - /// Codex review of #643: every path was an argument of one osascript run, so a big export - /// (10,000 files) went over macOS's 1 MiB argument limit and Photos got nothing. - #[test] - fn big_imports_run_in_batches_within_the_argument_limit() { - let long = format!("/{}", "d/".repeat(500)); // about 1,000 bytes, near the longest path macOS opens - let paths: Vec = (0..10_000).map(|i| format!("{long}{i:05}.jpg")).collect(); - let runs = Arc::new(Mutex::new(Vec::<(usize, usize, Duration)>::new())); - let r = runs.clone(); - let runner: Runner = Arc::new(move |args: &[String], limit| { - let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; - r.lock().unwrap().push((files, args.iter().map(|a| a.len() + 1).sum(), limit)); - Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) - }); - let got = execute(&runner, &paths, "Big").unwrap(); - assert_eq!((got.ids.len(), got.requested, got.warning()), (10_000, 10_000, None)); - let runs = runs.lock().unwrap().clone(); - assert_eq!(runs.len(), 10_000 / BATCH); - assert!(runs.iter().all(|&(files, _, _)| files == BATCH)); - let biggest = runs.iter().map(|&(_, bytes, _)| bytes).max().unwrap(); - assert!(biggest < 1 << 18, "{biggest} bytes of arguments in one run"); - // every run gets what is left of the request's limit, never more - assert!(runs.iter().all(|&(_, _, limit)| limit <= timeout_for(10_000))); - - // a later batch failing: the error says how far Photos got - let calls = Arc::new(Mutex::new(0)); - let c = calls.clone(); - let runner: Runner = Arc::new(move |args: &[String], _| { - let mut n = c.lock().unwrap(); - *n += 1; - let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; - if *n == 2 { - return Ok(failed("execution error: Photos got an error: User canceled. (-128)")); - } - Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) - }); - let e = execute(&runner, &paths[..450], "Big").unwrap_err(); - assert!(e.contains("cancelled") && e.contains("200 of the 450 files"), "{e}"); - assert_eq!(*calls.lock().unwrap(), 2, "nothing more is sent after a failed batch"); - - // the batches share the request's one deadline: time a batch takes comes off the next one's - let limits = Arc::new(Mutex::new(Vec::::new())); - let l = limits.clone(); - let runner: Runner = Arc::new(move |args: &[String], limit| { - l.lock().unwrap().push(limit); - std::thread::sleep(Duration::from_millis(300)); - let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; - Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) - }); - execute(&runner, &paths[..450], "Big").unwrap(); - let limits = limits.lock().unwrap().clone(); - assert_eq!(limits.len(), 3); - let whole = timeout_for(450); - assert!(limits[0] <= whole && limits[0] > whole - Duration::from_millis(250), "{limits:?}"); - assert!(limits[1] <= whole - Duration::from_millis(300) && limits[2] <= whole - Duration::from_millis(600), "{limits:?}"); - } - - #[test] - fn bad_requests_never_reach_osascript() { - let (dir, paths) = files("bad", 1); - let (runner, calls) = fake(ok("")); - assert!(import(&runner, &[], "").unwrap_err().contains("no files")); - assert!(import(&runner, &["relative/a.jpg".into()], "").unwrap_err().contains("absolute")); - assert!(import(&runner, &[format!("{}/missing.jpg", dir.display())], "").unwrap_err().contains("no such file")); - assert!(import(&runner, &[format!("{}/a\0b.jpg", dir.display())], "").unwrap_err().contains("NUL")); - assert!(import(&runner, &paths, "a\0b").unwrap_err().contains("NUL")); - assert!(calls.lock().unwrap().is_empty()); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn after_an_export_the_written_files_are_added() { - let (dir, paths) = files("after", 2); - let exported = vec![ - json!({"path": paths[0], "width": 10}), - json!({"skipped": "/elsewhere/x.jpg"}), - json!({"photo": 3, "error": "x"}), - json!({"path": paths[1]}), - ]; - let (runner, calls) = fake(ok("A/L0/001\nB/L0/001")); - let imports = Imports::default(); - let out = imports.reserve(runner.clone(), " Album ").unwrap().run(&exported, false); - assert_eq!(out["imported"], 2); - assert_eq!(out["album"], "Album"); - assert_eq!((out["job"].as_u64(), out["running"].as_bool()), (Some(1), Some(false))); - let args = calls.lock().unwrap()[0].clone(); - assert_eq!(&args[args.len() - 2..], &paths[..], "only the files written, in order"); - // nothing written, or cancelled: Photos isn't asked, and the slot is free again - let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"skipped": "x"})], false); - assert!(out["skipped"].as_str().unwrap().contains("wrote no files"), "{out}"); - assert_eq!(out.get("cancelled"), None, "{out}"); - let out = imports.reserve(runner.clone(), "").unwrap().run(&exported, true); - assert!(out["skipped"].as_str().unwrap().contains("cancelled"), "{out}"); - assert_eq!(out["cancelled"], true, "the app decides on the flag, not on the words: {out}"); - assert_eq!(calls.lock().unwrap().len(), 1); - assert!(imports.running().is_none()); - let _ = std::fs::remove_dir_all(dir); - } - - /// An export that will add its files reserves the slot before writing them: a competing - /// import is refused, naming the export's job, and the export is never turned away after - /// writing. Every way the export can end frees the slot. - #[test] - fn an_export_reserves_the_import_before_writing() { - let (dir, paths) = files("reserve", 1); - let (runner, calls) = fake(ok("A\n")); - let imports = Imports::default(); - let reserved = imports.reserve(runner.clone(), "Trip").unwrap(); - assert_eq!(reserved.job().map(|j| j.json()), Some(json!({"job": 1, "running": true, "exporting": true, "requested": 0, "album": "Trip"}))); - // while the export writes: other imports and exports are refused, naming it - let e = import_job(&runner, &imports, paths.clone(), "", false, None).unwrap_err(); - assert!(e.contains("An export is about to add its files to Apple Photos (import 1)"), "{e}"); - assert!(imports.reserve(runner.clone(), "").err().unwrap().contains("(import 1)")); - assert!(calls.lock().unwrap().is_empty()); - // the export hands over its files: they are imported under its job - let out = reserved.run(&[json!({"path": paths[0]})], false); - assert_eq!((out["job"].as_u64(), out["imported"].as_u64(), out.get("exporting")), (Some(1), Some(1), None), "{out}"); - // an export that fails or never runs (its reservation dropped) frees the slot - let dropped = imports.reserve(runner.clone(), "").unwrap(); - let id = dropped.job().unwrap().id; - drop(dropped); - let j = imports.get(id).unwrap(); - assert!(j.finished() && !j.take_announcement(), "skipped quietly"); - assert!(j.json()["skipped"].as_str().unwrap().contains("ended before writing")); - // a file the export reported but that is gone: Photos isn't asked, the slot is freed - let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"path": format!("{}/gone.jpg", dir.display())})], false); - assert!(out["error"].as_str().unwrap().contains("no such file"), "{out}"); - assert_eq!(calls.lock().unwrap().len(), 1); - assert!(imports.running().is_none()); - assert_eq!(import_job(&runner, &imports, paths, "", false, None).unwrap().id, 4); - let _ = std::fs::remove_dir_all(dir); - } - - /// Review of #236: a runner that panics, here or on a worker, ends its import as failed and - /// frees the slot; it never stays "running", refusing every later import. - #[test] - fn a_panicking_runner_frees_the_slot() { - let (dir, paths) = files("panic", 1); - let panicky: Runner = Arc::new(|_: &[String], _| panic!("runner exploded")); - let (good, _) = fake(ok("A\n")); - let imports = Imports::default(); - let stopped = |j: &ImportJob| j.json()["error"].as_str().is_some_and(|e| e.contains("stopped unexpectedly")); - // waiting here - let job = import_job(&panicky, &imports, paths.clone(), "", false, None).unwrap(); - assert!(job.finished() && stopped(&job), "{}", job.json()); - assert!(imports.running().is_none()); - // on a worker - let job = import_job(&panicky, &imports, paths.clone(), "", true, None).unwrap(); - let t0 = std::time::Instant::now(); - while !job.finished() { - assert!(t0.elapsed() < Duration::from_secs(20)); - std::thread::sleep(Duration::from_millis(5)); - } - assert!(stopped(&job), "{}", job.json()); - // an export's reservation, waiting and not - let out = imports.reserve(panicky.clone(), "").unwrap().run(&[json!({"path": paths[0]})], false); - assert!(out["error"].as_str().unwrap().contains("stopped unexpectedly"), "{out}"); - assert!(imports.running().is_none()); - let started = imports.reserve(panicky, "").unwrap().start(&[json!({"path": paths[0]})]); - let job = imports.get(started["job"].as_u64().unwrap()).unwrap(); - while !job.finished() { - assert!(t0.elapsed() < Duration::from_secs(20)); - std::thread::sleep(Duration::from_millis(5)); - } - assert!(stopped(&job)); - // the slot is free: the next import runs - assert_eq!(import_job(&good, &imports, paths, "", false, None).unwrap().json()["imported"], 1); - let _ = std::fs::remove_dir_all(dir); - } - - /// A process about to end waits for an import still running, so it never abandons osascript. - #[test] - fn waiting_for_idle_outlasts_a_running_import() { - let (dir, paths) = files("idle", 1); - let (runner, go) = delayed(); - let imports = Imports::default(); - let job = import_job(&runner, &imports, paths, "", true, None).unwrap(); - assert!(!job.finished()); - let release = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(200)); - go.send(()).unwrap(); - }); - imports.wait_idle(); - assert!(job.finished() && imports.running().is_none()); - release.join().unwrap(); - // nothing running: returns at once - imports.wait_idle(); - let _ = std::fs::remove_dir_all(dir); - } - - /// Review of #236: shutdown while an export is still writing the files it reserved the import - /// for. The deadline follows the job: the write allowance while the export writes, then the - /// import's own time limit from when Photos was asked, not one deadline fixed at the start - /// (which gave up with the import still inside its limit). Scaled down: 300 ms of writing, - /// imports limited to 1.5 s (+ 0.1 s). - #[test] - fn shutdown_follows_an_export_into_its_import() { - let (dir, paths) = files("shutdown", 2); - let limits = WaitLimits { - writing: Duration::from_millis(300), - import: |_| Duration::from_millis(1500), - margin: Duration::from_millis(100), - cap: Duration::from_secs(20), - }; - let taking = |ms: u64| -> Runner { - Arc::new(move |args: &[String], _| { - std::thread::sleep(Duration::from_millis(ms)); - let n = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; - Ok(Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) - }) - }; - let imports = Imports::default(); - // the export is still writing when the wait begins; Photos then takes 1 s, past the write - // allowance but inside the import's own limit - let reserved = imports.reserve(taking(1000), "").unwrap(); - let written: Vec = paths.iter().map(|p| json!({"path": p})).collect(); - let exporter = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(150)); - reserved.run(&written, false) - }); - let t0 = std::time::Instant::now(); - assert!(imports.wait_idle_within(limits), "waited for the import, not just the write allowance"); - assert!(t0.elapsed() >= Duration::from_millis(1000), "{:?}", t0.elapsed()); - assert_eq!(exporter.join().unwrap()["imported"], 2); - // an import past its own limit is given up on at its deadline (not the cap) - let job = import_job(&taking(3000), &imports, paths.clone(), "", true, None).unwrap(); - let t0 = std::time::Instant::now(); - assert!(!imports.wait_idle_within(limits)); - let waited = t0.elapsed(); - assert!(waited >= Duration::from_millis(1500) && waited < Duration::from_millis(2900), "{waited:?}"); - while !job.finished() { - std::thread::sleep(Duration::from_millis(20)); - } - // an export that never hands its files over: the write allowance, then it's left - let held = imports.reserve(taking(0), "").unwrap(); - let t0 = std::time::Instant::now(); - assert!(!imports.wait_idle_within(limits)); - let waited = t0.elapsed(); - assert!(waited >= Duration::from_millis(300) && waited < Duration::from_millis(1500), "{waited:?}"); - drop(held); - assert!(imports.wait_idle_within(limits)); - // and the cap holds over everything - let job = import_job(&taking(1000), &imports, paths, "", true, None).unwrap(); - let t0 = std::time::Instant::now(); - assert!(!imports.wait_idle_within(WaitLimits { cap: Duration::from_millis(200), ..limits })); - assert!(t0.elapsed() < Duration::from_millis(900)); - while !job.finished() { - std::thread::sleep(Duration::from_millis(20)); - } - let _ = std::fs::remove_dir_all(dir); - } - - /// A runner that waits for `go` (at most 20 s, so a regression fails instead of hanging), then - /// answers one id per file. - fn delayed() -> (Runner, std::sync::mpsc::Sender<()>) { - let (go, wait) = std::sync::mpsc::channel::<()>(); - let wait = Mutex::new(wait); - let r: Runner = Arc::new(move |args: &[String], _| { - let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); - let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; - Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) - }); - (r, go) - } - - /// Imports run as jobs: in the background the caller gets the job at once and its outcome - /// later; while one runs a second is refused (a retry must not add the files twice). - #[test] - fn a_slow_import_runs_as_a_job_and_refuses_a_second_one() { - let (dir, paths) = files("job", 2); - let (runner, go) = delayed(); - let imports = Imports::default(); - let t0 = std::time::Instant::now(); - let job = import_job(&runner, &imports, paths.clone(), " Trip ", true, None).unwrap(); - assert!(t0.elapsed() < Duration::from_secs(5), "the caller doesn't wait for Photos"); - assert_eq!(job.json(), json!({"job": 1, "running": true, "requested": 2, "album": "Trip"})); - assert_eq!(imports.running().map(|j| j.id), Some(1)); - let e = import_job(&runner, &imports, paths.clone(), "", true, None).unwrap_err(); - assert!(e.contains("still adding 2 file(s) from an earlier request (import 1)"), "{e}"); - let e = imports.reserve(runner.clone(), "").err().unwrap(); - assert!(e.contains("still adding"), "{e}"); - go.send(()).unwrap(); - while !job.finished() { - assert!(t0.elapsed() < Duration::from_secs(20), "the job finishes"); - std::thread::sleep(Duration::from_millis(5)); - } - assert!(job.take_announcement() && !job.take_announcement(), "announced once"); - let done = imports.get(1).unwrap().json(); - assert_eq!((done["running"].as_bool(), done["imported"].as_u64(), done["album"].as_str()), (Some(false), Some(2), Some("Trip")), "{done}"); - // finished: the next one may start; a failed one says why - let (fails, _) = fake(Err(RunError::Spawn("denied".into()))); - let job = import_job(&fails, &imports, paths, "", false, None).unwrap(); - assert_eq!(job.id, 2); - assert!(job.json()["error"].as_str().unwrap().contains("denied")); - assert!(imports.running().is_none() && imports.latest().is_some_and(|j| j.id == 2)); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn a_session_remembers_its_last_imports() { - let (dir, paths) = files("kept", 1); - let (runner, _) = fake(ok("A\n")); - let imports = Imports::default(); - for _ in 0..KEPT_JOBS + 3 { - import_job(&runner, &imports, paths.clone(), "", false, None).unwrap(); - } - let all = imports.all(); - assert_eq!(all.len(), KEPT_JOBS); - assert_eq!((all[0].id, all[KEPT_JOBS - 1].id), (4, KEPT_JOBS as u64 + 3)); - assert!(imports.get(1).is_none()); - let _ = std::fs::remove_dir_all(dir); - } - - /// The activity stack (issue #345): an import of its own shows a row while Photos works (no ✕, - /// no count, the album as detail) and loses it when Photos is done; an export's own import - /// shows none of its own, the export's row covers it. - #[test] - fn imports_of_their_own_show_in_the_activity_stack() { - let (dir, paths) = files("activity", 2); - let activity = Activity::default(); - let seen = Arc::new(Mutex::new(Vec::new())); - // Photos answers when the test lets it (`go`), so no assertion races the import's end - let (go, wait) = std::sync::mpsc::channel::<()>(); - let wait = Mutex::new(wait); - let (a, rows) = (activity.clone(), seen.clone()); - let runner: Runner = Arc::new(move |args: &[String], _| { - rows.lock().unwrap().push(a.list()); - let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); - let n = args.len() - args.iter().position(|x| x == MARKER).unwrap() - 2; - Ok(Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) - }); - let imports = Imports::default(); - let ended = |job: &ImportJob| { - let deadline = std::time::Instant::now() + Duration::from_secs(20); - while !(job.finished() && activity.list().is_empty()) { - assert!(std::time::Instant::now() < deadline, "the import ends and its row goes"); - std::thread::sleep(Duration::from_millis(5)); - } - }; - // waiting for Photos here - go.send(()).unwrap(); - let job = import_job(&runner, &imports, paths.clone(), "Trip", false, Some(&activity)).unwrap(); - assert_eq!(job.json()["imported"], 2); - assert!(activity.list().is_empty(), "the row goes when Photos is done"); - let during = seen.lock().unwrap().last().cloned().unwrap(); - assert_eq!(during.len(), 1, "{during:?}"); - assert_eq!((during[0].kind, during[0].label.as_str(), during[0].detail.as_str()), (ACTIVITY_KIND, ACTIVITY_LABEL, "Trip")); - assert_eq!((during[0].cancellable, during[0].total), (false, 0), "no ✕ and no count"); - // on a worker thread: the row stays while Photos works, and goes once the import ends - let job = import_job(&runner, &imports, paths.clone(), "", true, Some(&activity)).unwrap(); - assert_eq!(activity.list().first().map(|r| r.kind), Some(ACTIVITY_KIND)); - go.send(()).unwrap(); - ended(&job); - // an export's own import: the export's row covers it - let exported: Vec = paths.iter().map(|p| json!({"path": p})).collect(); - go.send(()).unwrap(); - imports.reserve(runner.clone(), "").unwrap().shown_in(activity.clone()).run(&exported, false); - assert!(seen.lock().unwrap().last().unwrap().is_empty(), "no row of its own while the export's worker waits"); - // ...unless it is left running on its own once the export is done - let started = imports.reserve(runner.clone(), "").unwrap().shown_in(activity.clone()).start(&exported); - assert_eq!(started["running"], true); - assert_eq!(activity.list().first().map(|r| r.kind), Some(ACTIVITY_KIND)); - go.send(()).unwrap(); - let job = imports.get(started["job"].as_u64().unwrap()).unwrap(); - ended(&job); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn timeouts_grow_with_the_batch_and_stay_bounded() { - assert_eq!(timeout_for(1), Duration::from_secs(605)); - assert_eq!(timeout_for(100), Duration::from_secs(1100)); - assert_eq!(timeout_for(usize::MAX), Duration::from_secs(7200)); - } -} +mod tests; diff --git a/crates/engine/src/apple_photos/tests.rs b/crates/engine/src/apple_photos/tests.rs new file mode 100644 index 000000000..f10929856 --- /dev/null +++ b/crates/engine/src/apple_photos/tests.rs @@ -0,0 +1,521 @@ +use super::*; + +/// A runner that records the arguments it got and answers `answer`. +fn fake(answer: Result) -> (Runner, Arc>>>) { + let calls = Arc::new(Mutex::new(Vec::new())); + let c = calls.clone(); + let r: Runner = Arc::new(move |args: &[String], _| { + c.lock().unwrap().push(args.to_vec()); + answer.clone() + }); + (r, calls) +} + +fn ok(stdout: &str) -> Result { + Ok(Output { status: Some(0), stdout: stdout.into(), stderr: String::new() }) +} + +fn failed(stderr: &str) -> Output { + Output { status: Some(1), stdout: String::new(), stderr: stderr.into() } +} + +/// The script part of an argument list: everything before the marker. +fn script_of(args: &[String]) -> &[String] { + let at = args.iter().position(|a| a == MARKER).unwrap(); + &args[..at] +} + +#[test] +fn names_and_paths_are_arguments_never_script() { + let hostile = [ + "", + "Holiday \"2026\"", + "back\\slash\\", + "line\nbreak\r\nand\ttab", + "end tell\ndo shell script \"rm -rf ~\"", + "-e", + "--help", + "日本の写真 📷 Ünïcödé", + "'single' & \"double\" » «", + ]; + let base = osascript_args("", &["/tmp/a.jpg".into()]); + for album in hostile { + let paths = vec!["/tmp/with \"quotes\".jpg".to_string(), "/tmp/new\nline/é\\x.jpg".to_string(), format!("/tmp/-{album}.jpg")]; + let args = osascript_args(album, &paths); + // the script is the same whatever the input… + assert_eq!(script_of(&args), script_of(&base)); + assert!(script_of(&args).chunks(2).all(|c| c[0] == "-e" && SCRIPT.lines().any(|l| l == c[1]))); + // …and the input follows the marker as whole arguments, unchanged + let at = args.iter().position(|a| a == MARKER).unwrap(); + assert_eq!(&args[at + 1], album); + assert_eq!(&args[at + 2..], &paths[..]); + } + // the script reads them from argv only + assert!(SCRIPT.starts_with("on run argv") && SCRIPT.contains("item 2 of argv") && SCRIPT.contains("item i of argv")); + assert!(!SCRIPT.contains("do shell script")); + // it imports into the album (made when missing) without skipping Photos' duplicate check + assert!(SCRIPT.contains("make new album named albumName")); + assert!(SCRIPT.contains("import mediaFiles into targetAlbum skip check duplicates false")); +} + +/// Photos' `album` elements are every album, those in folders too, in no set order: the script +/// keeps the top-level ones of exactly that name and refuses when there are several. +#[test] +fn the_album_is_a_single_top_level_one() { + let lookup = SCRIPT.find("every album whose name is albumName").unwrap(); + let choose = SCRIPT.find("set targetAlbum to item 1 of topLevel").unwrap(); + let make = SCRIPT.find("make new album named albumName").unwrap(); + let import = SCRIPT.find("import mediaFiles into targetAlbum").unwrap(); + assert!(lookup < choose && choose < make && make < import); + let filter = &SCRIPT[lookup..choose]; + assert!(filter.contains("parent of candidate") && filter.contains("candidateParent is missing value"), "{filter}"); + assert!(filter.contains("considering case"), "exact names: {filter}"); + assert!(filter.contains(&format!( + "if (count of topLevel) > 1 then error \"LightCraft: several top-level albums have this name\" number {AMBIGUOUS_ALBUM}" + ))); + let m = explain(&failed(&format!("execution error: LightCraft: several top-level albums have this name ({AMBIGUOUS_ALBUM})")), "Trip"); + assert!(m.contains("More than one album at the top level of Photos is named “Trip”") && m.contains("nothing was added"), "{m}"); +} + +#[test] +fn ids_are_one_per_line() { + assert_eq!(parse_ids("A1B2/L0/001\nC3D4/L0/001\n"), vec!["A1B2/L0/001", "C3D4/L0/001"]); + assert_eq!(parse_ids(" X/L0/001 \r\n\r\n Y/L0/001"), vec!["X/L0/001", "Y/L0/001"]); + assert!(parse_ids("").is_empty() && parse_ids("\n \n").is_empty()); +} + +#[test] +fn errors_say_what_to_do() { + let says = |stderr: &str, part: &str| { + let m = explain(&failed(stderr), "Trip"); + assert!(m.contains(part), "{stderr:?}: {m}"); + }; + says("146:180: execution error: Not authorized to send Apple events to Photos. (-1743)", "System Settings › Privacy & Security › Automation"); + says("execution error: Photos got an error: … (-1744)", "Automation"); + says("execution error: Can’t get application \"Photos\". (-10814)", "couldn't be found"); + says("execution error: Photos got an error: Application isn’t running. (-600)", "couldn't be found or opened"); + says("execution error: User canceled. (-128)", "cancelled"); + says("execution error: Photos got an error: AppleEvent timed out. (-1712)", "didn't answer in time"); + says("execution error: File not found (-43)", "couldn't find a file"); + says("execution error: Photos got an error: Something odd happened. (-2700)", "Something odd happened"); + assert!(explain(&Output { status: Some(3), ..Default::default() }, "").contains("exited with 3")); + assert!(explain(&Output { status: None, ..Default::default() }, "").contains("stopped")); + assert!(explain_run_error(&RunError::NoPhotos).contains("couldn't be found")); + assert!(explain_run_error(&RunError::Spawn("denied".into())).contains("denied")); + assert!(explain_run_error(&RunError::Timeout(Duration::from_secs(65))).contains("65 s")); + // a stray parenthesis is not an error number + assert!(explain(&failed("execution error: (not a number)"), "").contains("not a number")); +} + +fn files(tag: &str, n: usize) -> (std::path::PathBuf, Vec) { + let dir = std::env::temp_dir().join(format!("lc-photos-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let paths = (0..n) + .map(|i| { + let p = dir.join(format!("photo {i} é.jpg")); + std::fs::write(&p, b"jpeg").unwrap(); + p.to_string_lossy().to_string() + }) + .collect(); + (dir, paths) +} + +#[test] +fn an_import_passes_the_files_and_returns_the_ids() { + let (dir, paths) = files("import", 2); + let (runner, calls) = fake(ok("ID-1/L0/001\nID-2/L0/001\n")); + let got = import(&runner, &paths, " Trip “2026” ").unwrap(); + assert_eq!(got, Imported { ids: vec!["ID-1/L0/001".into(), "ID-2/L0/001".into()], requested: 2, album: Some("Trip “2026”".into()) }); + assert_eq!(got.warning(), None); + let args = calls.lock().unwrap()[0].clone(); + let at = args.iter().position(|a| a == MARKER).unwrap(); + assert_eq!(&args[at + 1..], &[vec!["Trip “2026”".to_string()], paths.clone()].concat()[..]); + // fewer back than asked: a partial import, said so + let (runner, _) = fake(ok("ID-1/L0/001\n")); + let got = import(&runner, &paths, "").unwrap(); + assert_eq!((got.ids.len(), got.album.clone()), (1, None)); + assert!(got.warning().unwrap().contains("1 of 2")); + assert_eq!(got.to_json()["warning"], json!(got.warning().unwrap())); + // a failed run is the mapped message + let (runner, _) = fake(Ok(failed("execution error: Not authorized to send Apple events to Photos. (-1743)"))); + assert!(import(&runner, &paths, "x").unwrap_err().contains("Automation")); + // a timeout names the request's limit + let (runner, _) = fake(Err(RunError::Timeout(Duration::from_secs(70)))); + assert!(import(&runner, &paths, "x").unwrap_err().contains(&format!("{} s", timeout_for(2).as_secs()))); + let _ = std::fs::remove_dir_all(dir); +} + +/// Codex review of #643: every path was an argument of one osascript run, so a big export +/// (10,000 files) went over macOS's 1 MiB argument limit and Photos got nothing. +#[test] +fn big_imports_run_in_batches_within_the_argument_limit() { + let long = format!("/{}", "d/".repeat(500)); // about 1,000 bytes, near the longest path macOS opens + let paths: Vec = (0..10_000).map(|i| format!("{long}{i:05}.jpg")).collect(); + let runs = Arc::new(Mutex::new(Vec::<(usize, usize, Duration)>::new())); + let r = runs.clone(); + let runner: Runner = Arc::new(move |args: &[String], limit| { + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + r.lock().unwrap().push((files, args.iter().map(|a| a.len() + 1).sum(), limit)); + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + let got = execute(&runner, &paths, "Big").unwrap(); + assert_eq!((got.ids.len(), got.requested, got.warning()), (10_000, 10_000, None)); + let runs = runs.lock().unwrap().clone(); + assert_eq!(runs.len(), 10_000 / BATCH); + assert!(runs.iter().all(|&(files, _, _)| files == BATCH)); + let biggest = runs.iter().map(|&(_, bytes, _)| bytes).max().unwrap(); + assert!(biggest < 1 << 18, "{biggest} bytes of arguments in one run"); + // every run gets what is left of the request's limit, never more + assert!(runs.iter().all(|&(_, _, limit)| limit <= timeout_for(10_000))); + + // a later batch failing: the error says how far Photos got + let calls = Arc::new(Mutex::new(0)); + let c = calls.clone(); + let runner: Runner = Arc::new(move |args: &[String], _| { + let mut n = c.lock().unwrap(); + *n += 1; + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + if *n == 2 { + return Ok(failed("execution error: Photos got an error: User canceled. (-128)")); + } + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + let e = execute(&runner, &paths[..450], "Big").unwrap_err(); + assert!(e.contains("cancelled") && e.contains("200 of the 450 files"), "{e}"); + assert_eq!(*calls.lock().unwrap(), 2, "nothing more is sent after a failed batch"); + + // the batches share the request's one deadline: time a batch takes comes off the next one's + let limits = Arc::new(Mutex::new(Vec::::new())); + let l = limits.clone(); + let runner: Runner = Arc::new(move |args: &[String], limit| { + l.lock().unwrap().push(limit); + std::thread::sleep(Duration::from_millis(300)); + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + execute(&runner, &paths[..450], "Big").unwrap(); + let limits = limits.lock().unwrap().clone(); + assert_eq!(limits.len(), 3); + let whole = timeout_for(450); + assert!(limits[0] <= whole && limits[0] > whole - Duration::from_millis(250), "{limits:?}"); + assert!(limits[1] <= whole - Duration::from_millis(300) && limits[2] <= whole - Duration::from_millis(600), "{limits:?}"); +} + +#[test] +fn bad_requests_never_reach_osascript() { + let (dir, paths) = files("bad", 1); + let (runner, calls) = fake(ok("")); + assert!(import(&runner, &[], "").unwrap_err().contains("no files")); + assert!(import(&runner, &["relative/a.jpg".into()], "").unwrap_err().contains("absolute")); + assert!(import(&runner, &[format!("{}/missing.jpg", dir.display())], "").unwrap_err().contains("no such file")); + assert!(import(&runner, &[format!("{}/a\0b.jpg", dir.display())], "").unwrap_err().contains("NUL")); + assert!(import(&runner, &paths, "a\0b").unwrap_err().contains("NUL")); + assert!(calls.lock().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn after_an_export_the_written_files_are_added() { + let (dir, paths) = files("after", 2); + let exported = vec![ + json!({"path": paths[0], "width": 10}), + json!({"skipped": "/elsewhere/x.jpg"}), + json!({"photo": 3, "error": "x"}), + json!({"path": paths[1]}), + ]; + let (runner, calls) = fake(ok("A/L0/001\nB/L0/001")); + let imports = Imports::default(); + let out = imports.reserve(runner.clone(), " Album ").unwrap().run(&exported, false); + assert_eq!(out["imported"], 2); + assert_eq!(out["album"], "Album"); + assert_eq!((out["job"].as_u64(), out["running"].as_bool()), (Some(1), Some(false))); + let args = calls.lock().unwrap()[0].clone(); + assert_eq!(&args[args.len() - 2..], &paths[..], "only the files written, in order"); + // nothing written, or cancelled: Photos isn't asked, and the slot is free again + let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"skipped": "x"})], false); + assert!(out["skipped"].as_str().unwrap().contains("wrote no files"), "{out}"); + assert_eq!(out.get("cancelled"), None, "{out}"); + let out = imports.reserve(runner.clone(), "").unwrap().run(&exported, true); + assert!(out["skipped"].as_str().unwrap().contains("cancelled"), "{out}"); + assert_eq!(out["cancelled"], true, "the app decides on the flag, not on the words: {out}"); + assert_eq!(calls.lock().unwrap().len(), 1); + assert!(imports.running().is_none()); + let _ = std::fs::remove_dir_all(dir); +} + +/// An export that will add its files reserves the slot before writing them: a competing +/// import is refused, naming the export's job, and the export is never turned away after +/// writing. Every way the export can end frees the slot. +#[test] +fn an_export_reserves_the_import_before_writing() { + let (dir, paths) = files("reserve", 1); + let (runner, calls) = fake(ok("A\n")); + let imports = Imports::default(); + let reserved = imports.reserve(runner.clone(), "Trip").unwrap(); + assert_eq!(reserved.job().map(|j| j.json()), Some(json!({"job": 1, "running": true, "exporting": true, "requested": 0, "album": "Trip"}))); + // while the export writes: other imports and exports are refused, naming it + let e = import_job(&runner, &imports, paths.clone(), "", false, None).unwrap_err(); + assert!(e.contains("An export is about to add its files to Apple Photos (import 1)"), "{e}"); + assert!(imports.reserve(runner.clone(), "").err().unwrap().contains("(import 1)")); + assert!(calls.lock().unwrap().is_empty()); + // the export hands over its files: they are imported under its job + let out = reserved.run(&[json!({"path": paths[0]})], false); + assert_eq!((out["job"].as_u64(), out["imported"].as_u64(), out.get("exporting")), (Some(1), Some(1), None), "{out}"); + // an export that fails or never runs (its reservation dropped) frees the slot + let dropped = imports.reserve(runner.clone(), "").unwrap(); + let id = dropped.job().unwrap().id; + drop(dropped); + let j = imports.get(id).unwrap(); + assert!(j.finished() && !j.take_announcement(), "skipped quietly"); + assert!(j.json()["skipped"].as_str().unwrap().contains("ended before writing")); + // a file the export reported but that is gone: Photos isn't asked, the slot is freed + let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"path": format!("{}/gone.jpg", dir.display())})], false); + assert!(out["error"].as_str().unwrap().contains("no such file"), "{out}"); + assert_eq!(calls.lock().unwrap().len(), 1); + assert!(imports.running().is_none()); + assert_eq!(import_job(&runner, &imports, paths, "", false, None).unwrap().id, 4); + let _ = std::fs::remove_dir_all(dir); +} + +/// Review of #236: a runner that panics, here or on a worker, ends its import as failed and +/// frees the slot; it never stays "running", refusing every later import. +#[test] +fn a_panicking_runner_frees_the_slot() { + let (dir, paths) = files("panic", 1); + let panicky: Runner = Arc::new(|_: &[String], _| panic!("runner exploded")); + let (good, _) = fake(ok("A\n")); + let imports = Imports::default(); + let stopped = |j: &ImportJob| j.json()["error"].as_str().is_some_and(|e| e.contains("stopped unexpectedly")); + // waiting here + let job = import_job(&panicky, &imports, paths.clone(), "", false, None).unwrap(); + assert!(job.finished() && stopped(&job), "{}", job.json()); + assert!(imports.running().is_none()); + // on a worker + let job = import_job(&panicky, &imports, paths.clone(), "", true, None).unwrap(); + let t0 = std::time::Instant::now(); + while !job.finished() { + assert!(t0.elapsed() < Duration::from_secs(20)); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(stopped(&job), "{}", job.json()); + // an export's reservation, waiting and not + let out = imports.reserve(panicky.clone(), "").unwrap().run(&[json!({"path": paths[0]})], false); + assert!(out["error"].as_str().unwrap().contains("stopped unexpectedly"), "{out}"); + assert!(imports.running().is_none()); + let started = imports.reserve(panicky, "").unwrap().start(&[json!({"path": paths[0]})]); + let job = imports.get(started["job"].as_u64().unwrap()).unwrap(); + while !job.finished() { + assert!(t0.elapsed() < Duration::from_secs(20)); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(stopped(&job)); + // the slot is free: the next import runs + assert_eq!(import_job(&good, &imports, paths, "", false, None).unwrap().json()["imported"], 1); + let _ = std::fs::remove_dir_all(dir); +} + +/// A process about to end waits for an import still running, so it never abandons osascript. +#[test] +fn waiting_for_idle_outlasts_a_running_import() { + let (dir, paths) = files("idle", 1); + let (runner, go) = delayed(); + let imports = Imports::default(); + let job = import_job(&runner, &imports, paths, "", true, None).unwrap(); + assert!(!job.finished()); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + go.send(()).unwrap(); + }); + imports.wait_idle(); + assert!(job.finished() && imports.running().is_none()); + release.join().unwrap(); + // nothing running: returns at once + imports.wait_idle(); + let _ = std::fs::remove_dir_all(dir); +} + +/// Review of #236: shutdown while an export is still writing the files it reserved the import +/// for. The deadline follows the job: the write allowance while the export writes, then the +/// import's own time limit from when Photos was asked, not one deadline fixed at the start +/// (which gave up with the import still inside its limit). Scaled down: 300 ms of writing, +/// imports limited to 1.5 s (+ 0.1 s). +#[test] +fn shutdown_follows_an_export_into_its_import() { + let (dir, paths) = files("shutdown", 2); + let limits = WaitLimits { + writing: Duration::from_millis(300), + import: |_| Duration::from_millis(1500), + margin: Duration::from_millis(100), + cap: Duration::from_secs(20), + }; + let taking = |ms: u64| -> Runner { + Arc::new(move |args: &[String], _| { + std::thread::sleep(Duration::from_millis(ms)); + let n = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) + }) + }; + let imports = Imports::default(); + // the export is still writing when the wait begins; Photos then takes 1 s, past the write + // allowance but inside the import's own limit + let reserved = imports.reserve(taking(1000), "").unwrap(); + let written: Vec = paths.iter().map(|p| json!({"path": p})).collect(); + let exporter = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(150)); + reserved.run(&written, false) + }); + let t0 = std::time::Instant::now(); + assert!(imports.wait_idle_within(limits), "waited for the import, not just the write allowance"); + assert!(t0.elapsed() >= Duration::from_millis(1000), "{:?}", t0.elapsed()); + assert_eq!(exporter.join().unwrap()["imported"], 2); + // an import past its own limit is given up on at its deadline (not the cap) + let job = import_job(&taking(3000), &imports, paths.clone(), "", true, None).unwrap(); + let t0 = std::time::Instant::now(); + assert!(!imports.wait_idle_within(limits)); + let waited = t0.elapsed(); + assert!(waited >= Duration::from_millis(1500) && waited < Duration::from_millis(2900), "{waited:?}"); + while !job.finished() { + std::thread::sleep(Duration::from_millis(20)); + } + // an export that never hands its files over: the write allowance, then it's left + let held = imports.reserve(taking(0), "").unwrap(); + let t0 = std::time::Instant::now(); + assert!(!imports.wait_idle_within(limits)); + let waited = t0.elapsed(); + assert!(waited >= Duration::from_millis(300) && waited < Duration::from_millis(1500), "{waited:?}"); + drop(held); + assert!(imports.wait_idle_within(limits)); + // and the cap holds over everything + let job = import_job(&taking(1000), &imports, paths, "", true, None).unwrap(); + let t0 = std::time::Instant::now(); + assert!(!imports.wait_idle_within(WaitLimits { cap: Duration::from_millis(200), ..limits })); + assert!(t0.elapsed() < Duration::from_millis(900)); + while !job.finished() { + std::thread::sleep(Duration::from_millis(20)); + } + let _ = std::fs::remove_dir_all(dir); +} + +/// A runner that waits for `go` (at most 20 s, so a regression fails instead of hanging), then +/// answers one id per file. +fn delayed() -> (Runner, std::sync::mpsc::Sender<()>) { + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = Mutex::new(wait); + let r: Runner = Arc::new(move |args: &[String], _| { + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: (0..files).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() }) + }); + (r, go) +} + +/// Imports run as jobs: in the background the caller gets the job at once and its outcome +/// later; while one runs a second is refused (a retry must not add the files twice). +#[test] +fn a_slow_import_runs_as_a_job_and_refuses_a_second_one() { + let (dir, paths) = files("job", 2); + let (runner, go) = delayed(); + let imports = Imports::default(); + let t0 = std::time::Instant::now(); + let job = import_job(&runner, &imports, paths.clone(), " Trip ", true, None).unwrap(); + assert!(t0.elapsed() < Duration::from_secs(5), "the caller doesn't wait for Photos"); + assert_eq!(job.json(), json!({"job": 1, "running": true, "requested": 2, "album": "Trip"})); + assert_eq!(imports.running().map(|j| j.id), Some(1)); + let e = import_job(&runner, &imports, paths.clone(), "", true, None).unwrap_err(); + assert!(e.contains("still adding 2 file(s) from an earlier request (import 1)"), "{e}"); + let e = imports.reserve(runner.clone(), "").err().unwrap(); + assert!(e.contains("still adding"), "{e}"); + go.send(()).unwrap(); + while !job.finished() { + assert!(t0.elapsed() < Duration::from_secs(20), "the job finishes"); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(job.take_announcement() && !job.take_announcement(), "announced once"); + let done = imports.get(1).unwrap().json(); + assert_eq!((done["running"].as_bool(), done["imported"].as_u64(), done["album"].as_str()), (Some(false), Some(2), Some("Trip")), "{done}"); + // finished: the next one may start; a failed one says why + let (fails, _) = fake(Err(RunError::Spawn("denied".into()))); + let job = import_job(&fails, &imports, paths, "", false, None).unwrap(); + assert_eq!(job.id, 2); + assert!(job.json()["error"].as_str().unwrap().contains("denied")); + assert!(imports.running().is_none() && imports.latest().is_some_and(|j| j.id == 2)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_session_remembers_its_last_imports() { + let (dir, paths) = files("kept", 1); + let (runner, _) = fake(ok("A\n")); + let imports = Imports::default(); + for _ in 0..KEPT_JOBS + 3 { + import_job(&runner, &imports, paths.clone(), "", false, None).unwrap(); + } + let all = imports.all(); + assert_eq!(all.len(), KEPT_JOBS); + assert_eq!((all[0].id, all[KEPT_JOBS - 1].id), (4, KEPT_JOBS as u64 + 3)); + assert!(imports.get(1).is_none()); + let _ = std::fs::remove_dir_all(dir); +} + +/// The activity stack (issue #345): an import of its own shows a row while Photos works (no ✕, +/// no count, the album as detail) and loses it when Photos is done; an export's own import +/// shows none of its own, the export's row covers it. +#[test] +fn imports_of_their_own_show_in_the_activity_stack() { + let (dir, paths) = files("activity", 2); + let activity = Activity::default(); + let seen = Arc::new(Mutex::new(Vec::new())); + // Photos answers when the test lets it (`go`), so no assertion races the import's end + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = Mutex::new(wait); + let (a, rows) = (activity.clone(), seen.clone()); + let runner: Runner = Arc::new(move |args: &[String], _| { + rows.lock().unwrap().push(a.list()); + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + let n = args.len() - args.iter().position(|x| x == MARKER).unwrap() - 2; + Ok(Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) + }); + let imports = Imports::default(); + let ended = |job: &ImportJob| { + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while !(job.finished() && activity.list().is_empty()) { + assert!(std::time::Instant::now() < deadline, "the import ends and its row goes"); + std::thread::sleep(Duration::from_millis(5)); + } + }; + // waiting for Photos here + go.send(()).unwrap(); + let job = import_job(&runner, &imports, paths.clone(), "Trip", false, Some(&activity)).unwrap(); + assert_eq!(job.json()["imported"], 2); + assert!(activity.list().is_empty(), "the row goes when Photos is done"); + let during = seen.lock().unwrap().last().cloned().unwrap(); + assert_eq!(during.len(), 1, "{during:?}"); + assert_eq!((during[0].kind, during[0].label.as_str(), during[0].detail.as_str()), (ACTIVITY_KIND, ACTIVITY_LABEL, "Trip")); + assert_eq!((during[0].cancellable, during[0].total), (false, 0), "no ✕ and no count"); + // on a worker thread: the row stays while Photos works, and goes once the import ends + let job = import_job(&runner, &imports, paths.clone(), "", true, Some(&activity)).unwrap(); + assert_eq!(activity.list().first().map(|r| r.kind), Some(ACTIVITY_KIND)); + go.send(()).unwrap(); + ended(&job); + // an export's own import: the export's row covers it + let exported: Vec = paths.iter().map(|p| json!({"path": p})).collect(); + go.send(()).unwrap(); + imports.reserve(runner.clone(), "").unwrap().shown_in(activity.clone()).run(&exported, false); + assert!(seen.lock().unwrap().last().unwrap().is_empty(), "no row of its own while the export's worker waits"); + // ...unless it is left running on its own once the export is done + let started = imports.reserve(runner.clone(), "").unwrap().shown_in(activity.clone()).start(&exported); + assert_eq!(started["running"], true); + assert_eq!(activity.list().first().map(|r| r.kind), Some(ACTIVITY_KIND)); + go.send(()).unwrap(); + let job = imports.get(started["job"].as_u64().unwrap()).unwrap(); + ended(&job); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn timeouts_grow_with_the_batch_and_stay_bounded() { + assert_eq!(timeout_for(1), Duration::from_secs(605)); + assert_eq!(timeout_for(100), Duration::from_secs(1100)); + assert_eq!(timeout_for(usize::MAX), Duration::from_secs(7200)); +} From 698ca5a884845aab12a240c4608ac1116e5462f6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:53 +0000 Subject: [PATCH 041/191] refactor(lightcraft-engine): move lightroom_catalog.rs tests to crates/engine/src/lightroom_catalog/tests.rs --- crates/engine/src/lightroom_catalog.rs | 816 +------------------ crates/engine/src/lightroom_catalog/tests.rs | 813 ++++++++++++++++++ 2 files changed, 814 insertions(+), 815 deletions(-) create mode 100644 crates/engine/src/lightroom_catalog/tests.rs diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 797a96708..207d1c148 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -1099,818 +1099,4 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_geom::CropGeometry; - fn row(v: Value) -> Row { - serde_json::from_value(v).unwrap() - } - fn sample() -> CatalogImport { - let mut photo = CatalogPhoto { - source_id: 1, - uuid: "test-master".into(), - path: "/missing/test.ARW".into(), - image: row(json!({"id_local":1,"rating":5,"pick":1,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})), - settings: "s = { Exposure2012 = 1.25, Contrast2012 = 17, CameraProfile = \"Example\" }".into(), - xmp: String::new(), - keywords: vec!["Travel|Goa".into()], - history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))], - snapshots: Vec::new(), - original: Original::Local, - }; - let mut copy = photo.clone(); - copy.source_id = 2; - copy.uuid = "test-copy".into(); - copy.image = row(json!({"id_local":2,"masterImage":1,"copyName":"Mono","rating":2,"pick":-1})); - copy.settings = "s = { ConvertToGrayscale = true, Exposure2012 = -0.5 }".into(); - photo.image.insert("captureTime".into(), json!("2024-01-16T12:34:56")); - CatalogImport { - source: "test.lrcat".into(), - photos: vec![copy, photo], - collections: vec![ - row(json!({"id_local":9,"name":"Travel","creationId":"collection.group"})), - row(json!({"id_local":10,"parent":9,"name":"Keepers","creationId":"collection"})), - ], - members: vec![ - row(json!({"collection":10,"image":2,"positionInCollection":"a"})), - row(json!({"collection":10,"image":1,"positionInCollection":"b"})), - ], - collection_content: Vec::new(), - warnings: Vec::new(), - skipped: Vec::new(), - } - } - /// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so - /// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual - /// copies go with it; the catalog's real photos still import. - #[test] - fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() { - let mut data = sample(); - let mut exe = data.photos[1].clone(); - exe.source_id = 3; - exe.uuid = "payload".into(); - exe.path = "/Users/sky/Downloads/payload.exe".into(); - exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})); - let mut exe_copy = exe.clone(); - exe_copy.source_id = 4; - exe_copy.uuid = "payload-copy".into(); - exe_copy.path = "/Users/sky/Downloads/payload.exe".into(); - exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"})); - // the copy comes before its master, as a catalog's table order may have it - data.photos.insert(0, exe_copy); - data.photos.push(exe); - for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] { - let mut one = data.clone(); - one.photos.retain(|p| p.source_id < 3); - let mut planted = one.photos[1].clone(); - planted.source_id = 5; - planted.uuid = "planted".into(); - planted.path = bad.into(); - planted.image = row(json!({"id_local":5,"fileFormat":"ARW"})); - one.photos.push(planted); - let mut s = crate::Session::new(); - let r = apply(&mut s, one, false).unwrap(); - assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}"); - assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record"); - } - let mut s = crate::Session::new(); - let r = apply(&mut s, data, false).unwrap(); - assert_eq!(r["photos"], 2, "only the real master and its copy: {r}"); - assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued"); - let warnings = r["warnings"].to_string(); - assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}"); - assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}"); - } - - #[test] - fn offline_local_master_and_copy_join_library_with_one_undo_step() { - let mut s = crate::Session::new(); - let data = sample(); - let master_id = s.catalog.alloc_photo_id(); - let mut master = Photo::new(master_id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 20, 10, "before"); - master.local = true; - s.commit("Browse folder", Op::AddPhoto { photo: Box::new(master) }).unwrap(); - s.undo.clear(); - let before = s.catalog.photo(master_id).unwrap().clone(); - let mut index = ImportIndex::default(); - let missing_sources = [key("/missing/test.ARW")].into_iter().collect(); - let mut import = |s: &mut crate::Session| { - let undo0 = s.undo.len(); - apply_prepared( - s, - data.clone(), - ApplyPrepared { - update_existing: false, - files: Default::default(), - index: &mut index, - archive_path: None, - missing_sources: &missing_sources, - undo0, - }, - ) - .unwrap() - .report - }; - let first = import(&mut s); - assert_eq!(first["mapping"]["1"], master_id.0); - assert_eq!(first["imported"], 2, "the promoted master and new copy both join All Photos"); - assert_eq!(first["missing"], json!(["/missing/test.ARW"])); - assert_eq!(s.catalog.len(), 2); - assert!(s.catalog.photos().all(|photo| !photo.local)); - assert_eq!(s.undo.len(), 1); - let second = import(&mut s); - assert_eq!(second["imported"], 0); - assert_eq!(second["mapping"], first["mapping"]); - s.undo_step().unwrap(); - s.undo_step().unwrap(); - assert_eq!(s.catalog.len(), 1); - assert_eq!(s.catalog.photo(master_id), Some(&before)); - assert!(s.catalog.albums().next().is_none()); - let third = import(&mut s); - assert_eq!(third["imported"], 2); - assert!(s.catalog.photos().all(|photo| !photo.local)); - } - - #[test] - fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() { - let mut s = crate::Session::new(); - let data = sample(); - let r = apply(&mut s, data.clone(), false).unwrap(); - assert_eq!(r["photos"], 2); - assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added"); - assert_eq!(r["missing"].as_array().unwrap().len(), 1); - let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap(); - assert_eq!(master.rating, 5); - assert_eq!(master.flag, Flag::Pick); - assert_eq!(master.meta.keywords, vec!["Travel|Goa"]); - assert_eq!(master.develop.light.exposure, 1.25); - let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap(); - assert_eq!(copy.copy_of, Some(master.id)); - assert_eq!(copy.rating, 2); - assert_eq!(copy.flag, Flag::Reject); - assert_eq!(copy.develop.light.exposure, -0.5); - let album = s.catalog.albums().find(|a| a.name == "Keepers").unwrap(); - assert_eq!(album.photos, vec![copy.id, master.id]); - assert!(album.parent.is_some()); - assert!(r["unmapped"]["1"].as_array().unwrap().iter().any(|v| v.as_str() == Some("CameraProfile"))); - assert_eq!(s.undo.len(), 1); - s.undo_step().unwrap(); - assert!(s.catalog.is_empty()); - assert!(s.catalog.albums().next().is_none()); - assert!(data.photos[1].settings.contains("CameraProfile")); // unmapped source stays intact - } - #[test] - fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() { - let mut s = crate::Session::new(); - let data = sample(); - apply(&mut s, data.clone(), false).unwrap(); - let id = s.catalog.photos().find(|p| p.copy_of.is_none()).unwrap().id; - s.set_develop(id, lightcraft_develop::DevelopSettings::default(), "Personal edit").unwrap(); - let r = apply(&mut s, data.clone(), false).unwrap(); - assert!(r["preservedExistingEdits"].as_u64().unwrap() > 0); - assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.0); - let before = s.catalog.to_snapshot(); - let mut cyclic = data; - cyclic.collections[0].insert("parent".into(), json!(10)); - assert!(apply(&mut s, cyclic, false).is_err()); - assert_eq!(s.catalog.to_snapshot(), before); - assert!( - mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) - .is_err() - ); - } - #[test] - fn compressed_xmp_is_bounded_and_settings_are_data_only() { - let packet = b"example"; - let mut encoded = (packet.len() as u32).to_be_bytes().to_vec(); - encoded.extend(miniz_oxide::deflate::compress_to_vec_zlib(packet, 6)); - assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet)); - encoded[0] = 0xff; - assert!(xmp(Some(&json!(encoded))).is_err()); - let (partial, _) = mapped_settings( - "s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", - crate::crs::Target::RawAbsolute, - 1.5, - Orientation::Normal, - 1.5, - ) - .unwrap(); - assert_eq!(partial["light"]["exposure"], 0.75); - } - - #[test] - fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() { - // Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed - // relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin - let settings = - "s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }"; - let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap(); - assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}"); - assert_eq!(partial["light"]["exposure"], 0.5); - assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}"); - let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); - assert_eq!( - (partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()), - (Some("custom"), Some(3578.0), Some(-5.0)) - ); - assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}"); - } - - #[test] - fn catalog_crops_without_has_crop_are_imported() { - // Lightroom Classic catalogs store crop edges and angle but never `HasCrop` - let (partial, unknown) = mapped_settings( - "s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }", - crate::crs::Target::RawAbsolute, - 1.5, - Orientation::Normal, - 1.5, - ) - .unwrap(); - let crop = &partial["crop"]["geometry"]; - assert_eq!(crop["rect"]["x0"], 0.042702); - assert_eq!(crop["rect"]["y0"], 0.1); - assert_eq!(crop["rect"]["x1"], 0.957298); - assert_eq!(crop["rect"]["y1"], 1.0); - assert_eq!(crop["angle"], 0.0); - assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}"); - // an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's) - let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); - assert_eq!(partial["crop"]["geometry"]["angle"], 1.5); - assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}"); - // no crop fields: the crop stays untouched - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); - assert!(partial.get("crop").is_none()); - } - - #[test] - fn lightroom_orientation_codes() { - // the three seen in a real catalog, checked against each file's EXIF orientation (1, 6, 8) - assert_eq!(lightroom_orientation("AB"), Some(Orientation::Normal)); - assert_eq!(lightroom_orientation("BC"), Some(Orientation::Rotate90)); - assert_eq!(lightroom_orientation("DA"), Some(Orientation::Rotate270)); - assert_eq!(lightroom_orientation("CD"), Some(Orientation::Rotate180)); - assert_eq!(lightroom_orientation("BA"), Some(Orientation::FlipH)); - assert_eq!(lightroom_orientation("DC"), Some(Orientation::FlipV)); - assert_eq!(lightroom_orientation("AD"), Some(Orientation::Transpose)); - assert_eq!(lightroom_orientation("CB"), Some(Orientation::Transverse)); - for code in ["", "A", "ABC", "AC", "ab", "XY"] { - assert_eq!(lightroom_orientation(code), None, "{code:?}"); - } - } - - #[test] - fn catalog_crops_follow_the_photo_orientation() { - let crop = |o: Orientation| { - let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }"; - let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); - let g = &partial["crop"]["geometry"]; - let r = &g["rect"]; - [r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9) - }; - // (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise - assert_eq!(crop(Orientation::Normal), [0.1, 0.2, 0.6, 0.9, 0.0]); - assert_eq!(crop(Orientation::Rotate90), [0.1, 0.1, 0.8, 0.6, 0.0]); - assert_eq!(crop(Orientation::Rotate270), [0.2, 0.4, 0.9, 0.9, 0.0]); - assert_eq!(crop(Orientation::Rotate180), [0.4, 0.1, 0.9, 0.8, 0.0]); - assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]); - // a mirror turns the straighten the other way - let angle = |o: Orientation| { - let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); - partial["crop"]["geometry"]["angle"].as_f64().unwrap() - }; - assert_eq!(angle(Orientation::Rotate90), -2.0); - assert_eq!(angle(Orientation::FlipH), 2.0); - // no crop: nothing to orient - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap(); - assert!(partial.get("crop").is_none()); - } - - /// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image - /// of aspect `w` (height 1) shown with orientation `o`. - fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String { - let (ow, oh) = if o.swaps_axes() { (1.0, w) } else { (w, 1.0) }; - let to_source = Affine::rotate_about(-g.angle.to_radians(), Point::new(ow / 2.0, oh / 2.0)); - let stored = |x: f64, y: f64| { - let p = to_source.apply(Point::new(x * ow, y * oh)); - o.inverse().map(p.x, p.y, ow, oh) - }; - // the rectangle's corners that land top-left and bottom-right on the stored image - let corners = [stored(g.rect.x0, g.rect.y0), stored(g.rect.x1, g.rect.y0), stored(g.rect.x1, g.rect.y1), stored(g.rect.x0, g.rect.y1)]; - let (tl, br) = (0..4).map(|i| (corners[i], corners[(i + 2) % 4])).find(|(a, b)| a.0 < b.0 && a.1 < b.1).unwrap(); - let angle = if o.to_parts().0 { g.angle } else { -g.angle }; - format!("s = {{ CropLeft = {}, CropTop = {}, CropRight = {}, CropBottom = {}, CropAngle = {angle} }}", tl.0 / w, tl.1, br.0 / w, br.1) - } - - #[test] - fn straightened_catalog_crops_round_trip() { - let g = CropGeometry { rect: Rect::new(0.2, 0.15, 0.7, 0.8), angle: 6.5 }; - for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] { - for angle in [6.5, -6.5] { - let g = CropGeometry { angle, ..g }; - let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); - let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap(); - let close = |a: f64, b: f64| (a - b).abs() < 1e-9; - assert!( - close(got.angle, g.angle) - && [(got.rect.x0, g.rect.x0), (got.rect.y0, g.rect.y0), (got.rect.x1, g.rect.x1), (got.rect.y1, g.rect.y1)] - .iter() - .all(|&(a, b)| close(a, b)), - "{o:?} {angle}: {got:?}" - ); - } - } - } - - #[test] - fn straightened_catalog_crops_match_lightroom_exports() { - // Settings and pixel sizes from a real Lightroom Classic catalog (9504 × 6336 stored) and the - // JPEGs Lightroom exported from it at full size. - for (settings, o, export) in [ - ( - "s = { CropAngle = -9.34444, CropBottom = 0.802034, CropLeft = 0.05499, CropRight = 0.94501, CropTop = 0.197966 }", - Orientation::Normal, - (7725.0, 5150.0), - ), - ( - "s = { CropAngle = -1.48964, CropBottom = 0.962457, CropLeft = 0.010429, CropRight = 0.989571, CropTop = 0.037543 }", - Orientation::Rotate90, - (6100.0, 9150.0), - ), - ] { - let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap(); - let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap(); - let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) }; - let (w, h) = (r.width() * ow, r.height() * oh); - assert!((w - export.0).abs() <= 3.0 && (h - export.1).abs() <= 3.0, "{o:?}: {w:.1} × {h:.1}, Lightroom exported {export:?}"); - } - } - - #[test] - fn imported_crops_use_the_catalog_orientation() { - let mut data = sample(); - data.photos.truncate(1); - data.photos[0].image.remove("masterImage"); - data.photos[0].image.insert("orientation".into(), json!("BC")); - data.photos[0].settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }".into(); - data.photos[0].xmp = String::new(); - let mut s = crate::Session::new(); - apply(&mut s, data, false).unwrap(); - let g = s.catalog.photos().next().unwrap().develop.crop.geometry; - let r = [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1].map(|v| (v * 1e9).round() / 1e9); - assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); - } - - #[test] - fn deferred_auto_tone_sentinels_never_become_real_slider_values() { - let mut data = sample(); - data.photos.truncate(1); - data.photos[0].image.remove("masterImage"); - data.photos[0].settings = - "s = { AutoTone = true, Exposure2012 = -999999, Contrast2012 = -999999, Shadows2012 = 65, Temperature = 6150 }".into(); - data.photos[0].xmp = r#""#.into(); - let mut s = crate::Session::new(); - let report = apply(&mut s, data, false).unwrap(); - let p = s.catalog.photos().next().unwrap(); - assert_eq!(p.develop.light.exposure, 0.0); - assert_eq!(p.develop.light.contrast, 0.0); - assert_eq!(p.develop.light.shadows, 65.0); - assert_eq!(p.develop.light.blacks, -22.0); - assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); - let (partial, _) = - mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) - .unwrap(); - assert_eq!(partial["light"]["exposure"], -5.0); - assert_eq!(partial["light"]["contrast"], -100.0); - } - - #[test] - fn wal_checksums_cover_commits_without_materializing_historical_databases() { - let mut main = vec![0; 512]; - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - assert_eq!(wal_checksum(&[1, 0, 0, 0, 2, 0, 0, 0], false, (0, 0)), (1, 3)); - for big in [false, true] { - let mut wal = vec![0; 32]; - wal[0..4].copy_from_slice(&(if big { 0x377f0683u32 } else { 0x377f0682u32 }).to_be_bytes()); - wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); - wal[8..12].copy_from_slice(&512u32.to_be_bytes()); - wal[16..20].copy_from_slice(&123u32.to_be_bytes()); - let mut sum = wal_checksum(&wal[..24], big, (0, 0)); - wal[24..28].copy_from_slice(&sum.0.to_be_bytes()); - wal[28..32].copy_from_slice(&sum.1.to_be_bytes()); - for _ in 0..128 { - let mut frame = vec![0; 536]; - frame[0..4].copy_from_slice(&1u32.to_be_bytes()); - frame[4..8].copy_from_slice(&1u32.to_be_bytes()); - frame[8..12].copy_from_slice(&123u32.to_be_bytes()); - sum = wal_checksum(&frame[..8], big, sum); - sum = wal_checksum(&frame[24..], big, sum); - frame[16..20].copy_from_slice(&sum.0.to_be_bytes()); - frame[20..24].copy_from_slice(&sum.1.to_be_bytes()); - wal.extend(frame); - } - assert_eq!(committed_wal_len(&main, &wal).unwrap(), wal.len()); - let end = wal.len(); - wal[end - 1] ^= 1; - assert_eq!(committed_wal_len(&main, &wal).unwrap(), end - 536); - wal[24] ^= 1; - assert!(committed_wal_len(&main, &wal).is_err()); - main[17] = 0; - assert!(committed_wal_len(&main, &wal).is_err()); - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - } - } - - #[test] - fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() { - let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport-{}", std::process::id())); - let mut s = crate::Session::new(); - s.open_library(&dir, false).unwrap(); - let mut data = sample(); - data.collections.reverse(); // Parent must be committed before its child regardless of row order. - let first = apply(&mut s, data.clone(), false).unwrap(); - s.close_library().unwrap(); - s.open_library(&dir, false).unwrap(); - let id = PhotoId(first["mapping"]["1"].as_u64().unwrap()); - s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap(); - let second = apply(&mut s, data, true).unwrap(); - assert_eq!(first["mapping"], second["mapping"]); - assert_eq!(first["imported"], 2); - assert_eq!(second["imported"], 0, "reimport updates records instead of adding them"); - assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too"); - assert_eq!(second["library"], dir.to_string_lossy().as_ref()); - assert_eq!(s.catalog.len(), 2); - assert_eq!(s.catalog.albums().count(), 2); - assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56")); - s.close_library().unwrap(); - std::fs::remove_dir_all(dir).unwrap(); - } - - // Construct a small SQLite fixture from public file-format records, never private media. - fn varint(mut n: u64) -> Vec { - let mut out = vec![(n & 127) as u8]; - n >>= 7; - while n > 0 { - out.push((n & 127) as u8 | 128); - n >>= 7; - } - out.reverse(); - out - } - fn record(values: &[SqlValue]) -> Vec { - let mut header = Vec::new(); - let mut body = Vec::new(); - for value in values { - let serial = match value { - SqlValue::Null => 0, - SqlValue::Integer(n) => { - body.extend(n.to_be_bytes()); - 6 - } - SqlValue::Text(s) => { - body.extend(s.as_bytes()); - 13 + 2 * s.len() as u64 - } - _ => unreachable!(), - }; - header.extend(varint(serial)); - } - let mut out = varint(1 + header.len() as u64); - out.extend(header); - out.extend(body); - out - } - fn leaf(page: &mut [u8], offset: usize, rows: &[Vec]) { - page[offset] = 13; - page[offset + 3..offset + 5].copy_from_slice(&(rows.len() as u16).to_be_bytes()); - let mut end = page.len(); - for (i, values) in rows.iter().enumerate() { - let payload = record(values); - let mut cell = varint(payload.len() as u64); - cell.extend(varint(i as u64 + 1)); - cell.extend(payload); - end -= cell.len(); - page[end..end + cell.len()].copy_from_slice(&cell); - page[offset + 8 + i * 2..offset + 10 + i * 2].copy_from_slice(&(end as u16).to_be_bytes()); - } - page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes()); - } - /// A one-row-per-table SQLite file: `(name, columns, row)` each. - fn native_catalog(tables: &[(&str, &str, Vec)]) -> Vec { - native_catalog_with_last_table_page(tables, None) - } - /// [`native_catalog`] whose last table claims `last_page` as its root page. - fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec)], last_page: Option) -> Vec { - let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect(); - native_catalog_rows(&tables, last_page) - } - /// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last - /// table's root page. - fn native_catalog_rows(tables: &[(&str, &str, Vec>)], last_page: Option) -> Vec { - use SqlValue::{Integer as I, Text as T}; - let mut bytes = vec![0u8; 5 * 2048]; - bytes[..16].copy_from_slice(b"SQLite format 3\0"); - bytes[16..18].copy_from_slice(&2048u16.to_be_bytes()); - bytes[18] = 1; - bytes[19] = 1; - bytes[21] = 64; - bytes[22] = 32; - bytes[23] = 32; - bytes[28..32].copy_from_slice(&5u32.to_be_bytes()); - bytes[44..48].copy_from_slice(&4u32.to_be_bytes()); - bytes[56..60].copy_from_slice(&1u32.to_be_bytes()); - let mut schema = Vec::new(); - for (i, (name, columns, rows)) in tables.iter().enumerate() { - schema.push(vec![ - T("table".into()), - T((*name).into()), - T((*name).into()), - I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }), - T(format!("CREATE TABLE {name} ({columns})")), - ]); - leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows); - } - leaf(&mut bytes[..2048], 100, &schema); - bytes - } - - fn photo_tables(root: (&str, &str)) -> Vec<(&'static str, &'static str, Vec)> { - use SqlValue::{Integer as I, Null as N, Text as T}; - vec![ - ( - "AgLibraryRootFolder", - "id_local INTEGER PRIMARY KEY, absolutePath, name, relativePathFromCatalog", - vec![N, T(root.0.into()), T("Photos".into()), T(root.1.into())], - ), - ("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]), - ("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]), - ( - "Adobe_images", - "id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick", - vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)], - ), - ] - } - - #[test] - fn reads_native_sqlite_tables_without_lightroom_or_external_processes() { - let bytes = native_catalog(&photo_tables(("/catalog/", ""))); - let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id())); - std::fs::write(&path, &bytes).unwrap(); - let catalog = read(&path).unwrap(); - assert_eq!(catalog.photos.len(), 1); - assert_eq!(catalog.photos[0].uuid, "fixture-image"); - assert_eq!(number(&catalog.photos[0].image, "rating"), 5); - assert!(catalog.photos[0].path.ends_with("test.ARW")); - // Put the required image table beyond the old 1 GiB cutoff without allocating it. - // A main-file-sized allocation would defeat the purpose of the page-backed reader. - // The large fixture relies on Unix sparse-file behavior; small parity runs everywhere. - #[cfg(unix)] - { - let far_offset = 1u64 << 30; - let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64)); - use std::io::{Seek, SeekFrom, Write}; - let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap(); - sparse.write_all(&bytes).unwrap(); - sparse.set_len(far_offset + 2048).unwrap(); - sparse.seek(SeekFrom::Start(far_offset)).unwrap(); - sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap(); - drop(sparse); - let large = read(&path).unwrap(); - assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap()); - let mut session = crate::Session::new(); - session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!(session.catalog.len(), 1); - assert_eq!(session.catalog.photos().next().unwrap().rating, 5); - session.execute("edit.undo", &json!({})).unwrap(); - assert!(session.catalog.is_empty()); - } - std::fs::write(&path, b"bad catalog").unwrap(); - assert!(read(&path).is_err()); - std::fs::remove_file(path).unwrap(); - } - - /// Reads `tables` as a catalog file and returns what the reader made of it. - fn read_tables(tables: &[(&str, &str, Vec>)], name: &str) -> CatalogImport { - let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id())); - std::fs::write(&path, native_catalog_rows(tables, None)).unwrap(); - let catalog = read(&path).unwrap(); - std::fs::remove_file(path).unwrap(); - catalog - } - - /// `tables` with each row as a one-row table, for [`native_catalog_rows`]. - fn one_row(tables: Vec<(&'static str, &'static str, Vec)>) -> Vec<(&'static str, &'static str, Vec>)> { - tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect() - } - - #[test] - fn images_with_incomplete_records_are_skipped_not_fatal() { - use SqlValue::{Integer as I, Text as T}; - // (table, column, value): the record that breaks, and the reason the import gives - let cases = [ - (3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file - (2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder - (1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root - (2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty - ]; - for (table, column, value, reason) in cases { - let mut tables = one_row(photo_tables(("/catalog/", ""))); - tables[table].2[0][column] = value; - let catalog = read_tables(&tables, &format!("skipped-{reason:?}")); - assert!(catalog.photos.is_empty(), "{reason:?}"); - assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]); - assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings); - } - } - - #[test] - fn a_virtual_copy_of_a_skipped_master_is_skipped_too() { - use SqlValue::{Integer as I, Null as N, Text as T}; - let mut tables = one_row(photo_tables(("/catalog/", ""))); - tables[3] = ( - "Adobe_images", - "id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick", - vec![ - // the master's file record is gone, so it is skipped … - vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)], - // … and its virtual copy goes with it, although its own file record is there - vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)], - ], - ); - let catalog = read_tables(&tables, "skipped-copy"); - assert!(catalog.photos.is_empty()); - assert_eq!( - catalog.skipped, - [SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }] - ); - validate(&catalog).unwrap(); - } - - #[test] - fn skipped_images_are_in_the_import_report() { - let mut data = sample(); - data.photos.retain(|p| p.source_id != 1); - drop_copies_of(&mut data, &HashSet::from([1])); - data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile }); - let mut s = crate::Session::new(); - let report = apply(&mut s, data, false).unwrap(); - assert_eq!(report["photos"], 0); - assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}])); - assert_eq!(report["warnings"], json!([])); - } - - #[test] - fn preparation_skips_missing_original_probes_but_keeps_catalog_records() { - let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let source = dir.join("source.lrcat"); - let root = dir.join("offline"); - let tables = photo_tables((root.to_str().unwrap(), "")); - std::fs::write(&source, native_catalog(&tables)).unwrap(); - let mut s = crate::Session::new(); - let probes = Arc::new(AtomicUsize::new(0)); - let calls = probes.clone(); - s.media.file_probe = Some(Arc::new(move |_| { - calls.fetch_add(1, Ordering::Relaxed); - Err("a known-missing original must not be probed".into()) - })); - let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap(); - let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); - assert_eq!(probes.load(Ordering::Relaxed), 0); - let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); - assert_eq!(result.report["imported"], 1); - assert_eq!(result.report["failed"], json!([])); - assert_eq!(result.report["missing"].as_array().unwrap().len(), 1); - let photo = s.catalog.photos().next().unwrap(); - assert_eq!(photo.rating, 5); - assert_eq!((photo.width, photo.height), (20, 10)); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.catalog.is_empty()); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn probed_original_is_counted_once_and_reimport_adds_nothing() { - let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id())); - std::fs::create_dir_all(dir.join("Photos")).unwrap(); - let source = dir.join("source.lrcat"); - std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); - std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); - let mut s = crate::Session::new(); - let probes = Arc::new(AtomicUsize::new(0)); - let calls = probes.clone(); - s.media.file_probe = Some(Arc::new(move |_| { - calls.fetch_add(1, Ordering::Relaxed); - Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() }) - })); - for expected in [1, 0] { - let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap(); - let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); - let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); - assert_eq!(completion.report["imported"], expected); - assert_eq!(completion.report["missing"], json!([])); - assert_eq!(completion.report["failed"], json!([])); - assert_eq!(s.catalog.len(), 1); - } - assert_eq!(probes.load(Ordering::Relaxed), 1); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn a_root_missing_here_is_found_relative_to_the_catalog() { - let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("Lightroom")).unwrap(); - std::fs::create_dir_all(dir.join("RAW")).unwrap(); - let path = dir.join("Lightroom").join("Lightroom.lrcat"); - std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../RAW/")))).unwrap(); - let catalog = read(&path).unwrap(); - assert_eq!(Path::new(&catalog.photos[0].path), dir.join("RAW").join("Photos").join("test.ARW")); - assert!(catalog.warnings.iter().any(|w| w.contains("relative to the catalog")), "{:?}", catalog.warnings); - // a relative path that leads nowhere keeps the recorded one - std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap(); - let catalog = read(&path).unwrap(); - assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); - assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn originals_on_other_computers_are_never_looked_at_or_added() { - let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("Photos")).unwrap(); - std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); - let source = dir.join("source.lrcat"); - let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()]; - if cfg!(unix) { - // `///` is a local folder to Linux, but a network path to the resolver. - roots.push(format!("/{}/", dir.display())); - } - for root in &roots { - std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap(); - let catalog = read(&source).unwrap(); - assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]); - assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings); - let mut s = crate::Session::new(); - let probes = Arc::new(AtomicUsize::new(0)); - let calls = probes.clone(); - s.media.file_probe = Some(Arc::new(move |_| { - calls.fetch_add(1, Ordering::Relaxed); - Err("a refused original must not be probed".into()) - })); - let report = import(&mut s, &source, false).unwrap(); - assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}"); - assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}"); - assert!(s.catalog.is_empty(), "{root}"); - } - // An absolute folder path would replace the root. - let mut tables = photo_tables((dir.to_str().unwrap(), "")); - tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display())); - std::fs::write(&source, native_catalog(&tables)).unwrap(); - assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_))); - // A root beside the catalog is followed. - std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); - let catalog = read(&source).unwrap(); - assert_eq!(catalog.photos[0].original, Original::Local); - assert!(catalog.photos[0].original_is_file()); - // A root that moved is found through its path relative to the catalog. - std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap(); - let catalog = read(&source).unwrap(); - assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn oversized_optional_table_warns_and_is_skipped() { - let mut main = vec![0u8; 2 * 512]; - main[..16].copy_from_slice(b"SQLite format 3\0"); - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - main[18] = 1; - main[19] = 1; - main[21] = 64; - main[22] = 32; - main[23] = 32; - main[28..32].copy_from_slice(&2u32.to_be_bytes()); - main[44..48].copy_from_slice(&4u32.to_be_bytes()); - main[56..60].copy_from_slice(&1u32.to_be_bytes()); - main[100] = 0x0d; - let page = &mut main[512..]; - page[0] = 0x0d; - page[3..5].copy_from_slice(&1u16.to_be_bytes()); - let mut cell = varint((64 * 1024 * 1024 + 1) as u64); - cell.extend(varint(1)); - let start = 500 - cell.len(); - page[start..start + cell.len()].copy_from_slice(&cell); - page[8..10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - page[5..7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - let db = Database::open_with_wal(main, &[]).unwrap(); - let reader = - Reader { db, tables: vec![LiveTable { name: "optional_history".into(), rootpage: 2, column_names: Some(vec!["payload".into()]) }] }; - let mut warnings = Vec::new(); - let rows = reader.optional_table("optional_history", &mut warnings).unwrap(); - assert!(rows.is_empty()); - assert!(warnings.iter().any(|warning| warning.contains("optional_history"))); - } -} +mod tests; diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs new file mode 100644 index 000000000..e9cab38f9 --- /dev/null +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -0,0 +1,813 @@ +use super::*; +use lightcraft_geom::CropGeometry; +fn row(v: Value) -> Row { + serde_json::from_value(v).unwrap() +} +fn sample() -> CatalogImport { + let mut photo = CatalogPhoto { + source_id: 1, + uuid: "test-master".into(), + path: "/missing/test.ARW".into(), + image: row(json!({"id_local":1,"rating":5,"pick":1,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})), + settings: "s = { Exposure2012 = 1.25, Contrast2012 = 17, CameraProfile = \"Example\" }".into(), + xmp: String::new(), + keywords: vec!["Travel|Goa".into()], + history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))], + snapshots: Vec::new(), + original: Original::Local, + }; + let mut copy = photo.clone(); + copy.source_id = 2; + copy.uuid = "test-copy".into(); + copy.image = row(json!({"id_local":2,"masterImage":1,"copyName":"Mono","rating":2,"pick":-1})); + copy.settings = "s = { ConvertToGrayscale = true, Exposure2012 = -0.5 }".into(); + photo.image.insert("captureTime".into(), json!("2024-01-16T12:34:56")); + CatalogImport { + source: "test.lrcat".into(), + photos: vec![copy, photo], + collections: vec![ + row(json!({"id_local":9,"name":"Travel","creationId":"collection.group"})), + row(json!({"id_local":10,"parent":9,"name":"Keepers","creationId":"collection"})), + ], + members: vec![ + row(json!({"collection":10,"image":2,"positionInCollection":"a"})), + row(json!({"collection":10,"image":1,"positionInCollection":"b"})), + ], + collection_content: Vec::new(), + warnings: Vec::new(), + skipped: Vec::new(), + } +} +/// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so +/// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual +/// copies go with it; the catalog's real photos still import. +#[test] +fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() { + let mut data = sample(); + let mut exe = data.photos[1].clone(); + exe.source_id = 3; + exe.uuid = "payload".into(); + exe.path = "/Users/sky/Downloads/payload.exe".into(); + exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})); + let mut exe_copy = exe.clone(); + exe_copy.source_id = 4; + exe_copy.uuid = "payload-copy".into(); + exe_copy.path = "/Users/sky/Downloads/payload.exe".into(); + exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"})); + // the copy comes before its master, as a catalog's table order may have it + data.photos.insert(0, exe_copy); + data.photos.push(exe); + for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] { + let mut one = data.clone(); + one.photos.retain(|p| p.source_id < 3); + let mut planted = one.photos[1].clone(); + planted.source_id = 5; + planted.uuid = "planted".into(); + planted.path = bad.into(); + planted.image = row(json!({"id_local":5,"fileFormat":"ARW"})); + one.photos.push(planted); + let mut s = crate::Session::new(); + let r = apply(&mut s, one, false).unwrap(); + assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record"); + } + let mut s = crate::Session::new(); + let r = apply(&mut s, data, false).unwrap(); + assert_eq!(r["photos"], 2, "only the real master and its copy: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued"); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}"); + assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}"); +} + +#[test] +fn offline_local_master_and_copy_join_library_with_one_undo_step() { + let mut s = crate::Session::new(); + let data = sample(); + let master_id = s.catalog.alloc_photo_id(); + let mut master = Photo::new(master_id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 20, 10, "before"); + master.local = true; + s.commit("Browse folder", Op::AddPhoto { photo: Box::new(master) }).unwrap(); + s.undo.clear(); + let before = s.catalog.photo(master_id).unwrap().clone(); + let mut index = ImportIndex::default(); + let missing_sources = [key("/missing/test.ARW")].into_iter().collect(); + let mut import = |s: &mut crate::Session| { + let undo0 = s.undo.len(); + apply_prepared( + s, + data.clone(), + ApplyPrepared { + update_existing: false, + files: Default::default(), + index: &mut index, + archive_path: None, + missing_sources: &missing_sources, + undo0, + }, + ) + .unwrap() + .report + }; + let first = import(&mut s); + assert_eq!(first["mapping"]["1"], master_id.0); + assert_eq!(first["imported"], 2, "the promoted master and new copy both join All Photos"); + assert_eq!(first["missing"], json!(["/missing/test.ARW"])); + assert_eq!(s.catalog.len(), 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); + assert_eq!(s.undo.len(), 1); + let second = import(&mut s); + assert_eq!(second["imported"], 0); + assert_eq!(second["mapping"], first["mapping"]); + s.undo_step().unwrap(); + s.undo_step().unwrap(); + assert_eq!(s.catalog.len(), 1); + assert_eq!(s.catalog.photo(master_id), Some(&before)); + assert!(s.catalog.albums().next().is_none()); + let third = import(&mut s); + assert_eq!(third["imported"], 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); +} + +#[test] +fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() { + let mut s = crate::Session::new(); + let data = sample(); + let r = apply(&mut s, data.clone(), false).unwrap(); + assert_eq!(r["photos"], 2); + assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added"); + assert_eq!(r["missing"].as_array().unwrap().len(), 1); + let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap(); + assert_eq!(master.rating, 5); + assert_eq!(master.flag, Flag::Pick); + assert_eq!(master.meta.keywords, vec!["Travel|Goa"]); + assert_eq!(master.develop.light.exposure, 1.25); + let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap(); + assert_eq!(copy.copy_of, Some(master.id)); + assert_eq!(copy.rating, 2); + assert_eq!(copy.flag, Flag::Reject); + assert_eq!(copy.develop.light.exposure, -0.5); + let album = s.catalog.albums().find(|a| a.name == "Keepers").unwrap(); + assert_eq!(album.photos, vec![copy.id, master.id]); + assert!(album.parent.is_some()); + assert!(r["unmapped"]["1"].as_array().unwrap().iter().any(|v| v.as_str() == Some("CameraProfile"))); + assert_eq!(s.undo.len(), 1); + s.undo_step().unwrap(); + assert!(s.catalog.is_empty()); + assert!(s.catalog.albums().next().is_none()); + assert!(data.photos[1].settings.contains("CameraProfile")); // unmapped source stays intact +} +#[test] +fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() { + let mut s = crate::Session::new(); + let data = sample(); + apply(&mut s, data.clone(), false).unwrap(); + let id = s.catalog.photos().find(|p| p.copy_of.is_none()).unwrap().id; + s.set_develop(id, lightcraft_develop::DevelopSettings::default(), "Personal edit").unwrap(); + let r = apply(&mut s, data.clone(), false).unwrap(); + assert!(r["preservedExistingEdits"].as_u64().unwrap() > 0); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.0); + let before = s.catalog.to_snapshot(); + let mut cyclic = data; + cyclic.collections[0].insert("parent".into(), json!(10)); + assert!(apply(&mut s, cyclic, false).is_err()); + assert_eq!(s.catalog.to_snapshot(), before); + assert!( + mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) + .is_err() + ); +} +#[test] +fn compressed_xmp_is_bounded_and_settings_are_data_only() { + let packet = b"example"; + let mut encoded = (packet.len() as u32).to_be_bytes().to_vec(); + encoded.extend(miniz_oxide::deflate::compress_to_vec_zlib(packet, 6)); + assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet)); + encoded[0] = 0xff; + assert!(xmp(Some(&json!(encoded))).is_err()); + let (partial, _) = mapped_settings( + "s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", + crate::crs::Target::RawAbsolute, + 1.5, + Orientation::Normal, + 1.5, + ) + .unwrap(); + assert_eq!(partial["light"]["exposure"], 0.75); +} + +#[test] +fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() { + // Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed + // relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin + let settings = + "s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }"; + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap(); + assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}"); + assert_eq!(partial["light"]["exposure"], 0.5); + assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}"); + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + assert_eq!( + (partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()), + (Some("custom"), Some(3578.0), Some(-5.0)) + ); + assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}"); +} + +#[test] +fn catalog_crops_without_has_crop_are_imported() { + // Lightroom Classic catalogs store crop edges and angle but never `HasCrop` + let (partial, unknown) = mapped_settings( + "s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }", + crate::crs::Target::RawAbsolute, + 1.5, + Orientation::Normal, + 1.5, + ) + .unwrap(); + let crop = &partial["crop"]["geometry"]; + assert_eq!(crop["rect"]["x0"], 0.042702); + assert_eq!(crop["rect"]["y0"], 0.1); + assert_eq!(crop["rect"]["x1"], 0.957298); + assert_eq!(crop["rect"]["y1"], 1.0); + assert_eq!(crop["angle"], 0.0); + assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}"); + // an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's) + let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + assert_eq!(partial["crop"]["geometry"]["angle"], 1.5); + assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}"); + // no crop fields: the crop stays untouched + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + assert!(partial.get("crop").is_none()); +} + +#[test] +fn lightroom_orientation_codes() { + // the three seen in a real catalog, checked against each file's EXIF orientation (1, 6, 8) + assert_eq!(lightroom_orientation("AB"), Some(Orientation::Normal)); + assert_eq!(lightroom_orientation("BC"), Some(Orientation::Rotate90)); + assert_eq!(lightroom_orientation("DA"), Some(Orientation::Rotate270)); + assert_eq!(lightroom_orientation("CD"), Some(Orientation::Rotate180)); + assert_eq!(lightroom_orientation("BA"), Some(Orientation::FlipH)); + assert_eq!(lightroom_orientation("DC"), Some(Orientation::FlipV)); + assert_eq!(lightroom_orientation("AD"), Some(Orientation::Transpose)); + assert_eq!(lightroom_orientation("CB"), Some(Orientation::Transverse)); + for code in ["", "A", "ABC", "AC", "ab", "XY"] { + assert_eq!(lightroom_orientation(code), None, "{code:?}"); + } +} + +#[test] +fn catalog_crops_follow_the_photo_orientation() { + let crop = |o: Orientation| { + let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }"; + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let g = &partial["crop"]["geometry"]; + let r = &g["rect"]; + [r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9) + }; + // (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise + assert_eq!(crop(Orientation::Normal), [0.1, 0.2, 0.6, 0.9, 0.0]); + assert_eq!(crop(Orientation::Rotate90), [0.1, 0.1, 0.8, 0.6, 0.0]); + assert_eq!(crop(Orientation::Rotate270), [0.2, 0.4, 0.9, 0.9, 0.0]); + assert_eq!(crop(Orientation::Rotate180), [0.4, 0.1, 0.9, 0.8, 0.0]); + assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]); + // a mirror turns the straighten the other way + let angle = |o: Orientation| { + let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + partial["crop"]["geometry"]["angle"].as_f64().unwrap() + }; + assert_eq!(angle(Orientation::Rotate90), -2.0); + assert_eq!(angle(Orientation::FlipH), 2.0); + // no crop: nothing to orient + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap(); + assert!(partial.get("crop").is_none()); +} + +/// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image +/// of aspect `w` (height 1) shown with orientation `o`. +fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String { + let (ow, oh) = if o.swaps_axes() { (1.0, w) } else { (w, 1.0) }; + let to_source = Affine::rotate_about(-g.angle.to_radians(), Point::new(ow / 2.0, oh / 2.0)); + let stored = |x: f64, y: f64| { + let p = to_source.apply(Point::new(x * ow, y * oh)); + o.inverse().map(p.x, p.y, ow, oh) + }; + // the rectangle's corners that land top-left and bottom-right on the stored image + let corners = [stored(g.rect.x0, g.rect.y0), stored(g.rect.x1, g.rect.y0), stored(g.rect.x1, g.rect.y1), stored(g.rect.x0, g.rect.y1)]; + let (tl, br) = (0..4).map(|i| (corners[i], corners[(i + 2) % 4])).find(|(a, b)| a.0 < b.0 && a.1 < b.1).unwrap(); + let angle = if o.to_parts().0 { g.angle } else { -g.angle }; + format!("s = {{ CropLeft = {}, CropTop = {}, CropRight = {}, CropBottom = {}, CropAngle = {angle} }}", tl.0 / w, tl.1, br.0 / w, br.1) +} + +#[test] +fn straightened_catalog_crops_round_trip() { + let g = CropGeometry { rect: Rect::new(0.2, 0.15, 0.7, 0.8), angle: 6.5 }; + for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] { + for angle in [6.5, -6.5] { + let g = CropGeometry { angle, ..g }; + let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + assert!( + close(got.angle, g.angle) + && [(got.rect.x0, g.rect.x0), (got.rect.y0, g.rect.y0), (got.rect.x1, g.rect.x1), (got.rect.y1, g.rect.y1)] + .iter() + .all(|&(a, b)| close(a, b)), + "{o:?} {angle}: {got:?}" + ); + } + } +} + +#[test] +fn straightened_catalog_crops_match_lightroom_exports() { + // Settings and pixel sizes from a real Lightroom Classic catalog (9504 × 6336 stored) and the + // JPEGs Lightroom exported from it at full size. + for (settings, o, export) in [ + ( + "s = { CropAngle = -9.34444, CropBottom = 0.802034, CropLeft = 0.05499, CropRight = 0.94501, CropTop = 0.197966 }", + Orientation::Normal, + (7725.0, 5150.0), + ), + ( + "s = { CropAngle = -1.48964, CropBottom = 0.962457, CropLeft = 0.010429, CropRight = 0.989571, CropTop = 0.037543 }", + Orientation::Rotate90, + (6100.0, 9150.0), + ), + ] { + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap(); + let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap(); + let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) }; + let (w, h) = (r.width() * ow, r.height() * oh); + assert!((w - export.0).abs() <= 3.0 && (h - export.1).abs() <= 3.0, "{o:?}: {w:.1} × {h:.1}, Lightroom exported {export:?}"); + } +} + +#[test] +fn imported_crops_use_the_catalog_orientation() { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].image.insert("orientation".into(), json!("BC")); + data.photos[0].settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }".into(); + data.photos[0].xmp = String::new(); + let mut s = crate::Session::new(); + apply(&mut s, data, false).unwrap(); + let g = s.catalog.photos().next().unwrap().develop.crop.geometry; + let r = [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1].map(|v| (v * 1e9).round() / 1e9); + assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); +} + +#[test] +fn deferred_auto_tone_sentinels_never_become_real_slider_values() { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].settings = + "s = { AutoTone = true, Exposure2012 = -999999, Contrast2012 = -999999, Shadows2012 = 65, Temperature = 6150 }".into(); + data.photos[0].xmp = r#""#.into(); + let mut s = crate::Session::new(); + let report = apply(&mut s, data, false).unwrap(); + let p = s.catalog.photos().next().unwrap(); + assert_eq!(p.develop.light.exposure, 0.0); + assert_eq!(p.develop.light.contrast, 0.0); + assert_eq!(p.develop.light.shadows, 65.0); + assert_eq!(p.develop.light.blacks, -22.0); + assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); + let (partial, _) = + mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) + .unwrap(); + assert_eq!(partial["light"]["exposure"], -5.0); + assert_eq!(partial["light"]["contrast"], -100.0); +} + +#[test] +fn wal_checksums_cover_commits_without_materializing_historical_databases() { + let mut main = vec![0; 512]; + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + assert_eq!(wal_checksum(&[1, 0, 0, 0, 2, 0, 0, 0], false, (0, 0)), (1, 3)); + for big in [false, true] { + let mut wal = vec![0; 32]; + wal[0..4].copy_from_slice(&(if big { 0x377f0683u32 } else { 0x377f0682u32 }).to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[16..20].copy_from_slice(&123u32.to_be_bytes()); + let mut sum = wal_checksum(&wal[..24], big, (0, 0)); + wal[24..28].copy_from_slice(&sum.0.to_be_bytes()); + wal[28..32].copy_from_slice(&sum.1.to_be_bytes()); + for _ in 0..128 { + let mut frame = vec![0; 536]; + frame[0..4].copy_from_slice(&1u32.to_be_bytes()); + frame[4..8].copy_from_slice(&1u32.to_be_bytes()); + frame[8..12].copy_from_slice(&123u32.to_be_bytes()); + sum = wal_checksum(&frame[..8], big, sum); + sum = wal_checksum(&frame[24..], big, sum); + frame[16..20].copy_from_slice(&sum.0.to_be_bytes()); + frame[20..24].copy_from_slice(&sum.1.to_be_bytes()); + wal.extend(frame); + } + assert_eq!(committed_wal_len(&main, &wal).unwrap(), wal.len()); + let end = wal.len(); + wal[end - 1] ^= 1; + assert_eq!(committed_wal_len(&main, &wal).unwrap(), end - 536); + wal[24] ^= 1; + assert!(committed_wal_len(&main, &wal).is_err()); + main[17] = 0; + assert!(committed_wal_len(&main, &wal).is_err()); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + } +} + +#[test] +fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport-{}", std::process::id())); + let mut s = crate::Session::new(); + s.open_library(&dir, false).unwrap(); + let mut data = sample(); + data.collections.reverse(); // Parent must be committed before its child regardless of row order. + let first = apply(&mut s, data.clone(), false).unwrap(); + s.close_library().unwrap(); + s.open_library(&dir, false).unwrap(); + let id = PhotoId(first["mapping"]["1"].as_u64().unwrap()); + s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap(); + let second = apply(&mut s, data, true).unwrap(); + assert_eq!(first["mapping"], second["mapping"]); + assert_eq!(first["imported"], 2); + assert_eq!(second["imported"], 0, "reimport updates records instead of adding them"); + assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too"); + assert_eq!(second["library"], dir.to_string_lossy().as_ref()); + assert_eq!(s.catalog.len(), 2); + assert_eq!(s.catalog.albums().count(), 2); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56")); + s.close_library().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); +} + +// Construct a small SQLite fixture from public file-format records, never private media. +fn varint(mut n: u64) -> Vec { + let mut out = vec![(n & 127) as u8]; + n >>= 7; + while n > 0 { + out.push((n & 127) as u8 | 128); + n >>= 7; + } + out.reverse(); + out +} +fn record(values: &[SqlValue]) -> Vec { + let mut header = Vec::new(); + let mut body = Vec::new(); + for value in values { + let serial = match value { + SqlValue::Null => 0, + SqlValue::Integer(n) => { + body.extend(n.to_be_bytes()); + 6 + } + SqlValue::Text(s) => { + body.extend(s.as_bytes()); + 13 + 2 * s.len() as u64 + } + _ => unreachable!(), + }; + header.extend(varint(serial)); + } + let mut out = varint(1 + header.len() as u64); + out.extend(header); + out.extend(body); + out +} +fn leaf(page: &mut [u8], offset: usize, rows: &[Vec]) { + page[offset] = 13; + page[offset + 3..offset + 5].copy_from_slice(&(rows.len() as u16).to_be_bytes()); + let mut end = page.len(); + for (i, values) in rows.iter().enumerate() { + let payload = record(values); + let mut cell = varint(payload.len() as u64); + cell.extend(varint(i as u64 + 1)); + cell.extend(payload); + end -= cell.len(); + page[end..end + cell.len()].copy_from_slice(&cell); + page[offset + 8 + i * 2..offset + 10 + i * 2].copy_from_slice(&(end as u16).to_be_bytes()); + } + page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes()); +} +/// A one-row-per-table SQLite file: `(name, columns, row)` each. +fn native_catalog(tables: &[(&str, &str, Vec)]) -> Vec { + native_catalog_with_last_table_page(tables, None) +} +/// [`native_catalog`] whose last table claims `last_page` as its root page. +fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec)], last_page: Option) -> Vec { + let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect(); + native_catalog_rows(&tables, last_page) +} +/// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last +/// table's root page. +fn native_catalog_rows(tables: &[(&str, &str, Vec>)], last_page: Option) -> Vec { + use SqlValue::{Integer as I, Text as T}; + let mut bytes = vec![0u8; 5 * 2048]; + bytes[..16].copy_from_slice(b"SQLite format 3\0"); + bytes[16..18].copy_from_slice(&2048u16.to_be_bytes()); + bytes[18] = 1; + bytes[19] = 1; + bytes[21] = 64; + bytes[22] = 32; + bytes[23] = 32; + bytes[28..32].copy_from_slice(&5u32.to_be_bytes()); + bytes[44..48].copy_from_slice(&4u32.to_be_bytes()); + bytes[56..60].copy_from_slice(&1u32.to_be_bytes()); + let mut schema = Vec::new(); + for (i, (name, columns, rows)) in tables.iter().enumerate() { + schema.push(vec![ + T("table".into()), + T((*name).into()), + T((*name).into()), + I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }), + T(format!("CREATE TABLE {name} ({columns})")), + ]); + leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows); + } + leaf(&mut bytes[..2048], 100, &schema); + bytes +} + +fn photo_tables(root: (&str, &str)) -> Vec<(&'static str, &'static str, Vec)> { + use SqlValue::{Integer as I, Null as N, Text as T}; + vec![ + ( + "AgLibraryRootFolder", + "id_local INTEGER PRIMARY KEY, absolutePath, name, relativePathFromCatalog", + vec![N, T(root.0.into()), T("Photos".into()), T(root.1.into())], + ), + ("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]), + ("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]), + ( + "Adobe_images", + "id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick", + vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)], + ), + ] +} + +#[test] +fn reads_native_sqlite_tables_without_lightroom_or_external_processes() { + let bytes = native_catalog(&photo_tables(("/catalog/", ""))); + let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let catalog = read(&path).unwrap(); + assert_eq!(catalog.photos.len(), 1); + assert_eq!(catalog.photos[0].uuid, "fixture-image"); + assert_eq!(number(&catalog.photos[0].image, "rating"), 5); + assert!(catalog.photos[0].path.ends_with("test.ARW")); + // Put the required image table beyond the old 1 GiB cutoff without allocating it. + // A main-file-sized allocation would defeat the purpose of the page-backed reader. + // The large fixture relies on Unix sparse-file behavior; small parity runs everywhere. + #[cfg(unix)] + { + let far_offset = 1u64 << 30; + let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64)); + use std::io::{Seek, SeekFrom, Write}; + let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap(); + sparse.write_all(&bytes).unwrap(); + sparse.set_len(far_offset + 2048).unwrap(); + sparse.seek(SeekFrom::Start(far_offset)).unwrap(); + sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap(); + drop(sparse); + let large = read(&path).unwrap(); + assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap()); + let mut session = crate::Session::new(); + session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(session.catalog.len(), 1); + assert_eq!(session.catalog.photos().next().unwrap().rating, 5); + session.execute("edit.undo", &json!({})).unwrap(); + assert!(session.catalog.is_empty()); + } + std::fs::write(&path, b"bad catalog").unwrap(); + assert!(read(&path).is_err()); + std::fs::remove_file(path).unwrap(); +} + +/// Reads `tables` as a catalog file and returns what the reader made of it. +fn read_tables(tables: &[(&str, &str, Vec>)], name: &str) -> CatalogImport { + let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id())); + std::fs::write(&path, native_catalog_rows(tables, None)).unwrap(); + let catalog = read(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + catalog +} + +/// `tables` with each row as a one-row table, for [`native_catalog_rows`]. +fn one_row(tables: Vec<(&'static str, &'static str, Vec)>) -> Vec<(&'static str, &'static str, Vec>)> { + tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect() +} + +#[test] +fn images_with_incomplete_records_are_skipped_not_fatal() { + use SqlValue::{Integer as I, Text as T}; + // (table, column, value): the record that breaks, and the reason the import gives + let cases = [ + (3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file + (2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder + (1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root + (2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty + ]; + for (table, column, value, reason) in cases { + let mut tables = one_row(photo_tables(("/catalog/", ""))); + tables[table].2[0][column] = value; + let catalog = read_tables(&tables, &format!("skipped-{reason:?}")); + assert!(catalog.photos.is_empty(), "{reason:?}"); + assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]); + assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings); + } +} + +#[test] +fn a_virtual_copy_of_a_skipped_master_is_skipped_too() { + use SqlValue::{Integer as I, Null as N, Text as T}; + let mut tables = one_row(photo_tables(("/catalog/", ""))); + tables[3] = ( + "Adobe_images", + "id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick", + vec![ + // the master's file record is gone, so it is skipped … + vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)], + // … and its virtual copy goes with it, although its own file record is there + vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)], + ], + ); + let catalog = read_tables(&tables, "skipped-copy"); + assert!(catalog.photos.is_empty()); + assert_eq!( + catalog.skipped, + [SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }] + ); + validate(&catalog).unwrap(); +} + +#[test] +fn skipped_images_are_in_the_import_report() { + let mut data = sample(); + data.photos.retain(|p| p.source_id != 1); + drop_copies_of(&mut data, &HashSet::from([1])); + data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile }); + let mut s = crate::Session::new(); + let report = apply(&mut s, data, false).unwrap(); + assert_eq!(report["photos"], 0); + assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}])); + assert_eq!(report["warnings"], json!([])); +} + +#[test] +fn preparation_skips_missing_original_probes_but_keeps_catalog_records() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("source.lrcat"); + let root = dir.join("offline"); + let tables = photo_tables((root.to_str().unwrap(), "")); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a known-missing original must not be probed".into()) + })); + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0); + let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(result.report["imported"], 1); + assert_eq!(result.report["failed"], json!([])); + assert_eq!(result.report["missing"].as_array().unwrap().len(), 1); + let photo = s.catalog.photos().next().unwrap(); + assert_eq!(photo.rating, 5); + assert_eq!((photo.width, photo.height), (20, 10)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn probed_original_is_counted_once_and_reimport_adds_nothing() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id())); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + let source = dir.join("source.lrcat"); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() }) + })); + for expected in [1, 0] { + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(completion.report["imported"], expected); + assert_eq!(completion.report["missing"], json!([])); + assert_eq!(completion.report["failed"], json!([])); + assert_eq!(s.catalog.len(), 1); + } + assert_eq!(probes.load(Ordering::Relaxed), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn a_root_missing_here_is_found_relative_to_the_catalog() { + let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Lightroom")).unwrap(); + std::fs::create_dir_all(dir.join("RAW")).unwrap(); + let path = dir.join("Lightroom").join("Lightroom.lrcat"); + std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../RAW/")))).unwrap(); + let catalog = read(&path).unwrap(); + assert_eq!(Path::new(&catalog.photos[0].path), dir.join("RAW").join("Photos").join("test.ARW")); + assert!(catalog.warnings.iter().any(|w| w.contains("relative to the catalog")), "{:?}", catalog.warnings); + // a relative path that leads nowhere keeps the recorded one + std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap(); + let catalog = read(&path).unwrap(); + assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); + assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn originals_on_other_computers_are_never_looked_at_or_added() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + let source = dir.join("source.lrcat"); + let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()]; + if cfg!(unix) { + // `///` is a local folder to Linux, but a network path to the resolver. + roots.push(format!("/{}/", dir.display())); + } + for root in &roots { + std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap(); + let catalog = read(&source).unwrap(); + assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]); + assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a refused original must not be probed".into()) + })); + let report = import(&mut s, &source, false).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}"); + assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}"); + assert!(s.catalog.is_empty(), "{root}"); + } + // An absolute folder path would replace the root. + let mut tables = photo_tables((dir.to_str().unwrap(), "")); + tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display())); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_))); + // A root beside the catalog is followed. + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let catalog = read(&source).unwrap(); + assert_eq!(catalog.photos[0].original, Original::Local); + assert!(catalog.photos[0].original_is_file()); + // A root that moved is found through its path relative to the catalog. + std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap(); + let catalog = read(&source).unwrap(); + assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn oversized_optional_table_warns_and_is_skipped() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + main[100] = 0x0d; + let page = &mut main[512..]; + page[0] = 0x0d; + page[3..5].copy_from_slice(&1u16.to_be_bytes()); + let mut cell = varint((64 * 1024 * 1024 + 1) as u64); + cell.extend(varint(1)); + let start = 500 - cell.len(); + page[start..start + cell.len()].copy_from_slice(&cell); + page[8..10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[5..7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + let db = Database::open_with_wal(main, &[]).unwrap(); + let reader = + Reader { db, tables: vec![LiveTable { name: "optional_history".into(), rootpage: 2, column_names: Some(vec!["payload".into()]) }] }; + let mut warnings = Vec::new(); + let rows = reader.optional_table("optional_history", &mut warnings).unwrap(); + assert!(rows.is_empty()); + assert!(warnings.iter().any(|warning| warning.contains("optional_history"))); +} From 34f15b567f3dd33537ab5cdba511603da1205b4c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:53 +0000 Subject: [PATCH 042/191] refactor(lightcraft-engine): move lightroom_sqlite.rs tests to crates/engine/src/lightroom_sqlite/tests.rs --- crates/engine/src/lightroom_sqlite.rs | 418 +------------------- crates/engine/src/lightroom_sqlite/tests.rs | 415 +++++++++++++++++++ 2 files changed, 416 insertions(+), 417 deletions(-) create mode 100644 crates/engine/src/lightroom_sqlite/tests.rs diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index 58019f2c1..60e03281c 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -963,420 +963,4 @@ fn be_u32_at(bytes: &[u8], at: usize) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - struct Scratch(PathBuf); - impl Scratch { - fn new(bytes: &[u8]) -> Self { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id())); - std::fs::write(&path, bytes).unwrap(); - Self(path) - } - } - impl Drop for Scratch { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } - } - - fn file_backed(mut db: Database) -> (Scratch, Database) { - let scratch = Scratch::new(&db.main); - db.file = Some(FileSource::open(&scratch.0).unwrap()); - db.main.truncate(100); - (scratch, db) - } - - #[test] - fn file_source_bounds_and_mutation_detection() { - let scratch = Scratch::new(&[0; 512]); - let source = FileSource::open(&scratch.0).unwrap(); - assert_eq!(source.page(1, 512).unwrap().len(), 512); - assert!(source.page(2, 512).unwrap_err().contains("truncated")); - File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap(); - assert!(source.page(1, 512).is_err()); - assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); - drop(source); - // Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows. - #[cfg(unix)] - { - File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap(); - assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit"); - } - } - - #[cfg(unix)] - #[test] - fn file_source_detects_same_sized_path_replacement() { - let original = Scratch::new(&[0; 512]); - let replacement = Scratch::new(&[0; 512]); - let source = FileSource::open(&original.0).unwrap(); - std::fs::rename(&replacement.0, &original.0).unwrap(); - assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); - } - - #[test] - fn file_source_detects_same_length_content_changes() { - let scratch = Scratch::new(&[0; 512]); - let source = FileSource::open(&scratch.0).unwrap(); - std::fs::write(&scratch.0, [1; 512]).unwrap(); - // Make the timestamp change deterministic even on filesystems with coarse timestamps. - File::options() - .write(true) - .open(&scratch.0) - .unwrap() - .set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2)) - .unwrap(); - assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); - } - - fn varint(mut value: u64) -> Vec { - let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)]; - value >>= 7; - while value != 0 { - out.push(u8::try_from(value & 0x7f).unwrap_or(0) | 0x80); - value >>= 7; - } - out.reverse(); - out - } - - fn leaf(page: &mut [u8], base: usize, rowid: u64, payload: &[u8]) { - page[base] = 0x0d; - page[base + 3..base + 5].copy_from_slice(&1u16.to_be_bytes()); - let mut cell = varint(payload.len() as u64); - cell.extend(varint(rowid)); - cell.extend(payload); - let start = page.len() - cell.len(); - page[start..].copy_from_slice(&cell); - page[base + 8..base + 10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - page[base + 5..base + 7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - } - - fn leaf_rows(page: &mut [u8], base: usize, rows: &[(u64, &[u8])]) { - page[base] = 0x0d; - page[base + 3..base + 5].copy_from_slice(&u16::try_from(rows.len()).unwrap_or(0).to_be_bytes()); - let mut end = page.len(); - for (index, (rowid, payload)) in rows.iter().enumerate() { - let mut cell = varint(payload.len() as u64); - cell.extend(varint(*rowid)); - cell.extend(*payload); - end -= cell.len(); - page[end..end + cell.len()].copy_from_slice(&cell); - let pointer = base + 8 + index * 2; - page[pointer..pointer + 2].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); - } - page[base + 5..base + 7].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); - } - - fn schema_record(name: &str, root: u8, sql: &str) -> Vec { - let mut header = vec![23]; - let text_serial = |text: &str| 13 + 2 * text.len() as u64; - header.extend(varint(text_serial(name))); - header.extend(varint(text_serial(name))); - header.push(1); - header.extend(varint(text_serial(sql))); - let mut body = b"table".to_vec(); - body.extend(name.as_bytes()); - body.extend(name.as_bytes()); - body.push(root); - body.extend(sql.as_bytes()); - let mut record = varint((1 + header.len()) as u64); - record.extend(header); - record.extend(body); - record - } - - #[test] - fn schema_names_and_integer_primary_key_alias_are_decoded() { - let sql = r#"CREATE TABLE "t" ("id_local" INTEGER PRIMARY KEY, "odd,name" TEXT, PRIMARY KEY ("id_local"))"#; - let (names, alias) = parse_create_table(sql).unwrap(); - assert_eq!(names, vec!["id_local", "odd,name"]); - assert_eq!(alias, Some(0)); - let (_, no_alias) = parse_create_table("CREATE TABLE t (id INTEGER PRIMARY KEY DESC, value TEXT)").unwrap(); - assert_eq!(no_alias, None); - let (table_names, table_alias) = parse_create_table("CREATE TABLE t (id INTEGER, value TEXT, PRIMARY KEY(id))").unwrap(); - assert_eq!(table_names, vec!["id", "value"]); - assert_eq!(table_alias, Some(0)); - } - - /// A database file with no schema yet has schema format 0 in its header (file format - /// §1.3.10): a catalog created in WAL mode whose tables are all still in its `-wal` file (one - /// Lightroom has open and hasn't checkpointed) was refused as "unsupported SQLite schema - /// format" before the WAL was looked at. It opens, with no tables; other formats stay refused. - #[test] - fn schema_format_zero_is_an_empty_database() { - let header = |format: u32| { - let mut main = vec![0u8; 512]; - main[..16].copy_from_slice(b"SQLite format 3\0"); - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - main[18] = 2; - main[19] = 2; - main[21] = 64; - main[22] = 32; - main[23] = 32; - main[28..32].copy_from_slice(&1u32.to_be_bytes()); - main[44..48].copy_from_slice(&format.to_be_bytes()); - main[56..60].copy_from_slice(&1u32.to_be_bytes()); - main[100] = 0x0d; - main[105..107].copy_from_slice(&512u16.to_be_bytes()); - main - }; - let db = Database::open_with_wal(header(0), &[]).unwrap(); - assert!(db.live_tables().unwrap().is_empty()); - assert!(Database::open_with_wal(header(4), &[]).is_ok()); - assert_eq!(Database::open_with_wal(header(5), &[]).err().as_deref(), Some("unsupported SQLite schema format")); - } - - #[test] - fn unsupported_unrelated_schema_entries_are_skipped() { - let mut main = vec![0u8; 2 * 512]; - main[..16].copy_from_slice(b"SQLite format 3\0"); - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - main[18] = 1; - main[19] = 1; - main[21] = 64; - main[22] = 32; - main[23] = 32; - main[28..32].copy_from_slice(&2u32.to_be_bytes()); - main[44..48].copy_from_slice(&4u32.to_be_bytes()); - main[56..60].copy_from_slice(&1u32.to_be_bytes()); - let virtual_sql = "CREATE VIRTUAL TABLE unrelated USING fts5(text)"; - let supported_sql = "CREATE TABLE supported (id INTEGER PRIMARY KEY)"; - let virtual_record = schema_record("unrelated", 2, virtual_sql); - let supported_record = schema_record("supported", 2, supported_sql); - leaf_rows(&mut main[..512], 100, &[(1, &virtual_record), (2, &supported_record)]); - let db = Database::open_with_wal(main, &[]).unwrap(); - let tables = db.live_tables().unwrap(); - assert_eq!(tables.len(), 1); - assert_eq!(tables[0].name, "supported"); - } - - #[test] - fn synthetic_btree_substitutes_rowid_and_reads_schema() { - let mut main = vec![0u8; 2 * 512]; - main[..16].copy_from_slice(b"SQLite format 3\0"); - main[16..18].copy_from_slice(&512u16.to_be_bytes()); - main[18] = 1; - main[19] = 1; - main[21] = 64; - main[22] = 32; - main[23] = 32; - main[28..32].copy_from_slice(&2u32.to_be_bytes()); - main[44..48].copy_from_slice(&4u32.to_be_bytes()); - main[56..60].copy_from_slice(&1u32.to_be_bytes()); - let sql = "CREATE TABLE \"t\" (\"id_local\" INTEGER PRIMARY KEY, \"name\" TEXT)"; - // rootpage is an integer field, so replace fourth text value with a compact record. - let mut header = vec![23, 15, 15, 1]; - header.extend(varint((13 + 2 * sql.len()) as u64)); - let mut body = b"tablett".to_vec(); - body.push(2); - body.extend(sql.as_bytes()); - let mut record = varint((1 + header.len()) as u64); - record.append(&mut header); - record.extend(body); - let schema = record; - leaf(&mut main[512..], 0, 1, &[3, 0, 15, b'x']); - leaf(&mut main[..512], 100, 1, &schema); - let db = Database::open_with_wal(main, &[]).unwrap(); - let tables = db.live_tables().unwrap(); - assert!(tables.iter().any(|table| table.name == "t")); - let rows = db.read_table(2, 2).unwrap(); - assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1))); - let scratch = Scratch::new(&db.main); - let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap(); - assert_eq!(file_db.live_tables().unwrap(), tables); - assert_eq!(file_db.read_table(2, 2).unwrap(), rows); - assert_eq!(file_db.main.len(), 100); - file_db.ensure_unchanged().unwrap(); - // The same committed WAL page overrides the lazily read main page. - let mut wal = vec![0; 32 + 24 + 512]; - wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); - wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); - wal[8..12].copy_from_slice(&512u32.to_be_bytes()); - wal[32..36].copy_from_slice(&2u32.to_be_bytes()); - wal[36..40].copy_from_slice(&2u32.to_be_bytes()); - leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']); - let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap(); - let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap(); - assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap()); - assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2)); - let mut malformed = db.main.clone(); - malformed.push(0); - let partial = Scratch::new(&malformed); - assert_eq!( - Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(), - "SQLite image is not a whole number of pages" - ); - let mut malformed = db.main.clone(); - malformed[24..28].copy_from_slice(&1u32.to_be_bytes()); - malformed[92..96].copy_from_slice(&1u32.to_be_bytes()); - malformed[28..32].copy_from_slice(&3u32.to_be_bytes()); - let oversized = Scratch::new(&malformed); - assert_eq!( - Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(), - "SQLite header page count exceeds image" - ); - } - - #[test] - fn interior_table_pointer_array_uses_cell_offsets() { - let mut main = vec![0u8; 4 * 512]; - let mut interior = vec![0u8; 512]; - interior[0] = 0x05; - interior[3..5].copy_from_slice(&1u16.to_be_bytes()); - interior[8..12].copy_from_slice(&4u32.to_be_bytes()); - interior[12..14].copy_from_slice(&500u16.to_be_bytes()); - interior[500..504].copy_from_slice(&3u32.to_be_bytes()); - interior[504] = 1; - main[512..1024].copy_from_slice(&interior); - leaf(&mut main[1024..1536], 0, 1, &[2, 0]); - leaf(&mut main[1536..], 0, 2, &[2, 0]); - let db = Database { - file: None, - main, - overlay: HashMap::new(), - page_size: 512, - reserved: 0, - encoding: TextEncoding::Utf8, - page_count: 4, - tables: Vec::new(), - rowid_aliases: HashMap::new(), - }; - assert_eq!(db.read_table(2, 1).unwrap().len(), 2); - } - - #[test] - fn negative_rowid_varint_is_signed() { - let mut main = vec![0u8; 512]; - let page = &mut main[..]; - page[100] = 0x0d; - page[103..105].copy_from_slice(&1u16.to_be_bytes()); - let start = 500usize; - page[start] = 2; - for offset in 1..10 { - if let Some(byte) = page.get_mut(start + offset) { - *byte = 0xff; - } - } - page[start + 10..start + 12].copy_from_slice(&[2, 0]); - page[108..110].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - page[105..107].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); - let mut aliases = HashMap::new(); - aliases.insert(1, 0); - let db = Database { - file: None, - main, - overlay: HashMap::new(), - page_size: 512, - reserved: 0, - encoding: TextEncoding::Utf8, - page_count: 1, - tables: Vec::new(), - rowid_aliases: aliases, - }; - let rows = db.read_table(1, 1).unwrap(); - assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(-1))); - } - - #[test] - fn reserved_page_trailer_cannot_supply_cells() { - let mut main = vec![0u8; 512]; - main[100] = 0x0d; - main[103..105].copy_from_slice(&1u16.to_be_bytes()); - main[108..110].copy_from_slice(&490u16.to_be_bytes()); - let db = Database { - file: None, - main, - overlay: HashMap::new(), - page_size: 512, - reserved: 32, - encoding: TextEncoding::Utf8, - page_count: 1, - tables: Vec::new(), - rowid_aliases: HashMap::new(), - }; - assert!(db.read_table(1, 1).is_err()); - let (_scratch, db) = file_backed(db); - assert!(db.read_table(1, 1).is_err()); - } - - #[test] - fn utf16_and_overflow_payloads_are_supported() { - let mut payload = vec![2, 17, b'A', 0]; - assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Le).unwrap(), vec![Value::Text("A".into())]); - payload.clear(); - payload.extend([2, 17, 0, b'A']); - assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Be).unwrap(), vec![Value::Text("A".into())]); - - let mut main = vec![0u8; 2 * 512]; - let db = Database { - file: None, - main: std::mem::take(&mut main), - overlay: HashMap::new(), - page_size: 512, - reserved: 0, - encoding: TextEncoding::Utf8, - page_count: 2, - tables: Vec::new(), - rowid_aliases: HashMap::new(), - }; - let mut cell_page = vec![b'x'; 512]; - // For a 512-byte table leaf & 600-byte payload, SQLite stores 92 - // payload bytes locally, followed by the overflow page pointer. - cell_page[92..96].copy_from_slice(&2u32.to_be_bytes()); - let inline_page = vec![b'i'; 512]; - assert_eq!(db.payload(&inline_page, 0, 110).unwrap(), vec![b'i'; 110]); - let mut overflow_page = vec![b'y'; 512]; - overflow_page[..4].copy_from_slice(&0u32.to_be_bytes()); - let mut db = db; - db.main[512..].copy_from_slice(&overflow_page); - let mut with_overflow = db.overlay; - with_overflow.insert(2, overflow_page); - db.overlay = with_overflow; - let bytes = db.payload(&cell_page, 0, 600).unwrap(); - assert_eq!(bytes.len(), 600); - assert_eq!(&bytes[..92], &[b'x'; 92]); - assert_eq!(&bytes[92..], &[b'y'; 508]); - let (_scratch, mut db) = file_backed(db); - db.overlay.clear(); - let bytes = db.payload(&cell_page, 0, 600).unwrap(); - assert_eq!(&bytes[92..], &[b'y'; 508]); - } - - #[test] - fn malformed_varint_and_wal_cycle_fail_cleanly() { - let mut at = 0; - assert!(read_varint(&[0x80; 8], &mut at).is_err()); - let mut wal = vec![0u8; 32 + 24 + 512]; - wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); - wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); - wal[8..12].copy_from_slice(&512u32.to_be_bytes()); - wal[16..20].copy_from_slice(&7u32.to_be_bytes()); - wal[20..24].copy_from_slice(&8u32.to_be_bytes()); - wal[32..36].copy_from_slice(&1u32.to_be_bytes()); - wal[36..40].copy_from_slice(&1u32.to_be_bytes()); - wal[40..44].copy_from_slice(&7u32.to_be_bytes()); - wal[44..48].copy_from_slice(&8u32.to_be_bytes()); - let parsed = parse_wal(&wal, 512).unwrap(); - assert_eq!(parsed.frame_count, 1); - assert_eq!(parsed.page_count, Some(1)); - assert_eq!(parsed.pages.get(&1).map(Vec::len), Some(512)); - let mut page = vec![0u8; 512]; - page[16..18].copy_from_slice(&512u16.to_be_bytes()); - let db = Database { - file: None, - main: vec![0; 512], - overlay: HashMap::new(), - page_size: 512, - reserved: 0, - encoding: TextEncoding::Utf8, - page_count: 1, - tables: Vec::new(), - rowid_aliases: HashMap::new(), - }; - assert!(db.payload(&page, 0, 600).is_err()); - } -} +mod tests; diff --git a/crates/engine/src/lightroom_sqlite/tests.rs b/crates/engine/src/lightroom_sqlite/tests.rs new file mode 100644 index 000000000..0c5b53f6a --- /dev/null +++ b/crates/engine/src/lightroom_sqlite/tests.rs @@ -0,0 +1,415 @@ +use super::*; + +struct Scratch(PathBuf); +impl Scratch { + fn new(bytes: &[u8]) -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + Self(path) + } +} +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +fn file_backed(mut db: Database) -> (Scratch, Database) { + let scratch = Scratch::new(&db.main); + db.file = Some(FileSource::open(&scratch.0).unwrap()); + db.main.truncate(100); + (scratch, db) +} + +#[test] +fn file_source_bounds_and_mutation_detection() { + let scratch = Scratch::new(&[0; 512]); + let source = FileSource::open(&scratch.0).unwrap(); + assert_eq!(source.page(1, 512).unwrap().len(), 512); + assert!(source.page(2, 512).unwrap_err().contains("truncated")); + File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap(); + assert!(source.page(1, 512).is_err()); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + drop(source); + // Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows. + #[cfg(unix)] + { + File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap(); + assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit"); + } +} + +#[cfg(unix)] +#[test] +fn file_source_detects_same_sized_path_replacement() { + let original = Scratch::new(&[0; 512]); + let replacement = Scratch::new(&[0; 512]); + let source = FileSource::open(&original.0).unwrap(); + std::fs::rename(&replacement.0, &original.0).unwrap(); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); +} + +#[test] +fn file_source_detects_same_length_content_changes() { + let scratch = Scratch::new(&[0; 512]); + let source = FileSource::open(&scratch.0).unwrap(); + std::fs::write(&scratch.0, [1; 512]).unwrap(); + // Make the timestamp change deterministic even on filesystems with coarse timestamps. + File::options() + .write(true) + .open(&scratch.0) + .unwrap() + .set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2)) + .unwrap(); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); +} + +fn varint(mut value: u64) -> Vec { + let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)]; + value >>= 7; + while value != 0 { + out.push(u8::try_from(value & 0x7f).unwrap_or(0) | 0x80); + value >>= 7; + } + out.reverse(); + out +} + +fn leaf(page: &mut [u8], base: usize, rowid: u64, payload: &[u8]) { + page[base] = 0x0d; + page[base + 3..base + 5].copy_from_slice(&1u16.to_be_bytes()); + let mut cell = varint(payload.len() as u64); + cell.extend(varint(rowid)); + cell.extend(payload); + let start = page.len() - cell.len(); + page[start..].copy_from_slice(&cell); + page[base + 8..base + 10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[base + 5..base + 7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); +} + +fn leaf_rows(page: &mut [u8], base: usize, rows: &[(u64, &[u8])]) { + page[base] = 0x0d; + page[base + 3..base + 5].copy_from_slice(&u16::try_from(rows.len()).unwrap_or(0).to_be_bytes()); + let mut end = page.len(); + for (index, (rowid, payload)) in rows.iter().enumerate() { + let mut cell = varint(payload.len() as u64); + cell.extend(varint(*rowid)); + cell.extend(*payload); + end -= cell.len(); + page[end..end + cell.len()].copy_from_slice(&cell); + let pointer = base + 8 + index * 2; + page[pointer..pointer + 2].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); + } + page[base + 5..base + 7].copy_from_slice(&u16::try_from(end).unwrap_or(0).to_be_bytes()); +} + +fn schema_record(name: &str, root: u8, sql: &str) -> Vec { + let mut header = vec![23]; + let text_serial = |text: &str| 13 + 2 * text.len() as u64; + header.extend(varint(text_serial(name))); + header.extend(varint(text_serial(name))); + header.push(1); + header.extend(varint(text_serial(sql))); + let mut body = b"table".to_vec(); + body.extend(name.as_bytes()); + body.extend(name.as_bytes()); + body.push(root); + body.extend(sql.as_bytes()); + let mut record = varint((1 + header.len()) as u64); + record.extend(header); + record.extend(body); + record +} + +#[test] +fn schema_names_and_integer_primary_key_alias_are_decoded() { + let sql = r#"CREATE TABLE "t" ("id_local" INTEGER PRIMARY KEY, "odd,name" TEXT, PRIMARY KEY ("id_local"))"#; + let (names, alias) = parse_create_table(sql).unwrap(); + assert_eq!(names, vec!["id_local", "odd,name"]); + assert_eq!(alias, Some(0)); + let (_, no_alias) = parse_create_table("CREATE TABLE t (id INTEGER PRIMARY KEY DESC, value TEXT)").unwrap(); + assert_eq!(no_alias, None); + let (table_names, table_alias) = parse_create_table("CREATE TABLE t (id INTEGER, value TEXT, PRIMARY KEY(id))").unwrap(); + assert_eq!(table_names, vec!["id", "value"]); + assert_eq!(table_alias, Some(0)); +} + +/// A database file with no schema yet has schema format 0 in its header (file format +/// §1.3.10): a catalog created in WAL mode whose tables are all still in its `-wal` file (one +/// Lightroom has open and hasn't checkpointed) was refused as "unsupported SQLite schema +/// format" before the WAL was looked at. It opens, with no tables; other formats stay refused. +#[test] +fn schema_format_zero_is_an_empty_database() { + let header = |format: u32| { + let mut main = vec![0u8; 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 2; + main[19] = 2; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&1u32.to_be_bytes()); + main[44..48].copy_from_slice(&format.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + main[100] = 0x0d; + main[105..107].copy_from_slice(&512u16.to_be_bytes()); + main + }; + let db = Database::open_with_wal(header(0), &[]).unwrap(); + assert!(db.live_tables().unwrap().is_empty()); + assert!(Database::open_with_wal(header(4), &[]).is_ok()); + assert_eq!(Database::open_with_wal(header(5), &[]).err().as_deref(), Some("unsupported SQLite schema format")); +} + +#[test] +fn unsupported_unrelated_schema_entries_are_skipped() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + let virtual_sql = "CREATE VIRTUAL TABLE unrelated USING fts5(text)"; + let supported_sql = "CREATE TABLE supported (id INTEGER PRIMARY KEY)"; + let virtual_record = schema_record("unrelated", 2, virtual_sql); + let supported_record = schema_record("supported", 2, supported_sql); + leaf_rows(&mut main[..512], 100, &[(1, &virtual_record), (2, &supported_record)]); + let db = Database::open_with_wal(main, &[]).unwrap(); + let tables = db.live_tables().unwrap(); + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].name, "supported"); +} + +#[test] +fn synthetic_btree_substitutes_rowid_and_reads_schema() { + let mut main = vec![0u8; 2 * 512]; + main[..16].copy_from_slice(b"SQLite format 3\0"); + main[16..18].copy_from_slice(&512u16.to_be_bytes()); + main[18] = 1; + main[19] = 1; + main[21] = 64; + main[22] = 32; + main[23] = 32; + main[28..32].copy_from_slice(&2u32.to_be_bytes()); + main[44..48].copy_from_slice(&4u32.to_be_bytes()); + main[56..60].copy_from_slice(&1u32.to_be_bytes()); + let sql = "CREATE TABLE \"t\" (\"id_local\" INTEGER PRIMARY KEY, \"name\" TEXT)"; + // rootpage is an integer field, so replace fourth text value with a compact record. + let mut header = vec![23, 15, 15, 1]; + header.extend(varint((13 + 2 * sql.len()) as u64)); + let mut body = b"tablett".to_vec(); + body.push(2); + body.extend(sql.as_bytes()); + let mut record = varint((1 + header.len()) as u64); + record.append(&mut header); + record.extend(body); + let schema = record; + leaf(&mut main[512..], 0, 1, &[3, 0, 15, b'x']); + leaf(&mut main[..512], 100, 1, &schema); + let db = Database::open_with_wal(main, &[]).unwrap(); + let tables = db.live_tables().unwrap(); + assert!(tables.iter().any(|table| table.name == "t")); + let rows = db.read_table(2, 2).unwrap(); + assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1))); + let scratch = Scratch::new(&db.main); + let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap(); + assert_eq!(file_db.live_tables().unwrap(), tables); + assert_eq!(file_db.read_table(2, 2).unwrap(), rows); + assert_eq!(file_db.main.len(), 100); + file_db.ensure_unchanged().unwrap(); + // The same committed WAL page overrides the lazily read main page. + let mut wal = vec![0; 32 + 24 + 512]; + wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[32..36].copy_from_slice(&2u32.to_be_bytes()); + wal[36..40].copy_from_slice(&2u32.to_be_bytes()); + leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']); + let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap(); + let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap(); + assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap()); + assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2)); + let mut malformed = db.main.clone(); + malformed.push(0); + let partial = Scratch::new(&malformed); + assert_eq!( + Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(), + "SQLite image is not a whole number of pages" + ); + let mut malformed = db.main.clone(); + malformed[24..28].copy_from_slice(&1u32.to_be_bytes()); + malformed[92..96].copy_from_slice(&1u32.to_be_bytes()); + malformed[28..32].copy_from_slice(&3u32.to_be_bytes()); + let oversized = Scratch::new(&malformed); + assert_eq!( + Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(), + "SQLite header page count exceeds image" + ); +} + +#[test] +fn interior_table_pointer_array_uses_cell_offsets() { + let mut main = vec![0u8; 4 * 512]; + let mut interior = vec![0u8; 512]; + interior[0] = 0x05; + interior[3..5].copy_from_slice(&1u16.to_be_bytes()); + interior[8..12].copy_from_slice(&4u32.to_be_bytes()); + interior[12..14].copy_from_slice(&500u16.to_be_bytes()); + interior[500..504].copy_from_slice(&3u32.to_be_bytes()); + interior[504] = 1; + main[512..1024].copy_from_slice(&interior); + leaf(&mut main[1024..1536], 0, 1, &[2, 0]); + leaf(&mut main[1536..], 0, 2, &[2, 0]); + let db = Database { + file: None, + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 4, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert_eq!(db.read_table(2, 1).unwrap().len(), 2); +} + +#[test] +fn negative_rowid_varint_is_signed() { + let mut main = vec![0u8; 512]; + let page = &mut main[..]; + page[100] = 0x0d; + page[103..105].copy_from_slice(&1u16.to_be_bytes()); + let start = 500usize; + page[start] = 2; + for offset in 1..10 { + if let Some(byte) = page.get_mut(start + offset) { + *byte = 0xff; + } + } + page[start + 10..start + 12].copy_from_slice(&[2, 0]); + page[108..110].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + page[105..107].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); + let mut aliases = HashMap::new(); + aliases.insert(1, 0); + let db = Database { + file: None, + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: aliases, + }; + let rows = db.read_table(1, 1).unwrap(); + assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(-1))); +} + +#[test] +fn reserved_page_trailer_cannot_supply_cells() { + let mut main = vec![0u8; 512]; + main[100] = 0x0d; + main[103..105].copy_from_slice(&1u16.to_be_bytes()); + main[108..110].copy_from_slice(&490u16.to_be_bytes()); + let db = Database { + file: None, + main, + overlay: HashMap::new(), + page_size: 512, + reserved: 32, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert!(db.read_table(1, 1).is_err()); + let (_scratch, db) = file_backed(db); + assert!(db.read_table(1, 1).is_err()); +} + +#[test] +fn utf16_and_overflow_payloads_are_supported() { + let mut payload = vec![2, 17, b'A', 0]; + assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Le).unwrap(), vec![Value::Text("A".into())]); + payload.clear(); + payload.extend([2, 17, 0, b'A']); + assert_eq!(decode_record(&payload, 1, TextEncoding::Utf16Be).unwrap(), vec![Value::Text("A".into())]); + + let mut main = vec![0u8; 2 * 512]; + let db = Database { + file: None, + main: std::mem::take(&mut main), + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 2, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + let mut cell_page = vec![b'x'; 512]; + // For a 512-byte table leaf & 600-byte payload, SQLite stores 92 + // payload bytes locally, followed by the overflow page pointer. + cell_page[92..96].copy_from_slice(&2u32.to_be_bytes()); + let inline_page = vec![b'i'; 512]; + assert_eq!(db.payload(&inline_page, 0, 110).unwrap(), vec![b'i'; 110]); + let mut overflow_page = vec![b'y'; 512]; + overflow_page[..4].copy_from_slice(&0u32.to_be_bytes()); + let mut db = db; + db.main[512..].copy_from_slice(&overflow_page); + let mut with_overflow = db.overlay; + with_overflow.insert(2, overflow_page); + db.overlay = with_overflow; + let bytes = db.payload(&cell_page, 0, 600).unwrap(); + assert_eq!(bytes.len(), 600); + assert_eq!(&bytes[..92], &[b'x'; 92]); + assert_eq!(&bytes[92..], &[b'y'; 508]); + let (_scratch, mut db) = file_backed(db); + db.overlay.clear(); + let bytes = db.payload(&cell_page, 0, 600).unwrap(); + assert_eq!(&bytes[92..], &[b'y'; 508]); +} + +#[test] +fn malformed_varint_and_wal_cycle_fail_cleanly() { + let mut at = 0; + assert!(read_varint(&[0x80; 8], &mut at).is_err()); + let mut wal = vec![0u8; 32 + 24 + 512]; + wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[16..20].copy_from_slice(&7u32.to_be_bytes()); + wal[20..24].copy_from_slice(&8u32.to_be_bytes()); + wal[32..36].copy_from_slice(&1u32.to_be_bytes()); + wal[36..40].copy_from_slice(&1u32.to_be_bytes()); + wal[40..44].copy_from_slice(&7u32.to_be_bytes()); + wal[44..48].copy_from_slice(&8u32.to_be_bytes()); + let parsed = parse_wal(&wal, 512).unwrap(); + assert_eq!(parsed.frame_count, 1); + assert_eq!(parsed.page_count, Some(1)); + assert_eq!(parsed.pages.get(&1).map(Vec::len), Some(512)); + let mut page = vec![0u8; 512]; + page[16..18].copy_from_slice(&512u16.to_be_bytes()); + let db = Database { + file: None, + main: vec![0; 512], + overlay: HashMap::new(), + page_size: 512, + reserved: 0, + encoding: TextEncoding::Utf8, + page_count: 1, + tables: Vec::new(), + rowid_aliases: HashMap::new(), + }; + assert!(db.payload(&page, 0, 600).is_err()); +} From a70d39eacdc196dde405f0cf8024f3897c0049f6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:54 +0000 Subject: [PATCH 043/191] refactor(lightcraft-engine): move slide_overlay.rs tests to crates/engine/src/slide_overlay/tests.rs --- crates/engine/src/slide_overlay.rs | 317 +---------------------- crates/engine/src/slide_overlay/tests.rs | 314 ++++++++++++++++++++++ 2 files changed, 315 insertions(+), 316 deletions(-) create mode 100644 crates/engine/src/slide_overlay/tests.rs diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs index a27f75d49..0d3d0c2b1 100644 --- a/crates/engine/src/slide_overlay.rs +++ b/crates/engine/src/slide_overlay.rs @@ -1026,319 +1026,4 @@ fn star(img: &mut Rgba8, x: f64, y: f64, size: f64, c: [u8; 3]) { } #[cfg(test)] -mod tests { - use super::*; - - fn layout(w: usize, h: usize) -> Layout { - lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default().merged(&json!({"margins": 0.1})).unwrap(), 1.5, w, h) - } - - fn photo() -> Photo { - let mut p = crate::rename::sample_photo(); - p.rating = 3; - p.meta.caption = "Morning fog".into(); - p.meta.shutter = "1/250".into(); - p.meta.aperture = Some(8.0); - p.meta.focal_mm = Some(50.0); - p - } - - #[test] - fn tokens_expand_like_the_rename_tokens_plus_slide_ones() { - let p = photo(); - assert_eq!(expand("{caption}", &p), "Morning fog"); - assert_eq!(expand("{exposure}", &p), "1/250 s at f/8"); - assert_eq!(expand("{focal} · ISO {iso}", &p), format!("50 mm · ISO {}", p.meta.iso.unwrap_or_default())); - assert_eq!(expand("{file}", &p), p.file_name); - assert_eq!(expand("{name}", &p), crate::rename::expand_tokens("{name}", &p, 1, 1), "rename tokens work"); - assert_eq!(expand("{date}", &p), crate::rename::expand_tokens("{date:%Y-%m-%d}", &p, 1, 1)); - assert_eq!(expand("{rating}", &p), "3"); - // unknown and slide-less tags show as typed, and are reported - assert_eq!(expand("{sparkle} {seq} {", &p), "{sparkle} {seq} {"); - assert_eq!(unknown_tokens("{title} {sparkle} {seq} {é}"), vec!["{sparkle}", "{seq}", "{é}"]); - // a photo without metadata: empty values, no panic - let mut bare = p.clone(); - bare.meta = Default::default(); - assert_eq!(expand("[{exposure}|{camera}|{caption}|{focal}]", &bare), "[|||]"); - } - - #[test] - fn nine_anchors_place_inside_the_slide_with_their_offsets() { - let l = layout(1000, 600); - let short = 600.0; - for anchor in ANCHORS { - for relative_to in [RelativeTo::Slide, RelativeTo::Photo] { - let at = |ox: f64, oy: f64| Placement { anchor, offset_x: ox, offset_y: oy, relative_to }.place(&l, 100.0, 40.0); - let b = at(0.0, 0.0); - assert!(b.x0 >= 0.0 && b.y0 >= 0.0 && b.x1 <= 1000.0 && b.y1 <= 600.0, "{anchor:?} {relative_to:?}: {b:?}"); - assert_eq!((b.width(), b.height()), (100.0, 40.0)); - let c = if relative_to == RelativeTo::Slide { l.slide } else { l.visible }; - let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0); - let id = anchor_id(anchor); - let want_x = if id.ends_with("eft") { - c.x0 + 50.0 - } else if id.ends_with("ight") { - c.x1 - 50.0 - } else { - (c.x0 + c.x1) / 2.0 - }; - let want_y = if id.starts_with("top") { - c.y0 + 20.0 - } else if id.starts_with("bottom") { - c.y1 - 20.0 - } else { - (c.y0 + c.y1) / 2.0 - }; - assert!((cx - want_x).abs() < 1e-9 && (cy - want_y).abs() < 1e-9, "{id} {relative_to:?}: {b:?}"); - // an inward offset moves it by that share of the shorter side - let (sx, sy) = (if id.ends_with("ight") { -0.02 } else { 0.02 }, if id.starts_with("bottom") { -0.03 } else { 0.03 }); - let m = at(sx, sy); - assert!((m.x0 - b.x0 - sx * short).abs() < 1e-9 && (m.y0 - b.y0 - sy * short).abs() < 1e-9, "{id}: {m:?}"); - } - } - // pushed off the slide: kept on it - let b = Placement { anchor: Anchor::BottomRight, offset_x: 0.5, offset_y: 0.5, relative_to: RelativeTo::Slide }.place(&l, 100.0, 40.0); - assert_eq!((b.x1, b.y1), (1000.0, 600.0)); - } - - fn blank(w: usize, h: usize) -> Rgba8 { - Image::filled(w, h, [0, 0, 0, 255]) - } - - fn lit(img: &Rgba8, r: &Rect, min: u8) -> usize { - let (x0, y0, x1, y1) = pixels(r, img.width, img.height); - (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[1] >= min).count() - } - - #[test] - fn rating_stars_count_matches_the_rating() { - let l = layout(800, 500); - let s = OverlaySettings::default().merged(&json!({"ratingStars": {"enabled": true, "color": "#00ff00", "size": 0.05}})).unwrap(); - let mut p = photo(); - let mut widths = Vec::new(); - for rating in 0..=5u8 { - p.rating = rating; - let o = SlideOverlays::resolve(&s, Some(&p)); - let boxes = o.boxes(&l); - let mut img = blank(800, 500); - o.draw_front(&mut img, &l); - let Some((_, b)) = boxes.iter().find(|(k, _)| *k == Kind::RatingStars) else { - assert_eq!(rating, 0, "no stars only for an unrated photo"); - assert_eq!(lit(&img, &l.slide, 1), 0); - continue; - }; - widths.push(b.width()); - // count the stars: separate runs of lit columns along the box's middle row - let y = ((b.y0 + b.y1) / 2.0) as usize; - let mut runs = 0; - let mut was = false; - for x in 0..800 { - let on = img.get(x, y)[1] > 128; - runs += usize::from(on && !was); - was = on; - } - assert_eq!(runs, usize::from(rating), "{rating} stars"); - } - assert!(widths.windows(2).all(|w| w[1] > w[0])); - } - - #[test] - fn text_with_a_shadow_draws_the_shadow_at_its_angle() { - let l = layout(800, 500); - let s = OverlaySettings::default() - .merged( - &json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0, - "shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]}), - ) - .unwrap(); - let o = SlideOverlays::resolve(&s, Some(&photo())); - let b = o.boxes(&l)[0].1; - let mut grey = Image::filled(800, 500, [128, 128, 128, 255]); - o.draw_front(&mut grey, &l); - // straight down by 10 px: dark pixels below the text box's glyphs, none above - let dark = |r: Rect| { - let (x0, y0, x1, y1) = pixels(&r, 800, 500); - (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| grey.get(x, y)[0] < 40).count() - }; - assert!(dark(Rect { y0: b.y1 - 2.0, y1: b.y1 + 10.0, ..b }) > 20, "the shadow falls below"); - assert_eq!(dark(Rect { y0: b.y0 - 12.0, y1: b.y0, ..b }), 0, "none above"); - // without the shadow: no dark pixels at all - let off = s.merged(&json!({"texts": [{"shadow": false}]})).unwrap(); - let mut grey2 = Image::filled(800, 500, [128, 128, 128, 255]); - SlideOverlays::resolve(&off, Some(&photo())).draw_front(&mut grey2, &l); - assert!(grey2.data.iter().all(|p| p[0] >= 128)); - } - - #[test] - fn identity_plate_image_draws_at_its_opacity_and_a_missing_one_is_reported() { - let dir = std::env::temp_dir().join(format!("lc-plate-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("plate.png"); - let red = Image::filled(40, 20, [255u8, 0, 0, 255]); - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&red), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(&path, png).unwrap(); - let l = layout(800, 500); - let s = OverlaySettings::default() - .merged(&json!({"identityPlate": {"enabled": true, "image": path.to_string_lossy(), "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}})) - .unwrap(); - assert!(s.warnings().is_empty()); - let o = SlideOverlays::resolve(&s, None); - let b = o.boxes(&l)[0].1; - assert_eq!((b.x0, b.y0, b.width(), b.height()), (0.0, 0.0, 100.0, 50.0), "50 px high (10 % of 500), its aspect kept"); - let mut img = blank(800, 500); - o.draw_front(&mut img, &l); - let p = img.get(50, 25); - assert!((p[0] as i32 - 128).abs() <= 2 && p[1] == 0, "half-opaque red over black: {p:?}"); - assert_eq!(img.get(150, 25)[0], 0, "only inside its box"); - // behind the image: the photo covers it - let behind = s.merged(&json!({"identityPlate": {"behind": true, "anchor": "center"}})).unwrap(); - let photo_img = Image::filled(30, 20, [0u8, 0, 255, 255]); - let ob = SlideOverlays::resolve(&behind, None); - let (img, l2) = - lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| { - ob.draw_behind(i, l) - }); - let _ = l2; - assert_eq!(img.get(400, 250), [0, 0, 255, 255], "the photo is on top"); - // a missing file: reported, nothing drawn, no panic - let gone = s.merged(&json!({"identityPlate": {"image": dir.join("nope.png").to_string_lossy()}})).unwrap(); - assert_eq!(gone.warnings().len(), 1); - let mut img = blank(800, 500); - SlideOverlays::resolve(&gone, None).draw_front(&mut img, &l); - assert!(img.data.iter().all(|p| p[0] == 0)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn overlays_are_resolution_independent() { - let s = OverlaySettings::default() - .merged(&json!({ - "identityPlate": {"enabled": true, "text": "Studio", "size": 0.06}, - "ratingStars": {"enabled": true, "size": 0.04}, - "texts": [{"text": "{caption}", "size": 0.04, "anchor": "bottomLeft", "offsetX": 0.02, "offsetY": -0.02, "relativeTo": "photo"}], - })) - .unwrap(); - let o = SlideOverlays::resolve(&s, Some(&photo())); - let (a, b) = (o.boxes(&layout(800, 500)), o.boxes(&layout(1600, 1000))); - assert_eq!(a.len(), 3); - for ((ka, ra), (kb, rb)) in a.iter().zip(&b) { - assert_eq!(ka, kb); - for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { - assert!((va * 2.0 - vb).abs() <= 4.0, "{ka:?}: {ra:?} vs {rb:?}"); - } - } - } - - #[test] - fn watermark_takes_the_export_insets_and_rotation() { - let s = OverlaySettings::default().merged(&json!({"watermark": {"inset": 0.1, "insetY": 0.2, "rotation": 90}})).unwrap(); - assert_eq!((s.watermark.inset_x, s.watermark.inset_y, s.watermark.rotation), (0.1, 0.2, 90)); - let s = s.merged(&json!({"watermark": {"insetX": 0.05}})).unwrap(); - assert_eq!((s.watermark.inset_x, s.watermark.inset_y), (0.05, 0.2)); - assert!(OverlaySettings::default().merged(&json!({"watermark": {"rotation": 45}})).is_err()); - } - - #[test] - fn hostile_settings_are_refused_or_clamped_and_never_panic() { - let d = OverlaySettings::default(); - for bad in [ - json!({"identityPlate": {"size": "big"}}), - json!({"identityPlate": {"anchor": "middle"}}), - json!({"identityPlate": 3}), - json!({"ratingStars": {"color": "#zzzzzz"}}), - json!({"texts": {"text": "a"}}), - json!({"texts": [{"relativeTo": "page"}]}), - json!({"texts": [{"sparkle": 1}]}), - json!({"texts": vec![json!({}); MAX_TEXTS + 1]}), - json!({"removeText": 0}), - json!({"removeText": -1}), - json!({"watermark": {"size": 9}}), - json!({"watermark": 5}), - json!({"showWatermark": "yes"}), - json!({"glitter": true}), - json!([1]), - ] { - assert!(d.merged(&bad).is_err(), "{bad}"); - } - let s = d - .merged(&json!({"identityPlate": {"size": 1e300, "opacity": -3, "offsetX": -1e9}, "ratingStars": {"size": 0}, "addText": {"size": -1, "shadowAngle": 900, "shadowRadius": 7}})) - .unwrap(); - assert_eq!((s.identity_plate.size, s.identity_plate.opacity, s.identity_plate.place.offset_x), (PLATE_SIZE_MAX, 0.0, -OFFSET_MAX)); - assert_eq!(s.rating_stars.size, SIZE_MIN); - assert_eq!((s.texts[0].size, s.texts[0].shadow.angle, s.texts[0].shadow.radius), (SIZE_MIN, 180.0, SHADOW_RADIUS_MAX)); - assert!(d.merged(&json!({"removeText": 0})).is_err()); - assert!(s.merged(&json!({"removeText": 0})).unwrap().texts.is_empty()); - // round trips (command JSON and prefs.json) - assert_eq!(d.merged(&s.to_json()).unwrap(), s); - let back: OverlaySettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); - assert_eq!(back, s); - // NaN / ∞ from a hand-edited prefs file - let mut wild = s.clone(); - wild.identity_plate.size = f64::NAN; - wild.texts[0].place.offset_y = f64::INFINITY; - wild.watermark.size = f32::NAN; - let w = wild.sanitized(); - assert_eq!( - (w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size), - (IdentityPlate::default().size, 0.0, Watermark::default().size) - ); - // drawing hostile text at every size: empty, huge, CJK, RTL, control characters - let huge = "W".repeat(50_000); - for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"] - { - let s = d - .merged(&json!({"identityPlate": {"enabled": true, "text": text, "size": PLATE_SIZE_MAX}, "ratingStars": {"enabled": true}, "addText": {"text": text, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX}, "showWatermark": true, "watermark": text})) - .unwrap(); - assert!(s.identity_plate.text.chars().count() <= TEXT_MAX_CHARS); - let mut bare = photo(); - bare.meta = Default::default(); - for (w, h) in [(1, 1), (17, 9), (320, 200)] { - let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), f64::NAN, w, h); - let o = SlideOverlays::resolve(&s, Some(&bare)); - let mut img = blank(l.slide.width() as usize, l.slide.height() as usize); - o.draw_behind(&mut img, &l); - o.draw_front(&mut img, &l); - for (_, b) in o.boxes(&l) { - assert!(b.x0.is_finite() && b.y1.is_finite()); - } - } - } - } - - /// Huge text and stars on a big slide cost what shows: glyphs off the slide aren't rasterised. - #[test] - fn huge_overlays_on_a_big_slide_stay_cheap() { - let long = "Wide ".repeat(400); - let s = OverlaySettings::default() - .merged(&json!({ - "ratingStars": {"enabled": true, "size": STAR_SIZE_MAX}, - "addText": {"text": long, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}, - })) - .unwrap(); - let mut p = photo(); - p.rating = 5; - let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), 1.5, 4000, 2500); - let mut img = blank(4000, 2500); - let t = std::time::Instant::now(); - SlideOverlays::resolve(&s, Some(&p)).draw_front(&mut img, &l); - assert!(t.elapsed().as_secs_f64() < 20.0, "{:?}", t.elapsed()); - assert!(img.get(100, 300)[0] > 0, "the text's start shows"); - } - - /// CJK text draws with the craft-fonts faces (skips without them). - #[test] - fn cjk_text_uses_the_craft_fonts() { - if crate::fonts::cjk(crate::fonts::CRAFT_FONTS).next().is_none() { - eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)"); - return; - } - let cjk = crate::text::TextLayout::new("日本語", 40.0, false, crate::fonts::CRAFT_FONTS).unwrap(); - let tofu = crate::text::TextLayout::new("日本語", 40.0, false, &[]).unwrap(); - let count = |t: &crate::text::TextLayout| { - let mut n = 0usize; - t.draw(0.0, 0.0, |_, _, c| n += usize::from(c > 0.5)); - n - }; - assert_ne!(count(&cjk), count(&tofu), "real glyphs, not missing-glyph boxes"); - } -} +mod tests; diff --git a/crates/engine/src/slide_overlay/tests.rs b/crates/engine/src/slide_overlay/tests.rs new file mode 100644 index 000000000..d2884c745 --- /dev/null +++ b/crates/engine/src/slide_overlay/tests.rs @@ -0,0 +1,314 @@ +use super::*; + +fn layout(w: usize, h: usize) -> Layout { + lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default().merged(&json!({"margins": 0.1})).unwrap(), 1.5, w, h) +} + +fn photo() -> Photo { + let mut p = crate::rename::sample_photo(); + p.rating = 3; + p.meta.caption = "Morning fog".into(); + p.meta.shutter = "1/250".into(); + p.meta.aperture = Some(8.0); + p.meta.focal_mm = Some(50.0); + p +} + +#[test] +fn tokens_expand_like_the_rename_tokens_plus_slide_ones() { + let p = photo(); + assert_eq!(expand("{caption}", &p), "Morning fog"); + assert_eq!(expand("{exposure}", &p), "1/250 s at f/8"); + assert_eq!(expand("{focal} · ISO {iso}", &p), format!("50 mm · ISO {}", p.meta.iso.unwrap_or_default())); + assert_eq!(expand("{file}", &p), p.file_name); + assert_eq!(expand("{name}", &p), crate::rename::expand_tokens("{name}", &p, 1, 1), "rename tokens work"); + assert_eq!(expand("{date}", &p), crate::rename::expand_tokens("{date:%Y-%m-%d}", &p, 1, 1)); + assert_eq!(expand("{rating}", &p), "3"); + // unknown and slide-less tags show as typed, and are reported + assert_eq!(expand("{sparkle} {seq} {", &p), "{sparkle} {seq} {"); + assert_eq!(unknown_tokens("{title} {sparkle} {seq} {é}"), vec!["{sparkle}", "{seq}", "{é}"]); + // a photo without metadata: empty values, no panic + let mut bare = p.clone(); + bare.meta = Default::default(); + assert_eq!(expand("[{exposure}|{camera}|{caption}|{focal}]", &bare), "[|||]"); +} + +#[test] +fn nine_anchors_place_inside_the_slide_with_their_offsets() { + let l = layout(1000, 600); + let short = 600.0; + for anchor in ANCHORS { + for relative_to in [RelativeTo::Slide, RelativeTo::Photo] { + let at = |ox: f64, oy: f64| Placement { anchor, offset_x: ox, offset_y: oy, relative_to }.place(&l, 100.0, 40.0); + let b = at(0.0, 0.0); + assert!(b.x0 >= 0.0 && b.y0 >= 0.0 && b.x1 <= 1000.0 && b.y1 <= 600.0, "{anchor:?} {relative_to:?}: {b:?}"); + assert_eq!((b.width(), b.height()), (100.0, 40.0)); + let c = if relative_to == RelativeTo::Slide { l.slide } else { l.visible }; + let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0); + let id = anchor_id(anchor); + let want_x = if id.ends_with("eft") { + c.x0 + 50.0 + } else if id.ends_with("ight") { + c.x1 - 50.0 + } else { + (c.x0 + c.x1) / 2.0 + }; + let want_y = if id.starts_with("top") { + c.y0 + 20.0 + } else if id.starts_with("bottom") { + c.y1 - 20.0 + } else { + (c.y0 + c.y1) / 2.0 + }; + assert!((cx - want_x).abs() < 1e-9 && (cy - want_y).abs() < 1e-9, "{id} {relative_to:?}: {b:?}"); + // an inward offset moves it by that share of the shorter side + let (sx, sy) = (if id.ends_with("ight") { -0.02 } else { 0.02 }, if id.starts_with("bottom") { -0.03 } else { 0.03 }); + let m = at(sx, sy); + assert!((m.x0 - b.x0 - sx * short).abs() < 1e-9 && (m.y0 - b.y0 - sy * short).abs() < 1e-9, "{id}: {m:?}"); + } + } + // pushed off the slide: kept on it + let b = Placement { anchor: Anchor::BottomRight, offset_x: 0.5, offset_y: 0.5, relative_to: RelativeTo::Slide }.place(&l, 100.0, 40.0); + assert_eq!((b.x1, b.y1), (1000.0, 600.0)); +} + +fn blank(w: usize, h: usize) -> Rgba8 { + Image::filled(w, h, [0, 0, 0, 255]) +} + +fn lit(img: &Rgba8, r: &Rect, min: u8) -> usize { + let (x0, y0, x1, y1) = pixels(r, img.width, img.height); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[1] >= min).count() +} + +#[test] +fn rating_stars_count_matches_the_rating() { + let l = layout(800, 500); + let s = OverlaySettings::default().merged(&json!({"ratingStars": {"enabled": true, "color": "#00ff00", "size": 0.05}})).unwrap(); + let mut p = photo(); + let mut widths = Vec::new(); + for rating in 0..=5u8 { + p.rating = rating; + let o = SlideOverlays::resolve(&s, Some(&p)); + let boxes = o.boxes(&l); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let Some((_, b)) = boxes.iter().find(|(k, _)| *k == Kind::RatingStars) else { + assert_eq!(rating, 0, "no stars only for an unrated photo"); + assert_eq!(lit(&img, &l.slide, 1), 0); + continue; + }; + widths.push(b.width()); + // count the stars: separate runs of lit columns along the box's middle row + let y = ((b.y0 + b.y1) / 2.0) as usize; + let mut runs = 0; + let mut was = false; + for x in 0..800 { + let on = img.get(x, y)[1] > 128; + runs += usize::from(on && !was); + was = on; + } + assert_eq!(runs, usize::from(rating), "{rating} stars"); + } + assert!(widths.windows(2).all(|w| w[1] > w[0])); +} + +#[test] +fn text_with_a_shadow_draws_the_shadow_at_its_angle() { + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged( + &json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0, + "shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]}), + ) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let b = o.boxes(&l)[0].1; + let mut grey = Image::filled(800, 500, [128, 128, 128, 255]); + o.draw_front(&mut grey, &l); + // straight down by 10 px: dark pixels below the text box's glyphs, none above + let dark = |r: Rect| { + let (x0, y0, x1, y1) = pixels(&r, 800, 500); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| grey.get(x, y)[0] < 40).count() + }; + assert!(dark(Rect { y0: b.y1 - 2.0, y1: b.y1 + 10.0, ..b }) > 20, "the shadow falls below"); + assert_eq!(dark(Rect { y0: b.y0 - 12.0, y1: b.y0, ..b }), 0, "none above"); + // without the shadow: no dark pixels at all + let off = s.merged(&json!({"texts": [{"shadow": false}]})).unwrap(); + let mut grey2 = Image::filled(800, 500, [128, 128, 128, 255]); + SlideOverlays::resolve(&off, Some(&photo())).draw_front(&mut grey2, &l); + assert!(grey2.data.iter().all(|p| p[0] >= 128)); +} + +#[test] +fn identity_plate_image_draws_at_its_opacity_and_a_missing_one_is_reported() { + let dir = std::env::temp_dir().join(format!("lc-plate-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("plate.png"); + let red = Image::filled(40, 20, [255u8, 0, 0, 255]); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&red), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(&path, png).unwrap(); + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged(&json!({"identityPlate": {"enabled": true, "image": path.to_string_lossy(), "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}})) + .unwrap(); + assert!(s.warnings().is_empty()); + let o = SlideOverlays::resolve(&s, None); + let b = o.boxes(&l)[0].1; + assert_eq!((b.x0, b.y0, b.width(), b.height()), (0.0, 0.0, 100.0, 50.0), "50 px high (10 % of 500), its aspect kept"); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let p = img.get(50, 25); + assert!((p[0] as i32 - 128).abs() <= 2 && p[1] == 0, "half-opaque red over black: {p:?}"); + assert_eq!(img.get(150, 25)[0], 0, "only inside its box"); + // behind the image: the photo covers it + let behind = s.merged(&json!({"identityPlate": {"behind": true, "anchor": "center"}})).unwrap(); + let photo_img = Image::filled(30, 20, [0u8, 0, 255, 255]); + let ob = SlideOverlays::resolve(&behind, None); + let (img, l2) = + lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| { + ob.draw_behind(i, l) + }); + let _ = l2; + assert_eq!(img.get(400, 250), [0, 0, 255, 255], "the photo is on top"); + // a missing file: reported, nothing drawn, no panic + let gone = s.merged(&json!({"identityPlate": {"image": dir.join("nope.png").to_string_lossy()}})).unwrap(); + assert_eq!(gone.warnings().len(), 1); + let mut img = blank(800, 500); + SlideOverlays::resolve(&gone, None).draw_front(&mut img, &l); + assert!(img.data.iter().all(|p| p[0] == 0)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn overlays_are_resolution_independent() { + let s = OverlaySettings::default() + .merged(&json!({ + "identityPlate": {"enabled": true, "text": "Studio", "size": 0.06}, + "ratingStars": {"enabled": true, "size": 0.04}, + "texts": [{"text": "{caption}", "size": 0.04, "anchor": "bottomLeft", "offsetX": 0.02, "offsetY": -0.02, "relativeTo": "photo"}], + })) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let (a, b) = (o.boxes(&layout(800, 500)), o.boxes(&layout(1600, 1000))); + assert_eq!(a.len(), 3); + for ((ka, ra), (kb, rb)) in a.iter().zip(&b) { + assert_eq!(ka, kb); + for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { + assert!((va * 2.0 - vb).abs() <= 4.0, "{ka:?}: {ra:?} vs {rb:?}"); + } + } +} + +#[test] +fn watermark_takes_the_export_insets_and_rotation() { + let s = OverlaySettings::default().merged(&json!({"watermark": {"inset": 0.1, "insetY": 0.2, "rotation": 90}})).unwrap(); + assert_eq!((s.watermark.inset_x, s.watermark.inset_y, s.watermark.rotation), (0.1, 0.2, 90)); + let s = s.merged(&json!({"watermark": {"insetX": 0.05}})).unwrap(); + assert_eq!((s.watermark.inset_x, s.watermark.inset_y), (0.05, 0.2)); + assert!(OverlaySettings::default().merged(&json!({"watermark": {"rotation": 45}})).is_err()); +} + +#[test] +fn hostile_settings_are_refused_or_clamped_and_never_panic() { + let d = OverlaySettings::default(); + for bad in [ + json!({"identityPlate": {"size": "big"}}), + json!({"identityPlate": {"anchor": "middle"}}), + json!({"identityPlate": 3}), + json!({"ratingStars": {"color": "#zzzzzz"}}), + json!({"texts": {"text": "a"}}), + json!({"texts": [{"relativeTo": "page"}]}), + json!({"texts": [{"sparkle": 1}]}), + json!({"texts": vec![json!({}); MAX_TEXTS + 1]}), + json!({"removeText": 0}), + json!({"removeText": -1}), + json!({"watermark": {"size": 9}}), + json!({"watermark": 5}), + json!({"showWatermark": "yes"}), + json!({"glitter": true}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"identityPlate": {"size": 1e300, "opacity": -3, "offsetX": -1e9}, "ratingStars": {"size": 0}, "addText": {"size": -1, "shadowAngle": 900, "shadowRadius": 7}})) + .unwrap(); + assert_eq!((s.identity_plate.size, s.identity_plate.opacity, s.identity_plate.place.offset_x), (PLATE_SIZE_MAX, 0.0, -OFFSET_MAX)); + assert_eq!(s.rating_stars.size, SIZE_MIN); + assert_eq!((s.texts[0].size, s.texts[0].shadow.angle, s.texts[0].shadow.radius), (SIZE_MIN, 180.0, SHADOW_RADIUS_MAX)); + assert!(d.merged(&json!({"removeText": 0})).is_err()); + assert!(s.merged(&json!({"removeText": 0})).unwrap().texts.is_empty()); + // round trips (command JSON and prefs.json) + assert_eq!(d.merged(&s.to_json()).unwrap(), s); + let back: OverlaySettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + // NaN / ∞ from a hand-edited prefs file + let mut wild = s.clone(); + wild.identity_plate.size = f64::NAN; + wild.texts[0].place.offset_y = f64::INFINITY; + wild.watermark.size = f32::NAN; + let w = wild.sanitized(); + assert_eq!( + (w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size), + (IdentityPlate::default().size, 0.0, Watermark::default().size) + ); + // drawing hostile text at every size: empty, huge, CJK, RTL, control characters + let huge = "W".repeat(50_000); + for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"] + { + let s = d + .merged(&json!({"identityPlate": {"enabled": true, "text": text, "size": PLATE_SIZE_MAX}, "ratingStars": {"enabled": true}, "addText": {"text": text, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX}, "showWatermark": true, "watermark": text})) + .unwrap(); + assert!(s.identity_plate.text.chars().count() <= TEXT_MAX_CHARS); + let mut bare = photo(); + bare.meta = Default::default(); + for (w, h) in [(1, 1), (17, 9), (320, 200)] { + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), f64::NAN, w, h); + let o = SlideOverlays::resolve(&s, Some(&bare)); + let mut img = blank(l.slide.width() as usize, l.slide.height() as usize); + o.draw_behind(&mut img, &l); + o.draw_front(&mut img, &l); + for (_, b) in o.boxes(&l) { + assert!(b.x0.is_finite() && b.y1.is_finite()); + } + } + } +} + +/// Huge text and stars on a big slide cost what shows: glyphs off the slide aren't rasterised. +#[test] +fn huge_overlays_on_a_big_slide_stay_cheap() { + let long = "Wide ".repeat(400); + let s = OverlaySettings::default() + .merged(&json!({ + "ratingStars": {"enabled": true, "size": STAR_SIZE_MAX}, + "addText": {"text": long, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}, + })) + .unwrap(); + let mut p = photo(); + p.rating = 5; + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), 1.5, 4000, 2500); + let mut img = blank(4000, 2500); + let t = std::time::Instant::now(); + SlideOverlays::resolve(&s, Some(&p)).draw_front(&mut img, &l); + assert!(t.elapsed().as_secs_f64() < 20.0, "{:?}", t.elapsed()); + assert!(img.get(100, 300)[0] > 0, "the text's start shows"); +} + +/// CJK text draws with the craft-fonts faces (skips without them). +#[test] +fn cjk_text_uses_the_craft_fonts() { + if crate::fonts::cjk(crate::fonts::CRAFT_FONTS).next().is_none() { + eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)"); + return; + } + let cjk = crate::text::TextLayout::new("日本語", 40.0, false, crate::fonts::CRAFT_FONTS).unwrap(); + let tofu = crate::text::TextLayout::new("日本語", 40.0, false, &[]).unwrap(); + let count = |t: &crate::text::TextLayout| { + let mut n = 0usize; + t.draw(0.0, 0.0, |_, _, c| n += usize::from(c > 0.5)); + n + }; + assert_ne!(count(&cjk), count(&tofu), "real glyphs, not missing-glyph boxes"); +} From 102fa85a9ca9cc294d0652aaf5543493000449ec Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:55 +0000 Subject: [PATCH 044/191] refactor(lightcraft-engine): move files.rs tests to crates/engine/src/files/tests.rs --- crates/engine/src/files.rs | 1785 +----------------------------- crates/engine/src/files/tests.rs | 1782 +++++++++++++++++++++++++++++ 2 files changed, 1783 insertions(+), 1784 deletions(-) create mode 100644 crates/engine/src/files/tests.rs diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 82d4c57d6..d446d1801 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1149,1787 +1149,4 @@ impl crate::Session { } #[cfg(test)] -pub(crate) mod tests { - /// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the - /// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides - /// them; an exported `travel|Italy|Rome` comes back as itself. - #[test] - fn a_lens_without_a_name_is_shown_by_its_range() { - // a Nikon DSLR raw: no LensModel, the range from the maker note - let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() }; - assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6"); - let named = lightcraft_meta::Metadata { - lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()), - lens_spec: Some([24.0, 70.0, 4.0, 4.0]), - ..Default::default() - }; - assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S"); - assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, ""); - } - - #[test] - fn imported_keywords_keep_their_hierarchy() { - let m = lightcraft_meta::Metadata { - keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()], - hierarchical_keywords: vec!["travel|Italy|Rome".into()], - ..Default::default() - }; - assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); - } - - #[test] - fn psd_probe_reads_embedded_capture_time_gps_and_title() { - let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); - let metadata = lightcraft_meta::Metadata { - capture_time: Some(capture), - gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), - title: Some("PSD metadata".into()), - ..Default::default() - }; - let bytes = crate::tests_xmp::synthetic_psd_with_metadata(&metadata); - let probe = probe_bytes("metadata.psd", &bytes).unwrap(); - assert_eq!(probe.captured.as_deref(), Some("2023-05-14T14:40:39")); - assert_eq!(probe.meta.gps, Some((45.0, 7.0))); - assert_eq!(probe.meta.title, "PSD metadata"); - } - - use super::*; - use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; - - /// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a - /// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot - /// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the - /// format and camera string agrees. - #[test] - fn white_balance_scale_follows_the_files_matrices_then_the_cameras() { - use lightcraft_raw::{ColorData, RawFormat}; - let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; - let mul = Some([2.2f32, 1.0, 1.6]); - let d65 = lightcraft_color::D65; - // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe - let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); - assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some()); - let (t, tint) = a7m3.as_shot(d65); - assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); - assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); - // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) - let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); - assert!(bare.legacy_relative && bare.scale_white.is_none()); - assert_eq!(bare.as_shot(d65), { - let (t, tint) = xy_to_temp_tint(d65); - (t.round(), tint.round()) - }); - // a camera without measured matrices: relative, whatever the multipliers - let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); - assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none()); - assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); - assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); - // a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as - // 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65) - let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; - let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ - (2500.0, g(1336.0, 3672.0)), - (3200.0, g(1720.0, 2620.0)), - (4500.0, g(2256.0, 1880.0)), - (6000.0, g(2680.0, 1544.0)), - (8500.0, g(3136.0, 1300.0)), - ]); - let with = ColorData { kelvin_presets: presets, ..Default::default() }; - let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2")); - assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some()); - let (t, tint) = a99m2.as_shot(d65); - assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); - assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); - // a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a - // Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K) - let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([ - (5651.0, g(1418.0, 1357.0)), - (7505.0, g(1653.0, 1171.0)), - (6594.0, g(1533.0, 1259.0)), - (3174.0, g(963.0, 2016.0)), - (8529.0, g(1807.0, 1080.0)), - ]); - let with_canon = ColorData { kelvin_presets: canon, ..Default::default() }; - let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III")); - assert!(!g1x3.relative && g1x3.scale_white.is_some()); - let (t, tint) = g1x3.as_shot(d65); - assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); - assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III")); - let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); - assert!(bare.legacy_relative && bare.scale_white.is_none()); - // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone - let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1")); - assert!(other.relative && other.scale_white.is_none()); - let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); - assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); - // a file with matrices of its own (DNG): absolute from them, no matter whose camera - let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; - let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); - assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none()); - let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); - assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); - assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); - } - - /// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source - /// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative - /// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets - /// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7. - #[test] - fn a_merged_dng_shows_the_white_balance_its_source_shows() { - use lightcraft_raw::{ColorData, RawFormat}; - let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; - let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; - let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ - (2500.0, g(1336.0, 3672.0)), - (3200.0, g(1720.0, 2620.0)), - (4500.0, g(2256.0, 1880.0)), - (6000.0, g(2680.0, 1544.0)), - (8500.0, g(3136.0, 1300.0)), - ]); - let with_presets = ColorData { kelvin_presets: presets, ..Default::default() }; - let cases = [ - (RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")), - (RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")), - (RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")), - (RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")), - ]; - for (format, color, mul, meta) in cases { - let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul)); - let source = WbScaleOf::new(format, &color, Some(mul), &meta); - let t = lightcraft_raw::color::camera_transform_of(&color, own); - let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap(); - let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta); - let shown = source.as_shot(own); - assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model); - } - } - - /// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need. - fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec { - let head = 160; - let mut f = vec![0u8; head]; - f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW "); - f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes()); - f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes()); - f.extend_from_slice(jpeg); - f.extend(std::iter::repeat_n(0x33u8, tail)); - f - } - - /// A raw's preview is read from its header and its JPEG only. Every preview used to cost a - /// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten - /// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the - /// quick path produces must equal the whole-file path's, exactly. - #[test] - fn preview_loading_reads_only_the_header_and_the_jpeg() { - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let file = raf_with_embedded_jpeg(&jpeg, 200_000); - let dir = crate::tests_xmp::temp_dir("preview-window"); - let path = dir.join("dscf0001.raf"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - let max_edge = 512; - - let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview"); - assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded"); - assert!(read.header_path, "the header path is what answered"); - assert!(!read.planned, "a RAF pointer in the header needs no planner"); - assert_eq!(read.total, file.len() as u64); - assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}"); - assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len()); - - // The quick path and the whole-file path agree byte for byte. - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); - let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); - assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); - assert_eq!(quick.data, whole.data, "byte for byte"); - - // A file with no locatable preview declines (the caller then reads it whole), a missing file errors. - let plain = dir.join("plain.jpg"); - std::fs::write(&plain, &jpeg).unwrap(); - assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none()); - assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the - /// file's own orientation, and the EXIF colour space the container declares for its preview. - #[derive(Clone, Copy, Default)] - struct Fixture { - dng: bool, - sony: bool, - orientation: Option, - /// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB. - color_space: Option, - } - - /// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the - /// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of - /// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than - /// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach. - fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec { - tiff_with_preview_strips(strip, &[], tail, what) - } - - /// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its - /// 1616 px preview beside the full-size one. - fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut preview = IfdBuilder::new(); - if what.dng { - preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); - } - if what.sony { - preview.set(t::MAKE, Value::Ascii("SONY".into())); - } - if let Some(o) = what.orientation { - preview.set(t::ORIENTATION, Value::Short(vec![o])); - } - if let Some(space) = what.color_space { - let mut exif = IfdBuilder::new(); - exif.set(0xA001, Value::Short(vec![space])); - if space == 0xFFFF { - let mut interop = IfdBuilder::new(); - interop.set(0x0001, Value::Undefined(b"R03".to_vec())); - exif.set_child(t::INTEROP_IFD, interop); - } - preview.set_child(t::EXIF_IFD, exif); - } - preview.set(t::COMPRESSION, Value::Short(vec![7])); - preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); - preview.set(t::IMAGE_WIDTH, Value::Long(vec![48])); - preview.set(t::IMAGE_LENGTH, Value::Long(vec![32])); - preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] }); - for jpeg in smaller { - let mut sub = IfdBuilder::new(); - sub.set(t::COMPRESSION, Value::Short(vec![7])); - sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); - sub.set(t::IMAGE_WIDTH, Value::Long(vec![48])); - sub.set(t::IMAGE_LENGTH, Value::Long(vec![32])); - sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] }); - preview.add_sub_ifd(sub); - } - let mut raw = IfdBuilder::new(); - raw.set(t::COMPRESSION, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000])); - raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] }); - TiffWriter::default().write(&[preview, raw]).expect("the fixture writes") - } - - /// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview - /// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges - /// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels - /// must equal the whole-file path's, exactly; a file the planner cannot describe must decline - /// rather than guess, so the caller keeps reading it whole. - #[test] - fn preview_loading_plans_the_ranges_it_needs() { - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() }); - let dir = crate::tests_xmp::temp_dir("preview-plan"); - let path = dir.join("preview.dng"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - let max_edge = 512; - - let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview"); - assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded"); - assert!(read.planned, "the planner is what answered"); - assert!(read.header_path, "and it is still a bounded read"); - assert_eq!(read.total, file.len() as u64); - assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len()); - assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len()); - - // The plan path and the whole-file path agree byte for byte. - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); - let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); - assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); - assert_eq!(quick.data, whole.data, "byte for byte"); - - // The same structure without the tag that makes it a raw: the planner declines, and the - // caller's whole-file read is what answers (no guessing at what a file might hold). - let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default()); - std::fs::write(&path, ¬_a_raw).unwrap(); - assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that - /// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an - /// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one. - #[test] - fn preview_loading_plans_the_smallest_covering_jpeg() { - let jpeg = |w: u32, h: u32| { - let rgb: Vec = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect(); - encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap() - }; - let (big, small) = (jpeg(96, 64), jpeg(48, 32)); - assert!(big.len() > small.len(), "the full-size JPEG is the largest"); - let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() }); - let dir = crate::tests_xmp::temp_dir("preview-plan-covering"); - let path = dir.join("two-previews.dng"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - - for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] { - let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview"); - assert!(read.planned, "the planner is what answered"); - assert!(&window == want, "a {edge} px box takes the {which} JPEG"); - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview"); - let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too"); - assert_eq!( - (quick.width, quick.height, quick.data), - (whole.width, whole.height, whole.data), - "{edge} px: the plan and the whole file agree" - ); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// A file whose preview the quick path could not show the way the whole-file path does is left to - /// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is - /// bigger than that header) and the preview JPEG carries none of its own, so a window read would - /// come out unrotated where the whole-file path rotates it — the plan must decline instead of - /// handing on a wrong picture. - #[test] - fn preview_loading_declines_an_orientation_only_the_whole_file_shows() { - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - // the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts - // IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image - let mut strip = jpeg.clone(); - strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize)); - let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() }); - let dir = crate::tests_xmp::temp_dir("preview-plan-orientation"); - let path = dir.join("rotated.dng"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - - assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation"); - assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see"); - assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own"); - assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A container can declare what its preview JPEG is in, and the whole-file path applies that - /// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies - /// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and - /// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one - /// that declares sRGB (what most bodies write) keeps the fast path as before. - #[test] - fn preview_loading_applies_a_container_colour_space() { - use lightcraft_raw::PreviewColorSpace; - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let dir = crate::tests_xmp::temp_dir("preview-plan-space"); - let path = dir.join("space.arw"); - let path = path.to_string_lossy().into_owned(); - - let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() }); - assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB"); - std::fs::write(&path, &adobe).unwrap(); - let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too"); - assert!(read.planned && read.adobe_rgb, "{read:?}"); - assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}"); - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes"); - let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too"); - assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it"); - let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes"); - assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)"); - - let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() }); - assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB"); - std::fs::write(&path, &srgb).unwrap(); - let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes"); - assert!(read.planned && !read.adobe_rgb, "the plan still answers"); - assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG"); - } - - /// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as - /// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time. - #[test] - fn undated_files_take_the_file_date() { - let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let rgb = vec![128u8; 16 * 16 * 3]; - let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let path = dir.join("export.jpg"); - std::fs::write(&path, &jpeg).unwrap(); - let path = path.to_string_lossy().to_string(); - let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - let want = crate::import::civil(mtime as i64); - let (_, probe) = fs_hooks(); - let info = probe(&path).unwrap(); - assert_eq!(info.captured, None, "the file itself has no date"); - assert_eq!(info.file_modified.as_deref(), Some(want.as_str())); - let mut s = crate::Session::new().with_fs(); - let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap(); - let id = lightcraft_catalog::PhotoId(r.imported[0]); - assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str())); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: - /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated - /// file or one that isn't an image. - #[test] - fn image_probe_reads_headers_like_the_decode_did() { - let (w, h) = (300u32, 200u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - for o in [None, Some(1), Some(6), Some(3), Some(8)] { - let exif = o.map(lightcraft_codecs::exif::minimal_exif); - let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() }; - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap(); - let png = lightcraft_codecs::encode_png(&img, &meta).unwrap(); - for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] { - let p = probe_bytes(name, bytes).unwrap(); - // what the probe computed from a decode before - let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap(); - let swap = Orientation::from_exif(d.orientation).swaps_axes(); - let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) }; - assert_eq!((p.width, p.height), want, "{name} {o:?}"); - assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}"); - assert_eq!(p.kind, MediaKind::Image); - assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" }); - assert_eq!(p.file_size, bytes.len() as u64); - assert!(p.content_hash.is_some()); - assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted"); - } - } - assert!(probe_bytes("x.jpg", b"not an image").is_err()); - } - - /// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets - /// start from), not on the black its transparent pixels store. - #[test] - fn transparent_png_develops_on_white() { - let rgba = [200, 40, 40, 255, 0, 0, 0, 0]; - let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap(); - let (img, _) = load_bytes(&png, 64).unwrap(); - assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0)); - assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0)); - } - - /// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample - /// of a block on a grey surface must not become green speckles (the block's green used to take the - /// clip level, which highlight reconstruction then kept). - #[test] - fn coarse_binning_keeps_specular_points_neutral() { - let (w, h) = (256usize, 192usize); - // grey after the file's white balance (as-shot neutral 0.5 : 1 : 0.7), brighter to the right - let neutral = [0.5f32, 1.0, 0.7]; - let cfa = lightcraft_raw::Cfa::bayer("RGGB").unwrap(); - let mut data: Vec = (0..w * h) - .map(|i| { - let (x, y) = (i % w, i / w); - let v = (0.3 + 0.2 * x as f32 / w as f32) * neutral[cfa.color_at(x, y).min(2) as usize]; - (256.0 + v * (16000.0 - 256.0)).round() as u16 - }) - .collect(); - // one clipped green sample (RGGB: even row, odd column) in every other 8 × 8 block - for by in 0..h / 8 { - for bx in (by % 2..w / 8).step_by(2) { - data[(by * 8 + 4) * w + bx * 8 + 5] = 16000; - } - } - let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); - let raw = lightcraft_raw::decode(&bytes).unwrap(); - assert_eq!(bin_factor(&raw, 32), Some(8)); - let (img, _) = load_bytes(&bytes, 32).unwrap(); - assert_eq!((img.width, img.height), (32, 24)); - let worst = img.data.iter().map(|p| p[1] / ((p[0] + p[2]) / 2.0).max(1e-6)).fold(0.0f32, f32::max); - assert!(worst < 1.12, "green speckles: green {worst:.2}× the other channels"); - } - - #[test] - fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() { - use lightcraft_denoise::product; - use lightcraft_raw::{DngWriteOptions, Method, Orientation, profile::HsvTable, write_dng}; - let dir = crate::tests_xmp::temp_dir("denoise-color-finish"); - let fixture = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&fixture).unwrap(); - raw.orientation = Orientation::Rotate90; - raw.color.baseline_exposure = 0.5; - raw.color.profile.look_table = - Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[23.0, 0.65, 0.8]; 2], srgb_value: false }); - let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap(); - let decoded = lightcraft_raw::decode(&bytes).unwrap(); - assert!(decoded.color.profile.look_table.is_some()); - // A cache containing the ordinary demosaic must undergo the identical downstream finish. - let camera_rgb = decoded.develop(Method::Ahd).unwrap(); - let key = "synthetic-color-finish"; - let path = dir.join("same.lcdn"); - product::write(&path, &camera_rgb, key).unwrap(); - let spec = DenoiseSpec { product: path, key: key.into(), make: None }; - let (ordinary, _) = load_bytes(&bytes, 2048).unwrap(); - let (plain, cached, _) = load_vec_pair(bytes, 2048, &spec).unwrap(); - assert_eq!(plain.data, ordinary.data, "requesting a twin must not change the ordinary path"); - let cached = cached.unwrap(); - assert_eq!((plain.width, plain.height), (24, 32)); - assert_eq!((cached.width, cached.height), (plain.width, plain.height)); - let worst = plain.data.iter().zip(&cached.data).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); - assert!(worst < 0.002, "color finish differs beyond cache half-float rounding: {worst}"); - let _ = std::fs::remove_dir_all(dir); - } - - /// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG - /// mostly cancel, edges remain. - fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec { - let l: Vec = (0..w * h).map(|i| lum(i % w, i / w)).collect(); - let mut out = vec![0.0; w * h]; - for y in 0..h { - for x in 0..w { - let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h)); - let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::(); - out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32; - } - } - out - } - - /// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its - /// normalised cross-correlation, or `None` when the patch has too little detail to tell. - fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> { - let pa: Vec = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect(); - let na = pa.iter().map(|v| v * v).sum::().sqrt(); - if na < 1e-3 * p as f32 { - return None; - } - let mut best = (f32::MIN, (0, 0)); - for dy in -r..=r { - for dx in -r..=r { - let (bx, by) = (x as i64 + dx, y as i64 + dy); - let (mut dot, mut nb) = (0.0f32, 0.0f32); - for i in 0..p { - for j in 0..p { - let v = b[(by as usize + i) * w + bx as usize + j]; - dot += pa[i * p + j] * v; - nb += v * v; - } - } - let c = dot / (na * nb.sqrt()).max(1e-12); - if c > best.0 { - best = (c, (dx, dy)); - } - } - } - Some((best.1, best.0)) - } - - #[test] - fn corpus_a7cr_warp_keeps_coordinates_after_orientation_and_resizing() { - use lightcraft_geom::{Orientation, Point}; - use lightcraft_pipeline::optics::Warp; - let root = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); - for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw"] { - let Ok(mut bytes) = std::fs::read(root.join("raw").join(name)) else { continue }; - let off = lightcraft_raw::probe_info(&bytes).unwrap(); - assert!(embedded_lens(&off).is_none()); - let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); - let at = tiff.find(0x7036).unwrap().offset as usize; - let mut enabled = Vec::new(); - tiff.order.put_u16(&mut enabled, 17); - bytes[at..at + 2].copy_from_slice(&enabled); - let mut raw = lightcraft_raw::probe_info(&bytes).unwrap(); - raw.orientation = Orientation::Normal; - let mut settings = lightcraft_develop::DevelopSettings::default(); - settings.optics.lens_profile = true; - let base_lens = embedded_lens(&raw).unwrap(); - let base = Warp::from_settings(9504.0, 6336.0, &settings, Some(&base_lens)); - for orientation in 1..=8 { - let o = Orientation::from_exif(orientation); - raw.orientation = o; - let lens = embedded_lens(&raw).unwrap(); - for edge in [1200.0, 2560.0, 9504.0] { - let scale = edge / 9504.0; - let (w, h) = if o.swaps_axes() { (6336.0 * scale, edge) } else { (edge, 6336.0 * scale) }; - let oriented = Warp::from_settings(w, h, &settings, Some(&lens)); - for (x, y) in [(0.05, 0.1), (0.5, 0.5), (0.9, 0.8)] { - let point = Point::new(x * 9504.0, y * 6336.0); - let source = base.corrected_to_source(point, 1); - let (px, py) = o.map(point.x, point.y, 9504.0, 6336.0); - let (sx, sy) = o.map(source.x, source.y, 9504.0, 6336.0); - let got = oriented.corrected_to_source(Point::new(px * scale, py * scale), 1); - assert!((got.x - sx * scale).hypot(got.y - sy * scale) < 1e-8, "{name} orientation {orientation} edge {edge}"); - } - } - } - } - } - - /// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the - /// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile - /// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide - /// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction. - #[test] - fn corpus_rw2_distortion_matches_the_camera_jpeg() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { - lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); - return; - }; - assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); - assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None); - - // (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all) - let files = [ - ("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners - ("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 % - ("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 % - ("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 % - ("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction - ("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 % - ]; - const EDGE: usize = 640; - const P: usize = 64; - const R: i64 = 12; - for (name, off_at_least) in files { - let bytes = std::fs::read(dir.join(name)).unwrap(); - let probed = probe_bytes(name, &bytes).unwrap(); - assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read"); - let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap(); - assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree"); - // Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear` - let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap(); - assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG"); - let mut s = lightcraft_develop::DevelopSettings::default(); - let render = |s: &lightcraft_develop::DevelopSettings| { - lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image - }; - let before = render(&s); - s.optics.lens_profile = true; - let after = render(&s); - let (w, h) = (after.width, after.height); - assert_eq!((before.width, before.height), (w, h)); - // the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre - let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap(); - let (cw, ch) = (cam.width as f64, cam.height as f64); - let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) }; - let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize); - let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell); - let lin = |v: u8| (v as f32 / 255.0).powf(2.2); - let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| { - let p = im.data[y * im.width + x]; - 0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2]) - }; - let reference = detail(w, h, |x, y| { - let p = cam.data[y * w + x]; - 0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2] - }); - let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y))); - let m = R as usize + 4; - let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)]; - let mut measured = 0; - for c in corners { - let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue }; - measured += 1; - assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}"); - if off_at_least > 0 - && let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R) - { - let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3; - assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}"); - } - } - assert!(measured >= 2, "{name}: only {measured} corners with detail"); - } - } - - /// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. - pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec { - let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); - let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); - b.extend_from_slice(&((24 + jpeg.len()) as u32).to_be_bytes()); - b.extend_from_slice(b"PRVW\0\0\0\0\0\x01"); - b.extend_from_slice(&(w as u16).to_be_bytes()); - b.extend_from_slice(&(h as u16).to_be_bytes()); - b.extend_from_slice(b"\0\x01"); - b.extend_from_slice(&(jpeg.len() as u32).to_be_bytes()); - b.extend_from_slice(&jpeg); - b - } - - /// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage: - /// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`). - fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec { - let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat(); - let mut craw = vec![0u8; 82]; - craw[24..26].copy_from_slice(&(w as u16).to_be_bytes()); - craw[26..28].copy_from_slice(&(h as u16).to_be_bytes()); - craw.extend(bx(b"CMP1", &[0xff; 4])); - let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1]; - stsd.extend(bx(b"CRAW", &craw)); - let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat(); - let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat(); - let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat(); - let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))); - let preview = cr3_with_preview(w, h); - [&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat() - } - - /// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG: - /// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review). - #[test] - fn corrupt_cr3_falls_back_to_embedded_preview() { - let b = cr3_with_corrupt_raw(48, 32); - let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt"); - assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}"); - let p = probe_bytes("x.cr3", &b).unwrap(); - assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); - assert!(p.preview_only.is_some_and(|why| why.contains("CR3"))); - let (img, src) = load_bytes(&b, 24).unwrap(); - assert_eq!((img.width, img.height), (24, 16)); - assert!(!src.raw); - // other formats still report a corrupt file as an error - let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - dng.truncate(dng.len() / 2); - assert!(load_bytes(&dng, 24).is_err()); - } - - /// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is - /// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it. - #[test] - fn dng_profile_look_is_applied() { - use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve}; - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - let sat = |img: &Rgb32f| { - img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::() - / img.data.len() as f32 - }; - let (before, info) = load_bytes(&plain, 64).unwrap(); - assert!(sat(&before) > 0.05, "the synthetic scene is coloured"); - assert!(info.camera_tone.is_none()); - // a map that removes all saturation, and a tone curve - let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false }; - raw.color.profile = ProfileLook { - hue_sat_map: [Some(grey), None], - look_table: None, - tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]), - gain_table_map: None, - }; - let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - let (after, info) = load_bytes(&with, 64).unwrap(); - assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after)); - // a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB) - let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::() / img.data.len() as f32; - assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before)); - let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone"); - assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18)); - // a look table alone changes the render too (applied after exposure) - raw.color.profile = ProfileLook { - look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }), - ..Default::default() - }; - let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); - assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); - } - - /// Corrections used while fitting a DNG's preview must not be baked into its editable source. - /// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative. - #[test] - fn dng_preview_corrections_leave_the_render_source_uncorrected() { - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); - let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; - let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - let (before, expected) = load_bytes(&before, 64).unwrap(); - let (after, info) = load_bytes(&corrected, 64).unwrap(); - assert_eq!(before.data, after.data, "lens correction belongs to the pipeline"); - assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some())); - assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved"); - } - - /// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit. - /// The source pixels stay uncorrected, including when the file is rotated. - #[test] - fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() { - use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb}; - use lightcraft_raw::{Opcode, Orientation}; - let (w, h) = (384usize, 256usize); - let data = (0..w * h) - .map(|i| { - let (x, y) = ((i % w) as f32, (i / w) as f32); - let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32; - let colour = match (i / w % 2, i % w % 2) { - (0, 0) => 1.2, - (1, 1) => 0.8, - _ => 1.0, - }; - 256 + (v * colour * 15744.0) as u16 - }) - .collect(); - let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - raw.color = lightcraft_raw::ColorData { - illuminant: [21, 0], - color_matrix: [Some(REC2020.from_xyz()), None], - as_shot_neutral: Some([1.0; 3]), - ..Default::default() - }; - let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - raw.opcodes.list3 = vec![ - Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, - Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }, - ]; - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); - let to_srgb = REC2020.to_space(&SRGB); - let pixels: Vec = corrected - .data - .iter() - .flat_map(|p| { - let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i])); - let y = luminance_2020(p).max(1e-6); - let gain = (1.0 - (-3.0 * y).exp()) / y; - to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.0)).round() as u8) - }) - .collect(); - let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); - let jpeg = lightcraft_codecs::encode_jpeg(&image, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); - for orientation in [Orientation::Normal, Orientation::Rotate90] { - raw.orientation = orientation; - let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - // Append a preview IFD to the little-endian DNG written above. - let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize; - let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize; - let next = root + 2 + 12 * count; - let start = file.len() as u32; - file[next..next + 4].copy_from_slice(&start.to_le_bytes()); - let jpeg_at = start + 2 + 4 * 12 + 4; - file.extend(4u16.to_le_bytes()); - for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] { - file.extend(tag.to_le_bytes()); - file.extend(4u16.to_le_bytes()); - file.extend(1u32.to_le_bytes()); - file.extend(value.to_le_bytes()); - } - file.extend(0u32.to_le_bytes()); - file.extend(&jpeg); - let (source, info) = load_bytes(&file, 96).unwrap(); - let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview"); - let target = 1.0 - (-3.0f32 * 0.18).exp(); - assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18)); - let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation); - assert_eq!(source.data, expected.data, "the editable source is not lens-corrected"); - } - } - - /// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's - /// own white-balanced neutral comes out yellow (blue 12 % low). - fn yellowing_look() -> lightcraft_color::Mat3 { - lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]) - } - - /// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the - /// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included. - #[test] - fn camera_look_in_lanes_matches_pixel_by_pixel() { - let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect(); - let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true }; - let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap(); - let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) }; - let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN]; - let img = Rgb32f::from_fn(37, 5, |x, y| { - let k = y * 37 + x; - if k % 5 == 0 { - [special[k % 10], special[k / 10 % 10], special[k / 100 % 10]] - } else { - [(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05] - } - }); - let mut lanes = img.clone(); - colour.apply(&mut lanes); - let m = colour.matrix.to_f32(); - for (p, got) in img.data.iter().zip(&lanes.data) { - assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}"); - } - } - - #[test] - fn clipped_white_renders_neutral_through_the_colour_model() { - use lightcraft_color::{LUMA_2020, Mat3}; - let look = yellowing_look(); - let white = clipped_white(&look); - let out = look.apply(white.map(f64::from)); - assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}"); - let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::(); - assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral"); - assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]); - // a model that can't be inverted, or only just, keeps the camera's neutral - assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - // so does one whose white would overflow the rebuilt highlights (2.5e38 in red here) - assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - // or move the camera's neutral implausibly far - assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - // what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite - for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] { - let w = clipped_white(&model); - let m = model.to_f32(); - let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]); - assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}"); - let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None }; - let mut img = Rgb32f::filled(4, 4, [1.0; 3]); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0)); - } - } - - /// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that - /// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral; - /// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it. - #[test] - fn clipped_highlights_stay_neutral_when_darkened() { - use lightcraft_pipeline::{RenderRequest, render}; - let wb = [2.4f32, 1.0, 1.6]; - let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None }; - // a grey wall (camera neutral) with a fully clipped light and a light clipped in green only - let mut img = Rgb32f::from_fn(96, 64, |x, y| { - let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); - if d(28.0) < 14.0 { - [1.0; 3] - } else if d(70.0) < 12.0 { - [0.6 / wb[0], 1.0, 0.7 / wb[2]] - } else { - [0.3 / wb[0], 0.3, 0.3 / wb[2]] - } - }); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - let info = SourceInfo { raw: true, ..Default::default() }; - let mut issue = lightcraft_develop::DevelopSettings::default(); - let l = &mut issue.light; - (l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0); - let mut h100 = lightcraft_develop::DevelopSettings::default(); - h100.light.highlights = -100.0; - for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] { - let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image; - let p = out.get(28, 32); - assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})"); - let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]); - assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral"); - } - } - - /// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`) - /// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise - /// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last - /// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row. - fn darkened_ramp(rows: std::ops::Range, height: usize) -> ([[u8; 4]; 3], u8) { - use lightcraft_pipeline::{RenderRequest, render}; - let wb = [2.5f32, 1.0, 1.6]; - let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; - let w = 1600usize; - let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32; - let sensor = - Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); - let y = (rows.start + rows.end) / 2; - let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap(); - let mut img = sensor.clone(); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - let mut s = lightcraft_develop::DevelopSettings::default(); - s.light.exposure = -2.0; - let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image; - let step = |x: usize| { - let (a, b) = (out.get(x - 1, y), out.get(x, y)); - (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) - }; - ([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0)) - } - - /// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards - /// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels - /// there. - #[test] - fn rendered_colour_is_continuous_where_the_first_channel_clips() { - let ([a, b, c], worst) = darkened_ramp(0..16, 16); - let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); - assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}"); - assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels"); - } - - /// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there - /// is no reliable colour around it. It still carries on across the first clip, however thin the line and however - /// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163]; - /// on 512 rows, from [225, 126, 153] to [218, 145, 151]). - #[test] - fn rendered_thin_line_is_continuous_where_the_first_channel_clips() { - let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); - for height in [32usize, 128, 512, 1024] { - for width in [1usize, 2, 4, 16] { - let top = (height - width) / 2; - let ([a, b, c], worst) = darkened_ramp(top..top + width, height); - let case = format!("{width} rows of {height}"); - assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}"); - assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); - } - } - } - - /// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1, - /// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale - /// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows - /// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the - /// rendered colour on either side of where red clips on the band's middle row, and the largest step between - /// neighbouring pixels along that row. - fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) { - use lightcraft_pipeline::{RenderRequest, render}; - let wb = [2.5f32, 1.0, 1.6]; - let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; - let (w, h) = (320usize, top + strip + band); - let sensor = Rgb32f::from_fn(w, h, |x, y| { - if y < top { - [0.8, 0.35, 0.3] - } else if y < top + strip { - [0.4, 0.95, 0.8] - } else { - [0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8] - } - }); - let y = top + strip + band / 2; - let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap(); - let mut img = sensor.clone(); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - let mut s = lightcraft_develop::DevelopSettings::default(); - s.light.exposure = -2.0; - let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image; - let step = |x: usize| { - let (a, b) = (out.get(x - 1, y), out.get(x, y)); - (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) - }; - ([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0)) - } - - /// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The - /// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly - /// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then - /// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped - /// (without the coloured surface: [198, 195, 178] then [205, 181, 168]). - #[test] - fn rendered_colour_is_continuous_where_a_second_channel_clips() { - for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] { - let ([a, b], worst) = second_clip(top, strip, band); - let case = format!("{top} rows of colour, {strip} of rim, {band} of band"); - let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0); - assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips"); - assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); - } - } - - /// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in - /// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo - /// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them). - #[test] - fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { - lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); - return; - }; - let (img, _) = load_bytes(&bytes, 1600).unwrap(); - let (w, h) = (img.width, img.height); - let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p); - let chroma = |p: [f32; 3]| { - let s = (p[0] + p[1] + p[2]).max(1e-6); - [p[0] / s, p[2] / s] - }; - // the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame - let px = |f: f32, n: usize| (f * n as f32) as usize; - let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]); - let mut rows = 0; - for y in px(0.24, h)..px(0.34, h) { - let row = &img.data[y * w..(y + 1) * w]; - let sky = lum(row[px(0.2, w)]); - let dark: Vec = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect(); - let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue }; - let k = w as f32 / 6000.0; - let mean = |a: usize, b: usize| { - let n = (b - a) as f64; - (a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n]) - }; - let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize); - for (side, (n, f)) in - [(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))] - .into_iter() - .enumerate() - { - for c in 0..2 { - near[side][c] += n[c]; - far[side][c] += f[c]; - } - } - rows += 1; - } - assert!(rows > 50, "the tree was not found ({rows} rows)"); - for side in 0..2 { - let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max); - assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]); - } - } - - /// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the - /// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The - /// copy's samples are much brighter than the image's here, so a picture says which one it came from. - #[test] - fn exports_never_develop_a_dngs_reduced_copy() { - use lightcraft_tiff::tags::{self as t, photometric}; - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; - use serde_json::json; - let linear = |subfile: u32, w: u32, h: u32, sample: u16| { - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); - ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] }); - ifd - }; - // (the copy must cover a thumbnail source's 512 px) - let (w, h) = (1200u32, 600u32); - let mut main = linear(0, w, h, 1500); - main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); - main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); - ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); - ifd0.set(t::MAKE, Value::Ascii("DJI".into())); - ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); - ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] }); - ifd0.add_sub_ifd(main); - ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000)); - let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); - let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; - let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; - - // the loaders: only the thumbnail's develops the copy, and only when it covers the size - let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap(); - assert!(used, "a thumbnail develops the copy"); - assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px"); - let (full, _) = load_vec(bytes.clone(), 512).unwrap(); - assert!(g(©) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(©), g(&full)); - - let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("merge-pano.dng"); - std::fs::write(&path, &bytes).unwrap(); - let mut s = crate::Session::new().with_fs(); - let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - let (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8); - // an export at a thumbnail's size, before any thumbnail - assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb); - let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); - // the thumbnail develops the copy and its source stays in memory… - s.media.clear_sources(); - let thumb = s.thumb_job(id, 64).unwrap().run(); - assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced)); - let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image); - s.accept(&thumb); - assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}"); - // …and an export after it still reads the full image - let job = s.export_job(id, 64, 64, space, depth).unwrap(); - assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy"); - let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); - assert!((again - export).abs() < 1.0, "{again} vs {export}"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right - /// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky - /// heuristic would never look) is used; a matte with an unknown name is ignored. - #[test] - fn dng_sky_matte_drives_the_sky_mask() { - use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; - use lightcraft_pipeline::{RenderRequest, render}; - use lightcraft_tiff::tags::{self as t, photometric}; - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; - let (w, h) = (16u32, 8u32); - let image = |ifd: &mut IfdBuilder, w: u32, h: u32, cpp: u16, bits: u16, data: Vec| { - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; cpp as usize])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![cpp])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![data] }); - }; - let mut raw = IfdBuilder::new(); - raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); - image(&mut raw, w, h, 3, 16, 12000u16.to_le_bytes().repeat((w * h * 3) as usize)); - let matte = |name: &str, right: bool| { - let mut m = IfdBuilder::new(); - m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK])); - m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK])); - m.set(t::SEMANTIC_NAME, Value::Ascii(name.into())); - image(&mut m, w / 2, h / 2, 1, 8, (0..w / 2 * h / 2).map(|i| if (i % (w / 2) >= w / 4) == right { 255 } else { 0 }).collect()); - m - }; - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); - ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0])); - ifd0.set(t::MAKE, Value::Ascii("Apple".into())); - ifd0.set(t::ORIENTATION, Value::Short(vec![6])); - ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); - ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); - image(&mut ifd0, 4, 2, 3, 8, vec![128; 24]); - ifd0.add_sub_ifd(raw); - ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticskymatte", true)); - ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticsomethingelse", false)); - let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); - - let (img, info) = load_bytes(&bytes, 64).unwrap(); - assert_eq!((img.width, img.height), (8, 16)); - assert!(info.mattes.is_some()); - let sky = Mask { - components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }], - adjust: LocalAdjustments { exposure: -3.0, ..Default::default() }, - ..Default::default() - }; - let s = DevelopSettings { masks: vec![sky], ..Default::default() }; - let req = RenderRequest::fit(8, 16); - let green = |s: &DevelopSettings, info: &SourceInfo, y: usize| render(&img, info, s, &req).image.get(4, y)[1] as i32; - let plain = DevelopSettings::default(); - assert_eq!(green(&s, &info, 2), green(&plain, &info, 2), "no sky at the top"); - assert!(green(&s, &info, 13) + 40 < green(&plain, &info, 13), "the sky at the bottom is darkened"); - // without the matte the heuristic looks at the top of the frame instead - let heuristic = SourceInfo { mattes: None, ..info.clone() }; - assert!(green(&s, &heuristic, 2) < green(&plain, &heuristic, 2)); - assert_eq!(green(&s, &heuristic, 13), green(&plain, &heuristic, 13)); - } - - /// A DNG's white balance is re-evaluated through its own colour model: the decoder hands the - /// pipeline the file's colour tags (without the profile look, applied at load) and the white - /// the pixels were developed for, and a custom white balance renders a neutral differently - /// from the Bradford adaptation it replaces. - #[test] - fn dng_white_balance_uses_the_camera_colour_model() { - use lightcraft_pipeline::{RenderRequest, render}; - let dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let raw = lightcraft_raw::decode(&dng).unwrap(); - let (img, info) = load_bytes(&dng, 64).unwrap(); - let cc = info.camera_color.as_ref().expect("a DNG with a colour matrix"); - assert_eq!(cc.developed_for, lightcraft_raw::color::as_shot_white_xy(&raw)); - assert!(cc.tags.profile.is_empty()); - assert_eq!(cc.tags.color_matrix, raw.color.color_matrix); - let mut s = lightcraft_develop::DevelopSettings::default(); - (s.wb.mode, s.wb.temp, s.wb.tint) = (lightcraft_develop::WbMode::Custom, 3000.0, 20.0); - let req = RenderRequest::fit(32, 32); - let camera = render(&img, &info, &s, &req).image; - let adapted = render(&img, &SourceInfo { camera_color: None, ..info.clone() }, &s, &req).image; - assert_ne!(camera.data, adapted.data); - } - - /// Apple ProRAW: like Lightroom Classic, the default render ignores the file's - /// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel - /// changes nothing. - #[test] - fn dng_gain_table_map_is_not_rendered() { - use lightcraft_raw::gaintable::GainTableMap; - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - let (before, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); - raw.color.profile.gain_table_map = Some(GainTableMap { - points_v: 1, - points_h: 1, - points_n: 1, - spacing_v: 1.0, - spacing_h: 1.0, - origin_v: 0.0, - origin_h: 0.0, - weights: [0.2, 0.2, 0.2, 0.2, 0.2], - gamma: 1.0, - gains: vec![2.0], - }); - let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - assert!(lightcraft_raw::decode(&with_map).unwrap().color.profile.gain_table_map.is_some(), "the map is kept"); - let (after, _) = load_bytes(&with_map, 64).unwrap(); - assert_eq!(before.data, after.data); - } - - /// The Profile option "Camera local tone mapping" renders the map by position (in the raw's - /// active area, whatever the photo's orientation), and only when it's on. - #[test] - fn dng_gain_table_map_renders_when_asked() { - use lightcraft_pipeline::{RenderRequest, render}; - use lightcraft_raw::gaintable::GainTableMap; - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - // ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour - raw.color.profile.gain_table_map = Some(GainTableMap { - points_v: 1, - points_h: 2, - points_n: 1, - spacing_v: 1.0, - spacing_h: 1.0, - origin_v: 0.0, - origin_h: 0.0, - weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0], - gamma: 1.0, - gains: vec![1.0, 4.0], - }); - let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - let (img, info) = load_bytes(&with_map, 64).unwrap(); - let (_, plain_info) = load_bytes(&plain, 64).unwrap(); - assert!(info.local_tone.is_some() && plain_info.local_tone.is_none()); - let req = RenderRequest::fit(64, 64); - let mut s = lightcraft_develop::DevelopSettings::default(); - // (darker, so that ×4 never reaches white) - s.light.exposure = -3.0; - let off = render(&img, &info, &s, &req).image; - assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing"); - s.profile.camera_local_tone = true; - assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)"); - let on = render(&img, &info, &s, &req).image; - // brightness gained, per column: none at the left edge, most at the right - let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::(); - let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); - let last = on.width - 1; - assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0)); - assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last)); - // the photo turned upside down: the map follows the raw, so the brightened side swaps - s.orientation = lightcraft_geom::Orientation::Rotate180; - s.profile.camera_local_tone = false; - let off = render(&img, &info, &s, &req).image; - s.profile.camera_local_tone = true; - let on = render(&img, &info, &s, &req).image; - let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); - assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last)); - // a rendered (non-raw) source never gets it - let jpeg = SourceInfo { raw: false, ..info.clone() }; - assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s)); - } - - #[test] - fn unsupported_raw_falls_back_to_embedded_preview() { - let b = cr3_with_preview(48, 32); - let p = probe_bytes("x.cr3", &b).unwrap(); - assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); - let (img, src) = load_bytes(&b, 24).unwrap(); - assert_eq!((img.width, img.height), (24, 16)); - assert!(!src.raw); - // no preview at all: a clear error, not a panic - assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err()); - } - - /// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's - /// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a - /// library reopen, and Reload / Relink clear it once the file decodes (undoably). - #[test] - fn unsupported_raw_imports_as_preview_only_and_survives_reopen() { - use serde_json::json; - let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let (lib, files) = (dir.join("lib"), dir.join("files")); - std::fs::create_dir_all(&files).unwrap(); - let path = files.join("DSC_0001.cr3"); - std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - // the import review already says so - let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap(); - assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}"); - let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - let p = s.catalog.photo(id).unwrap().clone(); - let why = p.preview_only.clone().expect("marked preview only"); - assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); - // stamped with the version that decided it, so a later version rechecks it (#662) - assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION); - assert!(!p.preview_only_stale()); - assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); - assert!(!p.develops_raw()); - // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults - assert_eq!(SourceInfo { native_long: None, ..crate::media::source_info(&p) }, SourceInfo::default()); - assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default()); - assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok()); - // agents see it - let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap(); - assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}"); - let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap(); - assert_eq!(i["preview_only"], json!(why), "{i}"); - // a library reopen keeps it - s.persist().unwrap(); - drop(s); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); - assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); - // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back - std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); - let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); - assert_eq!(r["reloaded"], json!([id.0]), "{r}"); - assert!(s.catalog.photo(id).unwrap().develops_raw()); - assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag"); - assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); - assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); - // relinking to a file that decodes clears it too - let dng = files.join("DSC_0001.dng"); - std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); - s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().preview_only, None); - drop(s); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif - /// declares a picture 25x its size. - fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] }); - ifd0.set_child( - t::EXIF_IFD, - IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])), - ); - let mut b = TiffWriter::default().write(&[ifd0]).unwrap(); - if shell { - b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100)); - } - b - } - - /// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size - /// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't - /// decode: preview only, with the reason, still showing that image. - #[test] - fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() { - let b = tiff_shell(24, 16, true); - let p = probe_bytes("IMG_0001.IIQ", &b).unwrap(); - assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16)); - let why = p.preview_only.expect("marked preview only"); - assert!(why.contains("600x400") && why.contains("24x16"), "{why}"); - let (img, src) = load_bytes(&b, 12).unwrap(); - assert_eq!((img.width, img.height, src.raw), (12, 8, false)); - assert!(embedded_preview_srgb(&b, 12).is_some()); - // the same pixels without the private block are a small ordinary image - let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap(); - assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16)); - assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none()); - - // TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws (#281) - for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![24])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![16])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - ifd0.set(t::MAKE, Value::Ascii(make.into())); - let px: Vec = (0..24 * 16).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - ifd0.set_image(ImageData::Strips { rows_per_strip: 16, strips: vec![px] }); - let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); - let p = probe_bytes("photo.tif", &bytes).unwrap(); - assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Image, None, 24, 16), "make: {make}"); - let (img, src) = load_bytes(&bytes, 24).unwrap(); - assert_eq!((img.width, img.height, src.raw), (24, 16, false), "make: {make}"); - } - } - - /// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first - /// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing. - #[test] - fn undecodable_container_with_a_preview_imports_as_preview_only() { - let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); - jpeg[0] = 0x02; - let mut f = b"\0MRM\0\x01\0\0".to_vec(); - f.extend(std::iter::repeat_n(0x11u8, 300)); - f.extend_from_slice(&jpeg); - f.extend(std::iter::repeat_n(0x22u8, 300)); - let p = probe_bytes("A.MRW", &f).unwrap(); - assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30)); - assert!(p.preview_only.is_some_and(|w| w.contains("Mrw"))); - let (img, src) = load_bytes(&f, 20).unwrap(); - assert_eq!((img.width, img.height, src.raw), (20, 15, false)); - // without a JPEG: a clear error - let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err(); - assert!(e.contains("without an embedded preview"), "{e}"); - } - - /// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606` - /// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic. - #[test] - fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); - let mos = |leaf_data: bool| { - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] }); - if leaf_data { - ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat())); - } - TiffWriter::default().write(&[ifd0]).unwrap() - }; - let p = probe_bytes("aptus22.mos", &mos(true)).unwrap(); - assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30)); - assert!(p.preview_only.is_some_and(|why| !why.is_empty())); - let (img, src) = load_bytes(&mos(true), 20).unwrap(); - assert_eq!((img.width, img.height, src.raw), (20, 15, false)); - // the same grey image without Leaf's block is an ordinary 16-bit TIFF - let plain = probe_bytes("grey.tif", &mos(false)).unwrap(); - assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12)); - } - - /// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its - /// headers; loading it says what is wrong instead of naming a bare TIFF compression number. - #[test] - fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); - ifd.set(t::COMPRESSION, Value::Short(vec![9])); - ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); - ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); - ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); - ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); - let f = TiffWriter::default().write(&[ifd]).unwrap(); - let p = probe_bytes("GOPR0001.GPR", &f).unwrap(); - assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16)); - let e = load_bytes(&f, 16).unwrap_err(); - assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}"); - } - - /// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview; - /// CR3 keeps its own wording. - #[test] - fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() { - use lightcraft_raw::RawError; - let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01"; - assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string())); - assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip"))); - assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok()); - assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok()); - assert!(preview_reason(b"x", RawError::NotRaw).is_err()); - assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 "))); - } - - #[test] - fn quick_jobs_show_embedded_previews_then_cached_renders() { - use crate::media::QuickSource; - let dir = std::env::temp_dir().join(format!("lc-quick-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("a.cr3"); - std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); - let mut s = crate::Session::new().with_fs(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - // loupe: an unedited raw opens on its embedded preview… - let q = s.quick_view_job(id, 1600, true).unwrap().run(); - assert_eq!(q.quick, Some(QuickSource::Embedded)); - let img = q.rendered.unwrap().image; - assert_eq!((img.width, img.height), (96, 64)); - // …and once the loupe has rendered it, on that render (any size) - let full = s.loupe_job(id, 48, 32, true).unwrap().run(); - assert!(full.rendered.is_ok() && full.quick.is_none()); - let q = s.quick_view_job(id, 1600, true).unwrap().run(); - assert_eq!(q.quick, Some(QuickSource::Cached)); - assert_eq!(q.rendered.unwrap().image.width, 48); - // grid: embedded first, then the real thumbnail (final, with the thumbnail job's key) - let job = s.thumb_job(id, 128).unwrap(); - let q = s.quick_thumb_job(&job).unwrap().run(); - assert_eq!((q.quick, q.key), (Some(QuickSource::Embedded), job.key)); - drop(job.clone().run()); - assert_eq!(s.quick_thumb_job(&job).unwrap().run().quick, Some(QuickSource::Cached)); - // an edited raw: no embedded stand-in (it wouldn't show the edit) - s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap(); - s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 1.0})).unwrap(); - let job = s.thumb_job(id, 128).unwrap(); - assert!(s.quick_thumb_job(&job).is_none()); - assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small)); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Issue #1448: the grid renders nothing behind an unedited raw's quick thumbnail, so when the - /// embedded preview can't be read (and nothing is cached) the quick job itself must render the - /// raw, or the cell stays empty for good ("no quick preview"). - #[test] - fn a_quick_thumbnail_without_an_embedded_preview_renders_the_raw() { - use crate::media::QuickSource; - let dir = std::env::temp_dir().join(format!("lc-quick-none-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("a.cr3"); - std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); - let mut s = crate::Session::new().with_fs(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - s.media.preview_loader = Some(std::sync::Arc::new(|_: &str, _| None)); - let job = s.thumb_job(id, 128).unwrap(); - let q = s.quick_thumb_job(&job).unwrap().run(); - assert_eq!((q.quick, q.key), (Some(QuickSource::Small), job.key)); - assert!(q.rendered.is_ok(), "{:?}", q.rendered.err()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/it/vendor_robust.rs` - /// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). - fn sony_arw(model: &str) -> Vec { - use lightcraft_tiff::tags::{self as t, photometric}; - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; - // 3:2, the aspect Sony's lens opcodes are limited to - let (w, h) = (72u32, 48u32); - let mut raw = IfdBuilder::new(); - raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14])); - raw.set(t::COMPRESSION, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); - raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); - raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); - raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479])); - raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] }); - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::MAKE, Value::Ascii("SONY".into())); - ifd0.set(t::MODEL, Value::Ascii(model.into())); - ifd0.add_sub_ifd(raw); - TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() - } - - /// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens - /// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off. - #[test] - fn arw_with_lens_data_imports_with_profile_corrections_on() { - let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let mut s = crate::Session::new().with_fs(); - for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] { - let path = dir.join(format!("{model}.arw")); - std::fs::write(&path, sony_arw(model)).unwrap(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap(); - assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}"); - assert!(!p.is_edited(), "{model}: as imported"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's - /// opcodes), off when it doesn't. - #[test] - fn rw2_and_dng_defaults_unchanged() { - use lightcraft_catalog::{Photo, PhotoId, Source}; - let s = crate::Session::new(); - let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() }; - for format in ["RW2", "DNG"] { - for embedded in [None, Some(lens)] { - let name = format!("x.{format}"); - let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, ""); - p.kind = MediaKind::Raw; - p.embedded_lens = embedded; - crate::import::apply_import_defaults(&s, &mut p); - assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}"); - } - } - } - - /// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read - /// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the - /// correction off keeps it off. - #[test] - fn existing_photo_settings_are_not_rewritten() { - use lightcraft_catalog::{Op, PhotoId}; - let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let (lib, files) = (dir.join("lib"), dir.join("files")); - std::fs::create_dir_all(&files).unwrap(); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - let import = |s: &mut crate::Session, name: &str| { - let path = files.join(name); - // trailing bytes of its own: not a duplicate of the other file - std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - PhotoId(r["imported"][0].as_u64().unwrap()) - }; - let old = import(&mut s, "old.arw"); - // as a release that didn't read the Sony tables catalogued it - let mut d = (*s.catalog.photo(old).unwrap().develop).clone(); - d.optics.lens_profile = false; - let ops = vec![ - Op::SetEmbeddedLens { id: old, lens: None }, - Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None }, - ]; - s.commit("x", Op::Batch { ops }).unwrap(); - let off = import(&mut s, "off.arw"); - s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap(); - let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect(); - assert!(before.iter().all(|p| !p.develop.optics.lens_profile)); - s.persist().unwrap(); - drop(s); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - for b in &before { - let p = s.catalog.photo(b.id).unwrap(); - assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name); - } - assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported"); - drop(s); - let _ = std::fs::remove_dir_all(&dir); - } -} +pub(crate) mod tests; diff --git a/crates/engine/src/files/tests.rs b/crates/engine/src/files/tests.rs new file mode 100644 index 000000000..d266ad35a --- /dev/null +++ b/crates/engine/src/files/tests.rs @@ -0,0 +1,1782 @@ +/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the +/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides +/// them; an exported `travel|Italy|Rome` comes back as itself. +#[test] +fn a_lens_without_a_name_is_shown_by_its_range() { + // a Nikon DSLR raw: no LensModel, the range from the maker note + let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() }; + assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6"); + let named = lightcraft_meta::Metadata { + lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()), + lens_spec: Some([24.0, 70.0, 4.0, 4.0]), + ..Default::default() + }; + assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S"); + assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, ""); +} + +#[test] +fn imported_keywords_keep_their_hierarchy() { + let m = lightcraft_meta::Metadata { + keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()], + hierarchical_keywords: vec!["travel|Italy|Rome".into()], + ..Default::default() + }; + assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); +} + +#[test] +fn psd_probe_reads_embedded_capture_time_gps_and_title() { + let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); + let metadata = lightcraft_meta::Metadata { + capture_time: Some(capture), + gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + title: Some("PSD metadata".into()), + ..Default::default() + }; + let bytes = crate::tests_xmp::synthetic_psd_with_metadata(&metadata); + let probe = probe_bytes("metadata.psd", &bytes).unwrap(); + assert_eq!(probe.captured.as_deref(), Some("2023-05-14T14:40:39")); + assert_eq!(probe.meta.gps, Some((45.0, 7.0))); + assert_eq!(probe.meta.title, "PSD metadata"); +} + +use super::*; +use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + +/// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a +/// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot +/// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the +/// format and camera string agrees. +#[test] +fn white_balance_scale_follows_the_files_matrices_then_the_cameras() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let mul = Some([2.2f32, 1.0, 1.6]); + let d65 = lightcraft_color::D65; + // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe + let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); + assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some()); + let (t, tint) = a7m3.as_shot(d65); + assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); + // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) + let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); + assert!(bare.legacy_relative && bare.scale_white.is_none()); + assert_eq!(bare.as_shot(d65), { + let (t, tint) = xy_to_temp_tint(d65); + (t.round(), tint.round()) + }); + // a camera without measured matrices: relative, whatever the multipliers + let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); + assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none()); + assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); + assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); + // a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as + // 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65) + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with = ColorData { kelvin_presets: presets, ..Default::default() }; + let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2")); + assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some()); + let (t, tint) = a99m2.as_shot(d65); + assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); + // a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a + // Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K) + let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([ + (5651.0, g(1418.0, 1357.0)), + (7505.0, g(1653.0, 1171.0)), + (6594.0, g(1533.0, 1259.0)), + (3174.0, g(963.0, 2016.0)), + (8529.0, g(1807.0, 1080.0)), + ]); + let with_canon = ColorData { kelvin_presets: canon, ..Default::default() }; + let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III")); + assert!(!g1x3.relative && g1x3.scale_white.is_some()); + let (t, tint) = g1x3.as_shot(d65); + assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III")); + let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); + assert!(bare.legacy_relative && bare.scale_white.is_none()); + // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone + let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1")); + assert!(other.relative && other.scale_white.is_none()); + let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); + assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); + // a file with matrices of its own (DNG): absolute from them, no matter whose camera + let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; + let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); + assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none()); + let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); + assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); + assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); +} + +/// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source +/// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative +/// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets +/// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7. +#[test] +fn a_merged_dng_shows_the_white_balance_its_source_shows() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with_presets = ColorData { kelvin_presets: presets, ..Default::default() }; + let cases = [ + (RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")), + (RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")), + (RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")), + (RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")), + ]; + for (format, color, mul, meta) in cases { + let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul)); + let source = WbScaleOf::new(format, &color, Some(mul), &meta); + let t = lightcraft_raw::color::camera_transform_of(&color, own); + let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap(); + let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta); + let shown = source.as_shot(own); + assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model); + } +} + +/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need. +fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec { + let head = 160; + let mut f = vec![0u8; head]; + f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW "); + f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes()); + f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes()); + f.extend_from_slice(jpeg); + f.extend(std::iter::repeat_n(0x33u8, tail)); + f +} + +/// A raw's preview is read from its header and its JPEG only. Every preview used to cost a +/// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten +/// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the +/// quick path produces must equal the whole-file path's, exactly. +#[test] +fn preview_loading_reads_only_the_header_and_the_jpeg() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let file = raf_with_embedded_jpeg(&jpeg, 200_000); + let dir = crate::tests_xmp::temp_dir("preview-window"); + let path = dir.join("dscf0001.raf"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + let max_edge = 512; + + let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview"); + assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded"); + assert!(read.header_path, "the header path is what answered"); + assert!(!read.planned, "a RAF pointer in the header needs no planner"); + assert_eq!(read.total, file.len() as u64); + assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}"); + assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len()); + + // The quick path and the whole-file path agree byte for byte. + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); + assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); + assert_eq!(quick.data, whole.data, "byte for byte"); + + // A file with no locatable preview declines (the caller then reads it whole), a missing file errors. + let plain = dir.join("plain.jpg"); + std::fs::write(&plain, &jpeg).unwrap(); + assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none()); + assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the +/// file's own orientation, and the EXIF colour space the container declares for its preview. +#[derive(Clone, Copy, Default)] +struct Fixture { + dng: bool, + sony: bool, + orientation: Option, + /// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB. + color_space: Option, +} + +/// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the +/// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of +/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than +/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach. +fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec { + tiff_with_preview_strips(strip, &[], tail, what) +} + +/// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its +/// 1616 px preview beside the full-size one. +fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut preview = IfdBuilder::new(); + if what.dng { + preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + } + if what.sony { + preview.set(t::MAKE, Value::Ascii("SONY".into())); + } + if let Some(o) = what.orientation { + preview.set(t::ORIENTATION, Value::Short(vec![o])); + } + if let Some(space) = what.color_space { + let mut exif = IfdBuilder::new(); + exif.set(0xA001, Value::Short(vec![space])); + if space == 0xFFFF { + let mut interop = IfdBuilder::new(); + interop.set(0x0001, Value::Undefined(b"R03".to_vec())); + exif.set_child(t::INTEROP_IFD, interop); + } + preview.set_child(t::EXIF_IFD, exif); + } + preview.set(t::COMPRESSION, Value::Short(vec![7])); + preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + preview.set(t::IMAGE_WIDTH, Value::Long(vec![48])); + preview.set(t::IMAGE_LENGTH, Value::Long(vec![32])); + preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] }); + for jpeg in smaller { + let mut sub = IfdBuilder::new(); + sub.set(t::COMPRESSION, Value::Short(vec![7])); + sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + sub.set(t::IMAGE_WIDTH, Value::Long(vec![48])); + sub.set(t::IMAGE_LENGTH, Value::Long(vec![32])); + sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] }); + preview.add_sub_ifd(sub); + } + let mut raw = IfdBuilder::new(); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000])); + raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] }); + TiffWriter::default().write(&[preview, raw]).expect("the fixture writes") +} + +/// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview +/// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges +/// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels +/// must equal the whole-file path's, exactly; a file the planner cannot describe must decline +/// rather than guess, so the caller keeps reading it whole. +#[test] +fn preview_loading_plans_the_ranges_it_needs() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan"); + let path = dir.join("preview.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + let max_edge = 512; + + let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview"); + assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded"); + assert!(read.planned, "the planner is what answered"); + assert!(read.header_path, "and it is still a bounded read"); + assert_eq!(read.total, file.len() as u64); + assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len()); + assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len()); + + // The plan path and the whole-file path agree byte for byte. + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); + assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); + assert_eq!(quick.data, whole.data, "byte for byte"); + + // The same structure without the tag that makes it a raw: the planner declines, and the + // caller's whole-file read is what answers (no guessing at what a file might hold). + let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default()); + std::fs::write(&path, ¬_a_raw).unwrap(); + assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that +/// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an +/// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one. +#[test] +fn preview_loading_plans_the_smallest_covering_jpeg() { + let jpeg = |w: u32, h: u32| { + let rgb: Vec = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect(); + encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap() + }; + let (big, small) = (jpeg(96, 64), jpeg(48, 32)); + assert!(big.len() > small.len(), "the full-size JPEG is the largest"); + let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan-covering"); + let path = dir.join("two-previews.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + + for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] { + let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview"); + assert!(read.planned, "the planner is what answered"); + assert!(&window == want, "a {edge} px box takes the {which} JPEG"); + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too"); + assert_eq!( + (quick.width, quick.height, quick.data), + (whole.width, whole.height, whole.data), + "{edge} px: the plan and the whole file agree" + ); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// A file whose preview the quick path could not show the way the whole-file path does is left to +/// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is +/// bigger than that header) and the preview JPEG carries none of its own, so a window read would +/// come out unrotated where the whole-file path rotates it — the plan must decline instead of +/// handing on a wrong picture. +#[test] +fn preview_loading_declines_an_orientation_only_the_whole_file_shows() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + // the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts + // IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image + let mut strip = jpeg.clone(); + strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize)); + let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan-orientation"); + let path = dir.join("rotated.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + + assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation"); + assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see"); + assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own"); + assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A container can declare what its preview JPEG is in, and the whole-file path applies that +/// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies +/// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and +/// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one +/// that declares sRGB (what most bodies write) keeps the fast path as before. +#[test] +fn preview_loading_applies_a_container_colour_space() { + use lightcraft_raw::PreviewColorSpace; + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let dir = crate::tests_xmp::temp_dir("preview-plan-space"); + let path = dir.join("space.arw"); + let path = path.to_string_lossy().into_owned(); + + let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() }); + assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB"); + std::fs::write(&path, &adobe).unwrap(); + let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too"); + assert!(read.planned && read.adobe_rgb, "{read:?}"); + assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}"); + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes"); + let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too"); + assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it"); + let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes"); + assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)"); + + let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() }); + assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB"); + std::fs::write(&path, &srgb).unwrap(); + let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes"); + assert!(read.planned && !read.adobe_rgb, "the plan still answers"); + assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG"); +} + +/// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as +/// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time. +#[test] +fn undated_files_take_the_file_date() { + let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let rgb = vec![128u8; 16 * 16 * 3]; + let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let path = dir.join("export.jpg"); + std::fs::write(&path, &jpeg).unwrap(); + let path = path.to_string_lossy().to_string(); + let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let want = crate::import::civil(mtime as i64); + let (_, probe) = fs_hooks(); + let info = probe(&path).unwrap(); + assert_eq!(info.captured, None, "the file itself has no date"); + assert_eq!(info.file_modified.as_deref(), Some(want.as_str())); + let mut s = crate::Session::new().with_fs(); + let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap(); + let id = lightcraft_catalog::PhotoId(r.imported[0]); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str())); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: +/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated +/// file or one that isn't an image. +#[test] +fn image_probe_reads_headers_like_the_decode_did() { + let (w, h) = (300u32, 200u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + for o in [None, Some(1), Some(6), Some(3), Some(8)] { + let exif = o.map(lightcraft_codecs::exif::minimal_exif); + let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() }; + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap(); + let png = lightcraft_codecs::encode_png(&img, &meta).unwrap(); + for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] { + let p = probe_bytes(name, bytes).unwrap(); + // what the probe computed from a decode before + let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap(); + let swap = Orientation::from_exif(d.orientation).swaps_axes(); + let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) }; + assert_eq!((p.width, p.height), want, "{name} {o:?}"); + assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}"); + assert_eq!(p.kind, MediaKind::Image); + assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" }); + assert_eq!(p.file_size, bytes.len() as u64); + assert!(p.content_hash.is_some()); + assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted"); + } + } + assert!(probe_bytes("x.jpg", b"not an image").is_err()); +} + +/// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets +/// start from), not on the black its transparent pixels store. +#[test] +fn transparent_png_develops_on_white() { + let rgba = [200, 40, 40, 255, 0, 0, 0, 0]; + let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap(); + let (img, _) = load_bytes(&png, 64).unwrap(); + assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0)); + assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0)); +} + +/// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample +/// of a block on a grey surface must not become green speckles (the block's green used to take the +/// clip level, which highlight reconstruction then kept). +#[test] +fn coarse_binning_keeps_specular_points_neutral() { + let (w, h) = (256usize, 192usize); + // grey after the file's white balance (as-shot neutral 0.5 : 1 : 0.7), brighter to the right + let neutral = [0.5f32, 1.0, 0.7]; + let cfa = lightcraft_raw::Cfa::bayer("RGGB").unwrap(); + let mut data: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let v = (0.3 + 0.2 * x as f32 / w as f32) * neutral[cfa.color_at(x, y).min(2) as usize]; + (256.0 + v * (16000.0 - 256.0)).round() as u16 + }) + .collect(); + // one clipped green sample (RGGB: even row, odd column) in every other 8 × 8 block + for by in 0..h / 8 { + for bx in (by % 2..w / 8).step_by(2) { + data[(by * 8 + 4) * w + bx * 8 + 5] = 16000; + } + } + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!(bin_factor(&raw, 32), Some(8)); + let (img, _) = load_bytes(&bytes, 32).unwrap(); + assert_eq!((img.width, img.height), (32, 24)); + let worst = img.data.iter().map(|p| p[1] / ((p[0] + p[2]) / 2.0).max(1e-6)).fold(0.0f32, f32::max); + assert!(worst < 1.12, "green speckles: green {worst:.2}× the other channels"); +} + +#[test] +fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() { + use lightcraft_denoise::product; + use lightcraft_raw::{DngWriteOptions, Method, Orientation, profile::HsvTable, write_dng}; + let dir = crate::tests_xmp::temp_dir("denoise-color-finish"); + let fixture = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&fixture).unwrap(); + raw.orientation = Orientation::Rotate90; + raw.color.baseline_exposure = 0.5; + raw.color.profile.look_table = + Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[23.0, 0.65, 0.8]; 2], srgb_value: false }); + let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap(); + let decoded = lightcraft_raw::decode(&bytes).unwrap(); + assert!(decoded.color.profile.look_table.is_some()); + // A cache containing the ordinary demosaic must undergo the identical downstream finish. + let camera_rgb = decoded.develop(Method::Ahd).unwrap(); + let key = "synthetic-color-finish"; + let path = dir.join("same.lcdn"); + product::write(&path, &camera_rgb, key).unwrap(); + let spec = DenoiseSpec { product: path, key: key.into(), make: None }; + let (ordinary, _) = load_bytes(&bytes, 2048).unwrap(); + let (plain, cached, _) = load_vec_pair(bytes, 2048, &spec).unwrap(); + assert_eq!(plain.data, ordinary.data, "requesting a twin must not change the ordinary path"); + let cached = cached.unwrap(); + assert_eq!((plain.width, plain.height), (24, 32)); + assert_eq!((cached.width, cached.height), (plain.width, plain.height)); + let worst = plain.data.iter().zip(&cached.data).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); + assert!(worst < 0.002, "color finish differs beyond cache half-float rounding: {worst}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG +/// mostly cancel, edges remain. +fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec { + let l: Vec = (0..w * h).map(|i| lum(i % w, i / w)).collect(); + let mut out = vec![0.0; w * h]; + for y in 0..h { + for x in 0..w { + let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h)); + let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::(); + out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32; + } + } + out +} + +/// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its +/// normalised cross-correlation, or `None` when the patch has too little detail to tell. +fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> { + let pa: Vec = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect(); + let na = pa.iter().map(|v| v * v).sum::().sqrt(); + if na < 1e-3 * p as f32 { + return None; + } + let mut best = (f32::MIN, (0, 0)); + for dy in -r..=r { + for dx in -r..=r { + let (bx, by) = (x as i64 + dx, y as i64 + dy); + let (mut dot, mut nb) = (0.0f32, 0.0f32); + for i in 0..p { + for j in 0..p { + let v = b[(by as usize + i) * w + bx as usize + j]; + dot += pa[i * p + j] * v; + nb += v * v; + } + } + let c = dot / (na * nb.sqrt()).max(1e-12); + if c > best.0 { + best = (c, (dx, dy)); + } + } + } + Some((best.1, best.0)) +} + +#[test] +fn corpus_a7cr_warp_keeps_coordinates_after_orientation_and_resizing() { + use lightcraft_geom::{Orientation, Point}; + use lightcraft_pipeline::optics::Warp; + let root = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); + for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw"] { + let Ok(mut bytes) = std::fs::read(root.join("raw").join(name)) else { continue }; + let off = lightcraft_raw::probe_info(&bytes).unwrap(); + assert!(embedded_lens(&off).is_none()); + let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); + let at = tiff.find(0x7036).unwrap().offset as usize; + let mut enabled = Vec::new(); + tiff.order.put_u16(&mut enabled, 17); + bytes[at..at + 2].copy_from_slice(&enabled); + let mut raw = lightcraft_raw::probe_info(&bytes).unwrap(); + raw.orientation = Orientation::Normal; + let mut settings = lightcraft_develop::DevelopSettings::default(); + settings.optics.lens_profile = true; + let base_lens = embedded_lens(&raw).unwrap(); + let base = Warp::from_settings(9504.0, 6336.0, &settings, Some(&base_lens)); + for orientation in 1..=8 { + let o = Orientation::from_exif(orientation); + raw.orientation = o; + let lens = embedded_lens(&raw).unwrap(); + for edge in [1200.0, 2560.0, 9504.0] { + let scale = edge / 9504.0; + let (w, h) = if o.swaps_axes() { (6336.0 * scale, edge) } else { (edge, 6336.0 * scale) }; + let oriented = Warp::from_settings(w, h, &settings, Some(&lens)); + for (x, y) in [(0.05, 0.1), (0.5, 0.5), (0.9, 0.8)] { + let point = Point::new(x * 9504.0, y * 6336.0); + let source = base.corrected_to_source(point, 1); + let (px, py) = o.map(point.x, point.y, 9504.0, 6336.0); + let (sx, sy) = o.map(source.x, source.y, 9504.0, 6336.0); + let got = oriented.corrected_to_source(Point::new(px * scale, py * scale), 1); + assert!((got.x - sx * scale).hypot(got.y - sy * scale) < 1e-8, "{name} orientation {orientation} edge {edge}"); + } + } + } + } +} + +/// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the +/// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile +/// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide +/// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction. +#[test] +fn corpus_rw2_distortion_matches_the_camera_jpeg() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); + return; + }; + assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); + assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None); + + // (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all) + let files = [ + ("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners + ("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 % + ("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 % + ("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 % + ("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction + ("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 % + ]; + const EDGE: usize = 640; + const P: usize = 64; + const R: i64 = 12; + for (name, off_at_least) in files { + let bytes = std::fs::read(dir.join(name)).unwrap(); + let probed = probe_bytes(name, &bytes).unwrap(); + assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read"); + let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap(); + assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree"); + // Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear` + let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap(); + assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG"); + let mut s = lightcraft_develop::DevelopSettings::default(); + let render = |s: &lightcraft_develop::DevelopSettings| { + lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image + }; + let before = render(&s); + s.optics.lens_profile = true; + let after = render(&s); + let (w, h) = (after.width, after.height); + assert_eq!((before.width, before.height), (w, h)); + // the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre + let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap(); + let (cw, ch) = (cam.width as f64, cam.height as f64); + let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) }; + let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize); + let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell); + let lin = |v: u8| (v as f32 / 255.0).powf(2.2); + let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| { + let p = im.data[y * im.width + x]; + 0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2]) + }; + let reference = detail(w, h, |x, y| { + let p = cam.data[y * w + x]; + 0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2] + }); + let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y))); + let m = R as usize + 4; + let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)]; + let mut measured = 0; + for c in corners { + let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue }; + measured += 1; + assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}"); + if off_at_least > 0 + && let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R) + { + let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3; + assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}"); + } + } + assert!(measured >= 2, "{name}: only {measured} corners with detail"); + } +} + +/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. +pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec { + let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); + b.extend_from_slice(&((24 + jpeg.len()) as u32).to_be_bytes()); + b.extend_from_slice(b"PRVW\0\0\0\0\0\x01"); + b.extend_from_slice(&(w as u16).to_be_bytes()); + b.extend_from_slice(&(h as u16).to_be_bytes()); + b.extend_from_slice(b"\0\x01"); + b.extend_from_slice(&(jpeg.len() as u32).to_be_bytes()); + b.extend_from_slice(&jpeg); + b +} + +/// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage: +/// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`). +fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec { + let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat(); + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&(w as u16).to_be_bytes()); + craw[26..28].copy_from_slice(&(h as u16).to_be_bytes()); + craw.extend(bx(b"CMP1", &[0xff; 4])); + let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1]; + stsd.extend(bx(b"CRAW", &craw)); + let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat(); + let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat(); + let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat(); + let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))); + let preview = cr3_with_preview(w, h); + [&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat() +} + +/// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG: +/// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review). +#[test] +fn corrupt_cr3_falls_back_to_embedded_preview() { + let b = cr3_with_corrupt_raw(48, 32); + let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt"); + assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}"); + let p = probe_bytes("x.cr3", &b).unwrap(); + assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); + assert!(p.preview_only.is_some_and(|why| why.contains("CR3"))); + let (img, src) = load_bytes(&b, 24).unwrap(); + assert_eq!((img.width, img.height), (24, 16)); + assert!(!src.raw); + // other formats still report a corrupt file as an error + let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + dng.truncate(dng.len() / 2); + assert!(load_bytes(&dng, 24).is_err()); +} + +/// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is +/// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it. +#[test] +fn dng_profile_look_is_applied() { + use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve}; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + let sat = |img: &Rgb32f| { + img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::() + / img.data.len() as f32 + }; + let (before, info) = load_bytes(&plain, 64).unwrap(); + assert!(sat(&before) > 0.05, "the synthetic scene is coloured"); + assert!(info.camera_tone.is_none()); + // a map that removes all saturation, and a tone curve + let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false }; + raw.color.profile = ProfileLook { + hue_sat_map: [Some(grey), None], + look_table: None, + tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]), + gain_table_map: None, + }; + let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (after, info) = load_bytes(&with, 64).unwrap(); + assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after)); + // a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB) + let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::() / img.data.len() as f32; + assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before)); + let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone"); + assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18)); + // a look table alone changes the render too (applied after exposure) + raw.color.profile = ProfileLook { + look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }), + ..Default::default() + }; + let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); + assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); +} + +/// Corrections used while fitting a DNG's preview must not be baked into its editable source. +/// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative. +#[test] +fn dng_preview_corrections_leave_the_render_source_uncorrected() { + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (before, expected) = load_bytes(&before, 64).unwrap(); + let (after, info) = load_bytes(&corrected, 64).unwrap(); + assert_eq!(before.data, after.data, "lens correction belongs to the pipeline"); + assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some())); + assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved"); +} + +/// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit. +/// The source pixels stay uncorrected, including when the file is rotated. +#[test] +fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() { + use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb}; + use lightcraft_raw::{Opcode, Orientation}; + let (w, h) = (384usize, 256usize); + let data = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32; + let colour = match (i / w % 2, i % w % 2) { + (0, 0) => 1.2, + (1, 1) => 0.8, + _ => 1.0, + }; + 256 + (v * colour * 15744.0) as u16 + }) + .collect(); + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.color = lightcraft_raw::ColorData { + illuminant: [21, 0], + color_matrix: [Some(REC2020.from_xyz()), None], + as_shot_neutral: Some([1.0; 3]), + ..Default::default() + }; + let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![ + Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, + Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }, + ]; + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let to_srgb = REC2020.to_space(&SRGB); + let pixels: Vec = corrected + .data + .iter() + .flat_map(|p| { + let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i])); + let y = luminance_2020(p).max(1e-6); + let gain = (1.0 - (-3.0 * y).exp()) / y; + to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.0)).round() as u8) + }) + .collect(); + let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); + let jpeg = lightcraft_codecs::encode_jpeg(&image, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); + for orientation in [Orientation::Normal, Orientation::Rotate90] { + raw.orientation = orientation; + let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + // Append a preview IFD to the little-endian DNG written above. + let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize; + let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize; + let next = root + 2 + 12 * count; + let start = file.len() as u32; + file[next..next + 4].copy_from_slice(&start.to_le_bytes()); + let jpeg_at = start + 2 + 4 * 12 + 4; + file.extend(4u16.to_le_bytes()); + for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] { + file.extend(tag.to_le_bytes()); + file.extend(4u16.to_le_bytes()); + file.extend(1u32.to_le_bytes()); + file.extend(value.to_le_bytes()); + } + file.extend(0u32.to_le_bytes()); + file.extend(&jpeg); + let (source, info) = load_bytes(&file, 96).unwrap(); + let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview"); + let target = 1.0 - (-3.0f32 * 0.18).exp(); + assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18)); + let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation); + assert_eq!(source.data, expected.data, "the editable source is not lens-corrected"); + } +} + +/// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's +/// own white-balanced neutral comes out yellow (blue 12 % low). +fn yellowing_look() -> lightcraft_color::Mat3 { + lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]) +} + +/// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the +/// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included. +#[test] +fn camera_look_in_lanes_matches_pixel_by_pixel() { + let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect(); + let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true }; + let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap(); + let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) }; + let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN]; + let img = Rgb32f::from_fn(37, 5, |x, y| { + let k = y * 37 + x; + if k % 5 == 0 { + [special[k % 10], special[k / 10 % 10], special[k / 100 % 10]] + } else { + [(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05] + } + }); + let mut lanes = img.clone(); + colour.apply(&mut lanes); + let m = colour.matrix.to_f32(); + for (p, got) in img.data.iter().zip(&lanes.data) { + assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}"); + } +} + +#[test] +fn clipped_white_renders_neutral_through_the_colour_model() { + use lightcraft_color::{LUMA_2020, Mat3}; + let look = yellowing_look(); + let white = clipped_white(&look); + let out = look.apply(white.map(f64::from)); + assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}"); + let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::(); + assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral"); + assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]); + // a model that can't be inverted, or only just, keeps the camera's neutral + assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // so does one whose white would overflow the rebuilt highlights (2.5e38 in red here) + assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // or move the camera's neutral implausibly far + assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite + for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] { + let w = clipped_white(&model); + let m = model.to_f32(); + let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]); + assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}"); + let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None }; + let mut img = Rgb32f::filled(4, 4, [1.0; 3]); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0)); + } +} + +/// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that +/// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral; +/// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it. +#[test] +fn clipped_highlights_stay_neutral_when_darkened() { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.4f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None }; + // a grey wall (camera neutral) with a fully clipped light and a light clipped in green only + let mut img = Rgb32f::from_fn(96, 64, |x, y| { + let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); + if d(28.0) < 14.0 { + [1.0; 3] + } else if d(70.0) < 12.0 { + [0.6 / wb[0], 1.0, 0.7 / wb[2]] + } else { + [0.3 / wb[0], 0.3, 0.3 / wb[2]] + } + }); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let info = SourceInfo { raw: true, ..Default::default() }; + let mut issue = lightcraft_develop::DevelopSettings::default(); + let l = &mut issue.light; + (l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0); + let mut h100 = lightcraft_develop::DevelopSettings::default(); + h100.light.highlights = -100.0; + for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] { + let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image; + let p = out.get(28, 32); + assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})"); + let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]); + assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral"); + } +} + +/// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`) +/// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise +/// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last +/// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row. +fn darkened_ramp(rows: std::ops::Range, height: usize) -> ([[u8; 4]; 3], u8) { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.5f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; + let w = 1600usize; + let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32; + let sensor = + Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); + let y = (rows.start + rows.end) / 2; + let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap(); + let mut img = sensor.clone(); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.light.exposure = -2.0; + let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image; + let step = |x: usize| { + let (a, b) = (out.get(x - 1, y), out.get(x, y)); + (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) + }; + ([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0)) +} + +/// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards +/// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels +/// there. +#[test] +fn rendered_colour_is_continuous_where_the_first_channel_clips() { + let ([a, b, c], worst) = darkened_ramp(0..16, 16); + let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); + assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}"); + assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels"); +} + +/// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there +/// is no reliable colour around it. It still carries on across the first clip, however thin the line and however +/// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163]; +/// on 512 rows, from [225, 126, 153] to [218, 145, 151]). +#[test] +fn rendered_thin_line_is_continuous_where_the_first_channel_clips() { + let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); + for height in [32usize, 128, 512, 1024] { + for width in [1usize, 2, 4, 16] { + let top = (height - width) / 2; + let ([a, b, c], worst) = darkened_ramp(top..top + width, height); + let case = format!("{width} rows of {height}"); + assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}"); + assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); + } + } +} + +/// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1, +/// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale +/// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows +/// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the +/// rendered colour on either side of where red clips on the band's middle row, and the largest step between +/// neighbouring pixels along that row. +fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.5f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; + let (w, h) = (320usize, top + strip + band); + let sensor = Rgb32f::from_fn(w, h, |x, y| { + if y < top { + [0.8, 0.35, 0.3] + } else if y < top + strip { + [0.4, 0.95, 0.8] + } else { + [0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8] + } + }); + let y = top + strip + band / 2; + let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap(); + let mut img = sensor.clone(); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.light.exposure = -2.0; + let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image; + let step = |x: usize| { + let (a, b) = (out.get(x - 1, y), out.get(x, y)); + (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) + }; + ([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0)) +} + +/// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The +/// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly +/// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then +/// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped +/// (without the coloured surface: [198, 195, 178] then [205, 181, 168]). +#[test] +fn rendered_colour_is_continuous_where_a_second_channel_clips() { + for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] { + let ([a, b], worst) = second_clip(top, strip, band); + let case = format!("{top} rows of colour, {strip} of rim, {band} of band"); + let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0); + assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips"); + assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); + } +} + +/// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in +/// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo +/// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them). +#[test] +fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); + return; + }; + let (img, _) = load_bytes(&bytes, 1600).unwrap(); + let (w, h) = (img.width, img.height); + let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p); + let chroma = |p: [f32; 3]| { + let s = (p[0] + p[1] + p[2]).max(1e-6); + [p[0] / s, p[2] / s] + }; + // the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame + let px = |f: f32, n: usize| (f * n as f32) as usize; + let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]); + let mut rows = 0; + for y in px(0.24, h)..px(0.34, h) { + let row = &img.data[y * w..(y + 1) * w]; + let sky = lum(row[px(0.2, w)]); + let dark: Vec = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect(); + let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue }; + let k = w as f32 / 6000.0; + let mean = |a: usize, b: usize| { + let n = (b - a) as f64; + (a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n]) + }; + let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize); + for (side, (n, f)) in + [(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))] + .into_iter() + .enumerate() + { + for c in 0..2 { + near[side][c] += n[c]; + far[side][c] += f[c]; + } + } + rows += 1; + } + assert!(rows > 50, "the tree was not found ({rows} rows)"); + for side in 0..2 { + let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max); + assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]); + } +} + +/// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the +/// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The +/// copy's samples are much brighter than the image's here, so a picture says which one it came from. +#[test] +fn exports_never_develop_a_dngs_reduced_copy() { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + use serde_json::json; + let linear = |subfile: u32, w: u32, h: u32, sample: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] }); + ifd + }; + // (the copy must cover a thumbnail source's 512 px) + let (w, h) = (1200u32, 600u32); + let mut main = linear(0, w, h, 1500); + main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); + main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("DJI".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] }); + ifd0.add_sub_ifd(main); + ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000)); + let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + + // the loaders: only the thumbnail's develops the copy, and only when it covers the size + let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap(); + assert!(used, "a thumbnail develops the copy"); + assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px"); + let (full, _) = load_vec(bytes.clone(), 512).unwrap(); + assert!(g(©) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(©), g(&full)); + + let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("merge-pano.dng"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + let (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8); + // an export at a thumbnail's size, before any thumbnail + assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb); + let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + // the thumbnail develops the copy and its source stays in memory… + s.media.clear_sources(); + let thumb = s.thumb_job(id, 64).unwrap().run(); + assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced)); + let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image); + s.accept(&thumb); + assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}"); + // …and an export after it still reads the full image + let job = s.export_job(id, 64, 64, space, depth).unwrap(); + assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy"); + let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + assert!((again - export).abs() < 1.0, "{again} vs {export}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right +/// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky +/// heuristic would never look) is used; a matte with an unknown name is ignored. +#[test] +fn dng_sky_matte_drives_the_sky_mask() { + use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_pipeline::{RenderRequest, render}; + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + let (w, h) = (16u32, 8u32); + let image = |ifd: &mut IfdBuilder, w: u32, h: u32, cpp: u16, bits: u16, data: Vec| { + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; cpp as usize])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![cpp])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![data] }); + }; + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); + image(&mut raw, w, h, 3, 16, 12000u16.to_le_bytes().repeat((w * h * 3) as usize)); + let matte = |name: &str, right: bool| { + let mut m = IfdBuilder::new(); + m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK])); + m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK])); + m.set(t::SEMANTIC_NAME, Value::Ascii(name.into())); + image(&mut m, w / 2, h / 2, 1, 8, (0..w / 2 * h / 2).map(|i| if (i % (w / 2) >= w / 4) == right { 255 } else { 0 }).collect()); + m + }; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("Apple".into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![6])); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); + image(&mut ifd0, 4, 2, 3, 8, vec![128; 24]); + ifd0.add_sub_ifd(raw); + ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticskymatte", true)); + ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticsomethingelse", false)); + let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + + let (img, info) = load_bytes(&bytes, 64).unwrap(); + assert_eq!((img.width, img.height), (8, 16)); + assert!(info.mattes.is_some()); + let sky = Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }], + adjust: LocalAdjustments { exposure: -3.0, ..Default::default() }, + ..Default::default() + }; + let s = DevelopSettings { masks: vec![sky], ..Default::default() }; + let req = RenderRequest::fit(8, 16); + let green = |s: &DevelopSettings, info: &SourceInfo, y: usize| render(&img, info, s, &req).image.get(4, y)[1] as i32; + let plain = DevelopSettings::default(); + assert_eq!(green(&s, &info, 2), green(&plain, &info, 2), "no sky at the top"); + assert!(green(&s, &info, 13) + 40 < green(&plain, &info, 13), "the sky at the bottom is darkened"); + // without the matte the heuristic looks at the top of the frame instead + let heuristic = SourceInfo { mattes: None, ..info.clone() }; + assert!(green(&s, &heuristic, 2) < green(&plain, &heuristic, 2)); + assert_eq!(green(&s, &heuristic, 13), green(&plain, &heuristic, 13)); +} + +/// A DNG's white balance is re-evaluated through its own colour model: the decoder hands the +/// pipeline the file's colour tags (without the profile look, applied at load) and the white +/// the pixels were developed for, and a custom white balance renders a neutral differently +/// from the Bradford adaptation it replaces. +#[test] +fn dng_white_balance_uses_the_camera_colour_model() { + use lightcraft_pipeline::{RenderRequest, render}; + let dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let raw = lightcraft_raw::decode(&dng).unwrap(); + let (img, info) = load_bytes(&dng, 64).unwrap(); + let cc = info.camera_color.as_ref().expect("a DNG with a colour matrix"); + assert_eq!(cc.developed_for, lightcraft_raw::color::as_shot_white_xy(&raw)); + assert!(cc.tags.profile.is_empty()); + assert_eq!(cc.tags.color_matrix, raw.color.color_matrix); + let mut s = lightcraft_develop::DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (lightcraft_develop::WbMode::Custom, 3000.0, 20.0); + let req = RenderRequest::fit(32, 32); + let camera = render(&img, &info, &s, &req).image; + let adapted = render(&img, &SourceInfo { camera_color: None, ..info.clone() }, &s, &req).image; + assert_ne!(camera.data, adapted.data); +} + +/// Apple ProRAW: like Lightroom Classic, the default render ignores the file's +/// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel +/// changes nothing. +#[test] +fn dng_gain_table_map_is_not_rendered() { + use lightcraft_raw::gaintable::GainTableMap; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + let (before, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); + raw.color.profile.gain_table_map = Some(GainTableMap { + points_v: 1, + points_h: 1, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [0.2, 0.2, 0.2, 0.2, 0.2], + gamma: 1.0, + gains: vec![2.0], + }); + let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + assert!(lightcraft_raw::decode(&with_map).unwrap().color.profile.gain_table_map.is_some(), "the map is kept"); + let (after, _) = load_bytes(&with_map, 64).unwrap(); + assert_eq!(before.data, after.data); +} + +/// The Profile option "Camera local tone mapping" renders the map by position (in the raw's +/// active area, whatever the photo's orientation), and only when it's on. +#[test] +fn dng_gain_table_map_renders_when_asked() { + use lightcraft_pipeline::{RenderRequest, render}; + use lightcraft_raw::gaintable::GainTableMap; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + // ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour + raw.color.profile.gain_table_map = Some(GainTableMap { + points_v: 1, + points_h: 2, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0], + gamma: 1.0, + gains: vec![1.0, 4.0], + }); + let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (img, info) = load_bytes(&with_map, 64).unwrap(); + let (_, plain_info) = load_bytes(&plain, 64).unwrap(); + assert!(info.local_tone.is_some() && plain_info.local_tone.is_none()); + let req = RenderRequest::fit(64, 64); + let mut s = lightcraft_develop::DevelopSettings::default(); + // (darker, so that ×4 never reaches white) + s.light.exposure = -3.0; + let off = render(&img, &info, &s, &req).image; + assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing"); + s.profile.camera_local_tone = true; + assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)"); + let on = render(&img, &info, &s, &req).image; + // brightness gained, per column: none at the left edge, most at the right + let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::(); + let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); + let last = on.width - 1; + assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0)); + assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last)); + // the photo turned upside down: the map follows the raw, so the brightened side swaps + s.orientation = lightcraft_geom::Orientation::Rotate180; + s.profile.camera_local_tone = false; + let off = render(&img, &info, &s, &req).image; + s.profile.camera_local_tone = true; + let on = render(&img, &info, &s, &req).image; + let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); + assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last)); + // a rendered (non-raw) source never gets it + let jpeg = SourceInfo { raw: false, ..info.clone() }; + assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s)); +} + +#[test] +fn unsupported_raw_falls_back_to_embedded_preview() { + let b = cr3_with_preview(48, 32); + let p = probe_bytes("x.cr3", &b).unwrap(); + assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); + let (img, src) = load_bytes(&b, 24).unwrap(); + assert_eq!((img.width, img.height), (24, 16)); + assert!(!src.raw); + // no preview at all: a clear error, not a panic + assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err()); +} + +/// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's +/// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a +/// library reopen, and Reload / Relink clear it once the file decodes (undoably). +#[test] +fn unsupported_raw_imports_as_preview_only_and_survives_reopen() { + use serde_json::json; + let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let path = files.join("DSC_0001.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + // the import review already says so + let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}"); + let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + let p = s.catalog.photo(id).unwrap().clone(); + let why = p.preview_only.clone().expect("marked preview only"); + assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); + // stamped with the version that decided it, so a later version rechecks it (#662) + assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION); + assert!(!p.preview_only_stale()); + assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); + assert!(!p.develops_raw()); + // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults + assert_eq!(SourceInfo { native_long: None, ..crate::media::source_info(&p) }, SourceInfo::default()); + assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default()); + assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok()); + // agents see it + let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap(); + assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}"); + let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap(); + assert_eq!(i["preview_only"], json!(why), "{i}"); + // a library reopen keeps it + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); + // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back + std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); + let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["reloaded"], json!([id.0]), "{r}"); + assert!(s.catalog.photo(id).unwrap().develops_raw()); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag"); + assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); + // relinking to a file that decodes clears it too + let dng = files.join("DSC_0001.dng"); + std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); + s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only, None); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif +/// declares a picture 25x its size. +fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] }); + ifd0.set_child( + t::EXIF_IFD, + IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])), + ); + let mut b = TiffWriter::default().write(&[ifd0]).unwrap(); + if shell { + b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100)); + } + b +} + +/// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size +/// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't +/// decode: preview only, with the reason, still showing that image. +#[test] +fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() { + let b = tiff_shell(24, 16, true); + let p = probe_bytes("IMG_0001.IIQ", &b).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16)); + let why = p.preview_only.expect("marked preview only"); + assert!(why.contains("600x400") && why.contains("24x16"), "{why}"); + let (img, src) = load_bytes(&b, 12).unwrap(); + assert_eq!((img.width, img.height, src.raw), (12, 8, false)); + assert!(embedded_preview_srgb(&b, 12).is_some()); + // the same pixels without the private block are a small ordinary image + let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap(); + assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16)); + assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none()); + + // TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws (#281) + for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![24])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + let px: Vec = (0..24 * 16).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + ifd0.set_image(ImageData::Strips { rows_per_strip: 16, strips: vec![px] }); + let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); + let p = probe_bytes("photo.tif", &bytes).unwrap(); + assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Image, None, 24, 16), "make: {make}"); + let (img, src) = load_bytes(&bytes, 24).unwrap(); + assert_eq!((img.width, img.height, src.raw), (24, 16, false), "make: {make}"); + } +} + +/// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first +/// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing. +#[test] +fn undecodable_container_with_a_preview_imports_as_preview_only() { + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + jpeg[0] = 0x02; + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + f.extend(std::iter::repeat_n(0x11u8, 300)); + f.extend_from_slice(&jpeg); + f.extend(std::iter::repeat_n(0x22u8, 300)); + let p = probe_bytes("A.MRW", &f).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30)); + assert!(p.preview_only.is_some_and(|w| w.contains("Mrw"))); + let (img, src) = load_bytes(&f, 20).unwrap(); + assert_eq!((img.width, img.height, src.raw), (20, 15, false)); + // without a JPEG: a clear error + let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err(); + assert!(e.contains("without an embedded preview"), "{e}"); +} + +/// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606` +/// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic. +#[test] +fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mos = |leaf_data: bool| { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] }); + if leaf_data { + ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat())); + } + TiffWriter::default().write(&[ifd0]).unwrap() + }; + let p = probe_bytes("aptus22.mos", &mos(true)).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30)); + assert!(p.preview_only.is_some_and(|why| !why.is_empty())); + let (img, src) = load_bytes(&mos(true), 20).unwrap(); + assert_eq!((img.width, img.height, src.raw), (20, 15, false)); + // the same grey image without Leaf's block is an ordinary 16-bit TIFF + let plain = probe_bytes("grey.tif", &mos(false)).unwrap(); + assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12)); +} + +/// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its +/// headers; loading it says what is wrong instead of naming a bare TIFF compression number. +#[test] +fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); + ifd.set(t::COMPRESSION, Value::Short(vec![9])); + ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); + let f = TiffWriter::default().write(&[ifd]).unwrap(); + let p = probe_bytes("GOPR0001.GPR", &f).unwrap(); + assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16)); + let e = load_bytes(&f, 16).unwrap_err(); + assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}"); +} + +/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview; +/// CR3 keeps its own wording. +#[test] +fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() { + use lightcraft_raw::RawError; + let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01"; + assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string())); + assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip"))); + assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok()); + assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok()); + assert!(preview_reason(b"x", RawError::NotRaw).is_err()); + assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 "))); +} + +#[test] +fn quick_jobs_show_embedded_previews_then_cached_renders() { + use crate::media::QuickSource; + let dir = std::env::temp_dir().join(format!("lc-quick-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + // loupe: an unedited raw opens on its embedded preview… + let q = s.quick_view_job(id, 1600, true).unwrap().run(); + assert_eq!(q.quick, Some(QuickSource::Embedded)); + let img = q.rendered.unwrap().image; + assert_eq!((img.width, img.height), (96, 64)); + // …and once the loupe has rendered it, on that render (any size) + let full = s.loupe_job(id, 48, 32, true).unwrap().run(); + assert!(full.rendered.is_ok() && full.quick.is_none()); + let q = s.quick_view_job(id, 1600, true).unwrap().run(); + assert_eq!(q.quick, Some(QuickSource::Cached)); + assert_eq!(q.rendered.unwrap().image.width, 48); + // grid: embedded first, then the real thumbnail (final, with the thumbnail job's key) + let job = s.thumb_job(id, 128).unwrap(); + let q = s.quick_thumb_job(&job).unwrap().run(); + assert_eq!((q.quick, q.key), (Some(QuickSource::Embedded), job.key)); + drop(job.clone().run()); + assert_eq!(s.quick_thumb_job(&job).unwrap().run().quick, Some(QuickSource::Cached)); + // an edited raw: no embedded stand-in (it wouldn't show the edit) + s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap(); + s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 1.0})).unwrap(); + let job = s.thumb_job(id, 128).unwrap(); + assert!(s.quick_thumb_job(&job).is_none()); + assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #1448: the grid renders nothing behind an unedited raw's quick thumbnail, so when the +/// embedded preview can't be read (and nothing is cached) the quick job itself must render the +/// raw, or the cell stays empty for good ("no quick preview"). +#[test] +fn a_quick_thumbnail_without_an_embedded_preview_renders_the_raw() { + use crate::media::QuickSource; + let dir = std::env::temp_dir().join(format!("lc-quick-none-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + s.media.preview_loader = Some(std::sync::Arc::new(|_: &str, _| None)); + let job = s.thumb_job(id, 128).unwrap(); + let q = s.quick_thumb_job(&job).unwrap().run(); + assert_eq!((q.quick, q.key), (Some(QuickSource::Small), job.key)); + assert!(q.rendered.is_ok(), "{:?}", q.rendered.err()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/it/vendor_robust.rs` +/// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). +fn sony_arw(model: &str) -> Vec { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + // 3:2, the aspect Sony's lens opcodes are limited to + let (w, h) = (72u32, 48u32); + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SONY".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() +} + +/// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens +/// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off. +#[test] +fn arw_with_lens_data_imports_with_profile_corrections_on() { + let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = crate::Session::new().with_fs(); + for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] { + let path = dir.join(format!("{model}.arw")); + std::fs::write(&path, sony_arw(model)).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap(); + assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}"); + assert!(!p.is_edited(), "{model}: as imported"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's +/// opcodes), off when it doesn't. +#[test] +fn rw2_and_dng_defaults_unchanged() { + use lightcraft_catalog::{Photo, PhotoId, Source}; + let s = crate::Session::new(); + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() }; + for format in ["RW2", "DNG"] { + for embedded in [None, Some(lens)] { + let name = format!("x.{format}"); + let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, ""); + p.kind = MediaKind::Raw; + p.embedded_lens = embedded; + crate::import::apply_import_defaults(&s, &mut p); + assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}"); + } + } +} + +/// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read +/// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the +/// correction off keeps it off. +#[test] +fn existing_photo_settings_are_not_rewritten() { + use lightcraft_catalog::{Op, PhotoId}; + let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let import = |s: &mut crate::Session, name: &str| { + let path = files.join(name); + // trailing bytes of its own: not a duplicate of the other file + std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + PhotoId(r["imported"][0].as_u64().unwrap()) + }; + let old = import(&mut s, "old.arw"); + // as a release that didn't read the Sony tables catalogued it + let mut d = (*s.catalog.photo(old).unwrap().develop).clone(); + d.optics.lens_profile = false; + let ops = vec![ + Op::SetEmbeddedLens { id: old, lens: None }, + Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None }, + ]; + s.commit("x", Op::Batch { ops }).unwrap(); + let off = import(&mut s, "off.arw"); + s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap(); + let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect(); + assert!(before.iter().all(|p| !p.develop.optics.lens_profile)); + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + for b in &before { + let p = s.catalog.photo(b.id).unwrap(); + assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name); + } + assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported"); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} From c0fe58a144655500b9d5ecef63543c23929399b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:55 +0000 Subject: [PATCH 045/191] refactor(lightcraft-engine): move demo.rs tests to crates/engine/src/demo/tests.rs --- crates/engine/src/demo.rs | 364 +------------------------------- crates/engine/src/demo/tests.rs | 361 +++++++++++++++++++++++++++++++ 2 files changed, 362 insertions(+), 363 deletions(-) create mode 100644 crates/engine/src/demo/tests.rs diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 38a42fe95..c89f29e0a 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -329,366 +329,4 @@ fn trim_scene_cache(c: &mut SceneCache, limit: usize) { } #[cfg(test)] -mod tests { - use super::*; - - fn small_scene() -> lightcraft_scenes::Scene { - lightcraft_scenes::demo_library().swap_remove(0) - } - - #[test] - fn a_scene_is_generated_once_per_size_and_shared() { - let s = small_scene(); - let a = scene_pixels(&s, 48).unwrap(); - let b = scene_pixels(&s, 48).unwrap(); - assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation"); - assert_eq!(a.width.max(a.height), 48); - assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly"); - let c = scene_pixels(&s, 40).unwrap(); - assert_eq!(c.width.max(c.height), 40, "another size is its own generation"); - } - - #[test] - fn concurrent_requests_wait_for_one_generation() { - let s = small_scene(); - let edge = 37; // a size no other test asks for - let got: Vec<_> = std::thread::scope(|sc| { - let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge).unwrap())).collect(); - hs.into_iter().map(|h| h.join().unwrap()).collect() - }); - assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); - } - - /// Run `f` on a thread of its own and wait for it at most `secs`: a deadlock fails the test instead of hanging it. - fn within(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T { - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let _ = tx.send(f()); - }); - rx.recv_timeout(std::time::Duration::from_secs(secs)).expect("deadlocked") - } - - /// Wait (at most ten seconds) until `n` callers wait for `slot`'s generation. - fn until_waiting(slot: &SceneSlot, n: usize) { - let started = std::time::Instant::now(); - while slot.lock().waiting < n { - assert!(started.elapsed() < std::time::Duration::from_secs(10), "the callers never got to wait"); - std::thread::sleep(std::time::Duration::from_millis(1)); - } - } - - fn pixels(n: usize) -> lightcraft_raster::Rgb32f { - lightcraft_raster::Rgb32f::new(n, 1) - } - - /// Rayon workers asking for a scene while it is generated never deadlock. The render is parallel, and a rayon worker - /// waiting for rows another worker took runs queued jobs on top of itself: generating on the caller's thread, the - /// faces scan's pool threads ended up waiting on the generation further down their own stack and hung. Requests - /// keep arriving on a four-thread pool while the scene is generated, as when several photos are queued; every one - /// of them must come back, at least four of them must have overlapped the generation, and the test waits with a - /// timeout, so a deadlock fails it instead of hanging. - #[test] - fn rayon_workers_asking_for_a_scene_being_generated_never_deadlock() { - let scene = Arc::new(small_scene()); - let edge = 601; // a size no other test asks for - const ASKED: usize = 24; - let (overlap, sizes) = within(120, move || { - let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); - let issued = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - for _ in 0..ASKED { - let (scene, done, issued) = (scene.clone(), done_tx.clone(), issued.clone()); - issued.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - pool.spawn(move || { - let got = scene_pixels(&scene, edge).unwrap(); - // how many requests had been made when this one came back - let _ = done.send((issued.load(std::sync::atomic::Ordering::SeqCst), got.data.len())); - }); - std::thread::sleep(std::time::Duration::from_millis(1)); - } - let replies: Vec<(usize, usize)> = (0..ASKED).map(|_| done_rx.recv().unwrap()).collect(); - (replies.iter().map(|r| r.0).min().unwrap(), replies.iter().map(|r| r.1).collect::>()) - }); - assert_eq!(sizes.len(), ASKED, "every request came back"); - assert!(sizes.iter().all(|n| *n == sizes[0] && *n > 0)); - assert!(overlap >= 4, "requests overlapped the generation (only {overlap} had been made when the first came back)"); - } - - /// However many requests (from rayon workers and other threads) arrive while a scene is generated, it is rendered - /// once, and never twice at the same time: duplicates would each hold a whole image (288 MB for a 24 MP original). - #[test] - fn a_scene_is_rendered_once_however_many_ask() { - use std::sync::atomic::{AtomicUsize, Ordering}; - let (renders, peak) = within(120, || { - let slot = Arc::new(SceneSlot::default()); - let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); - let render = { - let (running, peak, renders) = (running.clone(), peak.clone(), renders.clone()); - move |how: Render| { - let now = running.fetch_add(1, Ordering::SeqCst) + 1; - peak.fetch_max(now, Ordering::SeqCst); - renders.fetch_add(1, Ordering::SeqCst); - // work like the real render's: in parallel, the rendering thread waits for rows other threads took - use rayon::prelude::*; - let sum: u64 = match how { - Render::Parallel => (0..2_000_000u64).into_par_iter().map(|i| i % 7).sum(), - Render::Serial => (0..2_000_000u64).map(|i| i % 7).sum(), - }; - std::thread::sleep(std::time::Duration::from_millis(100)); - running.fetch_sub(1, Ordering::SeqCst); - pixels(sum as usize % 5 + 1) - } - }; - let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); - for _ in 0..16 { - let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); - pool.spawn(move || { - let _ = tx.send(slot.get_or_generate(render).unwrap()); - }); - } - for _ in 0..4 { - let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); - std::thread::spawn(move || { - let _ = tx.send(slot.get_or_generate(render).unwrap()); - }); - } - let got: Vec<_> = (0..20).map(|_| rx.recv().unwrap()).collect(); - assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); - (renders.load(Ordering::SeqCst), peak.load(Ordering::SeqCst)) - }); - assert_eq!((renders, peak), (1, 1)); - } - - /// A rayon worker (the faces scan's) renders a scene on its own thread alone, so the work stays within its pool - /// and that pool's limits; other threads render in parallel on the scene pool. - #[test] - fn a_rayon_worker_renders_alone_on_its_own_thread() { - let ((how, caller, renderer), (other_how, other_name)) = within(120, || { - let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); - let slot = SceneSlot::default(); - let mut seen = None; - pool.install(|| { - let caller = std::thread::current().id(); - slot.get_or_generate(|how| { - seen = Some((how, caller, std::thread::current().id())); - pixels(1) - }) - .unwrap(); - }); - let other = SceneSlot::default(); - let mut other_seen = None; - other - .get_or_generate(|how| { - other_seen = Some((how, std::thread::current().name().unwrap_or_default().to_string())); - pixels(1) - }) - .unwrap(); - (seen.unwrap(), other_seen.unwrap()) - }); - assert_eq!((how, renderer), (Render::Serial, caller)); - assert_eq!(other_how, Render::Parallel); - assert!(other_name.starts_with("lc-demo-scene-"), "{other_name}"); - } - - /// A caller finding a generation running waits for it instead of rendering its own (one 24 MP original costs - /// minutes of CPU time on a small machine). - #[test] - fn callers_wait_for_the_generation_running() { - let (a, b, renders) = within(120, || { - let slot = Arc::new(SceneSlot::default()); - let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (started_tx, started) = std::sync::mpsc::channel::<()>(); - let (go, go_rx) = std::sync::mpsc::channel::<()>(); - let (started_tx, go_rx) = (Arc::new(std::sync::Mutex::new(started_tx)), Arc::new(std::sync::Mutex::new(go_rx))); - let ask = |slot: &Arc| { - let (slot, renders, started_tx, go_rx) = (slot.clone(), renders.clone(), started_tx.clone(), go_rx.clone()); - std::thread::spawn(move || { - slot.get_or_generate(move |_| { - renders.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let _ = started_tx.lock().unwrap().send(()); - let _ = go_rx.lock().unwrap().recv(); - pixels(1) - }) - }) - }; - let first = ask(&slot); - started.recv().unwrap(); - let second = ask(&slot); - until_waiting(&slot, 1); - go.send(()).unwrap(); - let (a, b) = (first.join().unwrap().unwrap(), second.join().unwrap().unwrap()); - (a, b, renders.load(std::sync::atomic::Ordering::SeqCst)) - }); - assert!(Arc::ptr_eq(&a, &b) && a.width == 1, "one generation, shared"); - assert_eq!(renders, 1); - } - - /// A generation that fails (its render panicked) is an error for its callers, never a hang, and the next request - /// generates the scene again. - #[test] - fn a_failed_generation_is_an_error_and_the_next_request_tries_again() { - let (first, waiters, again) = within(120, || { - let slot = Arc::new(SceneSlot::default()); - let (started_tx, started) = std::sync::mpsc::channel::<()>(); - let (go, go_rx) = std::sync::mpsc::channel::<()>(); - let failing = { - let slot = slot.clone(); - std::thread::spawn(move || { - slot.get_or_generate(move |_| { - let _ = started_tx.send(()); - let _ = go_rx.recv(); - panic!("render failed"); - }) - }) - }; - started.recv().unwrap(); - let waiters: Vec<_> = (0..2) - .map(|_| { - let slot = slot.clone(); - std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) - }) - .collect(); - until_waiting(&slot, 2); - drop(go); - let first = failing.join().unwrap(); - let waiters: Vec<_> = waiters.into_iter().map(|w| w.join().unwrap()).collect(); - (first, waiters, slot.get_or_generate(|_| pixels(3))) - }); - assert!(first.is_err() && waiters.iter().all(Result::is_err), "the callers of the failed generation get its error"); - assert_eq!(again.unwrap().width, 3); - } - - /// Callers get the outcome of the generation they waited for, even when a retry starts (or finishes) before they - /// look: a failed generation followed by a retry is still an error for them. - #[test] - fn a_retry_never_changes_the_outcome_waited_for() { - let mut st = SlotState::default(); - let n = st.start(); - assert!(st.outcome(n).is_none(), "running"); - st.generation = Generation::Failed; - let m = st.start(); - assert!(matches!(st.outcome(n), Some(Err(_)))); - assert!(st.outcome(m).is_none()); - st.generation = Generation::Ready(Arc::new(pixels(1))); - assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded"); - assert!(matches!(st.outcome(m), Some(Ok(_)))); - // and with threads: a retry racing the waiters of a failed generation never hands them its result - let wrong = within(120, || { - let mut wrong = 0; - for _ in 0..20 { - let slot = Arc::new(SceneSlot::default()); - let (started_tx, started) = std::sync::mpsc::channel::<()>(); - let (go, go_rx) = std::sync::mpsc::channel::<()>(); - let failing = { - let slot = slot.clone(); - std::thread::spawn(move || { - slot.get_or_generate(move |_| { - let _ = started_tx.send(()); - let _ = go_rx.recv(); - panic!("render failed"); - }) - }) - }; - started.recv().unwrap(); - let waiters: Vec<_> = (0..3) - .map(|_| { - let slot = slot.clone(); - std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) - }) - .collect(); - until_waiting(&slot, 3); - let retry = { - let slot = slot.clone(); - std::thread::spawn(move || { - loop { - if matches!(slot.lock().generation, Generation::Failed) { - return slot.get_or_generate(|_| pixels(4)); - } - std::hint::spin_loop(); - } - }) - }; - drop(go); - let _ = failing.join().unwrap(); - wrong += waiters.into_iter().map(|w| w.join().unwrap()).filter(Result::is_ok).count(); - assert!(retry.join().unwrap().is_ok()); - } - wrong - }); - assert_eq!(wrong, 0, "waiters of a failed generation got a retry's pixels"); - } - - /// Generating a scene never touches the global rayon pool, so a process whose global pool couldn't start (asking it - /// anything then panics) still gets its demo scenes. Run in a process of its own: the global pool is the process's. - #[test] - fn scenes_are_generated_when_the_global_pool_is_broken() { - use std::io::Read; - use std::process::Stdio; - let mut child = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "demo::tests::with_a_broken_global_pool", "--ignored", "--test-threads=1"]) - .env("LC_DEMO_BROKEN_GLOBAL_POOL", "1") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - // Drain both pipes on threads so a chatty child can't block on a full pipe. - let drain = |mut r: Box| { - std::thread::spawn(move || { - let mut s = String::new(); - let _ = r.read_to_string(&mut s); - s - }) - }; - let (stdout, stderr) = (drain(Box::new(child.stdout.take().unwrap())), drain(Box::new(child.stderr.take().unwrap()))); - // A deadlocked child fails the test instead of hanging CI. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); - let status = loop { - if let Some(status) = child.try_wait().unwrap() { - break status; - } - if std::time::Instant::now() > deadline { - let _ = child.kill(); - let _ = child.wait(); - panic!("the child process did not finish within 120 s"); - } - std::thread::sleep(std::time::Duration::from_millis(20)); - }; - let (stdout, stderr) = (stdout.join().unwrap(), stderr.join().unwrap()); - assert!(status.success() && stdout.contains("1 passed"), "{stdout}\n{stderr}"); - } - - #[test] - #[ignore = "run in a process of its own by scenes_are_generated_when_the_global_pool_is_broken"] - fn with_a_broken_global_pool() { - if std::env::var_os("LC_DEMO_BROKEN_GLOBAL_POOL").is_none() { - return; - } - let broken = rayon::ThreadPoolBuilder::new().spawn_handler(|_| Err(std::io::Error::other("no threads"))).build_global(); - assert!(broken.is_err()); - assert!(std::panic::catch_unwind(rayon::current_num_threads).is_err(), "the global pool is broken"); - let s = small_scene(); - let got = scene_pixels(&s, 33).unwrap(); - assert_eq!(got.data, s.render_fit_serial(33).data); - } - - #[test] - fn the_cache_keeps_the_most_recent_scenes_within_its_limit() { - let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge }; - let entry = |max_edge, used, px: Option| { - let slot = Arc::new(SceneSlot::default()); - if let Some(n) = px { - slot.lock().generation = Generation::Ready(Arc::new(lightcraft_raster::Rgb32f::new(n, 1))); - } - SceneEntry { key: key(max_edge), slot, used } - }; - let px = std::mem::size_of::<[f32; 3]>(); - let mut c = SceneCache { entries: vec![entry(1, 3, Some(10)), entry(2, 1, Some(10)), entry(3, 0, None), entry(4, 2, Some(10))], tick: 3 }; - trim_scene_cache(&mut c, 25 * px); - let mut kept: Vec = c.entries.iter().map(|e| e.key.max_edge).collect(); - kept.sort_unstable(); - assert_eq!(kept, vec![1, 3, 4], "the least recently used finished scene went; the one in progress stays"); - trim_scene_cache(&mut c, 0); - assert_eq!(c.entries.iter().map(|e| e.key.max_edge).collect::>(), vec![3], "only the scene in progress is left"); - } -} +mod tests; diff --git a/crates/engine/src/demo/tests.rs b/crates/engine/src/demo/tests.rs new file mode 100644 index 000000000..21823fa9a --- /dev/null +++ b/crates/engine/src/demo/tests.rs @@ -0,0 +1,361 @@ +use super::*; + +fn small_scene() -> lightcraft_scenes::Scene { + lightcraft_scenes::demo_library().swap_remove(0) +} + +#[test] +fn a_scene_is_generated_once_per_size_and_shared() { + let s = small_scene(); + let a = scene_pixels(&s, 48).unwrap(); + let b = scene_pixels(&s, 48).unwrap(); + assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation"); + assert_eq!(a.width.max(a.height), 48); + assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly"); + let c = scene_pixels(&s, 40).unwrap(); + assert_eq!(c.width.max(c.height), 40, "another size is its own generation"); +} + +#[test] +fn concurrent_requests_wait_for_one_generation() { + let s = small_scene(); + let edge = 37; // a size no other test asks for + let got: Vec<_> = std::thread::scope(|sc| { + let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge).unwrap())).collect(); + hs.into_iter().map(|h| h.join().unwrap()).collect() + }); + assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); +} + +/// Run `f` on a thread of its own and wait for it at most `secs`: a deadlock fails the test instead of hanging it. +fn within(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv_timeout(std::time::Duration::from_secs(secs)).expect("deadlocked") +} + +/// Wait (at most ten seconds) until `n` callers wait for `slot`'s generation. +fn until_waiting(slot: &SceneSlot, n: usize) { + let started = std::time::Instant::now(); + while slot.lock().waiting < n { + assert!(started.elapsed() < std::time::Duration::from_secs(10), "the callers never got to wait"); + std::thread::sleep(std::time::Duration::from_millis(1)); + } +} + +fn pixels(n: usize) -> lightcraft_raster::Rgb32f { + lightcraft_raster::Rgb32f::new(n, 1) +} + +/// Rayon workers asking for a scene while it is generated never deadlock. The render is parallel, and a rayon worker +/// waiting for rows another worker took runs queued jobs on top of itself: generating on the caller's thread, the +/// faces scan's pool threads ended up waiting on the generation further down their own stack and hung. Requests +/// keep arriving on a four-thread pool while the scene is generated, as when several photos are queued; every one +/// of them must come back, at least four of them must have overlapped the generation, and the test waits with a +/// timeout, so a deadlock fails it instead of hanging. +#[test] +fn rayon_workers_asking_for_a_scene_being_generated_never_deadlock() { + let scene = Arc::new(small_scene()); + let edge = 601; // a size no other test asks for + const ASKED: usize = 24; + let (overlap, sizes) = within(120, move || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let issued = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + for _ in 0..ASKED { + let (scene, done, issued) = (scene.clone(), done_tx.clone(), issued.clone()); + issued.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + pool.spawn(move || { + let got = scene_pixels(&scene, edge).unwrap(); + // how many requests had been made when this one came back + let _ = done.send((issued.load(std::sync::atomic::Ordering::SeqCst), got.data.len())); + }); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let replies: Vec<(usize, usize)> = (0..ASKED).map(|_| done_rx.recv().unwrap()).collect(); + (replies.iter().map(|r| r.0).min().unwrap(), replies.iter().map(|r| r.1).collect::>()) + }); + assert_eq!(sizes.len(), ASKED, "every request came back"); + assert!(sizes.iter().all(|n| *n == sizes[0] && *n > 0)); + assert!(overlap >= 4, "requests overlapped the generation (only {overlap} had been made when the first came back)"); +} + +/// However many requests (from rayon workers and other threads) arrive while a scene is generated, it is rendered +/// once, and never twice at the same time: duplicates would each hold a whole image (288 MB for a 24 MP original). +#[test] +fn a_scene_is_rendered_once_however_many_ask() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (renders, peak) = within(120, || { + let slot = Arc::new(SceneSlot::default()); + let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); + let render = { + let (running, peak, renders) = (running.clone(), peak.clone(), renders.clone()); + move |how: Render| { + let now = running.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now, Ordering::SeqCst); + renders.fetch_add(1, Ordering::SeqCst); + // work like the real render's: in parallel, the rendering thread waits for rows other threads took + use rayon::prelude::*; + let sum: u64 = match how { + Render::Parallel => (0..2_000_000u64).into_par_iter().map(|i| i % 7).sum(), + Render::Serial => (0..2_000_000u64).map(|i| i % 7).sum(), + }; + std::thread::sleep(std::time::Duration::from_millis(100)); + running.fetch_sub(1, Ordering::SeqCst); + pixels(sum as usize % 5 + 1) + } + }; + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + for _ in 0..16 { + let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); + pool.spawn(move || { + let _ = tx.send(slot.get_or_generate(render).unwrap()); + }); + } + for _ in 0..4 { + let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); + std::thread::spawn(move || { + let _ = tx.send(slot.get_or_generate(render).unwrap()); + }); + } + let got: Vec<_> = (0..20).map(|_| rx.recv().unwrap()).collect(); + assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); + (renders.load(Ordering::SeqCst), peak.load(Ordering::SeqCst)) + }); + assert_eq!((renders, peak), (1, 1)); +} + +/// A rayon worker (the faces scan's) renders a scene on its own thread alone, so the work stays within its pool +/// and that pool's limits; other threads render in parallel on the scene pool. +#[test] +fn a_rayon_worker_renders_alone_on_its_own_thread() { + let ((how, caller, renderer), (other_how, other_name)) = within(120, || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let slot = SceneSlot::default(); + let mut seen = None; + pool.install(|| { + let caller = std::thread::current().id(); + slot.get_or_generate(|how| { + seen = Some((how, caller, std::thread::current().id())); + pixels(1) + }) + .unwrap(); + }); + let other = SceneSlot::default(); + let mut other_seen = None; + other + .get_or_generate(|how| { + other_seen = Some((how, std::thread::current().name().unwrap_or_default().to_string())); + pixels(1) + }) + .unwrap(); + (seen.unwrap(), other_seen.unwrap()) + }); + assert_eq!((how, renderer), (Render::Serial, caller)); + assert_eq!(other_how, Render::Parallel); + assert!(other_name.starts_with("lc-demo-scene-"), "{other_name}"); +} + +/// A caller finding a generation running waits for it instead of rendering its own (one 24 MP original costs +/// minutes of CPU time on a small machine). +#[test] +fn callers_wait_for_the_generation_running() { + let (a, b, renders) = within(120, || { + let slot = Arc::new(SceneSlot::default()); + let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let (started_tx, go_rx) = (Arc::new(std::sync::Mutex::new(started_tx)), Arc::new(std::sync::Mutex::new(go_rx))); + let ask = |slot: &Arc| { + let (slot, renders, started_tx, go_rx) = (slot.clone(), renders.clone(), started_tx.clone(), go_rx.clone()); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + renders.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let _ = started_tx.lock().unwrap().send(()); + let _ = go_rx.lock().unwrap().recv(); + pixels(1) + }) + }) + }; + let first = ask(&slot); + started.recv().unwrap(); + let second = ask(&slot); + until_waiting(&slot, 1); + go.send(()).unwrap(); + let (a, b) = (first.join().unwrap().unwrap(), second.join().unwrap().unwrap()); + (a, b, renders.load(std::sync::atomic::Ordering::SeqCst)) + }); + assert!(Arc::ptr_eq(&a, &b) && a.width == 1, "one generation, shared"); + assert_eq!(renders, 1); +} + +/// A generation that fails (its render panicked) is an error for its callers, never a hang, and the next request +/// generates the scene again. +#[test] +fn a_failed_generation_is_an_error_and_the_next_request_tries_again() { + let (first, waiters, again) = within(120, || { + let slot = Arc::new(SceneSlot::default()); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let failing = { + let slot = slot.clone(); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + let _ = started_tx.send(()); + let _ = go_rx.recv(); + panic!("render failed"); + }) + }) + }; + started.recv().unwrap(); + let waiters: Vec<_> = (0..2) + .map(|_| { + let slot = slot.clone(); + std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) + }) + .collect(); + until_waiting(&slot, 2); + drop(go); + let first = failing.join().unwrap(); + let waiters: Vec<_> = waiters.into_iter().map(|w| w.join().unwrap()).collect(); + (first, waiters, slot.get_or_generate(|_| pixels(3))) + }); + assert!(first.is_err() && waiters.iter().all(Result::is_err), "the callers of the failed generation get its error"); + assert_eq!(again.unwrap().width, 3); +} + +/// Callers get the outcome of the generation they waited for, even when a retry starts (or finishes) before they +/// look: a failed generation followed by a retry is still an error for them. +#[test] +fn a_retry_never_changes_the_outcome_waited_for() { + let mut st = SlotState::default(); + let n = st.start(); + assert!(st.outcome(n).is_none(), "running"); + st.generation = Generation::Failed; + let m = st.start(); + assert!(matches!(st.outcome(n), Some(Err(_)))); + assert!(st.outcome(m).is_none()); + st.generation = Generation::Ready(Arc::new(pixels(1))); + assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded"); + assert!(matches!(st.outcome(m), Some(Ok(_)))); + // and with threads: a retry racing the waiters of a failed generation never hands them its result + let wrong = within(120, || { + let mut wrong = 0; + for _ in 0..20 { + let slot = Arc::new(SceneSlot::default()); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let failing = { + let slot = slot.clone(); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + let _ = started_tx.send(()); + let _ = go_rx.recv(); + panic!("render failed"); + }) + }) + }; + started.recv().unwrap(); + let waiters: Vec<_> = (0..3) + .map(|_| { + let slot = slot.clone(); + std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) + }) + .collect(); + until_waiting(&slot, 3); + let retry = { + let slot = slot.clone(); + std::thread::spawn(move || { + loop { + if matches!(slot.lock().generation, Generation::Failed) { + return slot.get_or_generate(|_| pixels(4)); + } + std::hint::spin_loop(); + } + }) + }; + drop(go); + let _ = failing.join().unwrap(); + wrong += waiters.into_iter().map(|w| w.join().unwrap()).filter(Result::is_ok).count(); + assert!(retry.join().unwrap().is_ok()); + } + wrong + }); + assert_eq!(wrong, 0, "waiters of a failed generation got a retry's pixels"); +} + +/// Generating a scene never touches the global rayon pool, so a process whose global pool couldn't start (asking it +/// anything then panics) still gets its demo scenes. Run in a process of its own: the global pool is the process's. +#[test] +fn scenes_are_generated_when_the_global_pool_is_broken() { + use std::io::Read; + use std::process::Stdio; + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "demo::tests::with_a_broken_global_pool", "--ignored", "--test-threads=1"]) + .env("LC_DEMO_BROKEN_GLOBAL_POOL", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // Drain both pipes on threads so a chatty child can't block on a full pipe. + let drain = |mut r: Box| { + std::thread::spawn(move || { + let mut s = String::new(); + let _ = r.read_to_string(&mut s); + s + }) + }; + let (stdout, stderr) = (drain(Box::new(child.stdout.take().unwrap())), drain(Box::new(child.stderr.take().unwrap()))); + // A deadlocked child fails the test instead of hanging CI. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("the child process did not finish within 120 s"); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + let (stdout, stderr) = (stdout.join().unwrap(), stderr.join().unwrap()); + assert!(status.success() && stdout.contains("1 passed"), "{stdout}\n{stderr}"); +} + +#[test] +#[ignore = "run in a process of its own by scenes_are_generated_when_the_global_pool_is_broken"] +fn with_a_broken_global_pool() { + if std::env::var_os("LC_DEMO_BROKEN_GLOBAL_POOL").is_none() { + return; + } + let broken = rayon::ThreadPoolBuilder::new().spawn_handler(|_| Err(std::io::Error::other("no threads"))).build_global(); + assert!(broken.is_err()); + assert!(std::panic::catch_unwind(rayon::current_num_threads).is_err(), "the global pool is broken"); + let s = small_scene(); + let got = scene_pixels(&s, 33).unwrap(); + assert_eq!(got.data, s.render_fit_serial(33).data); +} + +#[test] +fn the_cache_keeps_the_most_recent_scenes_within_its_limit() { + let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge }; + let entry = |max_edge, used, px: Option| { + let slot = Arc::new(SceneSlot::default()); + if let Some(n) = px { + slot.lock().generation = Generation::Ready(Arc::new(lightcraft_raster::Rgb32f::new(n, 1))); + } + SceneEntry { key: key(max_edge), slot, used } + }; + let px = std::mem::size_of::<[f32; 3]>(); + let mut c = SceneCache { entries: vec![entry(1, 3, Some(10)), entry(2, 1, Some(10)), entry(3, 0, None), entry(4, 2, Some(10))], tick: 3 }; + trim_scene_cache(&mut c, 25 * px); + let mut kept: Vec = c.entries.iter().map(|e| e.key.max_edge).collect(); + kept.sort_unstable(); + assert_eq!(kept, vec![1, 3, 4], "the least recently used finished scene went; the one in progress stays"); + trim_scene_cache(&mut c, 0); + assert_eq!(c.entries.iter().map(|e| e.key.max_edge).collect::>(), vec![3], "only the scene in progress is left"); +} From 68abc4d4ceb6caba739b700b35ea08cb32b64358 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:55 +0000 Subject: [PATCH 046/191] refactor(lightcraft-engine): move slideshow.rs tests to crates/engine/src/slideshow/tests.rs --- crates/engine/src/slideshow.rs | 358 +-------------------------- crates/engine/src/slideshow/tests.rs | 355 ++++++++++++++++++++++++++ 2 files changed, 356 insertions(+), 357 deletions(-) create mode 100644 crates/engine/src/slideshow/tests.rs diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs index cb3a6b97d..0e1c1294d 100644 --- a/crates/engine/src/slideshow.rs +++ b/crates/engine/src/slideshow.rs @@ -607,360 +607,4 @@ impl Playback { } #[cfg(test)] -mod tests { - use super::*; - - fn ids(n: u64) -> Vec { - (1..=n).map(PhotoId).collect() - } - - fn settings(slide: f64, fade: f64) -> PlaybackSettings { - PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() } - } - - fn ph(n: u64) -> Item { - Item::Photo(PhotoId(n)) - } - - fn all_ready(_: Item) -> bool { - true - } - - #[test] - fn timeline_durations_and_fade_progress() { - let mut p = Playback::new(ids(3), Some(PhotoId(2)), settings(4.0, 1.0), 1, 10.0).unwrap(); - assert_eq!(p.current(), ph(2), "starts at the selected photo"); - assert_eq!(p.position(), (1, 3)); - assert_eq!(p.tick(13.9, all_ready), Tick::Idle); - assert_eq!(p.frame(13.9).from, None); - assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(ph(3))); - let f = p.frame(14.25); - assert_eq!((f.current, f.from), (ph(3), Some(ph(2)))); - assert!((f.progress - 0.25).abs() < 1e-6, "{f:?}"); - assert!((p.frame(14.5).progress - 0.5).abs() < 1e-6); - assert_eq!(p.tick(15.0, all_ready), Tick::Idle); - assert_eq!(p.frame(15.0).from, None, "the fade is over after fadeDuration"); - // the slide period counts from its transition: next at 18 - assert_eq!(p.tick(17.99, all_ready), Tick::Idle); - assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(ph(1)), "wraps to the photos before the start"); - assert_eq!(p.position(), (3, 3)); - } - - #[test] - fn fade_zero_is_a_cut_and_fade_color_is_reported() { - let mut p = Playback::new(ids(2), None, settings(2.0, 0.0), 1, 0.0).unwrap(); - assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(2))); - assert_eq!(p.frame(2.0).from, None); - let s = PlaybackSettings { fade_color: Some([255, 0, 0]), ..settings(2.0, 1.0) }; - let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); - p.tick(2.0, all_ready); - assert_eq!(p.frame(2.5).fade_color, Some([255, 0, 0])); - } - - #[test] - fn arrows_cut_immediately_and_restart_the_slide_time() { - let mut p = Playback::new(ids(4), None, settings(4.0, 2.0), 1, 0.0).unwrap(); - p.tick(4.0, all_ready); - assert!(p.frame(4.5).from.is_some(), "fading"); - assert_eq!(p.step(1, 4.5), Tick::Advanced(ph(3))); - assert_eq!(p.frame(4.5), Frame { current: ph(3), from: None, progress: 1.0, fade_color: None }, "a cut"); - assert_eq!(p.tick(8.4, all_ready), Tick::Idle, "full time from the step"); - assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(2))); - assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(1))); - // repeat on: before the first is the last - assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(4))); - assert_eq!(p.remaining(6.0), 4.0); - } - - #[test] - fn random_order_is_deterministic_per_seed_and_covers_every_photo_once_per_pass() { - let photos = ids(20); - let run = |seed: u64| { - let s = PlaybackSettings { random_order: true, ..settings(1.0, 0.0) }; - let mut p = Playback::new(photos.clone(), Some(PhotoId(5)), s, seed, 0.0).unwrap(); - let mut seen = vec![p.current_photo().unwrap()]; - for k in 1..60 { - p.tick(k as f64, all_ready); - seen.push(p.current_photo().unwrap()); - } - seen - }; - let a = run(42); - assert_eq!(a, run(42), "same seed, same order"); - assert_ne!(a, run(43), "another seed, another order"); - assert_eq!(a[0], PhotoId(5), "starts at the selected photo"); - assert_ne!(a[..20].to_vec(), photos, "shuffled"); - for pass in a.chunks(20).take(2) { - let mut sorted = pass.to_vec(); - sorted.sort(); - assert_eq!(sorted, photos, "every photo once per pass"); - } - assert_ne!(a[..20], a[20..40], "a new order for the next pass"); - assert!(a.windows(2).all(|w| w[0] != w[1]), "never the same photo twice in a row"); - } - - #[test] - fn repeat_off_stops_after_the_last_slide() { - let s = PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }; - let mut p = Playback::new(ids(3), Some(PhotoId(2)), s.clone(), 1, 0.0).unwrap(); - assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(3))); - assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1))); - assert_eq!(p.upcoming(2), vec![], "nothing after the last"); - assert_eq!(p.tick(3.0, all_ready), Tick::Ended); - assert!(p.is_ended()); - // → past the last one ends it too - let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); - assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(2))); - assert_eq!(p.step(1, 0.6), Tick::Ended); - // with repeat: it goes on - let mut p = Playback::new(ids(2), None, settings(1.0, 0.0), 1, 0.0).unwrap(); - for k in 1..10 { - assert!(matches!(p.tick(k as f64, all_ready), Tick::Advanced(_))); - } - } - - #[test] - fn pause_and_resume_keep_the_remaining_time() { - let mut p = Playback::new(ids(3), None, settings(4.0, 1.0), 1, 0.0).unwrap(); - assert!(p.toggle_pause(3.0)); - assert_eq!(p.tick(100.0, all_ready), Tick::Idle, "paused: nothing moves"); - assert_eq!(p.remaining(100.0), 1.0); - assert!(!p.toggle_pause(100.0)); - assert_eq!(p.tick(100.9, all_ready), Tick::Idle); - assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(ph(2)), "1 s left after resuming"); - // pausing mid-fade freezes the fade - p.toggle_pause(101.25); - assert!((p.frame(150.0).progress - 0.25).abs() < 1e-6); - p.toggle_pause(150.0); - assert!((p.frame(150.5).progress - 0.75).abs() < 1e-6); - } - - #[test] - fn a_transition_waits_for_the_next_slide_to_be_ready() { - let mut p = Playback::new(ids(3), None, settings(2.0, 1.0), 1, 0.0).unwrap(); - assert_eq!(p.upcoming(2), vec![ph(2), ph(3)], "what to prepare"); - let not_two = |id: Item| id != ph(2); - assert_eq!(p.tick(2.0, not_two), Tick::Waiting(ph(2))); - assert_eq!(p.frame(3.0), Frame { current: ph(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed"); - assert_eq!(p.waiting_for(), Some(ph(2))); - assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(ph(2))); - assert_eq!(p.frame(3.5).from, Some(ph(1)), "the fade starts once it is ready"); - assert!((p.frame(4.0).progress - 0.5).abs() < 1e-6, "and runs its full length"); - } - - #[test] - fn a_slide_s_time_starts_once_it_is_ready() { - let mut p = Playback::new(ids(3), None, settings(2.0, 0.5), 1, 0.0).unwrap(); - let none = |_: Item| false; - assert_eq!(p.tick(1.0, none), Tick::Idle); - assert_eq!(p.tick(5.0, none), Tick::Idle, "the first slide isn't rendered yet: its clock waits"); - assert_eq!(p.remaining(5.0), 2.0); - assert_eq!(p.tick(6.0, all_ready), Tick::Idle); - assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(ph(2)), "2 s from when it could be shown"); - } - - #[test] - fn hostile_settings_are_rejected_or_clamped() { - let d = PlaybackSettings::default(); - for bad in [ - json!({"slideDuration": "fast"}), - json!({"fadeDuration": null}), - json!({"repeat": 1}), - json!({"fadeColor": "#12345"}), - json!({"fadeColor": "#ééé"}), - json!({"fadeColor": "#+f+f+f"}), - json!({"fadeColor": "-12345"}), - json!({"fadeColor": [300, 0, 0]}), - json!({"fadeColor": [1, 2]}), - json!({"speed": 2}), - json!([1, 2]), - ] { - assert!(d.merged(&bad).is_err(), "{bad}"); - } - let s = d.merged(&json!({"slideDuration": 1e300, "fadeDuration": -5})).unwrap(); - assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MAX, 0.0)); - let s = d.merged(&json!({"slideDuration": 0.0, "fadeDuration": 9})).unwrap(); - assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MIN, SLIDE_MIN), "the fade fits in the slide"); - let s = d.merged(&json!({"fadeColor": "#FF8000"})).unwrap(); - assert_eq!(s.fade_color, Some([255, 128, 0])); - assert_eq!(s.to_json()["fadeColor"], "#ff8000"); - assert_eq!(d.merged(&json!({"fadeColor": [1, 2, 3]})).unwrap().fade_color, Some([1, 2, 3])); - // NaN / inf (from a hand-edited file or a caller in-process) take the defaults - let s = PlaybackSettings { slide_duration: f64::NAN, fade_duration: f64::INFINITY, ..d.clone() }.sanitized(); - assert_eq!((s.slide_duration, s.fade_duration), (d.slide_duration, d.fade_duration)); - // and the timeline never panics on hostile times - let mut p = Playback::new(ids(2), None, s, 0, f64::NAN).unwrap(); - p.tick(f64::NAN, all_ready); - p.tick(f64::INFINITY, all_ready); - p.toggle_pause(f64::NEG_INFINITY); - p.step(i32::MIN, f64::NAN); - let _ = p.frame(f64::NAN); - assert!(Playback::new(vec![], None, d, 0, 0.0).is_none()); - } - - #[test] - fn a_manual_slideshow_moves_only_with_the_arrows() { - let s = PlaybackSettings { manual: true, repeat: false, ..settings(1.0, 0.5) }; - let mut p = titled(3, s, Some(1.0), Some(1.0)); - assert_eq!(p.tick(1000.0, all_ready), Tick::Idle, "the intro stays up past its duration"); - assert_eq!(p.current(), Item::INTRO); - assert_eq!(p.remaining(1000.0), f64::INFINITY, "nothing is ever due"); - assert_eq!(p.upcoming(1), vec![ph(1)], "the next slide is still prepared for →"); - assert_eq!(p.step(1, 1000.0), Tick::Advanced(ph(1))); - assert_eq!(p.tick(5000.0, all_ready), Tick::Idle); - assert_eq!(p.current(), ph(1), "a photo stays up too"); - assert_eq!(p.step(-1, 5000.0), Tick::Advanced(Item::INTRO), "← goes back"); - assert_eq!(p.step(4, 5000.0), Tick::Advanced(Item::ENDING)); - assert_eq!(p.tick(9000.0, all_ready), Tick::Idle, "the ending stays up until → ends the show"); - assert_eq!(p.step(1, 9000.0), Tick::Ended); - // the setting round-trips and is checked like the others - let d = PlaybackSettings::default(); - assert!(!d.manual, "off by default"); - let m = d.merged(&json!({"manual": true})).unwrap(); - assert!(m.manual); - assert_eq!(m.to_json()["manual"], true); - assert!(d.merged(&json!({"manual": "yes"})).is_err()); - } - - #[test] - fn a_single_photo_repeats_without_changing() { - let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap(); - assert_eq!(p.upcoming(2), vec![]); - assert_eq!(p.tick(5.0, all_ready), Tick::Idle); - assert_eq!(p.frame(5.0).from, None); - } - - fn titled(n: u64, s: PlaybackSettings, intro: Option, ending: Option) -> Playback { - Playback::new(ids(n), None, s, 1, 0.0).unwrap().with_titles(intro, ending) - } - - #[test] - fn titles_play_once_around_the_photos_for_their_own_durations() { - let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; - let mut p = titled(3, s, Some(2.0), Some(3.0)); - assert_eq!(p.current(), Item::INTRO, "the intro comes first"); - assert_eq!((p.position(), p.title(), p.current_photo()), ((0, 3), Some(Title::Intro), None), "titles aren't photos"); - assert_eq!(p.upcoming(2), vec![ph(1), ph(2)]); - assert_eq!(p.tick(1.99, all_ready), Tick::Idle); - assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1)), "the intro's own 2 s"); - let f = p.frame(2.5); - assert_eq!((f.current, f.from), (ph(1), Some(Item::INTRO)), "the intro fades out like a slide"); - assert!((f.progress - 0.5).abs() < 1e-6); - assert_eq!(p.position(), (1, 3)); - assert_eq!(p.tick(6.0, all_ready), Tick::Advanced(ph(2))); - assert_eq!(p.tick(10.0, all_ready), Tick::Advanced(ph(3))); - assert_eq!(p.upcoming(2), vec![Item::ENDING], "the ending is prepared like a slide"); - assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(Item::ENDING)); - assert_eq!((p.position(), p.title()), ((3, 3), Some(Title::Ending))); - assert_eq!(p.frame(14.5).from, Some(ph(3)), "the ending fades in"); - assert_eq!(p.tick(16.99, all_ready), Tick::Idle); - assert_eq!(p.tick(17.0, all_ready), Tick::Ended, "the ending's own 3 s, then the show ends"); - // without titles nothing changes - let mut p = titled(2, PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }, None, None); - assert_eq!(p.current(), ph(1)); - assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(2))); - assert_eq!(p.tick(2.0, all_ready), Tick::Ended); - } - - #[test] - fn a_fade_never_outlasts_a_short_title() { - let s = PlaybackSettings { repeat: false, ..settings(4.0, 2.0) }; - let mut p = titled(1, s, Some(0.5), Some(0.5)); - assert_eq!(p.tick(0.5, all_ready), Tick::Advanced(ph(1))); - assert!((p.frame(1.5).progress - 0.5).abs() < 1e-6, "the photo fades in over the full 2 s"); - assert_eq!(p.tick(4.5, all_ready), Tick::Advanced(Item::ENDING)); - assert!((p.frame(4.75).progress - 0.5).abs() < 1e-6, "into the 0.5 s ending: a 0.5 s fade"); - assert_eq!(p.frame(5.0).from, None); - assert_eq!(p.tick(5.0, all_ready), Tick::Ended); - } - - #[test] - fn with_repeat_the_intro_plays_once_and_the_ending_never() { - let mut p = titled(2, settings(1.0, 0.0), Some(1.0), Some(1.0)); - let mut seen = vec![p.current()]; - for k in 1..12 { - p.tick(k as f64, all_ready); - seen.push(p.current()); - assert!(!p.upcoming(3).contains(&Item::ENDING), "no ending while repeating"); - } - assert_eq!(seen[0], Item::INTRO); - assert!(seen[1..].iter().all(|i| i.photo().is_some()), "passes follow each other without titles: {seen:?}"); - assert_eq!(seen[1..5], [ph(1), ph(2), ph(1), ph(2)]); - } - - #[test] - fn random_order_shuffles_the_photos_and_keeps_the_titles_in_place() { - for seed in [1, 2, 3] { - let s = PlaybackSettings { random_order: true, repeat: false, ..settings(1.0, 0.0) }; - let mut p = Playback::new(ids(10), None, s, seed, 0.0).unwrap().with_titles(Some(1.0), Some(1.0)); - let mut seen = vec![p.current()]; - let mut k = 1.0; - while p.tick(k, all_ready) != Tick::Ended { - seen.push(p.current()); - k += 1.0; - } - assert_eq!(seen.len(), 12, "{seen:?}"); - assert_eq!((seen[0], seen[11]), (Item::INTRO, Item::ENDING), "seed {seed}"); - let mut photos: Vec = seen[1..11].iter().filter_map(|i| i.photo()).collect(); - assert_ne!(photos, ids(10), "shuffled"); - photos.sort(); - assert_eq!(photos, ids(10), "every photo once, between the titles"); - } - } - - #[test] - fn arrows_step_across_the_titles() { - let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; - let mut p = titled(3, s, Some(2.0), Some(2.0)); - assert_eq!(p.step(-1, 0.5), Tick::Advanced(Item::INTRO), "← on the intro stays there"); - assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(1))); - assert_eq!(p.step(-1, 0.6), Tick::Advanced(Item::INTRO), "← from the first photo: back to the intro"); - assert_eq!(p.remaining(0.6), 2.0, "with its full time"); - assert_eq!(p.step(3, 1.0), Tick::Advanced(ph(3))); - assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); - assert_eq!(p.position(), (3, 3)); - assert_eq!(p.step(-1, 1.0), Tick::Advanced(ph(3)), "← from the ending: the last photo"); - assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); - assert_eq!(p.step(1, 1.0), Tick::Ended, "→ past the ending ends the show"); - assert_eq!(p.step(1, 2.0), Tick::Ended); - // repeating: ← from the first photo of a later pass wraps to the last, not the intro - let mut p = titled(3, settings(4.0, 0.0), Some(2.0), None); - assert_eq!(p.step(4, 0.0), Tick::Advanced(ph(1)), "intro, 1, 2, 3, then 1 again"); - assert_eq!(p.step(-1, 0.0), Tick::Advanced(ph(3))); - assert_eq!(p.step(i32::MAX, 0.0), Tick::Advanced(p.current())); - assert_eq!(p.step(i32::MIN, f64::NAN), Tick::Advanced(p.current())); - } - - #[test] - fn a_title_pauses_and_waits_like_a_slide() { - let mut p = titled(2, settings(4.0, 1.0), Some(3.0), None); - let not_intro = |i: Item| i != Item::INTRO; - assert_eq!(p.tick(5.0, not_intro), Tick::Idle, "the intro isn't rendered yet: its clock waits"); - assert_eq!(p.remaining(5.0), 3.0); - assert!(p.toggle_pause(6.0)); - assert_eq!(p.tick(100.0, all_ready), Tick::Idle); - assert_eq!(p.remaining(100.0), 2.0, "paused on the intro"); - assert!(!p.toggle_pause(100.0)); - assert_eq!(p.tick(101.9, all_ready), Tick::Idle); - let not_one = |i: Item| i != ph(1); - assert_eq!(p.tick(102.0, not_one), Tick::Waiting(ph(1)), "the first photo isn't ready: the intro is held"); - assert_eq!(p.frame(102.5).current, Item::INTRO); - assert_eq!(p.tick(103.0, all_ready), Tick::Advanced(ph(1))); - } - - #[test] - fn hostile_title_durations_are_clamped() { - let p = titled(2, settings(4.0, 1.0), Some(f64::NAN), Some(1e300)); - assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (4.0, SLIDE_MAX)); - let p = titled(2, settings(4.0, 1.0), Some(-1.0), Some(f64::NEG_INFINITY)); - assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (SLIDE_MIN, 4.0)); - // a single photo between titles, repeating: the photo holds after the intro - let mut p = titled(1, settings(1.0, 0.0), Some(1.0), Some(1.0)); - assert_eq!(p.upcoming(3), vec![ph(1)]); - assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(1))); - assert_eq!(p.upcoming(3), vec![]); - assert_eq!(p.tick(9.0, all_ready), Tick::Idle); - assert_eq!(Title::parse(" Ending "), Some(Title::Ending)); - assert_eq!(Title::parse("credits"), None); - } -} +mod tests; diff --git a/crates/engine/src/slideshow/tests.rs b/crates/engine/src/slideshow/tests.rs new file mode 100644 index 000000000..d329d5993 --- /dev/null +++ b/crates/engine/src/slideshow/tests.rs @@ -0,0 +1,355 @@ +use super::*; + +fn ids(n: u64) -> Vec { + (1..=n).map(PhotoId).collect() +} + +fn settings(slide: f64, fade: f64) -> PlaybackSettings { + PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() } +} + +fn ph(n: u64) -> Item { + Item::Photo(PhotoId(n)) +} + +fn all_ready(_: Item) -> bool { + true +} + +#[test] +fn timeline_durations_and_fade_progress() { + let mut p = Playback::new(ids(3), Some(PhotoId(2)), settings(4.0, 1.0), 1, 10.0).unwrap(); + assert_eq!(p.current(), ph(2), "starts at the selected photo"); + assert_eq!(p.position(), (1, 3)); + assert_eq!(p.tick(13.9, all_ready), Tick::Idle); + assert_eq!(p.frame(13.9).from, None); + assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(ph(3))); + let f = p.frame(14.25); + assert_eq!((f.current, f.from), (ph(3), Some(ph(2)))); + assert!((f.progress - 0.25).abs() < 1e-6, "{f:?}"); + assert!((p.frame(14.5).progress - 0.5).abs() < 1e-6); + assert_eq!(p.tick(15.0, all_ready), Tick::Idle); + assert_eq!(p.frame(15.0).from, None, "the fade is over after fadeDuration"); + // the slide period counts from its transition: next at 18 + assert_eq!(p.tick(17.99, all_ready), Tick::Idle); + assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(ph(1)), "wraps to the photos before the start"); + assert_eq!(p.position(), (3, 3)); +} + +#[test] +fn fade_zero_is_a_cut_and_fade_color_is_reported() { + let mut p = Playback::new(ids(2), None, settings(2.0, 0.0), 1, 0.0).unwrap(); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.frame(2.0).from, None); + let s = PlaybackSettings { fade_color: Some([255, 0, 0]), ..settings(2.0, 1.0) }; + let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); + p.tick(2.0, all_ready); + assert_eq!(p.frame(2.5).fade_color, Some([255, 0, 0])); +} + +#[test] +fn arrows_cut_immediately_and_restart_the_slide_time() { + let mut p = Playback::new(ids(4), None, settings(4.0, 2.0), 1, 0.0).unwrap(); + p.tick(4.0, all_ready); + assert!(p.frame(4.5).from.is_some(), "fading"); + assert_eq!(p.step(1, 4.5), Tick::Advanced(ph(3))); + assert_eq!(p.frame(4.5), Frame { current: ph(3), from: None, progress: 1.0, fade_color: None }, "a cut"); + assert_eq!(p.tick(8.4, all_ready), Tick::Idle, "full time from the step"); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(2))); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(1))); + // repeat on: before the first is the last + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(4))); + assert_eq!(p.remaining(6.0), 4.0); +} + +#[test] +fn random_order_is_deterministic_per_seed_and_covers_every_photo_once_per_pass() { + let photos = ids(20); + let run = |seed: u64| { + let s = PlaybackSettings { random_order: true, ..settings(1.0, 0.0) }; + let mut p = Playback::new(photos.clone(), Some(PhotoId(5)), s, seed, 0.0).unwrap(); + let mut seen = vec![p.current_photo().unwrap()]; + for k in 1..60 { + p.tick(k as f64, all_ready); + seen.push(p.current_photo().unwrap()); + } + seen + }; + let a = run(42); + assert_eq!(a, run(42), "same seed, same order"); + assert_ne!(a, run(43), "another seed, another order"); + assert_eq!(a[0], PhotoId(5), "starts at the selected photo"); + assert_ne!(a[..20].to_vec(), photos, "shuffled"); + for pass in a.chunks(20).take(2) { + let mut sorted = pass.to_vec(); + sorted.sort(); + assert_eq!(sorted, photos, "every photo once per pass"); + } + assert_ne!(a[..20], a[20..40], "a new order for the next pass"); + assert!(a.windows(2).all(|w| w[0] != w[1]), "never the same photo twice in a row"); +} + +#[test] +fn repeat_off_stops_after_the_last_slide() { + let s = PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }; + let mut p = Playback::new(ids(3), Some(PhotoId(2)), s.clone(), 1, 0.0).unwrap(); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(3))); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1))); + assert_eq!(p.upcoming(2), vec![], "nothing after the last"); + assert_eq!(p.tick(3.0, all_ready), Tick::Ended); + assert!(p.is_ended()); + // → past the last one ends it too + let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); + assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(2))); + assert_eq!(p.step(1, 0.6), Tick::Ended); + // with repeat: it goes on + let mut p = Playback::new(ids(2), None, settings(1.0, 0.0), 1, 0.0).unwrap(); + for k in 1..10 { + assert!(matches!(p.tick(k as f64, all_ready), Tick::Advanced(_))); + } +} + +#[test] +fn pause_and_resume_keep_the_remaining_time() { + let mut p = Playback::new(ids(3), None, settings(4.0, 1.0), 1, 0.0).unwrap(); + assert!(p.toggle_pause(3.0)); + assert_eq!(p.tick(100.0, all_ready), Tick::Idle, "paused: nothing moves"); + assert_eq!(p.remaining(100.0), 1.0); + assert!(!p.toggle_pause(100.0)); + assert_eq!(p.tick(100.9, all_ready), Tick::Idle); + assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(ph(2)), "1 s left after resuming"); + // pausing mid-fade freezes the fade + p.toggle_pause(101.25); + assert!((p.frame(150.0).progress - 0.25).abs() < 1e-6); + p.toggle_pause(150.0); + assert!((p.frame(150.5).progress - 0.75).abs() < 1e-6); +} + +#[test] +fn a_transition_waits_for_the_next_slide_to_be_ready() { + let mut p = Playback::new(ids(3), None, settings(2.0, 1.0), 1, 0.0).unwrap(); + assert_eq!(p.upcoming(2), vec![ph(2), ph(3)], "what to prepare"); + let not_two = |id: Item| id != ph(2); + assert_eq!(p.tick(2.0, not_two), Tick::Waiting(ph(2))); + assert_eq!(p.frame(3.0), Frame { current: ph(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed"); + assert_eq!(p.waiting_for(), Some(ph(2))); + assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.frame(3.5).from, Some(ph(1)), "the fade starts once it is ready"); + assert!((p.frame(4.0).progress - 0.5).abs() < 1e-6, "and runs its full length"); +} + +#[test] +fn a_slide_s_time_starts_once_it_is_ready() { + let mut p = Playback::new(ids(3), None, settings(2.0, 0.5), 1, 0.0).unwrap(); + let none = |_: Item| false; + assert_eq!(p.tick(1.0, none), Tick::Idle); + assert_eq!(p.tick(5.0, none), Tick::Idle, "the first slide isn't rendered yet: its clock waits"); + assert_eq!(p.remaining(5.0), 2.0); + assert_eq!(p.tick(6.0, all_ready), Tick::Idle); + assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(ph(2)), "2 s from when it could be shown"); +} + +#[test] +fn hostile_settings_are_rejected_or_clamped() { + let d = PlaybackSettings::default(); + for bad in [ + json!({"slideDuration": "fast"}), + json!({"fadeDuration": null}), + json!({"repeat": 1}), + json!({"fadeColor": "#12345"}), + json!({"fadeColor": "#ééé"}), + json!({"fadeColor": "#+f+f+f"}), + json!({"fadeColor": "-12345"}), + json!({"fadeColor": [300, 0, 0]}), + json!({"fadeColor": [1, 2]}), + json!({"speed": 2}), + json!([1, 2]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d.merged(&json!({"slideDuration": 1e300, "fadeDuration": -5})).unwrap(); + assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MAX, 0.0)); + let s = d.merged(&json!({"slideDuration": 0.0, "fadeDuration": 9})).unwrap(); + assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MIN, SLIDE_MIN), "the fade fits in the slide"); + let s = d.merged(&json!({"fadeColor": "#FF8000"})).unwrap(); + assert_eq!(s.fade_color, Some([255, 128, 0])); + assert_eq!(s.to_json()["fadeColor"], "#ff8000"); + assert_eq!(d.merged(&json!({"fadeColor": [1, 2, 3]})).unwrap().fade_color, Some([1, 2, 3])); + // NaN / inf (from a hand-edited file or a caller in-process) take the defaults + let s = PlaybackSettings { slide_duration: f64::NAN, fade_duration: f64::INFINITY, ..d.clone() }.sanitized(); + assert_eq!((s.slide_duration, s.fade_duration), (d.slide_duration, d.fade_duration)); + // and the timeline never panics on hostile times + let mut p = Playback::new(ids(2), None, s, 0, f64::NAN).unwrap(); + p.tick(f64::NAN, all_ready); + p.tick(f64::INFINITY, all_ready); + p.toggle_pause(f64::NEG_INFINITY); + p.step(i32::MIN, f64::NAN); + let _ = p.frame(f64::NAN); + assert!(Playback::new(vec![], None, d, 0, 0.0).is_none()); +} + +#[test] +fn a_manual_slideshow_moves_only_with_the_arrows() { + let s = PlaybackSettings { manual: true, repeat: false, ..settings(1.0, 0.5) }; + let mut p = titled(3, s, Some(1.0), Some(1.0)); + assert_eq!(p.tick(1000.0, all_ready), Tick::Idle, "the intro stays up past its duration"); + assert_eq!(p.current(), Item::INTRO); + assert_eq!(p.remaining(1000.0), f64::INFINITY, "nothing is ever due"); + assert_eq!(p.upcoming(1), vec![ph(1)], "the next slide is still prepared for →"); + assert_eq!(p.step(1, 1000.0), Tick::Advanced(ph(1))); + assert_eq!(p.tick(5000.0, all_ready), Tick::Idle); + assert_eq!(p.current(), ph(1), "a photo stays up too"); + assert_eq!(p.step(-1, 5000.0), Tick::Advanced(Item::INTRO), "← goes back"); + assert_eq!(p.step(4, 5000.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.tick(9000.0, all_ready), Tick::Idle, "the ending stays up until → ends the show"); + assert_eq!(p.step(1, 9000.0), Tick::Ended); + // the setting round-trips and is checked like the others + let d = PlaybackSettings::default(); + assert!(!d.manual, "off by default"); + let m = d.merged(&json!({"manual": true})).unwrap(); + assert!(m.manual); + assert_eq!(m.to_json()["manual"], true); + assert!(d.merged(&json!({"manual": "yes"})).is_err()); +} + +#[test] +fn a_single_photo_repeats_without_changing() { + let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap(); + assert_eq!(p.upcoming(2), vec![]); + assert_eq!(p.tick(5.0, all_ready), Tick::Idle); + assert_eq!(p.frame(5.0).from, None); +} + +fn titled(n: u64, s: PlaybackSettings, intro: Option, ending: Option) -> Playback { + Playback::new(ids(n), None, s, 1, 0.0).unwrap().with_titles(intro, ending) +} + +#[test] +fn titles_play_once_around_the_photos_for_their_own_durations() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; + let mut p = titled(3, s, Some(2.0), Some(3.0)); + assert_eq!(p.current(), Item::INTRO, "the intro comes first"); + assert_eq!((p.position(), p.title(), p.current_photo()), ((0, 3), Some(Title::Intro), None), "titles aren't photos"); + assert_eq!(p.upcoming(2), vec![ph(1), ph(2)]); + assert_eq!(p.tick(1.99, all_ready), Tick::Idle); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1)), "the intro's own 2 s"); + let f = p.frame(2.5); + assert_eq!((f.current, f.from), (ph(1), Some(Item::INTRO)), "the intro fades out like a slide"); + assert!((f.progress - 0.5).abs() < 1e-6); + assert_eq!(p.position(), (1, 3)); + assert_eq!(p.tick(6.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.tick(10.0, all_ready), Tick::Advanced(ph(3))); + assert_eq!(p.upcoming(2), vec![Item::ENDING], "the ending is prepared like a slide"); + assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(Item::ENDING)); + assert_eq!((p.position(), p.title()), ((3, 3), Some(Title::Ending))); + assert_eq!(p.frame(14.5).from, Some(ph(3)), "the ending fades in"); + assert_eq!(p.tick(16.99, all_ready), Tick::Idle); + assert_eq!(p.tick(17.0, all_ready), Tick::Ended, "the ending's own 3 s, then the show ends"); + // without titles nothing changes + let mut p = titled(2, PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }, None, None); + assert_eq!(p.current(), ph(1)); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.tick(2.0, all_ready), Tick::Ended); +} + +#[test] +fn a_fade_never_outlasts_a_short_title() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 2.0) }; + let mut p = titled(1, s, Some(0.5), Some(0.5)); + assert_eq!(p.tick(0.5, all_ready), Tick::Advanced(ph(1))); + assert!((p.frame(1.5).progress - 0.5).abs() < 1e-6, "the photo fades in over the full 2 s"); + assert_eq!(p.tick(4.5, all_ready), Tick::Advanced(Item::ENDING)); + assert!((p.frame(4.75).progress - 0.5).abs() < 1e-6, "into the 0.5 s ending: a 0.5 s fade"); + assert_eq!(p.frame(5.0).from, None); + assert_eq!(p.tick(5.0, all_ready), Tick::Ended); +} + +#[test] +fn with_repeat_the_intro_plays_once_and_the_ending_never() { + let mut p = titled(2, settings(1.0, 0.0), Some(1.0), Some(1.0)); + let mut seen = vec![p.current()]; + for k in 1..12 { + p.tick(k as f64, all_ready); + seen.push(p.current()); + assert!(!p.upcoming(3).contains(&Item::ENDING), "no ending while repeating"); + } + assert_eq!(seen[0], Item::INTRO); + assert!(seen[1..].iter().all(|i| i.photo().is_some()), "passes follow each other without titles: {seen:?}"); + assert_eq!(seen[1..5], [ph(1), ph(2), ph(1), ph(2)]); +} + +#[test] +fn random_order_shuffles_the_photos_and_keeps_the_titles_in_place() { + for seed in [1, 2, 3] { + let s = PlaybackSettings { random_order: true, repeat: false, ..settings(1.0, 0.0) }; + let mut p = Playback::new(ids(10), None, s, seed, 0.0).unwrap().with_titles(Some(1.0), Some(1.0)); + let mut seen = vec![p.current()]; + let mut k = 1.0; + while p.tick(k, all_ready) != Tick::Ended { + seen.push(p.current()); + k += 1.0; + } + assert_eq!(seen.len(), 12, "{seen:?}"); + assert_eq!((seen[0], seen[11]), (Item::INTRO, Item::ENDING), "seed {seed}"); + let mut photos: Vec = seen[1..11].iter().filter_map(|i| i.photo()).collect(); + assert_ne!(photos, ids(10), "shuffled"); + photos.sort(); + assert_eq!(photos, ids(10), "every photo once, between the titles"); + } +} + +#[test] +fn arrows_step_across_the_titles() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; + let mut p = titled(3, s, Some(2.0), Some(2.0)); + assert_eq!(p.step(-1, 0.5), Tick::Advanced(Item::INTRO), "← on the intro stays there"); + assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(1))); + assert_eq!(p.step(-1, 0.6), Tick::Advanced(Item::INTRO), "← from the first photo: back to the intro"); + assert_eq!(p.remaining(0.6), 2.0, "with its full time"); + assert_eq!(p.step(3, 1.0), Tick::Advanced(ph(3))); + assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.position(), (3, 3)); + assert_eq!(p.step(-1, 1.0), Tick::Advanced(ph(3)), "← from the ending: the last photo"); + assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.step(1, 1.0), Tick::Ended, "→ past the ending ends the show"); + assert_eq!(p.step(1, 2.0), Tick::Ended); + // repeating: ← from the first photo of a later pass wraps to the last, not the intro + let mut p = titled(3, settings(4.0, 0.0), Some(2.0), None); + assert_eq!(p.step(4, 0.0), Tick::Advanced(ph(1)), "intro, 1, 2, 3, then 1 again"); + assert_eq!(p.step(-1, 0.0), Tick::Advanced(ph(3))); + assert_eq!(p.step(i32::MAX, 0.0), Tick::Advanced(p.current())); + assert_eq!(p.step(i32::MIN, f64::NAN), Tick::Advanced(p.current())); +} + +#[test] +fn a_title_pauses_and_waits_like_a_slide() { + let mut p = titled(2, settings(4.0, 1.0), Some(3.0), None); + let not_intro = |i: Item| i != Item::INTRO; + assert_eq!(p.tick(5.0, not_intro), Tick::Idle, "the intro isn't rendered yet: its clock waits"); + assert_eq!(p.remaining(5.0), 3.0); + assert!(p.toggle_pause(6.0)); + assert_eq!(p.tick(100.0, all_ready), Tick::Idle); + assert_eq!(p.remaining(100.0), 2.0, "paused on the intro"); + assert!(!p.toggle_pause(100.0)); + assert_eq!(p.tick(101.9, all_ready), Tick::Idle); + let not_one = |i: Item| i != ph(1); + assert_eq!(p.tick(102.0, not_one), Tick::Waiting(ph(1)), "the first photo isn't ready: the intro is held"); + assert_eq!(p.frame(102.5).current, Item::INTRO); + assert_eq!(p.tick(103.0, all_ready), Tick::Advanced(ph(1))); +} + +#[test] +fn hostile_title_durations_are_clamped() { + let p = titled(2, settings(4.0, 1.0), Some(f64::NAN), Some(1e300)); + assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (4.0, SLIDE_MAX)); + let p = titled(2, settings(4.0, 1.0), Some(-1.0), Some(f64::NEG_INFINITY)); + assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (SLIDE_MIN, 4.0)); + // a single photo between titles, repeating: the photo holds after the intro + let mut p = titled(1, settings(1.0, 0.0), Some(1.0), Some(1.0)); + assert_eq!(p.upcoming(3), vec![ph(1)]); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(1))); + assert_eq!(p.upcoming(3), vec![]); + assert_eq!(p.tick(9.0, all_ready), Tick::Idle); + assert_eq!(Title::parse(" Ending "), Some(Title::Ending)); + assert_eq!(Title::parse("credits"), None); +} From 89083b6c77d6b947337c84d09f033608c695d712 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:56 +0000 Subject: [PATCH 047/191] refactor(lightcraft-engine): move camera_preview.rs tests to crates/engine/src/camera_preview/tests.rs --- crates/engine/src/camera_preview.rs | 2449 +-------------------- crates/engine/src/camera_preview/tests.rs | 2446 ++++++++++++++++++++ 2 files changed, 2447 insertions(+), 2448 deletions(-) create mode 100644 crates/engine/src/camera_preview/tests.rs diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 1d1d7468e..65e3739a6 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -2100,2451 +2100,4 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - fn steep_toe_scene() -> (Rgb32f, Rgb32f) { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - // A broad scene with a steep camera toe below the old JPEG floor of 0.015. - // Permute brightness so the held-out pixels span the same range independently. - let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25; - let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025]; - *src = rgb.map(|v| v * y / luminance_2020(rgb)); - let display = 3.0 * y * (y / (y + 0.02)).powi(2); - *dst = src.map(|v| v * display / y); - } - (sensor, reference) - } - - #[test] - fn camera_tone_follows_the_jpegs_deep_shadows() { - let (sensor, reference) = steep_toe_scene(); - let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap(); - let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); - assert!(dark.len() > 256); - let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; - assert!(error < 0.001, "deep-shadow luminance error {error}"); - assert_eq!(look.matrix, Mat3::IDENTITY); - } - - #[test] - fn colour_holdouts_stay_out_of_the_wider_tone_training() { - let (sensor, reference) = steep_toe_scene(); - let fit = |reference: &Rgb32f| { - let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None, false).unwrap(); - fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap() - }; - let expected = fit(&reference); - let mut changed = reference.clone(); - let mut index = 0; - for (x, y) in sensor.data.iter().zip(&mut changed.data) { - if colour_pair(*x, *y) { - if index % 3 == 0 { - *y = y.map(|v| v * 1.1); - assert!(colour_pair(*x, *y)); - } - index += 1; - } - } - assert_eq!(fit(&changed), expected); - } - - #[test] - fn camera_toe_is_robust_to_shadow_noise() { - let (sensor, clean) = steep_toe_scene(); - let mut noisy = clean.clone(); - for (i, p) in noisy.data.iter_mut().enumerate() { - if luminance_2020(*p) < 0.015 { - // Quantized JPEG levels with bounded noise and occasional compression outliers. - let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004; - *p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003); - if i % 23 == 0 { - *p = [0.014; 3]; - } - } - } - let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap(); - let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); - let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; - assert!(error < 0.001, "noisy shadow luminance error {error}"); - assert_eq!(look.tone.apply(0.0), 0.0); - assert_eq!(look.matrix, Mat3::IDENTITY); - for i in 1..1000 { - assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0)); - } - } - - #[test] - fn camera_toe_requires_lower_independent_dark_error() { - for holdout_matches_original in [true, false] { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 }; - *src = [y; 3]; - // Midtones teach the original slope of 3. Dark training teaches 0.3, - // but the independent dark holdout can agree with either slope. - let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 }; - *dst = [y * slope; 3]; - assert_eq!(colour_pair(*src, *dst), i >= 600); - } - let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None, false).unwrap(); - let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap(); - // Both sample sets start at a multiple of three, so this split matches - // the helper's separate colour and extra-sample indices. - let candidate = fit_tone( - sensor - .data - .iter() - .zip(&reference.data) - .enumerate() - .filter(|(i, _)| i % 3 != 0) - .map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from)))) - .collect(), - ) - .unwrap(); - let held: Pairs = - sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect(); - assert_eq!(held.len(), 200); - assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y)))); - assert!(rank_correlation_ties(&held) > 0.99); - let error = |tone: &CameraTone| { - held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::() - }; - assert_ne!(candidate, original); - let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap(); - if holdout_matches_original { - assert!(error(&candidate) > error(&original) + 0.1); - assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve"); - } else { - assert!(error(&candidate) < error(&original) * 0.99); - assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate"); - } - let mut previous = 0.0; - assert_eq!(selected.apply(0.0), 0.0); - for i in 0..10_000 { - let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0)); - assert!(y.is_finite() && y >= previous && y <= 1.0); - previous = y; - } - assert_eq!(selected.apply(f32::NAN), 0.0); - assert_eq!(selected.apply(f32::INFINITY), 0.0); - } - } - - #[test] - fn unreliable_dark_pairs_keep_the_original_tone_and_colour() { - let (sensor, reference) = steep_toe_scene(); - let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect(); - let mut excluded = sensor.clone(); - for &i in &dark { - excluded.data[i] = [f32::NAN; 3]; - } - let colour = Some((Mat3::IDENTITY, None)); - let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap(); - for case in 0..6 { - let (mut x, mut y) = (sensor.clone(), reference.clone()); - for (j, &i) in dark.iter().enumerate() { - match case { - 0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal - 1 => x.data[i] = [0.00001; 3], // below the sensor noise floor - 2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs - 3 => x.data[i] = [0.003; 3], // no scene luminance spread - 4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe - 5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise - _ => {} - } - } - let look = fit_pairs_with(&x, &y, colour.clone()).unwrap(); - assert_eq!(look.tone, original.tone, "case {case}"); - assert_eq!(look.matrix, original.matrix, "case {case}"); - assert!(look.hue_sat.is_none()); - } - } - - #[test] - fn camera_jpeg_matches_the_sensor_proxy_size_exactly() { - // An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy - // it is decoded at 384×257 (`proxies`: twice the proxy edge) next to a 192×128 sensor proxy. - let sensor = fit(&Rgb32f::new(9504, 6336), PROFILE_PROXY, PROFILE_PROXY, Filter::Box); - assert_eq!((sensor.width, sensor.height), (192, 128)); - let jpeg = Rgb32f::new(384, 257); - // what fitting gave before: a pixel short, so every pair was rejected - assert_eq!(fit(&jpeg, sensor.width, sensor.height, Filter::Box).width, 191); - let r = reference_at(&jpeg, &sensor); - assert_eq!((r.width, r.height), (192, 128)); - // and at the single-photo proxy size, where fitting happened to land on the same size - let sensor = fit(&Rgb32f::new(9504, 6336), PROXY, PROXY, Filter::Box); - let r = reference_at(&Rgb32f::new(192, 129), &sensor); - assert_eq!((r.width, r.height), (sensor.width, sensor.height)); - } - - #[test] - fn linear_arw_gets_a_sensor_proxy_without_demosaicing() { - use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect}; - let mut raw = RawImage { - format: RawFormat::Arw, - width: 32, - height: 32, - cpp: 3, - data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)), - cfa: None, - bits: 16, - black: BlackLevel::uniform(0.0), - white: vec![1.0], - active_area: Rect::new(0, 0, 32, 32), - crop: Rect::new(0, 0, 32, 32), - orientation: Orientation::from_exif(1), - color: ColorData::default(), - wb_multipliers: Some([1.0; 3]), - linearized: true, - pixel_aspect: 1.0, - opcodes: OpcodeLists::default(), - metadata: lightcraft_meta::Metadata::default(), - lens_metadata: Default::default(), - }; - let proxy = sensor_proxy(&raw, 2, 384).unwrap(); - assert_eq!((proxy.width, proxy.height), (32, 32)); - assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]); - raw.data = RawData::F32(Vec::new()); - assert!(sensor_proxy(&raw, 2, 384).is_none()); - } - #[test] - fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let mut sensor = Rgb32f::new(64, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.05 + (i % 31) as f32 * 0.017; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); - } - let original = sensor.clone(); - let fit = fit_pairs(&sensor, &reference).unwrap(); - assert_eq!(sensor.data, original.data); - let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); - let error: f64 = sensor - .data - .iter() - .zip(&reference.data) - .map(|(x, y)| { - let p = displayed(fit.matrix.apply(x.map(f64::from)), &tone); - (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 - }) - .sum::() - / sensor.data.len() as f64; - assert!(error.sqrt() < 0.025, "{error}"); - // The colour transform is homogeneous; tone mapping happens only after exposure. - let p = fit.matrix.apply([2.0, 2.0, 2.0]); - assert!(luma(p) > 1.0); - assert!(tone.apply(0.2) < tone.apply(0.4)); - } - #[test] - fn accepts_a_much_better_fit_despite_local_camera_processing() { - // The camera JPEG departs from any global matrix + curve (local tone, vignetting): ±0.12 - // per-pixel deviations, ~0.07 RMS. The fit is still far closer than the fallback. - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let mut sensor = Rgb32f::new(64, 64); - let mut reference = sensor.clone(); - let mut seed = 0x2545_f491_u32; - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.05 + (i % 31) as f32 * 0.017; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - let noise = (seed % 2001) as f32 / 1000.0 - 1.0; - (v * (1.0 - (-2.5 * y).exp()) / y + 0.12 * noise).clamp(0.005, 0.97) - }); - } - assert!(fit_pairs(&sensor, &reference).is_some()); - } - - /// A camera that rotates saturated yellow-greens toward green (like Sony's "Standard" on a lime - /// shirt) can't be followed by a matrix alone: the fitted hue/saturation table must close most - /// of the gap, keep neutrals neutral and keep luminance. - #[test] - fn hue_table_follows_a_hue_dependent_camera_rendering() { - let to = to_prophoto(); - let from = to.inverse().unwrap(); - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - let mut lime = Vec::new(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.05 + (i % 29) as f32 * 0.02; - // mostly greys and mild colours, plus a patch of saturated yellow-green - *src = if i % 7 == 0 { - [ev * 0.75, ev, ev * 0.2] - } else { - [ev * (0.85 + (i % 11) as f32 * 0.03), ev, ev * (0.85 + (i % 13) as f32 * 0.025)] - }; - let (h, s) = hue_saturation(to.apply(src.map(f64::from))).unwrap(); - // the camera turns hues in 50°..110° (ProPhoto) by up to +15° and boosts their saturation - let k = (1.0 - ((h - 80.0) / 30.0).powi(2)).max(0.0) * s.min(1.0); - let mut p = to.apply(src.map(f64::from)).map(|v| v as f32); - let table = HsvTable { - hue_divisions: 1, - sat_divisions: 2, - val_divisions: 1, - data: vec![[0.0, 1.0, 1.0], [15.0 * k as f32, 1.0 + 0.3 * k as f32, 1.0]], - srgb_value: false, - }; - p = table.apply(p); - let p = from.apply(p.map(f64::from)).map(|v| v as f32); - let scale = luminance_2020(*src) / luminance_2020(p); - let p = p.map(|v| v * scale); - let y = luminance_2020(p); - *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); - if i % 7 == 0 { - lime.push(i); - } - } - let fit = fit_pairs(&sensor, &reference).unwrap(); - let table = fit.hue_sat.as_ref().expect("a hue/saturation table is fitted"); - let hue_sat = HueSat::new(table).unwrap(); - let hue_error = |with_table: bool| { - lime.iter() - .map(|&i| { - let p = fit.matrix.apply(sensor.data[i].map(f64::from)).map(|v| v as f32); - let p = if with_table { hue_sat.apply(p) } else { p }; - let (h, _) = hue_saturation(to.apply(p.map(f64::from))).unwrap(); - let (t, _) = hue_saturation(to.apply(reference.data[i].map(f64::from))).unwrap(); - ((h - t + 180.0).rem_euclid(360.0) - 180.0).abs() - }) - .sum::() - / lime.len() as f64 - }; - let (before, after) = (hue_error(false), hue_error(true)); - assert!(after < before * 0.5, "lime hue error {before:.2}° -> {after:.2}°"); - // neutrals pass through and luminance is kept - let grey = [0.3, 0.3, 0.3]; - assert!(hue_sat.apply(grey).iter().all(|v| (v - 0.3).abs() < 1e-4), "{:?}", hue_sat.apply(grey)); - let green = [0.2, 0.4, 0.05]; - assert!((luminance_2020(hue_sat.apply(green)) - luminance_2020(green)).abs() < 1e-5); - } - - /// A camera that saturates shadows and bleaches highlights toward white (a per-channel curve), - /// which a luminance tone curve can't: the fitted chroma curve must follow it, so bright - /// warm-tinted rock renders white instead of cream. - #[test] - fn chroma_curve_follows_highlight_bleaching() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let camera_chroma = |o: f32| 1.3 - 1.1 * o; - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.02 + (i % 37) as f32 * 0.03; - *src = [ev * (0.75 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - let o = 1.0 - (-2.5 * y).exp(); - let k = camera_chroma(o); - *dst = p.map(|v| (o + (v * o / y - o) * k).clamp(0.0, 0.999)); - } - let fit = fit_pairs(&sensor, &reference).unwrap(); - // relative to the matrix, which already carries the average colourfulness - let chroma = fit.tone.chroma(); - assert!(chroma[6] < 0.5 * chroma[1], "highlights bleach relative to shadows: {chroma:?}"); - // and the rendered highlights land on the camera's, far closer than without the curve - let with = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); - let without = ToneMap::camera(&fit.tone.with_chroma([1.0; lightcraft_pipeline::tone::CHROMA_N]).unwrap(), 0.0, 0.0, 0.0); - let highlight_error = |map: &ToneMap| -> f64 { - sensor - .data - .iter() - .zip(&reference.data) - .filter(|(_, y)| luminance_2020(**y) > 0.7) - .map(|(x, y)| { - let p = displayed(fit.matrix.apply(x.map(f64::from)), map); - (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() - }) - .sum() - }; - let (a, b) = (highlight_error(&without), highlight_error(&with)); - assert!(b < 0.5 * a, "highlight error {a:.4} -> {b:.4}"); - } - - /// Pooled pairs of several "photos" recover the camera's matrix, and a photo given that - /// colour model (a camera profile) keeps it, fitting only its own tone. - #[test] - fn profile_colour_is_pooled_and_then_used_as_given() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let photo = |seed: usize, strength: f32| { - let mut sensor = Rgb32f::new(64, 48); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let i = i + seed * 7; - let ev = 0.04 + (i % 23) as f32 * 0.02; - *src = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - // each photo has its own tone (DRO, picture style) - *dst = p.map(|v| v * (1.0 - (-strength * y).exp()) / y); - } - (sensor, reference) - }; - let mut pool = Vec::new(); - for seed in 0..4 { - let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5); - pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0); - } - let (matrix, _) = fit_profile(&pool).unwrap(); - // the camera's colours (luminance-normalised: the tone curve sets brightness) - for x in [[0.8, 1.0, 0.75], [1.1, 1.0, 0.8], [0.75, 1.0, 1.2], [1.0; 3]] { - let (a, b) = (matrix.apply(x), known.apply(x)); - let (la, lb) = (luma(a), luma(b)); - assert!((0..3).all(|c| (a[c] / la - b[c] / lb).abs() < 0.01), "{x:?}: {a:?} vs {b:?}"); - } - let (sensor, reference) = photo(9, 3.5); - let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); - assert_eq!(look.matrix, matrix); - assert!(look.hue_sat.is_none()); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let error = sensor - .data - .iter() - .zip(&reference.data) - .map(|(x, y)| { - let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); - (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 - }) - .sum::() - / sensor.data.len() as f64; - assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt()); - } - - #[test] - fn profile_fits_tone_without_learning_colour_from_a_dull_scene() { - let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]); - let mut sensor = Rgb32f::new(64, 48); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - *src = [0.02 + (i % 997) as f32 * 0.00045; 3]; - let p = matrix.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); - } - assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix"); - let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); - assert_eq!(look.matrix, matrix); - reference.map_in_place(|p| [luminance_2020(p); 3]); - assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected"); - } - - #[test] - fn rejected_profile_preserves_a_usable_per_photo_fit() { - let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); - let mut sensor = Rgb32f::new(64, 64); - let mut reference = sensor.clone(); - let mut seed = 0x2545_f491_u32; - for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) { - *src = std::array::from_fn(|_| { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - 0.05 + (seed % 1000) as f32 * 0.0003 - }); - let p = matrix.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999)); - } - let per_photo = fit_pairs(&sensor, &reference).unwrap(); - let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap(); - assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit"); - assert_eq!(look.tone, per_photo.tone); - } - - #[test] - fn hue_sat_passes_black_and_non_finite_pixels_through() { - let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; - let hue_sat = HueSat::new(&table).unwrap(); - assert_eq!(hue_sat.apply([0.0; 3]), [0.0; 3]); - let nan = hue_sat.apply([f32::NAN, 0.2, 0.1]); - assert!(nan[0].is_nan() && nan[1] == 0.2 && nan[2] == 0.1); - assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples"); - } - - #[test] - fn hue_sat_never_turns_near_black_noise_white() { - // a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels - let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; - let hue_sat = HueSat::new(&table).unwrap(); - let mut seed = 0x9e37_79b9_7f4a_7c15u64; - let mut next = || { - seed ^= seed << 13; - seed ^= seed >> 7; - seed ^= seed << 17; - (seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0 - }; - let mut worst = 0.0f32; - for _ in 0..200_000 { - let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()]; - if luminance_2020(rgb) <= 0.0 { - continue; - } - let out = hue_sat.apply(rgb); - let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs())); - worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale); - } - assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel"); - } - - /// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey, - /// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table. - #[test] - fn hue_sat_lanes_match_one_by_one_bit_for_bit() { - let mut seed = 0x2545_f491_4f6c_dd1du64; - let mut next = move || { - seed ^= seed << 13; - seed ^= seed >> 7; - seed ^= seed << 17; - seed - }; - let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0; - let fitted = (0..72 * 5 * 5) - .map(|_| { - let r = next(); - [unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0] - }) - .collect(); - let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true }; - let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; - let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]; - let mut colours = Vec::new(); - for a in special { - for b in special { - colours.extend(special.map(|c| [a, b, c])); - } - } - for _ in 0..60_000 { - let r = next(); - let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize]; - colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset)); - } - for table in [fitted, saturating] { - let hue_sat = HueSat::new(&table).unwrap(); - for chunk in colours.chunks(16) { - let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c]))); - let out = hue_sat.apply_lanes(rgb); - for (l, p) in chunk.iter().enumerate() { - let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p)); - assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); - } - } - } - } - - #[test] - fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() { - assert_eq!(restore_weight(0.0), 0.0); - assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0); - assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0); - assert_eq!(restore_weight(1.0), 1.0); - assert_eq!(restore_weight(f32::NAN), 0.0); - // monotonic and without a step: neighbouring shares 0.001 apart differ by little - let mut last = 0.0f32; - for i in 0..=400 { - let w = restore_weight(i as f32 * 0.001); - assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001); - last = w; - } - } - - #[test] - fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() { - // the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT - let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; - let hue_sat = HueSat::new(&table).unwrap(); - let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) }; - for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] { - let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb))); - let (before, after) = (luminance_2020(rgb), luminance_2020(out)); - assert!(after >= MIN_LUMINANCE_KEPT * before); - assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after)); - } - } - - fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage { - let area = lightcraft_raw::Rect::new(0, 0, 2, 2); - RawImage { - format, - width: 2, - height: 2, - cpp: 1, - data: lightcraft_raw::RawData::U16(vec![0; 4]), - cfa: None, - bits: 14, - black: Default::default(), - white: vec![16383.0], - active_area: area, - crop: area, - orientation: Default::default(), - color, - wb_multipliers: Some([2.0, 1.0, 1.5]), - linearized: false, - pixel_aspect: 1.0, - opcodes: Default::default(), - metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, - lens_metadata: Default::default(), - } - } - - /// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain - /// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none. - #[test] - fn lens_geometry_reads_the_first_rectilinear_warp() { - use lightcraft_raw::Opcode; - let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear { - planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]], - center: c, - }; - assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] })); - let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; - assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02)); - assert!(lens_geometry(&[]).is_none()); - assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none()); - assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none()); - let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN]; - p[0] = 1.0; - p[18] = 1e6; - let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false }; - assert!(lens_geometry(&[chain, single]).is_none()); - } - - /// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp - /// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside - /// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone. - #[test] - fn the_sensor_proxy_follows_the_cameras_lens_warp() { - let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default()); - raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48); - raw.crop = raw.active_area; - // a 1/4 proxy whose pixels hold their own coordinates - let mut img = Rgb32f::new(16, 12); - for (i, p) in img.data.iter_mut().enumerate() { - *p = [(i % 16) as f32, (i / 16) as f32, 1.0]; - } - let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }; - let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap(); - assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4))); - // the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor - let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap(); - assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]); - let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap(); - assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite()); - assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none()); - } - - /// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected - /// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every - /// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected. - #[test] - fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - let Ok(entries) = std::fs::read_dir(&dir) else { - eprintln!("skip: no corpus at {}", dir.display()); - return; - }; - let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); - paths.sort(); - let (mut checked, mut gain) = (0, 0.0); - for path in paths { - let Ok(bytes) = std::fs::read(&path) else { continue }; - let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue }; - let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue }; - if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { - continue; - } - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) = - (proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens))) - else { - continue; - }; - let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference)); - eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display()); - // (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little) - assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display()); - gain += after - before; - checked += 1; - } - eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64); - assert!(checked == 0 || gain > 0.0); - } - - /// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit - /// is tried. - #[test] - fn a_files_own_matrices_come_first() { - let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); - let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone()); - let (_, t, look) = starting_colour_with( - &mut raw, - |_, _| panic!("no JPEG fit for a raw with its own matrices"), - |_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"), - |_, _| panic!("no dark tone fit for a raw with its own matrices"), - ); - assert_eq!(raw.color, color); - assert!(!t.matrix_is_fallback && look.is_none()); - } - - /// Steps 2 and 3 before step 4: an accepted JPEG fit (with or without a camera profile) is kept for a model that - /// has spectral matrices, and the spectral matrices are not written. Photos that had a fitted look before the - /// spectral table render exactly as they did. - #[test] - fn an_accepted_jpeg_fit_comes_before_the_spectral_matrices() { - for (make, model) in [("SONY", "ILCE-7M4"), ("Canon", "Canon EOS 600D")] { - assert!(lightcraft_raw::spectral::find(Some(make), model).is_some(), "{model}"); - let mut raw = raw_of(RawFormat::Cr2, make, model, Default::default()); - let fitted = dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()); - let (xy, t, look) = starting_colour_with( - &mut raw, - |raw, t| { - assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices"); - (Some(fitted.clone()), false) - }, - |_, _| panic!("no black-and-white JPEG fit after an accepted fit"), - |_, _| panic!("no dark tone fit after an accepted fit"), - ); - assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}"); - assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out"); - assert!(t.matrix_is_fallback, "{model}"); - assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); - } - } - - /// Step 4: when the JPEG fit fails (here: no camera JPEG at all), a model with a spectral row takes its matrices, - /// whether or not it has a camera profile (the ILCE-7M4 has a bundled one). - #[test] - fn a_failed_jpeg_fit_falls_back_to_the_spectral_matrices() { - assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); - let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); - let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[], None, None); - assert!(look.is_none()); - assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); - assert!(!t.matrix_is_fallback); - - let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); - let (xy, t, look) = - starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"), |_, _| None); - assert!(look.is_none()); - assert_eq!(raw.color.illuminant, [17, 21]); - assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some)); - assert!(!t.matrix_is_fallback); - assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); - // white-balanced white stays white - let w = t.matrix.apply([1.0; 3]); - assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); - } - - /// After step 4 or 5: a photo whose JPEG gave no fit and is black and white gets its tone curve on top of the - /// colour it has by then, the spectral matrices for a model in the table, the neutral fallback's otherwise - /// (issue #577). The black-and-white fit sees that transform. - #[test] - fn a_black_and_white_jpeg_adds_its_tone_on_top_of_the_colour_it_gets_otherwise() { - let toned = CameraLook { - matrix: Mat3::IDENTITY, - tone: dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()).tone, - hue_sat: None, - }; - let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); - let (xy, t, look) = starting_colour_with( - &mut raw, - |_, _| (None, true), - |raw, t| { - assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in"); - Some(toned.clone()) - }, - |_, _| panic!("an accepted monochrome look keeps its tone"), - ); - assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top"); - assert!(!t.matrix_is_fallback); - assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); - - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour_with( - &mut raw, - |_, _| (None, true), - |_, t| { - assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour"); - Some(toned.clone()) - }, - |_, _| panic!("an accepted monochrome look keeps its tone"), - ); - assert!(look.is_some() && t.matrix_is_fallback); - assert_eq!(raw.color, lightcraft_raw::ColorData::default()); - } - - /// Step 5: a camera outside the table whose fit fails keeps the neutral fallback. - #[test] - fn a_camera_outside_the_table_keeps_the_fallback() { - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[], None, None); - assert!(look.is_none() && t.matrix_is_fallback); - assert_eq!(raw.color, lightcraft_raw::ColorData::default()); - // without a model there is no table row - let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); - raw.metadata.model = None; - assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback); - } - - /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is - /// accepted keeps it and its colour data; one whose fit fails takes its spectral matrices when its model has - /// them; a DNG keeps its own matrices. White balance stays relative to the as-shot look for every raw without - /// matrices of its own. - #[test] - fn corpus_colour_precedence() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] { - let Ok(bytes) = std::fs::read(dir.join(name)) else { - lightcraft_testkit::skip!(Corpus, "{name} absent"); - continue; - }; - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - let lens = lens_geometry(&raw.opcodes.list3); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let own = lightcraft_raw::color::has_matrix(&raw.color); - let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); - let measured = lightcraft_raw::spectral::find(make, model).is_some(); - let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); - let before = raw.color.clone(); - let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref(), None); - let spectral = !own && fitted.is_none() && measured; - eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); - assert_eq!(raw.color != before, spectral, "{name}"); - assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); - // white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin, - // through the spectral matrices or its presets, whichever look it got (issue #730) - assert_eq!(info.relative_wb, !own && !covered, "{name}"); - assert_eq!(info.legacy_relative_wb, !own && covered, "{name}"); - if covered && !own { - assert!( - (2000.0..=50000.0).contains(&info.as_shot_temp) && info.as_shot_temp != 6500.0 && info.as_shot_tint.abs() < 100.0, - "{name}: {info:?}" - ); - } - // the same as the catalog decides from the probe (format and camera) - let probe = crate::files::probe_bytes(name, &bytes).unwrap(); - let mut p = lightcraft_catalog::Photo::new( - lightcraft_catalog::PhotoId(1), - lightcraft_catalog::Source::File { path: name.into() }, - name, - &probe.format, - 1, - 1, - "", - ); - p.kind = probe.kind; - p.meta = probe.meta.clone(); - assert_eq!(p.relative_wb(), info.relative_wb, "{name}"); - assert_eq!(probe.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); - } - } - - #[test] - fn supported_raws_get_a_file_local_look() { - // every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback) - let local = [ - RawFormat::Arw, - RawFormat::Nef, - RawFormat::Nrw, - RawFormat::Rw2, - RawFormat::Raf, - RawFormat::Cr3, - RawFormat::Cr2, - RawFormat::Pef, - RawFormat::Srw, - RawFormat::Orf, - RawFormat::Mrw, - RawFormat::Erf, - ]; - assert!(local.into_iter().all(file_local_look)); - // DNG carries its own colour model - assert!(!file_local_look(RawFormat::Dng)); - } - - #[test] - fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() { - let (w, h) = (192, 128); - let mut sensor = Rgb32f::new(w, h); - for (i, pixel) in sensor.data.iter_mut().enumerate() { - let (x, y) = ((i % w) as f32, (i / w) as f32); - let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos(); - *pixel = [value; 3]; - } - let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] }; - let mut reference = Rgb32f::new(w, h); - for (i, pixel) in reference.data.iter_mut().enumerate() { - let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); - *pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7)); - } - let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone"); - assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}"); - assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}"); - assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}"); - assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged"); - reference.data.fill([0.2; 3]); - assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing"); - for (i, pixel) in reference.data.iter_mut().enumerate() { - let (x, y) = ((i % w) as f32, (i / w) as f32); - *pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3]; - } - assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing"); - } - - /// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus - /// download or an unfinished codec cannot make this test claim a successful RAW calibration. - #[test] - fn corpus_cr3_gets_a_framing_aligned_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/cr3-canon-r100-raw.cr3"); - let Ok(bytes) = std::fs::read(&path) else { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - return; - }; - let raw = match lightcraft_raw::decode(&bytes) { - Ok(raw) => raw, - Err(lightcraft_raw::RawError::Unsupported(why)) => { - lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}"); - return; - } - Err(error) => panic!("CR3 corpus failed to decode: {error}"), - }; - assert_eq!(raw.format, RawFormat::Cr3); - assert_eq!(raw.info().developed_size(), (6000, 4000)); - let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform, None).0; - eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); - assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert!(info.raw && info.relative_wb); - assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0)); - assert_eq!(info.camera_tone.is_some(), look.is_some()); - } - - /// A public sample of `model` (skipped without the corpus) takes its colour from the bundled profile for `model` - /// and passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the - /// photo's own fit (the fallback when a profile is rejected) would not. - fn corpus_uses_the_bundled_profile(file: &str, model: &str) { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw") - .join(file); - let bytes = match std::fs::read(&path) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - return; - } - Err(e) => panic!("{}: {e}", path.display()), - }; - let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the model is built in"); - let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); - let used = crate::camera_profiles::get(model).expect("a profile for the model"); - assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test"); - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - assert_eq!(raw.metadata.model.as_deref(), Some(model)); - // as the app does (`files.rs`): the camera's own lens warp pairs the sensor with its JPEG (Nikon's in-file - // distortion for the Z 6II and Z 8), and the lens corrections leave the opcodes before the proxy is binned - let lens = lens_geometry(&raw.opcodes.list3); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform, lens.as_ref()).0.expect("fit accepted"); - let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); - for (row, want) in look.matrix.0.iter().zip(expected.0) { - for (got, want) in row.iter().zip(want) { - assert!((got - want).abs() < 1e-12, "the look's matrix is not the profile's: {:?} vs {:?}", look.matrix.0, expected.0); - } - } - assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); - } - - #[test] - fn corpus_a7cr_uses_the_bundled_profile() { - corpus_uses_the_bundled_profile("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR"); - } - - #[test] - fn corpus_a99m2_uses_the_bundled_profile() { - corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2"); - } - - /// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to. - #[test] - fn corpus_z6ii_uses_the_bundled_profile() { - corpus_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2"); - } - - /// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to. - #[test] - fn corpus_z8_uses_the_bundled_profile() { - corpus_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8"); - } - - /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white - /// balance is relative to the as-shot look. - #[test] - fn corpus_nef_gets_a_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/nef-nikon-d7500-lossless14.nef"); - let Ok(bytes) = std::fs::read(&path) else { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - return; - }; - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert!(info.camera_tone.is_some(), "no camera look fitted"); - assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); - } - - /// The public ILCE-7CR samples (61 MP; skipped without the corpus): lossless compressed L and M and compressed - /// ARW2 all get a look fitted to their own JPEG, not the neutral fallback. - #[test] - fn corpus_a7cr_codings_get_a_camera_look() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - for name in ["arw-sony-a7cr-lossless-l.arw", "arw-sony-a7cr-lossless-m.arw", "arw-sony-a7cr-compressed.arw"] { - let bytes = match std::fs::read(dir.join(name)) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - lightcraft_testkit::skip!(Corpus, "{name} absent"); - continue; - } - Err(e) => panic!("{name}: {e}"), - }; - let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!((img.width, img.height), (400, 267), "{name}: framed in the default crop"); - assert!(info.camera_tone.is_some(), "{name}: no camera look fitted"); - assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0, "{name}"); - } - } - - /// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3 - /// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it. - #[test] - fn corpus_rw2_with_an_in_camera_crop_gets_a_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/rw2-panasonic-fz1000m2-4x3.rw2"); - let Ok(bytes) = std::fs::read(&path) else { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - return; - }; - let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!((img.width, img.height), (400, 300), "framed in the in-camera aspect ratio"); - assert!(info.camera_tone.is_some(), "no camera look fitted"); - assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); - } - - /// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is - /// distortion-corrected, and on its glass façade the fit on all pixels misses the gate - /// (held-out RMS 0.113): it used to open grey with the neutral fallback. - #[test] - fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); - let Ok(bytes) = std::fs::read(&path) else { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - return; - }; - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert!(info.camera_tone.is_some(), "no camera look fitted"); - } - - /// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same - /// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation - /// table and its tone curve; without a look, those of the camera transform. - #[test] - fn dng_colour_renders_like_the_raws_starting_colour() { - use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; - let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37)); - assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb); - let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); - let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; - let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2()); - for look in [Some(&look), None] { - let dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &t, look).unwrap(); - let back = camera_transform_of(&dng, as_shot_white_of(&dng, None)); - for i in 0..3 { - assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb); - } - let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); - for c in samples { - let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c)); - assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}"); - } - assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone())); - let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve); - match look { - Some(look) => { - let tone = tone.expect("the look's tone curve"); - let worst = - (0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max); - // (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here) - assert!(worst < 4e-3, "{worst}"); - } - None => assert!(tone.is_none()), - } - } - } - - /// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white - /// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their - /// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8. - #[test] - fn dng_colour_declares_the_white_balance_the_source_shows() { - use crate::files::DngWhite; - use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers}; - let tags = ColorData::default(); - let as_shot = [2.5039f32, 1.0, 1.5078]; - let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot))); - let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); - look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]); - let tint = look.matrix.apply([1.0; 3]); - assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}"); - let renders_as_shot = |dng: &ColorData| { - let r = camera_transform_of(dng, as_shot_white_of(dng, None)); - assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb); - let m = look.matrix.mul(&t.matrix); - for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] { - let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2()); - let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2()); - assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}"); - } - }; - // a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0) - for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] { - let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap(); - let back = as_shot_white_of(&dng, None); - assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}"); - renders_as_shot(&dng); - } - // a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source - let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera"); - let mut model = tags.clone(); - assert!(camera.fill(&mut model)); - let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap(); - let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap(); - let back = as_shot_white_of(&dng, None); - assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}"); - for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] { - let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w)); - assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}"); - } - renders_as_shot(&dng); - } - - /// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without - /// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking - /// it from the multipliers again leaves the merged DNG rendering as the tags do. - #[test] - fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() { - use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; - for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] { - let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default()); - raw.wb_multipliers = Some(multipliers); - let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices"); - let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance"); - // the reciprocal of the multipliers, up to the scale a neutral is given in - let scale = neutral[1] * multipliers[1] as f64; - for i in 0..3 { - assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}"); - } - let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone())); - merged.set_as_shot_multipliers(multipliers); - let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") }; - let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None))); - for i in 0..3 { - assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb); - } - for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) { - assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix); - } - assert_eq!(a.baseline_exposure, b.baseline_exposure); - } - } - - /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. - fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { - CameraLook { - matrix: Mat3([[1.6, -0.4, -0.2], [-0.2, 1.4, -0.2], [0.0, -0.3, 1.3]]), - tone: CameraTone::new(xs.map(|x| [x, f(x)])).unwrap().with_chroma([1.3, 1.2, 1.1, 1.0, 1.0, 0.9, 0.6, 0.3]).unwrap(), - hue_sat: Some(HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[5.0, 1.1, 1.0]; 8], srgb_value: false }), - } - } - - /// `fixed` (from `fitted`) is `min(fitted, Sony's DRO-off curve)` at every knot but the - /// second-last, which may stay higher (at most the fitted curve) to keep the shoulder past the - /// last knot below the fitted one; nowhere brighter than `fitted`, colour unchanged. - fn assert_lowered(fitted: &CameraLook, fixed: &CameraLook, what: &str) { - let dro_off = sony_dro_off_tone().unwrap(); - assert_eq!((fixed.matrix, &fixed.hue_sat, fixed.tone.chroma()), (fitted.matrix, &fitted.hue_sat, fitted.tone.chroma()), "{what}"); - for (i, (k, own)) in fixed.tone.knots().iter().zip(fitted.tone.knots()).enumerate() { - let lowered = own[1].min(dro_off.apply(own[0])); - assert_eq!(k[0], own[0], "{what}: knot {i}"); - if i == 30 { - assert!(k[1] >= lowered && k[1] <= own[1], "{what}: knot {i} {} not in {lowered}..={}", k[1], own[1]); - } else { - assert!((k[1] - lowered).abs() < 1e-6, "{what}: knot {i} {} vs {lowered}", k[1]); - } - } - assert!(at_most(&fixed.tone, &fitted.tone), "{what}: brighter than the fitted curve somewhere"); - // the same check spelled out, from deep shadows to far past the last knot (the shoulder) - for i in 0..=2400 { - let x = 0.001 * 1.005f32.powi(i); - assert!(fixed.tone.apply(x) <= fitted.tone.apply(x) + 1e-5, "{what} at {x}: {} > {}", fixed.tone.apply(x), fitted.tone.apply(x)); - } - } - - /// Issue #244: Sony's Dynamic Range Optimizer lifts the camera JPEG's shadows, not the raw's. A - /// look fitted to such a JPEG keeps its colour (matrix, hue/saturation table, chroma curve) and - /// its curve is lowered to Sony's without DRO wherever that is darker, never raised. - #[test] - fn a_dro_look_keeps_its_colour_and_is_lowered_to_the_dro_off_tone() { - let dro_off = sony_dro_off_tone().expect("the built-in curve is valid"); - // the curve itself: black stays black, increasing, mid-grey where cameras put it (L* 50–75) - assert_eq!(dro_off.apply(0.0), 0.0); - let mut previous = 0.0; - for i in 1..200 { - let y = dro_off.apply(i as f32 * 0.005); - assert!(y > previous && y < 1.0, "{i}: {y}"); - previous = y; - } - assert!((0.18..0.48).contains(&dro_off.apply(0.18)), "{}", dro_off.apply(0.18)); - let xs: [f32; 32] = std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)); - // a JPEG with lifted shadows: the curve comes down to Sony's, colour and chroma curve stay - let lifted = dro_test_look(&xs, &|x| dro_off.apply(x).powf(0.8)); - let fixed = with_dro_off_tone(lifted.clone()); - assert_lowered(&lifted, &fixed, "lifted"); - assert!(fixed.tone.apply(0.02) < 0.85 * lifted.tone.apply(0.02)); - // a camera that maps raw values darker than Sony's curve keeps its own curve exactly - let darker = dro_test_look(&xs, &|x| 0.7 * dro_off.apply(x)); - assert_eq!(with_dro_off_tone(darker.clone()).tone, darker.tone); - // crossing curves: the lower of the two at the knots, still a valid increasing curve - let crossing = dro_test_look(&xs, &|x| (dro_off.apply(x) * (0.6 + x)).min(0.95)); - assert_lowered(&crossing, &with_dro_off_tone(crossing.clone()), "crossing"); - } - - /// Past its last knot a camera curve continues as a shoulder whose rate comes from its last two - /// knots. Lowering the second-last knot more than the last steepened that shoulder: the curve - /// fitted to the CC0 NEX-5T sample (raw.pixls.us, DRO Auto) went from 0.740 to 0.843 at scene - /// luminance 0.63, brighter than before. The lowered curve must stay at or below the fitted one - /// through the shoulder, on that curve and on many others. - #[test] - fn the_lowered_curve_stays_below_the_fitted_one_past_its_last_knot() { - let nex5t: [[f32; 2]; 32] = [ - [0.014948029, 0.017424058], - [0.018395446, 0.023981506], - [0.021539066, 0.030581286], - [0.024555191, 0.0374646], - [0.027602654, 0.044408206], - [0.030193308, 0.049894042], - [0.03261828, 0.055907387], - [0.034544908, 0.06156683], - [0.03669959, 0.06814718], - [0.039161213, 0.075263545], - [0.04173663, 0.0830011], - [0.044166446, 0.088166036], - [0.04682434, 0.095398724], - [0.049544845, 0.1027153], - [0.05235826, 0.11173598], - [0.05497513, 0.119017586], - [0.057503678, 0.12616614], - [0.059836045, 0.13521175], - [0.062677935, 0.14601946], - [0.06599234, 0.15573128], - [0.069809504, 0.16883339], - [0.0755404, 0.1854325], - [0.08250754, 0.20684941], - [0.089204684, 0.23466235], - [0.09650368, 0.25805998], - [0.102161564, 0.27849936], - [0.107812546, 0.29706743], - [0.11410169, 0.31379074], - [0.12000221, 0.33262977], - [0.12776275, 0.35407156], - [0.1387929, 0.38362882], - [0.19492775, 0.43935108], - ]; - let fitted = CameraLook { tone: CameraTone::new(nex5t).unwrap(), ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) }; - let fixed = with_dro_off_tone(fitted.clone()); - assert_lowered(&fitted, &fixed, "NEX-5T"); - assert!((fitted.tone.apply(0.63) - 0.7405).abs() < 1e-3 && fixed.tone.apply(0.63) <= fitted.tone.apply(0.63)); - assert_ne!(fixed.tone, fitted.tone, "the lifted mid-tones still come down"); - // curves that end at all kinds of scene luminances, slopes and heights relative to Sony's - let dro_off = sony_dro_off_tone().unwrap(); - let mut seed = 0x9e37_79b9_u32; - let mut next = move || { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - f32::from((seed >> 16) as u16) / 65536.0 - }; - for case in 0..300 { - let (start, span) = (0.002 + 0.03 * next(), 1.05 + 0.15 * next()); - let xs: [f32; 32] = std::array::from_fn(|i| start * span.powi(i as i32)); - let (lift, gain, top) = (0.5 + 0.6 * next(), 0.6 + 0.8 * next(), next()); - // brighter or darker than Sony's, with a last knot sometimes lowered less than the one before - let f = |x: f32| (gain * dro_off.apply(x).powf(lift) + top * 0.2 * (x / xs[31]).powi(8)).min(0.995); - let mut ys = xs.map(f); - for i in 1..32 { - ys[i] = ys[i].max(ys[i - 1]); - } - let Some(tone) = CameraTone::new(std::array::from_fn(|i| [xs[i], ys[i]])) else { continue }; - let fitted = CameraLook { tone, ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) }; - assert_lowered(&fitted, &with_dro_off_tone(fitted.clone()), &format!("case {case}")); - } - } - - /// Issue #244 (skipped without the corpus): the public ILCE-7RM2 sample (Standard creative - /// style) was shot with DRO Auto, so its curve comes down to Sony's without DRO in the shadows the - /// camera lifted; the ILCE-7M4 sample (DRO Auto, Vivid) has deeper shadows than that curve and - /// keeps them, only its brighter highlights come down. Both keep the colour fitted to their - /// JPEGs. The ILCE-7M3 sample (DRO off) keeps its own fit. - #[test] - fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - let samples = [ - ("arw-sony-a7rm2-12bit-uncompressed.arw", true, [0.01, 0.05, 0.1]), - ("arw-sony-a7m4-14bit.arw", true, [0.3, 0.4, 0.5]), - ("arw-sony-a7m3-compressed.arw", false, [0.0; 3]), - ]; - for (name, dro, lowered) in samples { - let path = dir.join(name); - let bytes = match std::fs::read(&path) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); - continue; - } - Err(e) => panic!("{}: {e}", path.display()), - }; - assert_eq!(lightcraft_raw::embedded_preview_dynamic_range_optimized(&bytes), Some(dro), "{name}"); - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look"); - // the look fitted to the JPEG, as before this change - let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap(); - let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); - let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); - let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); - if dro { - assert_lowered(&fitted, &look, name); - for x in lowered { - assert!(look.tone.apply(x) < 0.95 * fitted.tone.apply(x), "{name} at {x}: {} vs {}", look.tone.apply(x), fitted.tone.apply(x)); - } - } else { - assert_eq!((look.matrix, &look.hue_sat, look.tone), (fitted.matrix, &fitted.hue_sat, fitted.tone), "{name}"); - } - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!(info.camera_tone, Some(look.tone), "{name}"); - } - } - - /// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a - /// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree - /// everywhere away from the edges, and the look must still be found there. - #[test] - fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let (w, h, block, shift) = (96, 64, 8, 2); - // Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour. - let scene = |x: usize, y: usize| -> [f32; 3] { - let (bx, by) = (x / block, y / block); - let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 }; - [ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)] - }; - let camera = |p: [f32; 3]| { - let q = known.apply_f32(p); - let y = luminance_2020(q); - q.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) - }; - let mut sensor = Rgb32f::new(w, h); - let mut reference = sensor.clone(); - for y in 0..h { - for x in 0..w { - sensor.data[y * w + x] = scene(x, y); - // The JPEG shows the scene `shift` pixels to the right of where the raw has it. - reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y)); - } - } - let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges"); - // The look itself is right: on the aligned scene it reproduces the camera. - let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); - let (mut error, mut n) = (0.0, 0); - for y in 0..h { - for x in 0..w { - let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone); - let t = camera(scene(x, y)); - error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::(); - n += 3; - } - } - assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt()); - // Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well. - reference.data.fill([0.2; 3]); - assert!(fit_pairs(&sensor, &reference).is_none()); - } - - /// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes. - /// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away - /// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0 - /// corpus files. - #[test] - fn corpus_raf_colour_and_white_balance() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - // (the GFX100S shares the GFX 100's measured sensor, so its white balance is in Kelvin through those - // matrices; the X-T2 and X-T20 have none and stay relative) - for (name, accepted, kelvin) in - [("raf-fuji-xt2-865.raf", true, false), ("raf-fuji-gfx100s-4503.raf", true, true), ("raf-fuji-xt20-compressed.raf", true, false)] - { - let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; - let header = crate::files::probe_bytes(name, &bytes).unwrap(); - let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap(); - let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap(); - assert_eq!(small.camera_tone.is_some(), accepted, "{name}"); - assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit"); - assert_eq!(header.as_shot_wb, Some((small.as_shot_temp, small.as_shot_tint)), "{name}"); - assert!(small.raw && small.relative_wb != kelvin && small.legacy_relative_wb == kelvin, "{name}: {small:?}"); - if kelvin { - assert!(small.as_shot_temp != 6500.0 && (2000.0..50000.0).contains(&small.as_shot_temp), "{name}: {}", small.as_shot_temp); - } else { - assert!(small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}"); - } - } - } - - /// Sony ARWs without measured matrices read Kelvin from their white-balance presets - /// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and - /// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its - /// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files. - #[test] - fn corpus_arw_white_balance_in_kelvin_through_the_presets() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] { - let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); - continue; - }; - let header = crate::files::probe_bytes(name, &bytes).unwrap(); - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); - assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}"); - assert!( - (info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0, - "{name}: {} {}", - info.as_shot_temp, - info.as_shot_tint - ); - let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default()); - assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}"); - } - } - - #[test] - fn rejects_monochrome_invalid_and_unrelated_previews() { - let mut sensor = Rgb32f::new(32, 32); - let mut reference = sensor.clone(); - for (i, p) in sensor.data.iter_mut().enumerate() { - *p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1]; - } - reference.data.fill([0.2; 3]); - assert!(fit_pairs(&sensor, &reference).is_none()); - reference.data.fill([f32::NAN; 3]); - assert!(fit_pairs(&sensor, &reference).is_none()); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - *src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01]; - *dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; - } - assert!(fit_pairs(&sensor, &reference).is_none()); - sensor.data.fill([0.1, 0.15, 0.12]); - assert!(fit_pairs(&sensor, &reference).is_none()); - reference.data.truncate(8); - assert!(fit_pairs(&sensor, &reference).is_none()); - } - - /// A busy scene whose JPEG is the camera curve of the scene two proxy pixels to the right (a - /// lens-corrected preview): the fitted curve follows the camera's and has no flat steps. - #[test] - fn tone_fit_has_no_steps_on_a_misregistered_jpeg() { - let camera = |x: f64| 0.6 * x.powf(0.55) / (1.0 + 0.2 * x); - let (w, h) = (96usize, 64usize); - let mut seed = 0x2545_f491_u32; - let mut scene = Vec::with_capacity(w * h); - for _ in 0..w * h { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - scene.push(0.01 * (5.0 * f64::from(seed >> 8) / f64::from(1u32 << 24)).exp()); - } - let pairs: Vec<(f64, f64)> = - (0..h).flat_map(|y| (0..w - 2).map(move |x| (y * w + x, y * w + x + 2))).map(|(i, j)| (scene[i], camera(scene[j]))).collect(); - let curve = fit_tone(pairs).unwrap(); - let mut sorted = scene.clone(); - sorted.sort_by(f64::total_cmp); - let (lo, hi) = (sorted[sorted.len() / 50], sorted[sorted.len() * 49 / 50]); - let mut x = lo; - while x < hi { - let (a, b) = (curve.apply(x as f32), curve.apply((x * 1.02) as f32)); - assert!(b > a, "flat step at {x}: {a} -> {b}"); - assert!((f64::from(a) / camera(x) - 1.0).abs() < 0.05, "at {x}: {a} vs {}", camera(x)); - x *= 1.02; - } - } - - /// A synthetic photo whose JPEG is the camera look of the scene scaled per pixel by noise of - /// `spread` (a pseudo-random, registration-free stand-in for local tone mapping): the per-bin - /// median recovers the curve, while the JPEG's broadened distribution stretches the quantile fit. - fn noisy_look(spread: f32) -> (Rgb32f, Rgb32f) { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let (w, h) = (96usize, 64usize); - let mut sensor = Rgb32f::new(w, h); - let mut reference = sensor.clone(); - let mut seed = 0x9e37_79b9_u32; - let mut next = move || { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - f32::from((seed >> 16) as u16) / 32768.0 - 1.0 - }; - for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) { - let ev = 0.05 + (next() + 1.0) * 0.2; - *src = [ev * (0.8 + (next() + 1.0) * 0.1), ev, ev * (0.8 + (next() + 1.0) * 0.1)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - let scale = (1.0 - (-2.5 * y).exp()) / y * (1.0 + spread * next()); - *dst = p.map(|v| v * scale); - } - (sensor, reference) - } - - /// A camera rendering with constant colourfulness (no highlight bleaching) of a colourful scene - /// under a bright neutral sky, whose sensor pixels are clipped: one channel held at the clip - /// level turns the white sky magenta in the raw while the camera JPEG keeps it white. Returns - /// the sensor proxy, the JPEG proxy and the clip mask. - fn clipped_sky_scene() -> (Rgb32f, Rgb32f, Vec) { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let (w, h) = (96usize, 64usize); - let mut sensor = Rgb32f::new(w, h); - let mut reference = sensor.clone(); - let mut clipped = vec![false; w * h]; - let mut seed = 0x2545_f491_u32; - let mut next = move || { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - f32::from((seed >> 16) as u16) / 65536.0 - }; - let camera = |p: [f32; 3]| { - let y = luminance_2020(p); - let o = 1.0 - (-2.5 * y).exp(); - p.map(|v| (v * o / y).clamp(0.0, 0.97)) - }; - // the sky: a quarter of the frame, white in the scene and in the JPEG - let neutral = known.inverse().unwrap().apply_f32([0.32; 3]); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let scene = if i < w * h / 4 { - clipped[i] = true; - let sky = neutral.map(|v| v * (0.8 + 0.4 * next())); - *dst = camera(known.apply_f32(sky)); - // the green sample clips, red and blue (after white balance) go on rising - *src = [sky[0] * 1.35, sky[1] * 0.85, sky[2] * 1.4]; - continue; - } else { - // colourful foliage, leaves, bark and bright flowers from dark to light - let ev = 0.02 + next() * 0.4; - [ev * (0.6 + next() * 0.8), ev, ev * (0.5 + next() * 0.8)] - }; - *src = scene; - *dst = camera(known.apply_f32(scene)); - } - (sensor, reference, clipped) - } - - /// Mean colourfulness (distance from neutral of luminance-normalised RGB) of the unclipped - /// pixels rendered with `look`, over that of the camera JPEG, per display-luminance band. - fn colourfulness_ratio(look: &CameraLook, sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], band: std::ops::Range) -> f64 { - let chroma = |p: [f64; 3]| { - let y = luma(p); - p.iter().map(|v| (v / y - 1.0).powi(2)).sum::().sqrt() - }; - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let (mut rendered, mut camera) = (0.0, 0.0); - for ((x, y), c) in sensor.data.iter().zip(&reference.data).zip(clipped) { - let y = y.map(f64::from); - if *c || !band.contains(&luma(y)) { - continue; - } - rendered += chroma(displayed(look.matrix.apply(x.map(f64::from)), &tone)); - camera += chroma(y); - } - rendered / camera - } - - /// Clipped sensor pixels are left out of the look's training pairs: a clipped white sky no longer - /// dulls the colour matrix or makes the chroma curve remove colour at the sky's brightness. - #[test] - fn clipped_sky_does_not_dull_the_look() { - let (sensor, reference, clipped) = clipped_sky_scene(); - let old = fit_pairs(&sensor, &reference).expect("the earlier fit accepts the look"); - let new = fit_look(&sensor, &reference, &clipped, None).expect("look without the clipped pixels"); - let (mid, bright) = (0.1..0.35, 0.35..0.75); - let old_ratio = ( - colourfulness_ratio(&old, &sensor, &reference, &clipped, mid.clone()), - colourfulness_ratio(&old, &sensor, &reference, &clipped, bright.clone()), - ); - let new_ratio = - (colourfulness_ratio(&new, &sensor, &reference, &clipped, mid), colourfulness_ratio(&new, &sensor, &reference, &clipped, bright)); - // with the clipped sky in the pairs, the bright colours lose much of their colour - assert!(old_ratio.1 < 0.8, "earlier fit: colourfulness vs camera {old_ratio:?}, chroma {:?}", old.tone.chroma()); - // without it, the render keeps the camera's colourfulness at every brightness - for r in [new_ratio.0, new_ratio.1] { - assert!((0.9..1.1).contains(&r), "colourfulness vs camera {new_ratio:?}, chroma {:?}", new.tone.chroma()); - } - assert!(new.tone.chroma().iter().all(|k| *k > 0.85), "no colour cut: {:?}", new.tone.chroma()); - } - - /// When the accepted attempt misses the gates without the clipped pixels (here: too few pixels - /// left), the look fitted on all pixels is kept unchanged. - #[test] - fn clipped_fit_falls_back_to_all_pixels() { - let (sensor, reference, _) = clipped_sky_scene(); - let all = fit_pairs(&sensor, &reference).unwrap(); - // nearly everything clipped: too few pairs remain, the earlier look is used unchanged - let mut clipped = vec![true; sensor.data.len()]; - clipped[..100].iter_mut().for_each(|c| *c = false); - let fallback = fit_look(&sensor, &reference, &clipped, None).expect("the fallback keeps the look"); - assert_eq!((fallback.matrix.0, fallback.tone), (all.matrix.0, all.tone)); - // nothing clipped: the same look as before, exactly - let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap(); - assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); - // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) - let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; - let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap(); - assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); - assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); - } - - /// Quantile matching is tried first; the conditional-median fit is the fallback and is used - /// only where the quantile look misses the acceptance gates, so no photo loses its look. - #[test] - fn tone_fit_order_quantile_first_conditional_median_as_fallback() { - // Clean data: both fits pass, the default order uses the quantile curve (increasing). - let (sensor, reference) = noisy_look(0.0); - let default = fit_pairs(&sensor, &reference).expect("clean look"); - let quantile = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).expect("quantile passes"); - assert_eq!(default.tone, quantile.tone); - // Noisy data: the quantile fit misses the gates, the conditional median still passes. - let (sensor, reference) = noisy_look(0.33); - assert!(fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).is_none(), "quantile alone misses the gates"); - let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).expect("conditional median passes"); - let default = fit_pairs(&sensor, &reference).expect("the fallback keeps the look"); - assert_eq!(default.tone, conditional.tone); - // The order matters only through the first pass; with the fits swapped the conditional one wins even on clean data. - let (sensor, reference) = noisy_look(0.0); - let swapped = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian, ToneFit::Quantile]).unwrap(); - let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).unwrap(); - assert_eq!(swapped.tone, conditional.tone); - } - - /// The camera look of `scene` (the known matrix of these tests, then a bright camera curve). - fn bright_camera(p: [f32; 3]) -> [f32; 3] { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let q = known.apply_f32(p); - let y = luminance_2020(q); - if y <= 0.0 { [0.0; 3] } else { q.map(|v| v * (1.0 - (-4.0 * y).exp()) / y) } - } - - /// Mean squared error (all channels, display-linear) of `render` against `target` over `scene`. - fn mean_error(scene: &Rgb32f, target: impl Fn([f32; 3]) -> [f32; 3], render: impl Fn([f64; 3]) -> [f64; 3]) -> f64 { - let error: f64 = scene - .data - .iter() - .map(|x| { - let (p, t) = (render(x.map(f64::from)), target(*x)); - (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::() - }) - .sum(); - error / (3 * scene.data.len()) as f64 - } - - /// A colourful scene with fine detail everywhere (foliage) whose camera JPEG is lens-corrected: - /// barrel distortion moves the picture by up to about 4 proxy pixels toward the corners, so the - /// detail no longer pairs with itself and no global look passes the gates, on all pixels or - /// away from edges. The partial look still follows the camera's brightness, colourfulness and - /// hues: on the registered scene it is far closer to the camera than the neutral fallback, - /// which renders the scene dark and grey. - #[test] - fn partial_look_when_a_lens_corrected_jpeg_misses_the_gates() { - let (w, h) = (96usize, 64usize); - let scene = |u: f32, v: f32| -> [f32; 3] { - let base = 0.1 * (1.0 + 0.6 * (0.05 * u + 0.04 * v).sin()); - let detail = 1.0 + 0.8 * (1.7 * u).sin() * (1.3 * v).sin(); - let y = base * detail; - [y * (0.6 + 0.5 * (0.07 * u).sin().abs()), y, y * (0.5 + 0.6 * (0.09 * v + 0.03 * u).cos().abs())] - }; - let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); - let mut sensor = Rgb32f::new(w, h); - let mut reference = sensor.clone(); - for y in 0..h { - for x in 0..w { - let (dx, dy) = (x as f32 - cx, y as f32 - cy); - let k = 1.0 + 0.08 * (dx * dx + dy * dy) / (cx * cx); - sensor.data[y * w + x] = scene(x as f32, y as f32); - reference.data[y * w + x] = bright_camera(scene(cx + dx * k, cy + dy * k)); - } - } - assert!(fit_pairs(&sensor, &reference).is_none(), "the full look misses the gates"); - let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look instead of the neutral fallback"); - assert!(look.hue_sat.is_none()); - assert_ne!(look.matrix, Mat3::IDENTITY, "a colourful scene gets the damped matrix"); - // on the registered scene, against what the camera renders - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let neutral = ToneMap::new(0.0, 0.0, 0.0); - let partial = mean_error(&sensor, bright_camera, |x| displayed(look.matrix.apply(x), &tone)); - let fallback = mean_error(&sensor, bright_camera, |x| displayed(x, &neutral)); - assert!(partial < fallback * 0.25, "partial {partial:.5}, neutral fallback {fallback:.5}"); - assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8)); - } - - fn near_black_scene() -> (Rgb32f, Rgb32f) { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (input, output)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let y = if i % 60 == 0 { 0.3 + (i % 7) as f32 * 0.01 } else { 0.006 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.075 }; - let rgb = [1.15, 1.0, 0.75]; - *input = rgb.map(|v| v * y / luminance_2020(rgb)); - *output = input.map(|v| v * 0.08); - } - (sensor, reference) - } - - #[test] - fn a_near_black_scene_gets_tone_without_learning_colour() { - let (sensor, reference) = near_black_scene(); - assert!(collect_pairs(&sensor, &reference, 0.005, None, false).is_none(), "too few midtones even for the partial look"); - let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON D610", Default::default()); - let (_, _, look) = starting_colour_with( - &mut raw, - |_, _| (fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None), false), - |_, _| None, - |_, _| fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]), - ); - let look = look.expect("tone from the dark pixels"); - assert_eq!(look.matrix, Mat3::IDENTITY); - assert!(look.hue_sat.is_none()); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let error = |tone: &ToneMap| { - sensor - .data - .iter() - .zip(&reference.data) - .map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2)) - .sum::() - }; - assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001); - assert_eq!(look.tone.apply(0.0), 0.0); - let mut previous = 0.0; - for i in 0..10_000 { - let y = look.tone.apply(i as f32 / 10_000.0); - assert!(y.is_finite() && y >= previous && y <= 1.0); - previous = y; - } - // Models with spectral matrices keep them before the dark tone is fitted. - let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); - let (_, t, look) = starting_colour_with( - &mut raw, - |_, _| (None, false), - |_, _| None, - |raw, t| { - assert!(!t.matrix_is_fallback && lightcraft_raw::color::has_matrix(&raw.color)); - fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]) - }, - ); - assert!(!t.matrix_is_fallback); - assert_eq!(look.expect("tone on top of spectral colour").matrix, Mat3::IDENTITY); - } - - #[test] - fn dark_tone_holdout_never_enters_training() { - let (sensor, mut reference) = near_black_scene(); - let none = vec![false; sensor.data.len()]; - let original = fit_dark_tone(&sensor, &reference, &none).expect("dark tone"); - // Every pixel clears the wider signal floors. Changing only its held-out third must - // change the measured error, but cannot change the fitted curve. - for p in reference.data.iter_mut().step_by(3) { - *p = p.map(|v| v * 1.03); - } - let changed = fit_dark_tone(&sensor, &reference, &none).expect("still clearly better than the fallback"); - assert_eq!(changed.tone, original.tone); - } - - #[test] - fn dark_tone_requires_signal_correlation_and_unclipped_highlights() { - let (sensor, reference) = near_black_scene(); - let none = vec![false; sensor.data.len()]; - let mut unrelated = reference.clone(); - for (i, p) in unrelated.data.iter_mut().enumerate() { - *p = reference.data[(i * 3571) % reference.data.len()]; - } - assert!(fit_dark_tone(&sensor, &unrelated, &none).is_none(), "unrelated dark JPEG"); - let mut flat = reference.clone(); - flat.data.fill([0.002; 3]); - assert!(fit_dark_tone(&sensor, &flat, &none).is_none(), "a quantized flat JPEG cannot teach tone"); - let mut sparse = reference.clone(); - sparse.data.iter_mut().skip(80).for_each(|p| *p = [0.0; 3]); - assert!(fit_dark_tone(&sensor, &sparse, &none).is_none(), "fewer than 256 usable pairs"); - let mut bright = reference.clone(); - bright.data[0] = [0.95; 3]; - assert!(fit_dark_tone(&sensor, &bright, &none).is_none(), "dark pairs cannot constrain a bright highlight"); - let mut clipped = none; - clipped[0] = true; - let original = fit_dark_tone(&sensor, &reference, &clipped).expect("tone without the clipped pixel"); - let mut changed = sensor.clone(); - changed.data[0] = [0.18; 3]; - let look = fit_dark_tone(&changed, &reference, &clipped).expect("the clipped pixel is still closer to its JPEG"); - assert_eq!(look.tone, original.tone, "clipped samples cannot change the fitted curve"); - let mut highlight = reference.clone(); - highlight.data[0] = [0.8; 3]; - assert!(fit_dark_tone(&changed, &highlight, &clipped).is_none(), "the curve cannot darken a clipped highlight away from the JPEG"); - assert!(fit_dark_tone(&sensor, &Rgb32f::new(64, 96), &clipped).is_none(), "mismatched dimensions"); - assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask"); - } - - /// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is - /// learnt and the full look is never tried) whose camera renders it two stops brighter than the - /// neutral fallback: the partial look is the tone curve (and chroma curve) alone. - #[test] - fn partial_look_brightens_a_dull_scene_without_learning_colour() { - let (w, h) = (96usize, 64usize); - let mut sensor = Rgb32f::new(w, h); - for (i, p) in sensor.data.iter_mut().enumerate() { - let ev = 0.01 + (i % 37) as f32 * 0.004; - let tint = if i % 50 == 0 { 1.3 } else { 1.0 + (i % 5) as f32 * 0.004 }; - *p = [ev * tint, ev, ev * (2.0 - tint)]; - } - let camera = |p: [f32; 3]| { - let y = luminance_2020(p); - p.map(|v| v * (1.0 - (-12.0 * y).exp()) / y) - }; - let mut reference = sensor.clone(); - reference.map_in_place(camera); - assert!(fit_pairs(&sensor, &reference).is_none(), "too little colour for the full look"); - let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look"); - assert_eq!(look.matrix, Mat3::IDENTITY, "no colour matrix from a colourless scene"); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let neutral = ToneMap::new(0.0, 0.0, 0.0); - let lightness = - |tone: &ToneMap| sensor.data.iter().map(|p| f64::from(tone.apply(luminance_2020(*p)))).sum::() / sensor.data.len() as f64; - let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / reference.data.len() as f64; - assert!((lightness(&tone) / target - 1.0).abs() < 0.05, "{} vs {target}", lightness(&tone)); - assert!(lightness(&neutral) < target * 0.6, "the neutral fallback is far darker"); - } - - /// When the neutral fallback already matches the camera JPEG, or the JPEG is unrelated or - /// monochrome, the partial look isn't clearly better and the photo keeps the neutral fallback. - #[test] - fn partial_look_only_when_clearly_better_than_the_neutral_fallback() { - let (w, h) = (96usize, 64usize); - let mut sensor = Rgb32f::new(w, h); - for (i, p) in sensor.data.iter_mut().enumerate() { - let ev = 0.02 + (i % 41) as f32 * 0.012; - *p = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.045)]; - } - let none = vec![false; w * h]; - // the camera renders exactly as the neutral fallback: nothing to gain - let neutral = ToneMap::new(0.0, 0.0, 0.0); - let mut reference = sensor.clone(); - reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32)); - let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; - assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback"); - // an unrelated picture - for (i, p) in reference.data.iter_mut().enumerate() { - *p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; - } - assert!(fit_look(&sensor, &reference, &none, None).is_none(), "unrelated JPEG"); - // a black-and-white JPEG of the scene - reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [0.8 * luminance_2020(*s).sqrt(); 3]); - assert!(fit_look(&sensor, &reference, &none, None).is_none(), "monochrome JPEG"); - } - - /// A coloured scene whose camera JPEG is black and white: the camera's tone curve applied to the scene's - /// luminance, grey in every pixel. - fn black_and_white_scene() -> (Rgb32f, Rgb32f) { - let (w, h) = (96usize, 64usize); - let mut sensor = Rgb32f::new(w, h); - for (i, p) in sensor.data.iter_mut().enumerate() { - let ev = 0.01 + (i % 53) as f32 * 0.009; - *p = [ev * (0.6 + (i % 11) as f32 * 0.08), ev, ev * (0.6 + (i % 13) as f32 * 0.07)]; - } - let mut reference = sensor.clone(); - reference.map_in_place(|p| [1.0 - (-3.0 * luminance_2020(p)).exp(); 3]); - (sensor, reference) - } - - #[test] - fn black_and_white_jpegs_are_recognised() { - let (sensor, mut reference) = black_and_white_scene(); - assert!(monochrome(&reference)); - assert!(!monochrome(&sensor), "a colour JPEG"); - // a few coloured pixels (JPEG noise) are allowed, one in a hundred is colour - let n = reference.data.len(); - reference.data.iter_mut().take(n / 1000).for_each(|p| p[0] += 0.05); - assert!(monochrome(&reference)); - reference.data.iter_mut().take(n / 100).for_each(|p| p[0] += 0.05); - assert!(!monochrome(&reference)); - // a sepia-toned monochrome - let (_, mut toned) = black_and_white_scene(); - toned.map_in_place(|p| [p[0] * 1.08, p[1], p[2] * 0.85]); - assert!(!monochrome(&toned)); - assert!(!monochrome(&Rgb32f::new(8, 8)), "too small to tell"); - // the check reads pixels, not the picture style: a colour JPEG whose pixels are all grey within 0.002 counts - let (_, mut faint) = black_and_white_scene(); - faint.map_in_place(|p| [p[0] + 0.001, p[1], p[2] - 0.0005]); - assert!(monochrome(&faint)); - faint.map_in_place(|p| [p[0] + 0.003, p[1], p[2]]); - assert!(!monochrome(&faint)); - // pixels that aren't numbers can't vouch for grey - let (_, mut broken) = black_and_white_scene(); - broken.data.iter_mut().take(n / 100).for_each(|p| *p = [f32::NAN; 3]); - assert!(!monochrome(&broken)); - } - - /// Issue #577: a black-and-white JPEG gives no look (no colour to fit), but its tone curve renders the scene's - /// brightness like the camera, with the colour left as it was and the chroma curve neutral. - #[test] - fn a_black_and_white_jpeg_gives_its_tone_curve() { - let (sensor, reference) = black_and_white_scene(); - let none = vec![false; sensor.data.len()]; - assert!(fit_look(&sensor, &reference, &none, None).is_none(), "no colour to fit"); - let look = fit_tone_only(&sensor, &reference, None).expect("a tone curve"); - assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "the colour is left as it was"); - assert!(look.tone.chroma().iter().all(|k| *k == 1.0), "the chroma curve stays neutral: {:?}", look.tone.chroma()); - let n = sensor.data.len() as f64; - let mean = |tone: &ToneMap| sensor.data.iter().map(|p| luma(displayed(p.map(f64::from), tone))).sum::() / n; - let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / n; - let toned = mean(&ToneMap::camera(&look.tone, 0.0, 0.0, 0.0)); - assert!((toned / target - 1.0).abs() < 0.05, "{toned} vs {target}"); - assert!(mean(&ToneMap::new(0.0, 0.0, 0.0)) < target * 0.8, "the default curve is darker"); - // with a camera profile's colour, the look carries it - let matrix = Mat3([[1.2, -0.15, -0.05], [-0.1, 1.15, -0.05], [-0.02, -0.1, 1.12]]); - let look = fit_tone_only(&sensor, &reference, Some((matrix, None))).expect("a tone curve with the profile's colour"); - assert_eq!(look.matrix, matrix); - } - - /// A camera profile's colour can fail the checks where the photo's own colour passes, so [`monochrome_look`] tries - /// again on the transform the photo has, as [`fit_look`] tries the photo's own colour after a profile's. - #[test] - fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() { - // saturated colours, and a profile whose colour gets their luminance wrong (red and blue swapped) - let hues = [[1.0, 0.15, 0.1], [0.1, 1.0, 0.15], [0.15, 0.1, 1.0], [1.0, 0.9, 0.1], [0.1, 0.9, 1.0], [1.0, 0.1, 0.9]]; - let mut sensor = Rgb32f::new(96, 64); - for (i, p) in sensor.data.iter_mut().enumerate() { - let ev = 0.02 + (i % 47) as f32 * 0.008; - *p = hues[i % 6].map(|c| c * ev); - } - // a JPEG a little brighter than the default curve, so a curve has to fit closely to be clearly better - let neutral = ToneMap::new(0.0, 0.0, 0.0); - let mut reference = sensor.clone(); - reference.map_in_place(|p| [(1.15 * luma(displayed(p.map(f64::from), &neutral))) as f32; 3]); - let swapped = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); - assert!(fit_tone_only(&sensor, &reference, Some((swapped, None))).is_none(), "the profile's colour fails the checks"); - let own = fit_tone_only(&sensor, &reference, None); - assert!(own.is_some(), "the photo's own colour passes"); - // and the step runs for a model with a bundled profile (the ILCE-7M4 also has spectral matrices) - assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); - let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour_with( - &mut raw, - |_, _| (None, true), - |_, t| { - assert!(!t.matrix_is_fallback, "on the spectral matrices"); - own.clone() - }, - |_, _| None, - ); - assert!(look.is_some() && !t.matrix_is_fallback); - } - - /// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 (it has a bundled camera profile) whose camera JPEG, - /// in a minimal TIFF, is black and white gets the tone curve, on the colour of the transform it is given; the - /// same file with a tinted JPEG gets nothing from this step. - #[test] - fn monochrome_look_reads_the_files_black_and_white_jpeg() { - use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, Rect}; - let (w, h) = (192usize, 128usize); - let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32); - let raw = RawImage { - format: RawFormat::Arw, - width: w, - height: h, - cpp: 3, - data: RawData::F32((0..w * h).flat_map(|i| [level(i % w, i / w); 3]).collect()), - cfa: None, - bits: 16, - black: BlackLevel::uniform(0.0), - white: vec![1.0], - active_area: Rect::new(0, 0, w, h), - crop: Rect::new(0, 0, w, h), - orientation: Orientation::from_exif(1), - color: ColorData::default(), - wb_multipliers: Some([1.0; 3]), - linearized: true, - pixel_aspect: 1.0, - opcodes: OpcodeLists::default(), - metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, - lens_metadata: Default::default(), - }; - assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); - // the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG - let file = |tint: [f32; 3]| { - let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; - let pixels: Vec = (0..w * h) - .flat_map(|i| { - let v = 1.0 - (-3.0 * level(i % w, i / w)).exp(); - tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8) - }) - .collect(); - let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); - let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); - // II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it - let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec(); - for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] { - tiff.extend(tag.to_le_bytes()); - tiff.extend(4u16.to_le_bytes()); - tiff.extend(1u32.to_le_bytes()); - tiff.extend(value.to_le_bytes()); - } - tiff.extend(0u32.to_le_bytes()); - assert_eq!(tiff.len(), 38); - tiff.extend(jpeg); - tiff - }; - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG"); - assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour"); - let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let mid = curve.apply(0.25); - assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}"); - assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white"); - } - - /// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve - /// already renders, gives no look. - #[test] - fn a_black_and_white_jpeg_gives_a_tone_curve_only_when_clearly_better() { - let (sensor, mut reference) = black_and_white_scene(); - let neutral = ToneMap::new(0.0, 0.0, 0.0); - reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [luma(displayed(s.map(f64::from), &neutral)) as f32; 3]); - assert!(monochrome(&reference)); - assert!(fit_tone_only(&sensor, &reference, None).is_none(), "no clear gain over the default curve"); - for (i, p) in reference.data.iter_mut().enumerate() { - *p = [0.05 + (i % 29) as f32 * 0.02; 3]; - } - assert!(fit_tone_only(&sensor, &reference, None).is_none(), "another picture"); - // a flat grey JPEG ranks nothing, even when the scene's pixels are stored in brightness order (with ties - // ranked in pixel order it looked perfectly related, and the curve flattened the photo to one grey) - let mut sorted = sensor.clone(); - sorted.data.sort_by(|a, b| luminance_2020(*a).total_cmp(&luminance_2020(*b))); - reference.data.iter_mut().for_each(|p| *p = [0.4; 3]); - assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG"); - } - - /// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted. - #[test] - fn corpus_arw6_full_frame_gets_a_camera_look() { - const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) }; - let path = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/arw6-sony-ilce7rm6-ff-land-c.arw"); - let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); - return; - }; - let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert!(info.camera_tone.is_some(), "no camera look fitted"); - } - - /// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right. - fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) { - let scene = |x: f32, y: f32| -> [f32; 3] { - let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32; - [l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))] - }; - let camera = |p: [f32; 3]| { - let y = luminance_2020(p); - p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) - }; - let mut sensor = Rgb32f::new(w, h); - let mut reference = sensor.clone(); - for y in 0..h { - for x in 0..w { - sensor.data[y * w + x] = scene(x as f32, y as f32); - reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32)); - } - } - (sensor, reference) - } - - /// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a - /// flat and a differently sized preview. - #[test] - fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() { - let (sensor, reference) = scene_pair(96, 64, 0); - assert!(structure_agrees(&sensor, &reference)); - assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated"); - assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed"); - let mut stripes = reference.clone(); - for (i, p) in stripes.data.iter_mut().enumerate() { - *p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] }; - } - assert!(!structure_agrees(&sensor, &stripes), "a broken preview"); - let mut flat = reference.clone(); - flat.data.fill([0.2; 3]); - assert!(!structure_agrees(&sensor, &flat)); - assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1)); - } - - /// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that - /// renders mid-grey brighter than the default tone map is followed. - #[test] - fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() { - let (mut sensor, mut reference) = scene_pair(96, 64, 0); - for image in [&mut sensor, &mut reference] { - for p in image.data.iter_mut().step_by(5) { - *p = [p[0] * 1.6, p[1], p[2] * 0.5]; - } - } - let colour = Some((Mat3::IDENTITY, None)); - let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; - let look = - fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check) - .expect("tone fitted"); - assert_eq!(look.matrix, Mat3::IDENTITY); - assert!(look.hue_sat.is_none()); - let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); - let target = 1.0 - (-2.5f32 * 0.18).exp(); - assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); - assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); - assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself"); - } - - /// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the - /// camera preview's, in one direction only: clipping what the camera clips is not refused. - #[test] - fn dng_highlight_gate_compares_near_white_shares() { - let mut sensor = Rgb32f::new(32, 32); - for (i, p) in sensor.data.iter_mut().enumerate() { - *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; - } - let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); - let mut white = Rgb32f::new(32, 32); - white.data.iter_mut().for_each(|p| *p = [1.0; 3]); - let mut black = Rgb32f::new(32, 32); - black.data.iter_mut().for_each(|p| *p = [0.1; 3]); - assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white"); - assert!(!clips_more(&sensor, &black, &dark, &bright)); - assert!(!clips_more(&sensor, &black, &bright, &bright)); - assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much"); - } - - /// A dark scene's shadows below the usual training floor still reach the tone-only fit. - #[test] - fn dng_tone_fit_trains_on_dark_pixels() { - let mut sensor = Rgb32f::new(64, 64); - let mut reference = Rgb32f::new(64, 64); - for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() { - let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 }; - *s = [v, v * 1.1, v * 0.9]; - *r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)]; - } - let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len()); - let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len()); - assert!(dark > usual, "{dark} vs {usual}"); - } - - /// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. - #[test] - fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() { - use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; - let lens = EmbeddedLens { - vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }), - ..Default::default() - }; - let mut proxy = Rgb32f::new(96, 64); - proxy.data.fill([0.2; 3]); - correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal); - let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2); - assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}"); - let mut plain = Rgb32f::new(96, 64); - plain.data.fill([0.2; 3]); - correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal); - assert!(plain.data.iter().all(|p| *p == [0.2; 3])); - } - - /// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's - /// full-resolution corrections applied once rather than silently losing the tone fit. - #[test] - fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() { - use lightcraft_raw::Opcode; - let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]); - raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; - assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none()); - let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable"); - assert_eq!((proxy.width, proxy.height), (32, 24)); - let center = proxy.data[12 * 32 + 16][1]; - let corner = proxy.data[0][1]; - assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}"); - let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); - assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development"); - let small = sensor_proxy(&raw, 2, 16).unwrap(); - assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce"); - } - - /// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the - /// identity, with or without a look table; and no other raw format. - #[test] - fn dng_tone_gates_follow_the_files_own_tags() { - use lightcraft_raw::profile::{HsvTable, ToneCurve}; - let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); - let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; - let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { - let raw = raw_of(format, "Make", "Model", color); - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - dng_tone_eligible(&raw, &t) - }; - assert!(eligible(RawFormat::Dng, color.clone())); - assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path"); - assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep"); - let mut identity = color.clone(); - identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]); - assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone"); - let mut curved = color.clone(); - curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); - assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); - let mut looked = color; - looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); - assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone"); - } - - /// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not. - #[test] - fn dng_tone_fit_takes_lightroom_sized_previews() { - let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0); - assert!(1024.0 >= floor(7008.0)); - assert!(256.0 < floor(7008.0)); - assert!(1024.0 >= floor(9504.0), "a 61 MP raw too"); - } - - /// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped - /// to the JPEG through a steep camera curve (a hazy scene the camera stretches). - fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> { - (0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect() - } - - /// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that - /// of the darkest 1/32 (their last and first knots). - fn knot_range(mut scene: Vec) -> f64 { - scene.sort_by(f64::total_cmp); - let n = scene.len(); - scene[31 * n / 32 + n / 64] / scene[n / 64] - } - - /// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets - /// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit. - #[test] - fn tone_fits_accept_low_contrast_scenes() { - for fit in [fit_tone, fit_tone_conditional] { - let pairs = low_contrast_pairs(1.47); - let range = knot_range(pairs.iter().map(|p| p.0).collect()); - assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}"); - let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631"); - assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1)); - assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame"); - } - } - - /// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera - /// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback. - #[test] - fn a_low_contrast_scene_gets_a_camera_look() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.06 + (i % 31) as f32 * 0.0008; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y); - } - let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback"); - let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect()); - assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let error: f64 = sensor - .data - .iter() - .zip(&reference.data) - .map(|(x, y)| { - let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); - (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 - }) - .sum::() - / sensor.data.len() as f64; - assert!(error.sqrt() < 0.02, "{}", error.sqrt()); - } - - /// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the - /// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out - /// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky - /// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries' - /// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright - /// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range). - #[test] - fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - for (case, sky_value, is_sky) in [ - ("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool), - ("clipped sky", 1.0, &|i: usize| i < 5600), - ("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)), - ("sky over the pairs' range", 1.6, &|i: usize| i < 5600), - ] { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - let mut clipped = vec![false; sensor.data.len()]; - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - if is_sky(i) { - // bright, nearly white in the camera JPEG - *src = [sky_value; 3]; - *dst = [0.93; 3]; - clipped[i] = case == "clipped sky"; - continue; - } - let ev = 0.06 + (i % 31) as f32 * 0.0008; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - // nearly flat midtones: the fitted curve's extrapolation can't reach the sky - *dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y); - } - let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue }; - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let correction = look.hue_sat.as_ref().and_then(HueSat::new); - let p = look.matrix.apply([f64::from(sky_value); 3]); - let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); - let sky = luma(displayed(p, &tone)); - assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}"); - } - } - - /// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to - /// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the - /// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the - /// sky at 0.61 where the camera JPEG has 0.93. - #[test] - fn a_narrow_curve_is_checked_as_lowered_for_dro() { - let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - if i < 5600 { - *src = [0.9; 3]; - *dst = [0.93; 3]; - continue; - } - let ev = 0.06 + (i % 31) as f32 * 0.0008; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - let p = known.apply_f32(*src); - let y = luminance_2020(p); - *dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y); - } - let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0))); - if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) { - let look = with_dro_off_tone(look); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let correction = look.hue_sat.as_ref().and_then(HueSat::new); - let p = look.matrix.apply([0.9; 3]); - let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); - let sky = luma(displayed(p, &tone)); - assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}"); - } - } - - /// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning - /// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted. - #[test] - fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - let ev = 0.06 + (i % 31) as f32 * 0.0008; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - *dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3]; - } - let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect()); - assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); - let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve"); - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2)); - let rms = (error.sum::() / sensor.data.len() as f64).sqrt(); - assert!(rms < 0.02, "{rms}"); - } - - /// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is - /// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit. - #[test] - fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() { - let mut sensor = Rgb32f::new(96, 64); - let mut reference = sensor.clone(); - for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { - if i < 5600 { - *src = [0.9; 3]; - *dst = [0.93; 3]; - continue; - } - let ev = 0.06 + (i % 31) as f32 * 0.0008; - *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; - *dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3]; - } - if let Some(look) = fit_tone_only(&sensor, &reference, None) { - let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0))); - assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}"); - } - } -} +mod tests; diff --git a/crates/engine/src/camera_preview/tests.rs b/crates/engine/src/camera_preview/tests.rs new file mode 100644 index 000000000..bd2703f96 --- /dev/null +++ b/crates/engine/src/camera_preview/tests.rs @@ -0,0 +1,2446 @@ +use super::*; + +fn steep_toe_scene() -> (Rgb32f, Rgb32f) { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + // A broad scene with a steep camera toe below the old JPEG floor of 0.015. + // Permute brightness so the held-out pixels span the same range independently. + let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25; + let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025]; + *src = rgb.map(|v| v * y / luminance_2020(rgb)); + let display = 3.0 * y * (y / (y + 0.02)).powi(2); + *dst = src.map(|v| v * display / y); + } + (sensor, reference) +} + +#[test] +fn camera_tone_follows_the_jpegs_deep_shadows() { + let (sensor, reference) = steep_toe_scene(); + let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap(); + let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); + assert!(dark.len() > 256); + let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; + assert!(error < 0.001, "deep-shadow luminance error {error}"); + assert_eq!(look.matrix, Mat3::IDENTITY); +} + +#[test] +fn colour_holdouts_stay_out_of_the_wider_tone_training() { + let (sensor, reference) = steep_toe_scene(); + let fit = |reference: &Rgb32f| { + let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None, false).unwrap(); + fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap() + }; + let expected = fit(&reference); + let mut changed = reference.clone(); + let mut index = 0; + for (x, y) in sensor.data.iter().zip(&mut changed.data) { + if colour_pair(*x, *y) { + if index % 3 == 0 { + *y = y.map(|v| v * 1.1); + assert!(colour_pair(*x, *y)); + } + index += 1; + } + } + assert_eq!(fit(&changed), expected); +} + +#[test] +fn camera_toe_is_robust_to_shadow_noise() { + let (sensor, clean) = steep_toe_scene(); + let mut noisy = clean.clone(); + for (i, p) in noisy.data.iter_mut().enumerate() { + if luminance_2020(*p) < 0.015 { + // Quantized JPEG levels with bounded noise and occasional compression outliers. + let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004; + *p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003); + if i % 23 == 0 { + *p = [0.014; 3]; + } + } + } + let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap(); + let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); + let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; + assert!(error < 0.001, "noisy shadow luminance error {error}"); + assert_eq!(look.tone.apply(0.0), 0.0); + assert_eq!(look.matrix, Mat3::IDENTITY); + for i in 1..1000 { + assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0)); + } +} + +#[test] +fn camera_toe_requires_lower_independent_dark_error() { + for holdout_matches_original in [true, false] { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 }; + *src = [y; 3]; + // Midtones teach the original slope of 3. Dark training teaches 0.3, + // but the independent dark holdout can agree with either slope. + let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 }; + *dst = [y * slope; 3]; + assert_eq!(colour_pair(*src, *dst), i >= 600); + } + let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None, false).unwrap(); + let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap(); + // Both sample sets start at a multiple of three, so this split matches + // the helper's separate colour and extra-sample indices. + let candidate = fit_tone( + sensor + .data + .iter() + .zip(&reference.data) + .enumerate() + .filter(|(i, _)| i % 3 != 0) + .map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from)))) + .collect(), + ) + .unwrap(); + let held: Pairs = + sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect(); + assert_eq!(held.len(), 200); + assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y)))); + assert!(rank_correlation_ties(&held) > 0.99); + let error = |tone: &CameraTone| { + held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::() + }; + assert_ne!(candidate, original); + let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap(); + if holdout_matches_original { + assert!(error(&candidate) > error(&original) + 0.1); + assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve"); + } else { + assert!(error(&candidate) < error(&original) * 0.99); + assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate"); + } + let mut previous = 0.0; + assert_eq!(selected.apply(0.0), 0.0); + for i in 0..10_000 { + let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0)); + assert!(y.is_finite() && y >= previous && y <= 1.0); + previous = y; + } + assert_eq!(selected.apply(f32::NAN), 0.0); + assert_eq!(selected.apply(f32::INFINITY), 0.0); + } +} + +#[test] +fn unreliable_dark_pairs_keep_the_original_tone_and_colour() { + let (sensor, reference) = steep_toe_scene(); + let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect(); + let mut excluded = sensor.clone(); + for &i in &dark { + excluded.data[i] = [f32::NAN; 3]; + } + let colour = Some((Mat3::IDENTITY, None)); + let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap(); + for case in 0..6 { + let (mut x, mut y) = (sensor.clone(), reference.clone()); + for (j, &i) in dark.iter().enumerate() { + match case { + 0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal + 1 => x.data[i] = [0.00001; 3], // below the sensor noise floor + 2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs + 3 => x.data[i] = [0.003; 3], // no scene luminance spread + 4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe + 5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise + _ => {} + } + } + let look = fit_pairs_with(&x, &y, colour.clone()).unwrap(); + assert_eq!(look.tone, original.tone, "case {case}"); + assert_eq!(look.matrix, original.matrix, "case {case}"); + assert!(look.hue_sat.is_none()); + } +} + +#[test] +fn camera_jpeg_matches_the_sensor_proxy_size_exactly() { + // An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy + // it is decoded at 384×257 (`proxies`: twice the proxy edge) next to a 192×128 sensor proxy. + let sensor = fit(&Rgb32f::new(9504, 6336), PROFILE_PROXY, PROFILE_PROXY, Filter::Box); + assert_eq!((sensor.width, sensor.height), (192, 128)); + let jpeg = Rgb32f::new(384, 257); + // what fitting gave before: a pixel short, so every pair was rejected + assert_eq!(fit(&jpeg, sensor.width, sensor.height, Filter::Box).width, 191); + let r = reference_at(&jpeg, &sensor); + assert_eq!((r.width, r.height), (192, 128)); + // and at the single-photo proxy size, where fitting happened to land on the same size + let sensor = fit(&Rgb32f::new(9504, 6336), PROXY, PROXY, Filter::Box); + let r = reference_at(&Rgb32f::new(192, 129), &sensor); + assert_eq!((r.width, r.height), (sensor.width, sensor.height)); +} + +#[test] +fn linear_arw_gets_a_sensor_proxy_without_demosaicing() { + use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect}; + let mut raw = RawImage { + format: RawFormat::Arw, + width: 32, + height: 32, + cpp: 3, + data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)), + cfa: None, + bits: 16, + black: BlackLevel::uniform(0.0), + white: vec![1.0], + active_area: Rect::new(0, 0, 32, 32), + crop: Rect::new(0, 0, 32, 32), + orientation: Orientation::from_exif(1), + color: ColorData::default(), + wb_multipliers: Some([1.0; 3]), + linearized: true, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + metadata: lightcraft_meta::Metadata::default(), + lens_metadata: Default::default(), + }; + let proxy = sensor_proxy(&raw, 2, 384).unwrap(); + assert_eq!((proxy.width, proxy.height), (32, 32)); + assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]); + raw.data = RawData::F32(Vec::new()); + assert!(sensor_proxy(&raw, 2, 384).is_none()); +} +#[test] +fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(64, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.05 + (i % 31) as f32 * 0.017; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); + } + let original = sensor.clone(); + let fit = fit_pairs(&sensor, &reference).unwrap(); + assert_eq!(sensor.data, original.data); + let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); + let error: f64 = sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| { + let p = displayed(fit.matrix.apply(x.map(f64::from)), &tone); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 + }) + .sum::() + / sensor.data.len() as f64; + assert!(error.sqrt() < 0.025, "{error}"); + // The colour transform is homogeneous; tone mapping happens only after exposure. + let p = fit.matrix.apply([2.0, 2.0, 2.0]); + assert!(luma(p) > 1.0); + assert!(tone.apply(0.2) < tone.apply(0.4)); +} +#[test] +fn accepts_a_much_better_fit_despite_local_camera_processing() { + // The camera JPEG departs from any global matrix + curve (local tone, vignetting): ±0.12 + // per-pixel deviations, ~0.07 RMS. The fit is still far closer than the fallback. + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(64, 64); + let mut reference = sensor.clone(); + let mut seed = 0x2545_f491_u32; + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.05 + (i % 31) as f32 * 0.017; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let noise = (seed % 2001) as f32 / 1000.0 - 1.0; + (v * (1.0 - (-2.5 * y).exp()) / y + 0.12 * noise).clamp(0.005, 0.97) + }); + } + assert!(fit_pairs(&sensor, &reference).is_some()); +} + +/// A camera that rotates saturated yellow-greens toward green (like Sony's "Standard" on a lime +/// shirt) can't be followed by a matrix alone: the fitted hue/saturation table must close most +/// of the gap, keep neutrals neutral and keep luminance. +#[test] +fn hue_table_follows_a_hue_dependent_camera_rendering() { + let to = to_prophoto(); + let from = to.inverse().unwrap(); + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + let mut lime = Vec::new(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.05 + (i % 29) as f32 * 0.02; + // mostly greys and mild colours, plus a patch of saturated yellow-green + *src = if i % 7 == 0 { + [ev * 0.75, ev, ev * 0.2] + } else { + [ev * (0.85 + (i % 11) as f32 * 0.03), ev, ev * (0.85 + (i % 13) as f32 * 0.025)] + }; + let (h, s) = hue_saturation(to.apply(src.map(f64::from))).unwrap(); + // the camera turns hues in 50°..110° (ProPhoto) by up to +15° and boosts their saturation + let k = (1.0 - ((h - 80.0) / 30.0).powi(2)).max(0.0) * s.min(1.0); + let mut p = to.apply(src.map(f64::from)).map(|v| v as f32); + let table = HsvTable { + hue_divisions: 1, + sat_divisions: 2, + val_divisions: 1, + data: vec![[0.0, 1.0, 1.0], [15.0 * k as f32, 1.0 + 0.3 * k as f32, 1.0]], + srgb_value: false, + }; + p = table.apply(p); + let p = from.apply(p.map(f64::from)).map(|v| v as f32); + let scale = luminance_2020(*src) / luminance_2020(p); + let p = p.map(|v| v * scale); + let y = luminance_2020(p); + *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); + if i % 7 == 0 { + lime.push(i); + } + } + let fit = fit_pairs(&sensor, &reference).unwrap(); + let table = fit.hue_sat.as_ref().expect("a hue/saturation table is fitted"); + let hue_sat = HueSat::new(table).unwrap(); + let hue_error = |with_table: bool| { + lime.iter() + .map(|&i| { + let p = fit.matrix.apply(sensor.data[i].map(f64::from)).map(|v| v as f32); + let p = if with_table { hue_sat.apply(p) } else { p }; + let (h, _) = hue_saturation(to.apply(p.map(f64::from))).unwrap(); + let (t, _) = hue_saturation(to.apply(reference.data[i].map(f64::from))).unwrap(); + ((h - t + 180.0).rem_euclid(360.0) - 180.0).abs() + }) + .sum::() + / lime.len() as f64 + }; + let (before, after) = (hue_error(false), hue_error(true)); + assert!(after < before * 0.5, "lime hue error {before:.2}° -> {after:.2}°"); + // neutrals pass through and luminance is kept + let grey = [0.3, 0.3, 0.3]; + assert!(hue_sat.apply(grey).iter().all(|v| (v - 0.3).abs() < 1e-4), "{:?}", hue_sat.apply(grey)); + let green = [0.2, 0.4, 0.05]; + assert!((luminance_2020(hue_sat.apply(green)) - luminance_2020(green)).abs() < 1e-5); +} + +/// A camera that saturates shadows and bleaches highlights toward white (a per-channel curve), +/// which a luminance tone curve can't: the fitted chroma curve must follow it, so bright +/// warm-tinted rock renders white instead of cream. +#[test] +fn chroma_curve_follows_highlight_bleaching() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let camera_chroma = |o: f32| 1.3 - 1.1 * o; + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.02 + (i % 37) as f32 * 0.03; + *src = [ev * (0.75 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + let o = 1.0 - (-2.5 * y).exp(); + let k = camera_chroma(o); + *dst = p.map(|v| (o + (v * o / y - o) * k).clamp(0.0, 0.999)); + } + let fit = fit_pairs(&sensor, &reference).unwrap(); + // relative to the matrix, which already carries the average colourfulness + let chroma = fit.tone.chroma(); + assert!(chroma[6] < 0.5 * chroma[1], "highlights bleach relative to shadows: {chroma:?}"); + // and the rendered highlights land on the camera's, far closer than without the curve + let with = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); + let without = ToneMap::camera(&fit.tone.with_chroma([1.0; lightcraft_pipeline::tone::CHROMA_N]).unwrap(), 0.0, 0.0, 0.0); + let highlight_error = |map: &ToneMap| -> f64 { + sensor + .data + .iter() + .zip(&reference.data) + .filter(|(_, y)| luminance_2020(**y) > 0.7) + .map(|(x, y)| { + let p = displayed(fit.matrix.apply(x.map(f64::from)), map); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() + }) + .sum() + }; + let (a, b) = (highlight_error(&without), highlight_error(&with)); + assert!(b < 0.5 * a, "highlight error {a:.4} -> {b:.4}"); +} + +/// Pooled pairs of several "photos" recover the camera's matrix, and a photo given that +/// colour model (a camera profile) keeps it, fitting only its own tone. +#[test] +fn profile_colour_is_pooled_and_then_used_as_given() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let photo = |seed: usize, strength: f32| { + let mut sensor = Rgb32f::new(64, 48); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let i = i + seed * 7; + let ev = 0.04 + (i % 23) as f32 * 0.02; + *src = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + // each photo has its own tone (DRO, picture style) + *dst = p.map(|v| v * (1.0 - (-strength * y).exp()) / y); + } + (sensor, reference) + }; + let mut pool = Vec::new(); + for seed in 0..4 { + let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5); + pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0); + } + let (matrix, _) = fit_profile(&pool).unwrap(); + // the camera's colours (luminance-normalised: the tone curve sets brightness) + for x in [[0.8, 1.0, 0.75], [1.1, 1.0, 0.8], [0.75, 1.0, 1.2], [1.0; 3]] { + let (a, b) = (matrix.apply(x), known.apply(x)); + let (la, lb) = (luma(a), luma(b)); + assert!((0..3).all(|c| (a[c] / la - b[c] / lb).abs() < 0.01), "{x:?}: {a:?} vs {b:?}"); + } + let (sensor, reference) = photo(9, 3.5); + let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); + assert_eq!(look.matrix, matrix); + assert!(look.hue_sat.is_none()); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| { + let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 + }) + .sum::() + / sensor.data.len() as f64; + assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt()); +} + +#[test] +fn profile_fits_tone_without_learning_colour_from_a_dull_scene() { + let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]); + let mut sensor = Rgb32f::new(64, 48); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + *src = [0.02 + (i % 997) as f32 * 0.00045; 3]; + let p = matrix.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y); + } + assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix"); + let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); + assert_eq!(look.matrix, matrix); + reference.map_in_place(|p| [luminance_2020(p); 3]); + assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected"); +} + +#[test] +fn rejected_profile_preserves_a_usable_per_photo_fit() { + let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); + let mut sensor = Rgb32f::new(64, 64); + let mut reference = sensor.clone(); + let mut seed = 0x2545_f491_u32; + for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) { + *src = std::array::from_fn(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + 0.05 + (seed % 1000) as f32 * 0.0003 + }); + let p = matrix.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999)); + } + let per_photo = fit_pairs(&sensor, &reference).unwrap(); + let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap(); + assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit"); + assert_eq!(look.tone, per_photo.tone); +} + +#[test] +fn hue_sat_passes_black_and_non_finite_pixels_through() { + let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let hue_sat = HueSat::new(&table).unwrap(); + assert_eq!(hue_sat.apply([0.0; 3]), [0.0; 3]); + let nan = hue_sat.apply([f32::NAN, 0.2, 0.1]); + assert!(nan[0].is_nan() && nan[1] == 0.2 && nan[2] == 0.1); + assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples"); +} + +#[test] +fn hue_sat_never_turns_near_black_noise_white() { + // a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels + let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let hue_sat = HueSat::new(&table).unwrap(); + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0 + }; + let mut worst = 0.0f32; + for _ in 0..200_000 { + let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()]; + if luminance_2020(rgb) <= 0.0 { + continue; + } + let out = hue_sat.apply(rgb); + let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs())); + worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale); + } + assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel"); +} + +/// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey, +/// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table. +#[test] +fn hue_sat_lanes_match_one_by_one_bit_for_bit() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0; + let fitted = (0..72 * 5 * 5) + .map(|_| { + let r = next(); + [unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0] + }) + .collect(); + let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true }; + let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]; + let mut colours = Vec::new(); + for a in special { + for b in special { + colours.extend(special.map(|c| [a, b, c])); + } + } + for _ in 0..60_000 { + let r = next(); + let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize]; + colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset)); + } + for table in [fitted, saturating] { + let hue_sat = HueSat::new(&table).unwrap(); + for chunk in colours.chunks(16) { + let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c]))); + let out = hue_sat.apply_lanes(rgb); + for (l, p) in chunk.iter().enumerate() { + let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p)); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); + } + } + } +} + +#[test] +fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() { + assert_eq!(restore_weight(0.0), 0.0); + assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0); + assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0); + assert_eq!(restore_weight(1.0), 1.0); + assert_eq!(restore_weight(f32::NAN), 0.0); + // monotonic and without a step: neighbouring shares 0.001 apart differ by little + let mut last = 0.0f32; + for i in 0..=400 { + let w = restore_weight(i as f32 * 0.001); + assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001); + last = w; + } +} + +#[test] +fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() { + // the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT + let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let hue_sat = HueSat::new(&table).unwrap(); + let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) }; + for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] { + let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb))); + let (before, after) = (luminance_2020(rgb), luminance_2020(out)); + assert!(after >= MIN_LUMINANCE_KEPT * before); + assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after)); + } +} + +fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage { + let area = lightcraft_raw::Rect::new(0, 0, 2, 2); + RawImage { + format, + width: 2, + height: 2, + cpp: 1, + data: lightcraft_raw::RawData::U16(vec![0; 4]), + cfa: None, + bits: 14, + black: Default::default(), + white: vec![16383.0], + active_area: area, + crop: area, + orientation: Default::default(), + color, + wb_multipliers: Some([2.0, 1.0, 1.5]), + linearized: false, + pixel_aspect: 1.0, + opcodes: Default::default(), + metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, + lens_metadata: Default::default(), + } +} + +/// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain +/// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none. +#[test] +fn lens_geometry_reads_the_first_rectilinear_warp() { + use lightcraft_raw::Opcode; + let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear { + planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]], + center: c, + }; + assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] })); + let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02)); + assert!(lens_geometry(&[]).is_none()); + assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none()); + assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none()); + let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN]; + p[0] = 1.0; + p[18] = 1e6; + let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false }; + assert!(lens_geometry(&[chain, single]).is_none()); +} + +/// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp +/// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside +/// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone. +#[test] +fn the_sensor_proxy_follows_the_cameras_lens_warp() { + let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default()); + raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48); + raw.crop = raw.active_area; + // a 1/4 proxy whose pixels hold their own coordinates + let mut img = Rgb32f::new(16, 12); + for (i, p) in img.data.iter_mut().enumerate() { + *p = [(i % 16) as f32, (i / 16) as f32, 1.0]; + } + let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }; + let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap(); + assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4))); + // the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor + let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap(); + assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]); + let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap(); + assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite()); + assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none()); +} + +/// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected +/// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every +/// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected. +#[test] +fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(entries) = std::fs::read_dir(&dir) else { + eprintln!("skip: no corpus at {}", dir.display()); + return; + }; + let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); + paths.sort(); + let (mut checked, mut gain) = (0, 0.0); + for path in paths { + let Ok(bytes) = std::fs::read(&path) else { continue }; + let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue }; + let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue }; + if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { + continue; + } + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) = + (proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens))) + else { + continue; + }; + let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference)); + eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display()); + // (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little) + assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display()); + gain += after - before; + checked += 1; + } + eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64); + assert!(checked == 0 || gain > 0.0); +} + +/// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit +/// is tried. +#[test] +fn a_files_own_matrices_come_first() { + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| panic!("no JPEG fit for a raw with its own matrices"), + |_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"), + |_, _| panic!("no dark tone fit for a raw with its own matrices"), + ); + assert_eq!(raw.color, color); + assert!(!t.matrix_is_fallback && look.is_none()); +} + +/// Steps 2 and 3 before step 4: an accepted JPEG fit (with or without a camera profile) is kept for a model that +/// has spectral matrices, and the spectral matrices are not written. Photos that had a fitted look before the +/// spectral table render exactly as they did. +#[test] +fn an_accepted_jpeg_fit_comes_before_the_spectral_matrices() { + for (make, model) in [("SONY", "ILCE-7M4"), ("Canon", "Canon EOS 600D")] { + assert!(lightcraft_raw::spectral::find(Some(make), model).is_some(), "{model}"); + let mut raw = raw_of(RawFormat::Cr2, make, model, Default::default()); + let fitted = dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()); + let (xy, t, look) = starting_colour_with( + &mut raw, + |raw, t| { + assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices"); + (Some(fitted.clone()), false) + }, + |_, _| panic!("no black-and-white JPEG fit after an accepted fit"), + |_, _| panic!("no dark tone fit after an accepted fit"), + ); + assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}"); + assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out"); + assert!(t.matrix_is_fallback, "{model}"); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + } +} + +/// Step 4: when the JPEG fit fails (here: no camera JPEG at all), a model with a spectral row takes its matrices, +/// whether or not it has a camera profile (the ILCE-7M4 has a bundled one). +#[test] +fn a_failed_jpeg_fit_falls_back_to_the_spectral_matrices() { + assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); + let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); + let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); + let (_, t, look) = starting_colour(&mut raw, &[], None, None); + assert!(look.is_none()); + assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); + assert!(!t.matrix_is_fallback); + + let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (xy, t, look) = + starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"), |_, _| None); + assert!(look.is_none()); + assert_eq!(raw.color.illuminant, [17, 21]); + assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some)); + assert!(!t.matrix_is_fallback); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + // white-balanced white stays white + let w = t.matrix.apply([1.0; 3]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); +} + +/// After step 4 or 5: a photo whose JPEG gave no fit and is black and white gets its tone curve on top of the +/// colour it has by then, the spectral matrices for a model in the table, the neutral fallback's otherwise +/// (issue #577). The black-and-white fit sees that transform. +#[test] +fn a_black_and_white_jpeg_adds_its_tone_on_top_of_the_colour_it_gets_otherwise() { + let toned = CameraLook { + matrix: Mat3::IDENTITY, + tone: dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()).tone, + hue_sat: None, + }; + let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (xy, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, true), + |raw, t| { + assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in"); + Some(toned.clone()) + }, + |_, _| panic!("an accepted monochrome look keeps its tone"), + ); + assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top"); + assert!(!t.matrix_is_fallback); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, true), + |_, t| { + assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour"); + Some(toned.clone()) + }, + |_, _| panic!("an accepted monochrome look keeps its tone"), + ); + assert!(look.is_some() && t.matrix_is_fallback); + assert_eq!(raw.color, lightcraft_raw::ColorData::default()); +} + +/// Step 5: a camera outside the table whose fit fails keeps the neutral fallback. +#[test] +fn a_camera_outside_the_table_keeps_the_fallback() { + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); + let (_, t, look) = starting_colour(&mut raw, &[], None, None); + assert!(look.is_none() && t.matrix_is_fallback); + assert_eq!(raw.color, lightcraft_raw::ColorData::default()); + // without a model there is no table row + let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); + raw.metadata.model = None; + assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback); +} + +/// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is +/// accepted keeps it and its colour data; one whose fit fails takes its spectral matrices when its model has +/// them; a DNG keeps its own matrices. White balance stays relative to the as-shot look for every raw without +/// matrices of its own. +#[test] +fn corpus_colour_precedence() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + lightcraft_testkit::skip!(Corpus, "{name} absent"); + continue; + }; + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + let lens = lens_geometry(&raw.opcodes.list3); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let own = lightcraft_raw::color::has_matrix(&raw.color); + let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); + let measured = lightcraft_raw::spectral::find(make, model).is_some(); + let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); + let before = raw.color.clone(); + let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref(), None); + let spectral = !own && fitted.is_none() && measured; + eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); + assert_eq!(raw.color != before, spectral, "{name}"); + assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); + // white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin, + // through the spectral matrices or its presets, whichever look it got (issue #730) + assert_eq!(info.relative_wb, !own && !covered, "{name}"); + assert_eq!(info.legacy_relative_wb, !own && covered, "{name}"); + if covered && !own { + assert!( + (2000.0..=50000.0).contains(&info.as_shot_temp) && info.as_shot_temp != 6500.0 && info.as_shot_tint.abs() < 100.0, + "{name}: {info:?}" + ); + } + // the same as the catalog decides from the probe (format and camera) + let probe = crate::files::probe_bytes(name, &bytes).unwrap(); + let mut p = lightcraft_catalog::Photo::new( + lightcraft_catalog::PhotoId(1), + lightcraft_catalog::Source::File { path: name.into() }, + name, + &probe.format, + 1, + 1, + "", + ); + p.kind = probe.kind; + p.meta = probe.meta.clone(); + assert_eq!(p.relative_wb(), info.relative_wb, "{name}"); + assert_eq!(probe.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); + } +} + +#[test] +fn supported_raws_get_a_file_local_look() { + // every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback) + let local = [ + RawFormat::Arw, + RawFormat::Nef, + RawFormat::Nrw, + RawFormat::Rw2, + RawFormat::Raf, + RawFormat::Cr3, + RawFormat::Cr2, + RawFormat::Pef, + RawFormat::Srw, + RawFormat::Orf, + RawFormat::Mrw, + RawFormat::Erf, + ]; + assert!(local.into_iter().all(file_local_look)); + // DNG carries its own colour model + assert!(!file_local_look(RawFormat::Dng)); +} + +#[test] +fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() { + let (w, h) = (192, 128); + let mut sensor = Rgb32f::new(w, h); + for (i, pixel) in sensor.data.iter_mut().enumerate() { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos(); + *pixel = [value; 3]; + } + let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] }; + let mut reference = Rgb32f::new(w, h); + for (i, pixel) in reference.data.iter_mut().enumerate() { + let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); + *pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7)); + } + let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone"); + assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}"); + assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}"); + assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}"); + assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged"); + reference.data.fill([0.2; 3]); + assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing"); + for (i, pixel) in reference.data.iter_mut().enumerate() { + let (x, y) = ((i % w) as f32, (i / w) as f32); + *pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3]; + } + assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing"); +} + +/// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus +/// download or an unfinished codec cannot make this test claim a successful RAW calibration. +#[test] +fn corpus_cr3_gets_a_framing_aligned_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/cr3-canon-r100-raw.cr3"); + let Ok(bytes) = std::fs::read(&path) else { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + return; + }; + let raw = match lightcraft_raw::decode(&bytes) { + Ok(raw) => raw, + Err(lightcraft_raw::RawError::Unsupported(why)) => { + lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}"); + return; + } + Err(error) => panic!("CR3 corpus failed to decode: {error}"), + }; + assert_eq!(raw.format, RawFormat::Cr3); + assert_eq!(raw.info().developed_size(), (6000, 4000)); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform, None).0; + eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); + assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.raw && info.relative_wb); + assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0)); + assert_eq!(info.camera_tone.is_some(), look.is_some()); +} + +/// A public sample of `model` (skipped without the corpus) takes its colour from the bundled profile for `model` +/// and passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the +/// photo's own fit (the fallback when a profile is rejected) would not. +fn corpus_uses_the_bundled_profile(file: &str, model: &str) { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw") + .join(file); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + return; + } + Err(e) => panic!("{}: {e}", path.display()), + }; + let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the model is built in"); + let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); + let used = crate::camera_profiles::get(model).expect("a profile for the model"); + assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test"); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!(raw.metadata.model.as_deref(), Some(model)); + // as the app does (`files.rs`): the camera's own lens warp pairs the sensor with its JPEG (Nikon's in-file + // distortion for the Z 6II and Z 8), and the lens corrections leave the opcodes before the proxy is binned + let lens = lens_geometry(&raw.opcodes.list3); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform, lens.as_ref()).0.expect("fit accepted"); + let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); + for (row, want) in look.matrix.0.iter().zip(expected.0) { + for (got, want) in row.iter().zip(want) { + assert!((got - want).abs() < 1e-12, "the look's matrix is not the profile's: {:?} vs {:?}", look.matrix.0, expected.0); + } + } + assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); +} + +#[test] +fn corpus_a7cr_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR"); +} + +#[test] +fn corpus_a99m2_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2"); +} + +/// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to. +#[test] +fn corpus_z6ii_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2"); +} + +/// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to. +#[test] +fn corpus_z8_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8"); +} + +/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white +/// balance is relative to the as-shot look. +#[test] +fn corpus_nef_gets_a_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/nef-nikon-d7500-lossless14.nef"); + let Ok(bytes) = std::fs::read(&path) else { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); + assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); +} + +/// The public ILCE-7CR samples (61 MP; skipped without the corpus): lossless compressed L and M and compressed +/// ARW2 all get a look fitted to their own JPEG, not the neutral fallback. +#[test] +fn corpus_a7cr_codings_get_a_camera_look() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for name in ["arw-sony-a7cr-lossless-l.arw", "arw-sony-a7cr-lossless-m.arw", "arw-sony-a7cr-compressed.arw"] { + let bytes = match std::fs::read(dir.join(name)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + lightcraft_testkit::skip!(Corpus, "{name} absent"); + continue; + } + Err(e) => panic!("{name}: {e}"), + }; + let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!((img.width, img.height), (400, 267), "{name}: framed in the default crop"); + assert!(info.camera_tone.is_some(), "{name}: no camera look fitted"); + assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0, "{name}"); + } +} + +/// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3 +/// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it. +#[test] +fn corpus_rw2_with_an_in_camera_crop_gets_a_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/rw2-panasonic-fz1000m2-4x3.rw2"); + let Ok(bytes) = std::fs::read(&path) else { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + return; + }; + let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!((img.width, img.height), (400, 300), "framed in the in-camera aspect ratio"); + assert!(info.camera_tone.is_some(), "no camera look fitted"); + assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); +} + +/// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is +/// distortion-corrected, and on its glass façade the fit on all pixels misses the gate +/// (held-out RMS 0.113): it used to open grey with the neutral fallback. +#[test] +fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); + let Ok(bytes) = std::fs::read(&path) else { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); +} + +/// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same +/// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation +/// table and its tone curve; without a look, those of the camera transform. +#[test] +fn dng_colour_renders_like_the_raws_starting_colour() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37)); + assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb); + let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); + let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; + let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2()); + for look in [Some(&look), None] { + let dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &t, look).unwrap(); + let back = camera_transform_of(&dng, as_shot_white_of(&dng, None)); + for i in 0..3 { + assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb); + } + let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); + for c in samples { + let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c)); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}"); + } + assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone())); + let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve); + match look { + Some(look) => { + let tone = tone.expect("the look's tone curve"); + let worst = + (0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max); + // (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here) + assert!(worst < 4e-3, "{worst}"); + } + None => assert!(tone.is_none()), + } + } +} + +/// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white +/// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their +/// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8. +#[test] +fn dng_colour_declares_the_white_balance_the_source_shows() { + use crate::files::DngWhite; + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers}; + let tags = ColorData::default(); + let as_shot = [2.5039f32, 1.0, 1.5078]; + let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot))); + let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); + look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]); + let tint = look.matrix.apply([1.0; 3]); + assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}"); + let renders_as_shot = |dng: &ColorData| { + let r = camera_transform_of(dng, as_shot_white_of(dng, None)); + assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb); + let m = look.matrix.mul(&t.matrix); + for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] { + let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2()); + let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2()); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}"); + } + }; + // a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0) + for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] { + let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap(); + let back = as_shot_white_of(&dng, None); + assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}"); + renders_as_shot(&dng); + } + // a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source + let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera"); + let mut model = tags.clone(); + assert!(camera.fill(&mut model)); + let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap(); + let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap(); + let back = as_shot_white_of(&dng, None); + assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}"); + for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] { + let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w)); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}"); + } + renders_as_shot(&dng); +} + +/// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without +/// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking +/// it from the multipliers again leaves the merged DNG rendering as the tags do. +#[test] +fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] { + let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default()); + raw.wb_multipliers = Some(multipliers); + let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices"); + let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance"); + // the reciprocal of the multipliers, up to the scale a neutral is given in + let scale = neutral[1] * multipliers[1] as f64; + for i in 0..3 { + assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}"); + } + let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone())); + merged.set_as_shot_multipliers(multipliers); + let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") }; + let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None))); + for i in 0..3 { + assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb); + } + for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) { + assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix); + } + assert_eq!(a.baseline_exposure, b.baseline_exposure); + } +} + +/// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. +fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { + CameraLook { + matrix: Mat3([[1.6, -0.4, -0.2], [-0.2, 1.4, -0.2], [0.0, -0.3, 1.3]]), + tone: CameraTone::new(xs.map(|x| [x, f(x)])).unwrap().with_chroma([1.3, 1.2, 1.1, 1.0, 1.0, 0.9, 0.6, 0.3]).unwrap(), + hue_sat: Some(HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[5.0, 1.1, 1.0]; 8], srgb_value: false }), + } +} + +/// `fixed` (from `fitted`) is `min(fitted, Sony's DRO-off curve)` at every knot but the +/// second-last, which may stay higher (at most the fitted curve) to keep the shoulder past the +/// last knot below the fitted one; nowhere brighter than `fitted`, colour unchanged. +fn assert_lowered(fitted: &CameraLook, fixed: &CameraLook, what: &str) { + let dro_off = sony_dro_off_tone().unwrap(); + assert_eq!((fixed.matrix, &fixed.hue_sat, fixed.tone.chroma()), (fitted.matrix, &fitted.hue_sat, fitted.tone.chroma()), "{what}"); + for (i, (k, own)) in fixed.tone.knots().iter().zip(fitted.tone.knots()).enumerate() { + let lowered = own[1].min(dro_off.apply(own[0])); + assert_eq!(k[0], own[0], "{what}: knot {i}"); + if i == 30 { + assert!(k[1] >= lowered && k[1] <= own[1], "{what}: knot {i} {} not in {lowered}..={}", k[1], own[1]); + } else { + assert!((k[1] - lowered).abs() < 1e-6, "{what}: knot {i} {} vs {lowered}", k[1]); + } + } + assert!(at_most(&fixed.tone, &fitted.tone), "{what}: brighter than the fitted curve somewhere"); + // the same check spelled out, from deep shadows to far past the last knot (the shoulder) + for i in 0..=2400 { + let x = 0.001 * 1.005f32.powi(i); + assert!(fixed.tone.apply(x) <= fitted.tone.apply(x) + 1e-5, "{what} at {x}: {} > {}", fixed.tone.apply(x), fitted.tone.apply(x)); + } +} + +/// Issue #244: Sony's Dynamic Range Optimizer lifts the camera JPEG's shadows, not the raw's. A +/// look fitted to such a JPEG keeps its colour (matrix, hue/saturation table, chroma curve) and +/// its curve is lowered to Sony's without DRO wherever that is darker, never raised. +#[test] +fn a_dro_look_keeps_its_colour_and_is_lowered_to_the_dro_off_tone() { + let dro_off = sony_dro_off_tone().expect("the built-in curve is valid"); + // the curve itself: black stays black, increasing, mid-grey where cameras put it (L* 50–75) + assert_eq!(dro_off.apply(0.0), 0.0); + let mut previous = 0.0; + for i in 1..200 { + let y = dro_off.apply(i as f32 * 0.005); + assert!(y > previous && y < 1.0, "{i}: {y}"); + previous = y; + } + assert!((0.18..0.48).contains(&dro_off.apply(0.18)), "{}", dro_off.apply(0.18)); + let xs: [f32; 32] = std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)); + // a JPEG with lifted shadows: the curve comes down to Sony's, colour and chroma curve stay + let lifted = dro_test_look(&xs, &|x| dro_off.apply(x).powf(0.8)); + let fixed = with_dro_off_tone(lifted.clone()); + assert_lowered(&lifted, &fixed, "lifted"); + assert!(fixed.tone.apply(0.02) < 0.85 * lifted.tone.apply(0.02)); + // a camera that maps raw values darker than Sony's curve keeps its own curve exactly + let darker = dro_test_look(&xs, &|x| 0.7 * dro_off.apply(x)); + assert_eq!(with_dro_off_tone(darker.clone()).tone, darker.tone); + // crossing curves: the lower of the two at the knots, still a valid increasing curve + let crossing = dro_test_look(&xs, &|x| (dro_off.apply(x) * (0.6 + x)).min(0.95)); + assert_lowered(&crossing, &with_dro_off_tone(crossing.clone()), "crossing"); +} + +/// Past its last knot a camera curve continues as a shoulder whose rate comes from its last two +/// knots. Lowering the second-last knot more than the last steepened that shoulder: the curve +/// fitted to the CC0 NEX-5T sample (raw.pixls.us, DRO Auto) went from 0.740 to 0.843 at scene +/// luminance 0.63, brighter than before. The lowered curve must stay at or below the fitted one +/// through the shoulder, on that curve and on many others. +#[test] +fn the_lowered_curve_stays_below_the_fitted_one_past_its_last_knot() { + let nex5t: [[f32; 2]; 32] = [ + [0.014948029, 0.017424058], + [0.018395446, 0.023981506], + [0.021539066, 0.030581286], + [0.024555191, 0.0374646], + [0.027602654, 0.044408206], + [0.030193308, 0.049894042], + [0.03261828, 0.055907387], + [0.034544908, 0.06156683], + [0.03669959, 0.06814718], + [0.039161213, 0.075263545], + [0.04173663, 0.0830011], + [0.044166446, 0.088166036], + [0.04682434, 0.095398724], + [0.049544845, 0.1027153], + [0.05235826, 0.11173598], + [0.05497513, 0.119017586], + [0.057503678, 0.12616614], + [0.059836045, 0.13521175], + [0.062677935, 0.14601946], + [0.06599234, 0.15573128], + [0.069809504, 0.16883339], + [0.0755404, 0.1854325], + [0.08250754, 0.20684941], + [0.089204684, 0.23466235], + [0.09650368, 0.25805998], + [0.102161564, 0.27849936], + [0.107812546, 0.29706743], + [0.11410169, 0.31379074], + [0.12000221, 0.33262977], + [0.12776275, 0.35407156], + [0.1387929, 0.38362882], + [0.19492775, 0.43935108], + ]; + let fitted = CameraLook { tone: CameraTone::new(nex5t).unwrap(), ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) }; + let fixed = with_dro_off_tone(fitted.clone()); + assert_lowered(&fitted, &fixed, "NEX-5T"); + assert!((fitted.tone.apply(0.63) - 0.7405).abs() < 1e-3 && fixed.tone.apply(0.63) <= fitted.tone.apply(0.63)); + assert_ne!(fixed.tone, fitted.tone, "the lifted mid-tones still come down"); + // curves that end at all kinds of scene luminances, slopes and heights relative to Sony's + let dro_off = sony_dro_off_tone().unwrap(); + let mut seed = 0x9e37_79b9_u32; + let mut next = move || { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f32::from((seed >> 16) as u16) / 65536.0 + }; + for case in 0..300 { + let (start, span) = (0.002 + 0.03 * next(), 1.05 + 0.15 * next()); + let xs: [f32; 32] = std::array::from_fn(|i| start * span.powi(i as i32)); + let (lift, gain, top) = (0.5 + 0.6 * next(), 0.6 + 0.8 * next(), next()); + // brighter or darker than Sony's, with a last knot sometimes lowered less than the one before + let f = |x: f32| (gain * dro_off.apply(x).powf(lift) + top * 0.2 * (x / xs[31]).powi(8)).min(0.995); + let mut ys = xs.map(f); + for i in 1..32 { + ys[i] = ys[i].max(ys[i - 1]); + } + let Some(tone) = CameraTone::new(std::array::from_fn(|i| [xs[i], ys[i]])) else { continue }; + let fitted = CameraLook { tone, ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) }; + assert_lowered(&fitted, &with_dro_off_tone(fitted.clone()), &format!("case {case}")); + } +} + +/// Issue #244 (skipped without the corpus): the public ILCE-7RM2 sample (Standard creative +/// style) was shot with DRO Auto, so its curve comes down to Sony's without DRO in the shadows the +/// camera lifted; the ILCE-7M4 sample (DRO Auto, Vivid) has deeper shadows than that curve and +/// keeps them, only its brighter highlights come down. Both keep the colour fitted to their +/// JPEGs. The ILCE-7M3 sample (DRO off) keeps its own fit. +#[test] +fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let samples = [ + ("arw-sony-a7rm2-12bit-uncompressed.arw", true, [0.01, 0.05, 0.1]), + ("arw-sony-a7m4-14bit.arw", true, [0.3, 0.4, 0.5]), + ("arw-sony-a7m3-compressed.arw", false, [0.0; 3]), + ]; + for (name, dro, lowered) in samples { + let path = dir.join(name); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); + continue; + } + Err(e) => panic!("{}: {e}", path.display()), + }; + assert_eq!(lightcraft_raw::embedded_preview_dynamic_range_optimized(&bytes), Some(dro), "{name}"); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look"); + // the look fitted to the JPEG, as before this change + let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap(); + let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); + let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); + let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); + if dro { + assert_lowered(&fitted, &look, name); + for x in lowered { + assert!(look.tone.apply(x) < 0.95 * fitted.tone.apply(x), "{name} at {x}: {} vs {}", look.tone.apply(x), fitted.tone.apply(x)); + } + } else { + assert_eq!((look.matrix, &look.hue_sat, look.tone), (fitted.matrix, &fitted.hue_sat, fitted.tone), "{name}"); + } + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(info.camera_tone, Some(look.tone), "{name}"); + } +} + +/// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a +/// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree +/// everywhere away from the edges, and the look must still be found there. +#[test] +fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let (w, h, block, shift) = (96, 64, 8, 2); + // Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour. + let scene = |x: usize, y: usize| -> [f32; 3] { + let (bx, by) = (x / block, y / block); + let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 }; + [ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)] + }; + let camera = |p: [f32; 3]| { + let q = known.apply_f32(p); + let y = luminance_2020(q); + q.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) + }; + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + sensor.data[y * w + x] = scene(x, y); + // The JPEG shows the scene `shift` pixels to the right of where the raw has it. + reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y)); + } + } + let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges"); + // The look itself is right: on the aligned scene it reproduces the camera. + let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0); + let (mut error, mut n) = (0.0, 0); + for y in 0..h { + for x in 0..w { + let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone); + let t = camera(scene(x, y)); + error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::(); + n += 3; + } + } + assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt()); + // Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well. + reference.data.fill([0.2; 3]); + assert!(fit_pairs(&sensor, &reference).is_none()); +} + +/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes. +/// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away +/// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0 +/// corpus files. +#[test] +fn corpus_raf_colour_and_white_balance() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + // (the GFX100S shares the GFX 100's measured sensor, so its white balance is in Kelvin through those + // matrices; the X-T2 and X-T20 have none and stay relative) + for (name, accepted, kelvin) in + [("raf-fuji-xt2-865.raf", true, false), ("raf-fuji-gfx100s-4503.raf", true, true), ("raf-fuji-xt20-compressed.raf", true, false)] + { + let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; + let header = crate::files::probe_bytes(name, &bytes).unwrap(); + let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap(); + let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap(); + assert_eq!(small.camera_tone.is_some(), accepted, "{name}"); + assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit"); + assert_eq!(header.as_shot_wb, Some((small.as_shot_temp, small.as_shot_tint)), "{name}"); + assert!(small.raw && small.relative_wb != kelvin && small.legacy_relative_wb == kelvin, "{name}: {small:?}"); + if kelvin { + assert!(small.as_shot_temp != 6500.0 && (2000.0..50000.0).contains(&small.as_shot_temp), "{name}: {}", small.as_shot_temp); + } else { + assert!(small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}"); + } + } +} + +/// Sony ARWs without measured matrices read Kelvin from their white-balance presets +/// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and +/// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its +/// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files. +#[test] +fn corpus_arw_white_balance_in_kelvin_through_the_presets() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let header = crate::files::probe_bytes(name, &bytes).unwrap(); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); + assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}"); + assert!( + (info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0, + "{name}: {} {}", + info.as_shot_temp, + info.as_shot_tint + ); + let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default()); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}"); + } +} + +#[test] +fn rejects_monochrome_invalid_and_unrelated_previews() { + let mut sensor = Rgb32f::new(32, 32); + let mut reference = sensor.clone(); + for (i, p) in sensor.data.iter_mut().enumerate() { + *p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1]; + } + reference.data.fill([0.2; 3]); + assert!(fit_pairs(&sensor, &reference).is_none()); + reference.data.fill([f32::NAN; 3]); + assert!(fit_pairs(&sensor, &reference).is_none()); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + *src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01]; + *dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; + } + assert!(fit_pairs(&sensor, &reference).is_none()); + sensor.data.fill([0.1, 0.15, 0.12]); + assert!(fit_pairs(&sensor, &reference).is_none()); + reference.data.truncate(8); + assert!(fit_pairs(&sensor, &reference).is_none()); +} + +/// A busy scene whose JPEG is the camera curve of the scene two proxy pixels to the right (a +/// lens-corrected preview): the fitted curve follows the camera's and has no flat steps. +#[test] +fn tone_fit_has_no_steps_on_a_misregistered_jpeg() { + let camera = |x: f64| 0.6 * x.powf(0.55) / (1.0 + 0.2 * x); + let (w, h) = (96usize, 64usize); + let mut seed = 0x2545_f491_u32; + let mut scene = Vec::with_capacity(w * h); + for _ in 0..w * h { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + scene.push(0.01 * (5.0 * f64::from(seed >> 8) / f64::from(1u32 << 24)).exp()); + } + let pairs: Vec<(f64, f64)> = + (0..h).flat_map(|y| (0..w - 2).map(move |x| (y * w + x, y * w + x + 2))).map(|(i, j)| (scene[i], camera(scene[j]))).collect(); + let curve = fit_tone(pairs).unwrap(); + let mut sorted = scene.clone(); + sorted.sort_by(f64::total_cmp); + let (lo, hi) = (sorted[sorted.len() / 50], sorted[sorted.len() * 49 / 50]); + let mut x = lo; + while x < hi { + let (a, b) = (curve.apply(x as f32), curve.apply((x * 1.02) as f32)); + assert!(b > a, "flat step at {x}: {a} -> {b}"); + assert!((f64::from(a) / camera(x) - 1.0).abs() < 0.05, "at {x}: {a} vs {}", camera(x)); + x *= 1.02; + } +} + +/// A synthetic photo whose JPEG is the camera look of the scene scaled per pixel by noise of +/// `spread` (a pseudo-random, registration-free stand-in for local tone mapping): the per-bin +/// median recovers the curve, while the JPEG's broadened distribution stretches the quantile fit. +fn noisy_look(spread: f32) -> (Rgb32f, Rgb32f) { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + let mut seed = 0x9e37_79b9_u32; + let mut next = move || { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f32::from((seed >> 16) as u16) / 32768.0 - 1.0 + }; + for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) { + let ev = 0.05 + (next() + 1.0) * 0.2; + *src = [ev * (0.8 + (next() + 1.0) * 0.1), ev, ev * (0.8 + (next() + 1.0) * 0.1)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + let scale = (1.0 - (-2.5 * y).exp()) / y * (1.0 + spread * next()); + *dst = p.map(|v| v * scale); + } + (sensor, reference) +} + +/// A camera rendering with constant colourfulness (no highlight bleaching) of a colourful scene +/// under a bright neutral sky, whose sensor pixels are clipped: one channel held at the clip +/// level turns the white sky magenta in the raw while the camera JPEG keeps it white. Returns +/// the sensor proxy, the JPEG proxy and the clip mask. +fn clipped_sky_scene() -> (Rgb32f, Rgb32f, Vec) { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + let mut clipped = vec![false; w * h]; + let mut seed = 0x2545_f491_u32; + let mut next = move || { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f32::from((seed >> 16) as u16) / 65536.0 + }; + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + let o = 1.0 - (-2.5 * y).exp(); + p.map(|v| (v * o / y).clamp(0.0, 0.97)) + }; + // the sky: a quarter of the frame, white in the scene and in the JPEG + let neutral = known.inverse().unwrap().apply_f32([0.32; 3]); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let scene = if i < w * h / 4 { + clipped[i] = true; + let sky = neutral.map(|v| v * (0.8 + 0.4 * next())); + *dst = camera(known.apply_f32(sky)); + // the green sample clips, red and blue (after white balance) go on rising + *src = [sky[0] * 1.35, sky[1] * 0.85, sky[2] * 1.4]; + continue; + } else { + // colourful foliage, leaves, bark and bright flowers from dark to light + let ev = 0.02 + next() * 0.4; + [ev * (0.6 + next() * 0.8), ev, ev * (0.5 + next() * 0.8)] + }; + *src = scene; + *dst = camera(known.apply_f32(scene)); + } + (sensor, reference, clipped) +} + +/// Mean colourfulness (distance from neutral of luminance-normalised RGB) of the unclipped +/// pixels rendered with `look`, over that of the camera JPEG, per display-luminance band. +fn colourfulness_ratio(look: &CameraLook, sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], band: std::ops::Range) -> f64 { + let chroma = |p: [f64; 3]| { + let y = luma(p); + p.iter().map(|v| (v / y - 1.0).powi(2)).sum::().sqrt() + }; + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let (mut rendered, mut camera) = (0.0, 0.0); + for ((x, y), c) in sensor.data.iter().zip(&reference.data).zip(clipped) { + let y = y.map(f64::from); + if *c || !band.contains(&luma(y)) { + continue; + } + rendered += chroma(displayed(look.matrix.apply(x.map(f64::from)), &tone)); + camera += chroma(y); + } + rendered / camera +} + +/// Clipped sensor pixels are left out of the look's training pairs: a clipped white sky no longer +/// dulls the colour matrix or makes the chroma curve remove colour at the sky's brightness. +#[test] +fn clipped_sky_does_not_dull_the_look() { + let (sensor, reference, clipped) = clipped_sky_scene(); + let old = fit_pairs(&sensor, &reference).expect("the earlier fit accepts the look"); + let new = fit_look(&sensor, &reference, &clipped, None).expect("look without the clipped pixels"); + let (mid, bright) = (0.1..0.35, 0.35..0.75); + let old_ratio = ( + colourfulness_ratio(&old, &sensor, &reference, &clipped, mid.clone()), + colourfulness_ratio(&old, &sensor, &reference, &clipped, bright.clone()), + ); + let new_ratio = + (colourfulness_ratio(&new, &sensor, &reference, &clipped, mid), colourfulness_ratio(&new, &sensor, &reference, &clipped, bright)); + // with the clipped sky in the pairs, the bright colours lose much of their colour + assert!(old_ratio.1 < 0.8, "earlier fit: colourfulness vs camera {old_ratio:?}, chroma {:?}", old.tone.chroma()); + // without it, the render keeps the camera's colourfulness at every brightness + for r in [new_ratio.0, new_ratio.1] { + assert!((0.9..1.1).contains(&r), "colourfulness vs camera {new_ratio:?}, chroma {:?}", new.tone.chroma()); + } + assert!(new.tone.chroma().iter().all(|k| *k > 0.85), "no colour cut: {:?}", new.tone.chroma()); +} + +/// When the accepted attempt misses the gates without the clipped pixels (here: too few pixels +/// left), the look fitted on all pixels is kept unchanged. +#[test] +fn clipped_fit_falls_back_to_all_pixels() { + let (sensor, reference, _) = clipped_sky_scene(); + let all = fit_pairs(&sensor, &reference).unwrap(); + // nearly everything clipped: too few pairs remain, the earlier look is used unchanged + let mut clipped = vec![true; sensor.data.len()]; + clipped[..100].iter_mut().for_each(|c| *c = false); + let fallback = fit_look(&sensor, &reference, &clipped, None).expect("the fallback keeps the look"); + assert_eq!((fallback.matrix.0, fallback.tone), (all.matrix.0, all.tone)); + // nothing clipped: the same look as before, exactly + let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap(); + assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); + // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap(); + assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); + assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); +} + +/// Quantile matching is tried first; the conditional-median fit is the fallback and is used +/// only where the quantile look misses the acceptance gates, so no photo loses its look. +#[test] +fn tone_fit_order_quantile_first_conditional_median_as_fallback() { + // Clean data: both fits pass, the default order uses the quantile curve (increasing). + let (sensor, reference) = noisy_look(0.0); + let default = fit_pairs(&sensor, &reference).expect("clean look"); + let quantile = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).expect("quantile passes"); + assert_eq!(default.tone, quantile.tone); + // Noisy data: the quantile fit misses the gates, the conditional median still passes. + let (sensor, reference) = noisy_look(0.33); + assert!(fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).is_none(), "quantile alone misses the gates"); + let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).expect("conditional median passes"); + let default = fit_pairs(&sensor, &reference).expect("the fallback keeps the look"); + assert_eq!(default.tone, conditional.tone); + // The order matters only through the first pass; with the fits swapped the conditional one wins even on clean data. + let (sensor, reference) = noisy_look(0.0); + let swapped = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian, ToneFit::Quantile]).unwrap(); + let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).unwrap(); + assert_eq!(swapped.tone, conditional.tone); +} + +/// The camera look of `scene` (the known matrix of these tests, then a bright camera curve). +fn bright_camera(p: [f32; 3]) -> [f32; 3] { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let q = known.apply_f32(p); + let y = luminance_2020(q); + if y <= 0.0 { [0.0; 3] } else { q.map(|v| v * (1.0 - (-4.0 * y).exp()) / y) } +} + +/// Mean squared error (all channels, display-linear) of `render` against `target` over `scene`. +fn mean_error(scene: &Rgb32f, target: impl Fn([f32; 3]) -> [f32; 3], render: impl Fn([f64; 3]) -> [f64; 3]) -> f64 { + let error: f64 = scene + .data + .iter() + .map(|x| { + let (p, t) = (render(x.map(f64::from)), target(*x)); + (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::() + }) + .sum(); + error / (3 * scene.data.len()) as f64 +} + +/// A colourful scene with fine detail everywhere (foliage) whose camera JPEG is lens-corrected: +/// barrel distortion moves the picture by up to about 4 proxy pixels toward the corners, so the +/// detail no longer pairs with itself and no global look passes the gates, on all pixels or +/// away from edges. The partial look still follows the camera's brightness, colourfulness and +/// hues: on the registered scene it is far closer to the camera than the neutral fallback, +/// which renders the scene dark and grey. +#[test] +fn partial_look_when_a_lens_corrected_jpeg_misses_the_gates() { + let (w, h) = (96usize, 64usize); + let scene = |u: f32, v: f32| -> [f32; 3] { + let base = 0.1 * (1.0 + 0.6 * (0.05 * u + 0.04 * v).sin()); + let detail = 1.0 + 0.8 * (1.7 * u).sin() * (1.3 * v).sin(); + let y = base * detail; + [y * (0.6 + 0.5 * (0.07 * u).sin().abs()), y, y * (0.5 + 0.6 * (0.09 * v + 0.03 * u).cos().abs())] + }; + let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + let (dx, dy) = (x as f32 - cx, y as f32 - cy); + let k = 1.0 + 0.08 * (dx * dx + dy * dy) / (cx * cx); + sensor.data[y * w + x] = scene(x as f32, y as f32); + reference.data[y * w + x] = bright_camera(scene(cx + dx * k, cy + dy * k)); + } + } + assert!(fit_pairs(&sensor, &reference).is_none(), "the full look misses the gates"); + let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look instead of the neutral fallback"); + assert!(look.hue_sat.is_none()); + assert_ne!(look.matrix, Mat3::IDENTITY, "a colourful scene gets the damped matrix"); + // on the registered scene, against what the camera renders + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let partial = mean_error(&sensor, bright_camera, |x| displayed(look.matrix.apply(x), &tone)); + let fallback = mean_error(&sensor, bright_camera, |x| displayed(x, &neutral)); + assert!(partial < fallback * 0.25, "partial {partial:.5}, neutral fallback {fallback:.5}"); + assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8)); +} + +fn near_black_scene() -> (Rgb32f, Rgb32f) { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (input, output)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let y = if i % 60 == 0 { 0.3 + (i % 7) as f32 * 0.01 } else { 0.006 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.075 }; + let rgb = [1.15, 1.0, 0.75]; + *input = rgb.map(|v| v * y / luminance_2020(rgb)); + *output = input.map(|v| v * 0.08); + } + (sensor, reference) +} + +#[test] +fn a_near_black_scene_gets_tone_without_learning_colour() { + let (sensor, reference) = near_black_scene(); + assert!(collect_pairs(&sensor, &reference, 0.005, None, false).is_none(), "too few midtones even for the partial look"); + let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON D610", Default::default()); + let (_, _, look) = starting_colour_with( + &mut raw, + |_, _| (fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None), false), + |_, _| None, + |_, _| fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]), + ); + let look = look.expect("tone from the dark pixels"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = |tone: &ToneMap| { + sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2)) + .sum::() + }; + assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001); + assert_eq!(look.tone.apply(0.0), 0.0); + let mut previous = 0.0; + for i in 0..10_000 { + let y = look.tone.apply(i as f32 / 10_000.0); + assert!(y.is_finite() && y >= previous && y <= 1.0); + previous = y; + } + // Models with spectral matrices keep them before the dark tone is fitted. + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, false), + |_, _| None, + |raw, t| { + assert!(!t.matrix_is_fallback && lightcraft_raw::color::has_matrix(&raw.color)); + fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]) + }, + ); + assert!(!t.matrix_is_fallback); + assert_eq!(look.expect("tone on top of spectral colour").matrix, Mat3::IDENTITY); +} + +#[test] +fn dark_tone_holdout_never_enters_training() { + let (sensor, mut reference) = near_black_scene(); + let none = vec![false; sensor.data.len()]; + let original = fit_dark_tone(&sensor, &reference, &none).expect("dark tone"); + // Every pixel clears the wider signal floors. Changing only its held-out third must + // change the measured error, but cannot change the fitted curve. + for p in reference.data.iter_mut().step_by(3) { + *p = p.map(|v| v * 1.03); + } + let changed = fit_dark_tone(&sensor, &reference, &none).expect("still clearly better than the fallback"); + assert_eq!(changed.tone, original.tone); +} + +#[test] +fn dark_tone_requires_signal_correlation_and_unclipped_highlights() { + let (sensor, reference) = near_black_scene(); + let none = vec![false; sensor.data.len()]; + let mut unrelated = reference.clone(); + for (i, p) in unrelated.data.iter_mut().enumerate() { + *p = reference.data[(i * 3571) % reference.data.len()]; + } + assert!(fit_dark_tone(&sensor, &unrelated, &none).is_none(), "unrelated dark JPEG"); + let mut flat = reference.clone(); + flat.data.fill([0.002; 3]); + assert!(fit_dark_tone(&sensor, &flat, &none).is_none(), "a quantized flat JPEG cannot teach tone"); + let mut sparse = reference.clone(); + sparse.data.iter_mut().skip(80).for_each(|p| *p = [0.0; 3]); + assert!(fit_dark_tone(&sensor, &sparse, &none).is_none(), "fewer than 256 usable pairs"); + let mut bright = reference.clone(); + bright.data[0] = [0.95; 3]; + assert!(fit_dark_tone(&sensor, &bright, &none).is_none(), "dark pairs cannot constrain a bright highlight"); + let mut clipped = none; + clipped[0] = true; + let original = fit_dark_tone(&sensor, &reference, &clipped).expect("tone without the clipped pixel"); + let mut changed = sensor.clone(); + changed.data[0] = [0.18; 3]; + let look = fit_dark_tone(&changed, &reference, &clipped).expect("the clipped pixel is still closer to its JPEG"); + assert_eq!(look.tone, original.tone, "clipped samples cannot change the fitted curve"); + let mut highlight = reference.clone(); + highlight.data[0] = [0.8; 3]; + assert!(fit_dark_tone(&changed, &highlight, &clipped).is_none(), "the curve cannot darken a clipped highlight away from the JPEG"); + assert!(fit_dark_tone(&sensor, &Rgb32f::new(64, 96), &clipped).is_none(), "mismatched dimensions"); + assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask"); +} + +/// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is +/// learnt and the full look is never tried) whose camera renders it two stops brighter than the +/// neutral fallback: the partial look is the tone curve (and chroma curve) alone. +#[test] +fn partial_look_brightens_a_dull_scene_without_learning_colour() { + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.01 + (i % 37) as f32 * 0.004; + let tint = if i % 50 == 0 { 1.3 } else { 1.0 + (i % 5) as f32 * 0.004 }; + *p = [ev * tint, ev, ev * (2.0 - tint)]; + } + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + p.map(|v| v * (1.0 - (-12.0 * y).exp()) / y) + }; + let mut reference = sensor.clone(); + reference.map_in_place(camera); + assert!(fit_pairs(&sensor, &reference).is_none(), "too little colour for the full look"); + let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look"); + assert_eq!(look.matrix, Mat3::IDENTITY, "no colour matrix from a colourless scene"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let lightness = + |tone: &ToneMap| sensor.data.iter().map(|p| f64::from(tone.apply(luminance_2020(*p)))).sum::() / sensor.data.len() as f64; + let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / reference.data.len() as f64; + assert!((lightness(&tone) / target - 1.0).abs() < 0.05, "{} vs {target}", lightness(&tone)); + assert!(lightness(&neutral) < target * 0.6, "the neutral fallback is far darker"); +} + +/// When the neutral fallback already matches the camera JPEG, or the JPEG is unrelated or +/// monochrome, the partial look isn't clearly better and the photo keeps the neutral fallback. +#[test] +fn partial_look_only_when_clearly_better_than_the_neutral_fallback() { + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.02 + (i % 41) as f32 * 0.012; + *p = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.045)]; + } + let none = vec![false; w * h]; + // the camera renders exactly as the neutral fallback: nothing to gain + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let mut reference = sensor.clone(); + reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32)); + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback"); + // an unrelated picture + for (i, p) in reference.data.iter_mut().enumerate() { + *p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; + } + assert!(fit_look(&sensor, &reference, &none, None).is_none(), "unrelated JPEG"); + // a black-and-white JPEG of the scene + reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [0.8 * luminance_2020(*s).sqrt(); 3]); + assert!(fit_look(&sensor, &reference, &none, None).is_none(), "monochrome JPEG"); +} + +/// A coloured scene whose camera JPEG is black and white: the camera's tone curve applied to the scene's +/// luminance, grey in every pixel. +fn black_and_white_scene() -> (Rgb32f, Rgb32f) { + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.01 + (i % 53) as f32 * 0.009; + *p = [ev * (0.6 + (i % 11) as f32 * 0.08), ev, ev * (0.6 + (i % 13) as f32 * 0.07)]; + } + let mut reference = sensor.clone(); + reference.map_in_place(|p| [1.0 - (-3.0 * luminance_2020(p)).exp(); 3]); + (sensor, reference) +} + +#[test] +fn black_and_white_jpegs_are_recognised() { + let (sensor, mut reference) = black_and_white_scene(); + assert!(monochrome(&reference)); + assert!(!monochrome(&sensor), "a colour JPEG"); + // a few coloured pixels (JPEG noise) are allowed, one in a hundred is colour + let n = reference.data.len(); + reference.data.iter_mut().take(n / 1000).for_each(|p| p[0] += 0.05); + assert!(monochrome(&reference)); + reference.data.iter_mut().take(n / 100).for_each(|p| p[0] += 0.05); + assert!(!monochrome(&reference)); + // a sepia-toned monochrome + let (_, mut toned) = black_and_white_scene(); + toned.map_in_place(|p| [p[0] * 1.08, p[1], p[2] * 0.85]); + assert!(!monochrome(&toned)); + assert!(!monochrome(&Rgb32f::new(8, 8)), "too small to tell"); + // the check reads pixels, not the picture style: a colour JPEG whose pixels are all grey within 0.002 counts + let (_, mut faint) = black_and_white_scene(); + faint.map_in_place(|p| [p[0] + 0.001, p[1], p[2] - 0.0005]); + assert!(monochrome(&faint)); + faint.map_in_place(|p| [p[0] + 0.003, p[1], p[2]]); + assert!(!monochrome(&faint)); + // pixels that aren't numbers can't vouch for grey + let (_, mut broken) = black_and_white_scene(); + broken.data.iter_mut().take(n / 100).for_each(|p| *p = [f32::NAN; 3]); + assert!(!monochrome(&broken)); +} + +/// Issue #577: a black-and-white JPEG gives no look (no colour to fit), but its tone curve renders the scene's +/// brightness like the camera, with the colour left as it was and the chroma curve neutral. +#[test] +fn a_black_and_white_jpeg_gives_its_tone_curve() { + let (sensor, reference) = black_and_white_scene(); + let none = vec![false; sensor.data.len()]; + assert!(fit_look(&sensor, &reference, &none, None).is_none(), "no colour to fit"); + let look = fit_tone_only(&sensor, &reference, None).expect("a tone curve"); + assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "the colour is left as it was"); + assert!(look.tone.chroma().iter().all(|k| *k == 1.0), "the chroma curve stays neutral: {:?}", look.tone.chroma()); + let n = sensor.data.len() as f64; + let mean = |tone: &ToneMap| sensor.data.iter().map(|p| luma(displayed(p.map(f64::from), tone))).sum::() / n; + let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / n; + let toned = mean(&ToneMap::camera(&look.tone, 0.0, 0.0, 0.0)); + assert!((toned / target - 1.0).abs() < 0.05, "{toned} vs {target}"); + assert!(mean(&ToneMap::new(0.0, 0.0, 0.0)) < target * 0.8, "the default curve is darker"); + // with a camera profile's colour, the look carries it + let matrix = Mat3([[1.2, -0.15, -0.05], [-0.1, 1.15, -0.05], [-0.02, -0.1, 1.12]]); + let look = fit_tone_only(&sensor, &reference, Some((matrix, None))).expect("a tone curve with the profile's colour"); + assert_eq!(look.matrix, matrix); +} + +/// A camera profile's colour can fail the checks where the photo's own colour passes, so [`monochrome_look`] tries +/// again on the transform the photo has, as [`fit_look`] tries the photo's own colour after a profile's. +#[test] +fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() { + // saturated colours, and a profile whose colour gets their luminance wrong (red and blue swapped) + let hues = [[1.0, 0.15, 0.1], [0.1, 1.0, 0.15], [0.15, 0.1, 1.0], [1.0, 0.9, 0.1], [0.1, 0.9, 1.0], [1.0, 0.1, 0.9]]; + let mut sensor = Rgb32f::new(96, 64); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.02 + (i % 47) as f32 * 0.008; + *p = hues[i % 6].map(|c| c * ev); + } + // a JPEG a little brighter than the default curve, so a curve has to fit closely to be clearly better + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let mut reference = sensor.clone(); + reference.map_in_place(|p| [(1.15 * luma(displayed(p.map(f64::from), &neutral))) as f32; 3]); + let swapped = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]); + assert!(fit_tone_only(&sensor, &reference, Some((swapped, None))).is_none(), "the profile's colour fails the checks"); + let own = fit_tone_only(&sensor, &reference, None); + assert!(own.is_some(), "the photo's own colour passes"); + // and the step runs for a model with a bundled profile (the ILCE-7M4 also has spectral matrices) + assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); + let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, true), + |_, t| { + assert!(!t.matrix_is_fallback, "on the spectral matrices"); + own.clone() + }, + |_, _| None, + ); + assert!(look.is_some() && !t.matrix_is_fallback); +} + +/// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 (it has a bundled camera profile) whose camera JPEG, +/// in a minimal TIFF, is black and white gets the tone curve, on the colour of the transform it is given; the +/// same file with a tinted JPEG gets nothing from this step. +#[test] +fn monochrome_look_reads_the_files_black_and_white_jpeg() { + use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, Rect}; + let (w, h) = (192usize, 128usize); + let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32); + let raw = RawImage { + format: RawFormat::Arw, + width: w, + height: h, + cpp: 3, + data: RawData::F32((0..w * h).flat_map(|i| [level(i % w, i / w); 3]).collect()), + cfa: None, + bits: 16, + black: BlackLevel::uniform(0.0), + white: vec![1.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::from_exif(1), + color: ColorData::default(), + wb_multipliers: Some([1.0; 3]), + linearized: true, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, + lens_metadata: Default::default(), + }; + assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); + // the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG + let file = |tint: [f32; 3]| { + let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + let pixels: Vec = (0..w * h) + .flat_map(|i| { + let v = 1.0 - (-3.0 * level(i % w, i / w)).exp(); + tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8) + }) + .collect(); + let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); + let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); + // II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it + let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] { + tiff.extend(tag.to_le_bytes()); + tiff.extend(4u16.to_le_bytes()); + tiff.extend(1u32.to_le_bytes()); + tiff.extend(value.to_le_bytes()); + } + tiff.extend(0u32.to_le_bytes()); + assert_eq!(tiff.len(), 38); + tiff.extend(jpeg); + tiff + }; + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG"); + assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour"); + let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let mid = curve.apply(0.25); + assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}"); + assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white"); +} + +/// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve +/// already renders, gives no look. +#[test] +fn a_black_and_white_jpeg_gives_a_tone_curve_only_when_clearly_better() { + let (sensor, mut reference) = black_and_white_scene(); + let neutral = ToneMap::new(0.0, 0.0, 0.0); + reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [luma(displayed(s.map(f64::from), &neutral)) as f32; 3]); + assert!(monochrome(&reference)); + assert!(fit_tone_only(&sensor, &reference, None).is_none(), "no clear gain over the default curve"); + for (i, p) in reference.data.iter_mut().enumerate() { + *p = [0.05 + (i % 29) as f32 * 0.02; 3]; + } + assert!(fit_tone_only(&sensor, &reference, None).is_none(), "another picture"); + // a flat grey JPEG ranks nothing, even when the scene's pixels are stored in brightness order (with ties + // ranked in pixel order it looked perfectly related, and the curve flattened the photo to one grey) + let mut sorted = sensor.clone(); + sorted.data.sort_by(|a, b| luminance_2020(*a).total_cmp(&luminance_2020(*b))); + reference.data.iter_mut().for_each(|p| *p = [0.4; 3]); + assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG"); +} + +/// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted. +#[test] +fn corpus_arw6_full_frame_gets_a_camera_look() { + const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) }; + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/arw6-sony-ilce7rm6-ff-land-c.arw"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); +} + +/// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right. +fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) { + let scene = |x: f32, y: f32| -> [f32; 3] { + let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32; + [l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))] + }; + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) + }; + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + sensor.data[y * w + x] = scene(x as f32, y as f32); + reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32)); + } + } + (sensor, reference) +} + +/// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a +/// flat and a differently sized preview. +#[test] +fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() { + let (sensor, reference) = scene_pair(96, 64, 0); + assert!(structure_agrees(&sensor, &reference)); + assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated"); + assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed"); + let mut stripes = reference.clone(); + for (i, p) in stripes.data.iter_mut().enumerate() { + *p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] }; + } + assert!(!structure_agrees(&sensor, &stripes), "a broken preview"); + let mut flat = reference.clone(); + flat.data.fill([0.2; 3]); + assert!(!structure_agrees(&sensor, &flat)); + assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1)); +} + +/// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that +/// renders mid-grey brighter than the default tone map is followed. +#[test] +fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() { + let (mut sensor, mut reference) = scene_pair(96, 64, 0); + for image in [&mut sensor, &mut reference] { + for p in image.data.iter_mut().step_by(5) { + *p = [p[0] * 1.6, p[1], p[2] * 0.5]; + } + } + let colour = Some((Mat3::IDENTITY, None)); + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + let look = + fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check) + .expect("tone fitted"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let target = 1.0 - (-2.5f32 * 0.18).exp(); + assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); + assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); + assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself"); +} + +/// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the +/// camera preview's, in one direction only: clipping what the camera clips is not refused. +#[test] +fn dng_highlight_gate_compares_near_white_shares() { + let mut sensor = Rgb32f::new(32, 32); + for (i, p) in sensor.data.iter_mut().enumerate() { + *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; + } + let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); + let mut white = Rgb32f::new(32, 32); + white.data.iter_mut().for_each(|p| *p = [1.0; 3]); + let mut black = Rgb32f::new(32, 32); + black.data.iter_mut().for_each(|p| *p = [0.1; 3]); + assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white"); + assert!(!clips_more(&sensor, &black, &dark, &bright)); + assert!(!clips_more(&sensor, &black, &bright, &bright)); + assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much"); +} + +/// A dark scene's shadows below the usual training floor still reach the tone-only fit. +#[test] +fn dng_tone_fit_trains_on_dark_pixels() { + let mut sensor = Rgb32f::new(64, 64); + let mut reference = Rgb32f::new(64, 64); + for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() { + let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 }; + *s = [v, v * 1.1, v * 0.9]; + *r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)]; + } + let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len()); + let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len()); + assert!(dark > usual, "{dark} vs {usual}"); +} + +/// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. +#[test] +fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; + let lens = EmbeddedLens { + vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }), + ..Default::default() + }; + let mut proxy = Rgb32f::new(96, 64); + proxy.data.fill([0.2; 3]); + correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal); + let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2); + assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}"); + let mut plain = Rgb32f::new(96, 64); + plain.data.fill([0.2; 3]); + correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal); + assert!(plain.data.iter().all(|p| *p == [0.2; 3])); +} + +/// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's +/// full-resolution corrections applied once rather than silently losing the tone fit. +#[test] +fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() { + use lightcraft_raw::Opcode; + let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]); + raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none()); + let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable"); + assert_eq!((proxy.width, proxy.height), (32, 24)); + let center = proxy.data[12 * 32 + 16][1]; + let corner = proxy.data[0][1]; + assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}"); + let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development"); + let small = sensor_proxy(&raw, 2, 16).unwrap(); + assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce"); +} + +/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the +/// identity, with or without a look table; and no other raw format. +#[test] +fn dng_tone_gates_follow_the_files_own_tags() { + use lightcraft_raw::profile::{HsvTable, ToneCurve}; + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { + let raw = raw_of(format, "Make", "Model", color); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + dng_tone_eligible(&raw, &t) + }; + assert!(eligible(RawFormat::Dng, color.clone())); + assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path"); + assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep"); + let mut identity = color.clone(); + identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]); + assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone"); + let mut curved = color.clone(); + curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); + let mut looked = color; + looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); + assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone"); +} + +/// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not. +#[test] +fn dng_tone_fit_takes_lightroom_sized_previews() { + let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0); + assert!(1024.0 >= floor(7008.0)); + assert!(256.0 < floor(7008.0)); + assert!(1024.0 >= floor(9504.0), "a 61 MP raw too"); +} + +/// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped +/// to the JPEG through a steep camera curve (a hazy scene the camera stretches). +fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> { + (0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect() +} + +/// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that +/// of the darkest 1/32 (their last and first knots). +fn knot_range(mut scene: Vec) -> f64 { + scene.sort_by(f64::total_cmp); + let n = scene.len(); + scene[31 * n / 32 + n / 64] / scene[n / 64] +} + +/// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets +/// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit. +#[test] +fn tone_fits_accept_low_contrast_scenes() { + for fit in [fit_tone, fit_tone_conditional] { + let pairs = low_contrast_pairs(1.47); + let range = knot_range(pairs.iter().map(|p| p.0).collect()); + assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}"); + let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631"); + assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1)); + assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame"); + } +} + +/// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera +/// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback. +#[test] +fn a_low_contrast_scene_gets_a_camera_look() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y); + } + let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback"); + let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect()); + assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error: f64 = sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| { + let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 + }) + .sum::() + / sensor.data.len() as f64; + assert!(error.sqrt() < 0.02, "{}", error.sqrt()); +} + +/// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the +/// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out +/// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky +/// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries' +/// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright +/// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range). +#[test] +fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + for (case, sky_value, is_sky) in [ + ("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool), + ("clipped sky", 1.0, &|i: usize| i < 5600), + ("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)), + ("sky over the pairs' range", 1.6, &|i: usize| i < 5600), + ] { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + let mut clipped = vec![false; sensor.data.len()]; + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if is_sky(i) { + // bright, nearly white in the camera JPEG + *src = [sky_value; 3]; + *dst = [0.93; 3]; + clipped[i] = case == "clipped sky"; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + // nearly flat midtones: the fitted curve's extrapolation can't reach the sky + *dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y); + } + let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue }; + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let correction = look.hue_sat.as_ref().and_then(HueSat::new); + let p = look.matrix.apply([f64::from(sky_value); 3]); + let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); + let sky = luma(displayed(p, &tone)); + assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}"); + } +} + +/// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to +/// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the +/// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the +/// sky at 0.61 where the camera JPEG has 0.93. +#[test] +fn a_narrow_curve_is_checked_as_lowered_for_dro() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if i < 5600 { + *src = [0.9; 3]; + *dst = [0.93; 3]; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y); + } + let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0))); + if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) { + let look = with_dro_off_tone(look); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let correction = look.hue_sat.as_ref().and_then(HueSat::new); + let p = look.matrix.apply([0.9; 3]); + let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); + let sky = luma(displayed(p, &tone)); + assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}"); + } +} + +/// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning +/// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted. +#[test] +fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + *dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3]; + } + let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect()); + assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); + let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2)); + let rms = (error.sum::() / sensor.data.len() as f64).sqrt(); + assert!(rms < 0.02, "{rms}"); +} + +/// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is +/// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit. +#[test] +fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if i < 5600 { + *src = [0.9; 3]; + *dst = [0.93; 3]; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + *dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3]; + } + if let Some(look) = fit_tone_only(&sensor, &reference, None) { + let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0))); + assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}"); + } +} From a3ec3eadef2fc6f9a83942c387014f283a017328 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:57 +0000 Subject: [PATCH 048/191] refactor(lightcraft-engine): move rename.rs tests to crates/engine/src/rename/tests.rs --- crates/engine/src/rename.rs | 593 +----------------------------- crates/engine/src/rename/tests.rs | 590 +++++++++++++++++++++++++++++ 2 files changed, 591 insertions(+), 592 deletions(-) create mode 100644 crates/engine/src/rename/tests.rs diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index c569a2e1f..b48f31291 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -1183,595 +1183,4 @@ impl Session { } #[cfg(test)] -pub(crate) mod tests { - use super::*; - use std::cell::RefCell; - - /// Windows has no file id in std: two spellings of one file are the same file whichever is - /// asked about first (a save after a partial rename saw `Trip.XMP` as a collision with the - /// known `Trip.xmp`), and only the spelling on disk is listed. - #[cfg(windows)] - #[test] - fn same_file_spellings_are_symmetric_on_windows() { - let dir = std::env::temp_dir().join(format!("lc-same-file-spelling-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let (real, alias, other) = (dir.join("Trip.xmp"), dir.join("Trip.XMP"), dir.join("other.xmp")); - std::fs::write(&real, b"packet").unwrap(); - std::fs::write(&other, b"packet").unwrap(); - assert!(RealFs.same_file(&real, &alias) && RealFs.same_file(&alias, &real)); - assert!(!RealFs.same_file(&real, &other) && !RealFs.same_file(&other, &alias)); - assert!(RealFs.listed(&real) && !RealFs.listed(&alias)); - std::fs::remove_dir_all(&dir).unwrap(); - } - - /// The cross-volume move's check compares files chunk by chunk: equal files across several - /// chunks, a difference in the last chunk, different lengths, a missing file. - #[test] - fn same_contents_compares_in_chunks() { - let dir = std::env::temp_dir().join(format!("lc-same-contents-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let big: Vec = (0..(2usize << 20) + 123).map(|i| (i % 251) as u8).collect(); - let (a, b) = (dir.join("a"), dir.join("b")); - std::fs::write(&a, &big).unwrap(); - std::fs::write(&b, &big).unwrap(); - assert!(same_contents(&a, &b).unwrap()); - let mut other = big.clone(); - *other.last_mut().unwrap() ^= 1; - std::fs::write(&b, &other).unwrap(); - assert!(!same_contents(&a, &b).unwrap()); - std::fs::write(&b, &big[..big.len() - 1]).unwrap(); - assert!(!same_contents(&a, &b).unwrap()); - std::fs::write(&a, b"").unwrap(); - std::fs::write(&b, b"").unwrap(); - assert!(same_contents(&a, &b).unwrap()); - assert!(same_contents(&a, &dir.join("missing")).is_err()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or - /// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail. - pub(crate) struct FakeFs { - pub ci: bool, - pub files: RefCell)>>, - pub fail: RefCell>, - } - - impl FakeFs { - pub fn new(ci: bool, files: &[(&str, &str)]) -> FakeFs { - FakeFs { - ci, - files: RefCell::new(files.iter().map(|(p, c)| (p.to_string(), c.as_bytes().to_vec())).collect()), - fail: RefCell::new(Vec::new()), - } - } - /// The model volume spells paths with `/`; the code under test joins them with the host's separator. - fn norm(p: &Path) -> String { - p.to_string_lossy().replace('\\', "/") - } - fn idx(&self, p: &Path) -> Option { - let p = Self::norm(p); - self.files.borrow().iter().position(|(f, _)| { - let f = f.replace('\\', "/"); - if self.ci { folded_path(&f) == folded_path(&p) } else { f == p } - }) - } - /// The listing: (exact path, contents), sorted. - pub fn listing(&self) -> Vec<(String, String)> { - let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.replace('\\', "/"), String::from_utf8_lossy(c).to_string())).collect(); - v.sort(); - v - } - } - - impl MoveFs for FakeFs { - fn packet_version(&self, path: &Path) -> std::io::Result { - let i = self.idx(path).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such packet"))?; - self.files.borrow().get(i).map(|(_, bytes)| PacketVersion::of(bytes)).ok_or_else(|| std::io::Error::other("packet disappeared")) - } - fn exists(&self, p: &Path) -> bool { - self.idx(p).is_some() - } - fn is_file(&self, p: &Path) -> bool { - self.exists(p) - } - fn same_file(&self, a: &Path, b: &Path) -> bool { - self.idx(a).is_some() && self.idx(a) == self.idx(b) - } - fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { - return Err(std::io::Error::other("injected failure")); - } - if self.exists(b) { - return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists")); - } - let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; - self.files.borrow_mut()[i].0 = Self::norm(b); - Ok(()) - } - fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { - return Err(std::io::Error::other("injected failure")); - } - if self.exists(b) { - return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists")); - } - let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; - let c = self.files.borrow()[i].1.clone(); - self.files.borrow_mut().push((Self::norm(b), c)); - Ok(()) - } - fn remove_file(&self, p: &Path) -> std::io::Result<()> { - let i = self.idx(p).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; - self.files.borrow_mut().remove(i); - Ok(()) - } - fn listed(&self, p: &Path) -> bool { - let p = Self::norm(p); - self.files.borrow().iter().any(|(f, _)| f.replace('\\', "/") == p) - } - fn stem_sibling(&self, sidecar: &Path) -> Option { - let stem = Self::norm(&sidecar.with_extension("")); - self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| { - let p = Path::new(f); - Self::norm(&p.with_extension("")) == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp")) - }) - } - } - - fn file_photo(id: u64, path: &str) -> Photo { - let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - // the host's separator, so `dir.join(name)` in the planner spells the same path the same way - let path = path.replace('/', std::path::MAIN_SEPARATOR_STR); - Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00") - } - - /// Compare the unfinished operation and every observed packet after partial progress. - /// Companion order is incidental; paths and recorded content versions are exact. - pub(crate) fn assert_repair_packets(actual: &SidecarMove, unfinished: &SidecarMove, packets: &[(PathBuf, &[u8])]) { - let mut expected = unfinished.clone(); - expected.companions = - packets.iter().map(|(path, bytes)| RetainedPacket { path: path.clone(), version: Some(PacketVersion::of(bytes)) }).collect(); - let mut actual = actual.clone(); - actual.companions.sort_by(|a, b| a.path.cmp(&b.path)); - expected.companions.sort_by(|a, b| a.path.cmp(&b.path)); - assert_eq!(actual, expected); - } - - #[test] - fn folder_moves_repair_retained_sidecars_before_changing_paths() { - for command in ["folder.rename", "folder.move"] { - for queue in ["pending", "undo", "redo"] { - for (naming, copied) in [(SidecarNaming::Stem, false), (SidecarNaming::Full, false), (SidecarNaming::Stem, true)] { - let root = crate::tests_xmp::temp_dir("folder-sidecar-repair"); - let from = root.join("Trip"); - std::fs::create_dir_all(&from).unwrap(); - let photo = from.join("b.jpg"); - std::fs::write(&photo, b"Photo bytes").unwrap(); - let old_sidecar = crate::sidecar::sidecar_path(&from.join("a.jpg").to_string_lossy(), naming); - let sidecar = crate::sidecar::sidecar_path(&photo.to_string_lossy(), naming); - std::fs::write(&old_sidecar, b"Retained edits").unwrap(); - // An ordinary occupied destination must block repair without replacing its bytes. - std::fs::write(&sidecar, b"Other edits").unwrap(); - let mut s = Session::new(); - s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, &photo.to_string_lossy())) }).unwrap(); - if copied { - let sibling = from.join("a.raw"); - std::fs::write(&sibling, b"Shared original").unwrap(); - s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(2, &sibling.to_string_lossy())) }).unwrap(); - } - s.commit("Rating", Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap(); - if queue == "redo" { - s.undo_step().unwrap(); - } - let repair = SidecarMove { - from: old_sidecar.clone(), - to: sidecar.clone(), - copied, - companions: vec![RetainedPacket { path: old_sidecar.clone(), version: Some(RealFs.packet_version(&old_sidecar).unwrap()) }], - }; - match queue { - "undo" => s.undo.last_mut().unwrap().retry_sidecars.push(repair), - "redo" => s.redo.last_mut().unwrap().retry_sidecars.push(repair), - _ => s.pending_sidecar_repairs.push(repair), - } - let params = if command == "folder.rename" { - serde_json::json!({"path": from, "name": "Holiday"}) - } else { - serde_json::json!({"path": from, "into": root.join("Archive")}) - }; - let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive").join("Trip") }; - let before = s.catalog.to_snapshot(); - let history = (s.undo.len(), s.redo.len()); - assert!(s.execute(command, ¶ms).is_err()); - assert!(from.is_dir() && !to.exists()); - assert_eq!(s.catalog.to_snapshot(), before); - assert_eq!((s.undo.len(), s.redo.len()), history); - assert_eq!(std::fs::read(&old_sidecar).unwrap(), b"Retained edits"); - assert_eq!(std::fs::read(&sidecar).unwrap(), b"Other edits"); - std::fs::remove_file(&sidecar).unwrap(); - s.execute(command, ¶ms).unwrap(); - assert!(s.pending_sidecar_repairs.is_empty()); - assert!(s.undo.iter().chain(&s.redo).all(|e| e.retry_sidecars.is_empty())); - let moved_sidecar = to.join(sidecar.file_name().unwrap()); - assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits"); - assert_eq!(to.join(old_sidecar.file_name().unwrap()).exists(), copied); - for (step, current) in [("edit.undo", &from), ("edit.redo", &to)] { - s.execute(step, &serde_json::json!({})).unwrap(); - assert_eq!( - s.catalog.photo(PhotoId(1)).unwrap().source, - Source::File { path: current.join("b.jpg").to_string_lossy().into_owned() } - ); - assert_eq!(std::fs::read(current.join(sidecar.file_name().unwrap())).unwrap(), b"Retained edits"); - assert_eq!(current.join(old_sidecar.file_name().unwrap()).exists(), copied); - } - // Explicit IDs choose targets after the command's selection gate. - s.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); - s.execute("photo.rename", &serde_json::json!({"ids": [1], "template": "Latest"})).unwrap(); - s.execute("edit.undo", &serde_json::json!({})).unwrap(); - assert!(to.join("b.jpg").is_file()); - assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits"); - s.execute("edit.redo", &serde_json::json!({})).unwrap(); - assert!(to.join("Latest.jpg").is_file()); - let final_sidecar = crate::sidecar::sidecar_path(&to.join("Latest.jpg").to_string_lossy(), naming); - assert_eq!(std::fs::read(final_sidecar).unwrap(), b"Retained edits"); - std::fs::remove_dir_all(root).unwrap(); - } - } - } - } - - #[cfg(windows)] - #[test] - fn mixed_separator_noop_rename_preserves_files_catalog_and_undo() { - let fs = FakeFs::new(true, &[("N:/photos/IMG.jpg", "image"), ("N:/photos/IMG.xmp", "edits"), ("N:/photos/other.jpg", "other")]); - // Any attempt to move the unchanged photo (including rollback) must fail. - fs.fail.borrow_mut().push("IMG".into()); - let mut s = Session::new(); - // spelled with `/` on purpose (not `file_photo`, which uses the host's separator) - for (id, path) in [(1, "N:/photos/IMG.jpg"), (2, "N:/photos/other.jpg")] { - let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00"); - s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - } - let before = fs.listing(); - let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "{name}", 1); - assert_ne!(plans[0].from_path, plans[0].to_path, "fixture exercises different separator spelling"); - assert_eq!(s.apply_rename_with(&fs, &plans).unwrap(), 0); - assert_eq!(fs.listing(), before); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "N:/photos/IMG.jpg".into() }); - assert!(s.undo.is_empty()); - move_file_with(&fs, "N:/photos/IMG.jpg", "N:/photos\\IMG.jpg").unwrap(); - let separator_only = Op::SetFile { id: PhotoId(1), file_name: "IMG.jpg".into(), source: Source::File { path: "N:/photos\\IMG.jpg".into() } }; - assert!(s.file_moves(&separator_only).is_empty()); - - // A batch may combine that no-op with a genuine rename, without touching IMG or its sidecar. - let mut batch = plans; - batch.extend(s.plan_rename_with(&fs, &[PhotoId(2)], "renamed", 1)); - assert_eq!(s.apply_rename_with(&fs, &batch).unwrap(), 1); - assert_eq!( - fs.listing(), - vec![ - ("N:/photos/IMG.jpg".into(), "image".into()), - ("N:/photos/IMG.xmp".into(), "edits".into()), - ("N:/photos/renamed.jpg".into(), "other".into()) - ] - ); - let undo = &s.undo.last().unwrap().op; - let moves = s.file_moves(undo); - assert_eq!(moves.len(), 1); - assert_eq!(Path::new(&moves[0].1), Path::new("N:/photos/other.jpg")); - move_all(&fs, &moves).unwrap(); - s.catalog.apply(undo.clone()).unwrap(); - assert_eq!(fs.listing(), before); - } - - /// Issue #95: a case-only rename is a real change on a case-insensitive volume and never - /// replaces a different file that differs only by case on a case-sensitive one. - #[test] - fn case_only_renames_never_replace_another_file() { - // case-sensitive: IMG_1.JPG and img_1.JPG are two photos - let fs = FakeFs::new(false, &[("/p/IMG_1.JPG", "first"), ("/p/img_1.JPG", "second"), ("/p/IMG_1.xmp", "sc1"), ("/p/img_1.xmp", "sc2")]); - assert!(move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap_err().message.contains("already exists")); - assert_eq!(fs.listing().len(), 4); - assert!(fs.listing().contains(&("/p/img_1.JPG".into(), "second".into()))); - // the planner picks a free name instead - let mut s = Session::new(); - s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap(); - let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1); - assert_eq!(plans[0].to, "img_1-1.JPG"); - move_file_with(&fs, "/p/IMG_1.JPG", plans[0].to_path.as_deref().unwrap()).unwrap(); - assert_eq!( - fs.listing(), - vec![ - ("/p/img_1-1.JPG".into(), "first".into()), - ("/p/img_1-1.xmp".into(), "sc1".into()), - ("/p/img_1.JPG".into(), "second".into()), - ("/p/img_1.xmp".into(), "sc2".into()) - ] - ); - // case-sensitive, target free: a plain rename - let fs = FakeFs::new(false, &[("/p/IMG_2.JPG", "x")]); - move_file_with(&fs, "/p/IMG_2.JPG", "/p/img_2.JPG").unwrap(); - assert_eq!(fs.listing(), vec![("/p/img_2.JPG".into(), "x".into())]); - - // case-insensitive: the "existing" target is the photo itself → renamed via a temp name - let fs = FakeFs::new(true, &[("/p/IMG_1.JPG", "first"), ("/p/IMG_1.xmp", "sc1")]); - let mut s = Session::new(); - s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap(); - let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1); - assert_eq!(plans[0].to, "img_1.JPG", "not a collision with itself"); - move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap(); - assert_eq!(fs.listing(), vec![("/p/img_1.JPG".into(), "first".into()), ("/p/img_1.xmp".into(), "sc1".into())]); - // case-insensitive, another file under another name: still a collision - let fs = FakeFs::new(true, &[("/p/A.JPG", "a"), ("/p/b.JPG", "b")]); - assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err()); - } - - /// On a case-insensitive volume (Windows, macOS) `a.XMP` answers for `a.xmp`: a renamed - /// photo's sidecars keep the spelling they have on disk, and a failure on the real spelling - /// is the one reported and queued. - #[test] - fn case_insensitive_renames_keep_each_sidecars_spelling() { - let fs = FakeFs::new(true, &[("/p/a.jpg", "A"), ("/p/a.xmp", "stem"), ("/p/a.jpg.XMP", "full"), ("/p/b.jpg", "B"), ("/p/b.Xmp", "mixed")]); - move_file_with(&fs, "/p/a.jpg", "/p/c.jpg").unwrap(); - move_file_with(&fs, "/p/b.jpg", "/p/d.jpg").unwrap(); - assert_eq!( - fs.listing(), - vec![ - ("/p/c.jpg".into(), "A".into()), - ("/p/c.jpg.XMP".into(), "full".into()), - ("/p/c.xmp".into(), "stem".into()), - ("/p/d.jpg".into(), "B".into()), - ("/p/d.xmp".into(), "mixed".into()) - ] - ); - // the move of the sidecar's real spelling is the one attempted (and here fails): the photo - // goes back and nothing changed - let before = fs.listing(); - *fs.fail.borrow_mut() = vec!["/p/c.xmp".into()]; - let e = move_file_with(&fs, "/p/c.jpg", "/p/a.jpg").unwrap_err(); - assert!(e.message.replace('\\', "/").contains("rename /p/c.xmp: injected failure"), "{e}"); - assert!(!e.moved && e.pending_sidecars.is_empty()); - assert_eq!(fs.listing(), before); - } - - /// Issue #105: a rollback that fails is reported, and the library follows the files that kept - /// their new names (an undoable partial rename), so nothing goes missing. - #[test] - fn failed_rollbacks_are_reported_and_followed() { - let fs = FakeFs::new(false, &[("/p/a.jpg", "A"), ("/p/a.xmp", "As"), ("/p/b.jpg", "B"), ("/p/c.jpg", "C")]); - let mut s = Session::new(); - for (i, f) in ["/p/a.jpg", "/p/b.jpg", "/p/c.jpg"].iter().enumerate() { - s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(i as u64 + 1, f)) }).unwrap(); - } - let ids = [PhotoId(1), PhotoId(2), PhotoId(3)]; - let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1); - // c can't be renamed (the share went away), and neither can Trip-1 be moved back - fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]); - let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string().replace('\\', "/"); - assert!(err.contains("rename /p/c.jpg"), "{err}"); - assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}"); - assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}"); - assert_eq!( - fs.listing(), - vec![ - ("/p/Trip-1.jpg".into(), "A".into()), - ("/p/Trip-1.xmp".into(), "As".into()), - ("/p/b.jpg".into(), "B".into()), - ("/p/c.jpg".into(), "C".into()) - ] - ); - // the catalog matches the disk - let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source { - Source::File { path } => path.replace('\\', "/"), - Source::Demo { .. } => String::new(), - }; - assert_eq!(path(&s, 1), "/p/Trip-1.jpg"); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().file_name, "Trip-1.jpg"); - assert_eq!((path(&s, 2), path(&s, 3)), ("/p/b.jpg".into(), "/p/c.jpg".into())); - assert_eq!(s.undo.last().map(|e| e.label.as_str()), Some("Rename 1 Photo (partly)")); - - // a sidecar that won't move, and a file that won't go back: the file keeps its new name - // with the sidecars that did move, and says so - let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]); - fs.fail.borrow_mut().extend(["d.jpg.xmp".to_string(), "/p/e.jpg".to_string()]); - let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err(); - assert!(e.moved, "{e}"); - assert!(e.message.replace('\\', "/").contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}"); - assert_eq!(fs.listing(), vec![("/p/d.jpg.xmp".into(), "Df".into()), ("/p/e.jpg".into(), "D".into()), ("/p/e.xmp".into(), "Ds".into())]); - // … and when the file does go back, its sidecars follow it - let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]); - fs.fail.borrow_mut().push("d.jpg.xmp".to_string()); - let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err(); - assert!(!e.moved); - assert_eq!(fs.listing().len(), 3); - assert!(fs.listing().iter().all(|(p, _)| p.starts_with("/p/d."))); - } - - #[test] - fn pending_sidecars_preserve_shared_copies_and_do_not_repeat_case_aliases() { - // These are synthetic volume semantics; no host driver or OS runtime is involved. - for ci in [false, true] { - let fs = FakeFs::new(ci, &[("/p/a.CR3", "Raw"), ("/p/a.JPG", "Jpeg"), ("/p/a.XMP", "Shared edits"), ("/p/a.CR3.XMP", "Raw edits")]); - fs.fail.borrow_mut().extend(["/p/a.XMP".into(), "/p/b.CR3".into()]); - let mut error = move_file_with(&fs, "/p/a.CR3", "/p/b.CR3").unwrap_err(); - assert!(error.moved); - assert_eq!(error.pending_sidecars.len(), 2); - assert!(error.pending_sidecars[0].copied); - assert!(!error.pending_sidecars[1].copied); - fs.fail.borrow_mut().clear(); - retry_sidecars(&fs, &mut error.pending_sidecars).unwrap(); - assert!(error.pending_sidecars.is_empty()); - assert_eq!( - fs.listing(), - vec![ - ("/p/a.JPG".into(), "Jpeg".into()), - ("/p/a.XMP".into(), "Shared edits".into()), - ("/p/b.CR3".into(), "Raw".into()), - ("/p/b.CR3.XMP".into(), "Raw edits".into()), - ("/p/b.XMP".into(), "Shared edits".into()), - ] - ); - } - } - - /// Issue #92 (comment): renaming one of `IMG_1.CR3` + `IMG_1.JPG` copies their shared - /// `IMG_1.xmp` instead of taking it away from the other; the last one renamed takes it. - #[test] - fn shared_stem_sidecar_is_copied_not_taken() { - let fs = FakeFs::new(false, &[("/p/IMG_1.CR3", "raw"), ("/p/IMG_1.JPG", "jpg"), ("/p/IMG_1.xmp", "shared"), ("/p/IMG_1.CR3.xmp", "own")]); - move_file_with(&fs, "/p/IMG_1.CR3", "/p/Trip_1.CR3").unwrap(); - assert_eq!( - fs.listing(), - vec![ - ("/p/IMG_1.JPG".into(), "jpg".into()), - ("/p/IMG_1.xmp".into(), "shared".into()), - ("/p/Trip_1.CR3".into(), "raw".into()), - ("/p/Trip_1.CR3.xmp".into(), "own".into()), - ("/p/Trip_1.xmp".into(), "shared".into()), - ] - ); - // the JPEG is the last one using it: it moves - move_file_with(&fs, "/p/IMG_1.JPG", "/p/Trip_2.JPG").unwrap(); - assert!(fs.listing().contains(&("/p/Trip_2.xmp".into(), "shared".into()))); - assert!(!fs.exists(Path::new("/p/IMG_1.xmp"))); - // a failure after the copy removes the copy and keeps the original - let fs = FakeFs::new(false, &[("/p/A.CR3", "raw"), ("/p/A.JPG", "jpg"), ("/p/A.xmp", "shared"), ("/p/A.CR3.xmp", "own")]); - fs.fail.borrow_mut().push("A.CR3.xmp".into()); - assert!(move_file_with(&fs, "/p/A.CR3", "/p/B.CR3").is_err()); - assert_eq!(fs.listing().len(), 4); - assert!(fs.listing().contains(&("/p/A.xmp".into(), "shared".into())) && !fs.exists(Path::new("/p/B.xmp"))); - } - - /// Issue #95 on the real disk, whichever kind of volume the temp folder is on. - #[test] - fn case_only_rename_on_disk() { - let dir = std::env::temp_dir().join(format!("lc-rename-case-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let (upper, lower) = (dir.join("IMG_1.JPG"), dir.join("img_1.JPG")); - std::fs::write(&upper, b"first").unwrap(); - let case_sensitive = !lower.exists(); - let p = |q: &Path| q.to_string_lossy().to_string(); - if case_sensitive { - std::fs::write(&lower, b"second").unwrap(); - assert!(move_file(&p(&upper), &p(&lower)).is_err()); - assert_eq!(std::fs::read(&lower).unwrap(), b"second"); - assert_eq!(std::fs::read(&upper).unwrap(), b"first"); - } else { - move_file(&p(&upper), &p(&lower)).unwrap(); - let names: Vec = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); - assert_eq!(names, vec!["img_1.JPG".to_string()]); - assert_eq!(std::fs::read(&lower).unwrap(), b"first"); - } - // a plain rename never replaces - std::fs::write(dir.join("other.JPG"), b"other").unwrap(); - assert!(move_file(&p(&dir.join("other.JPG")), &p(&lower)).is_err()); - assert_eq!(std::fs::read(dir.join("other.JPG")).unwrap(), b"other"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn folder_templates_stay_inside_the_destination() { - let mut p = sample_photo(); - assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]); - assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too"); - assert_eq!(expand_folder("{date:%Y}/{date:%B}", &p, 1), vec!["2026", "January"]); - assert_eq!(expand_folder("{date:%Y}/{date:%m %b}", &p, 1), vec!["2026", "01 Jan"]); - // metadata can't add levels or climb out - p.meta.camera = "../../etc/x".into(); - p.meta.title = "..".into(); - assert_eq!(expand_folder("{camera}/{title}/{date:%Y}", &p, 1), vec!["-..-etc-x", "unknown", "2026"]); - p.meta.camera = "C:\\Windows".into(); - assert_eq!(expand_folder("{camera}", &p, 1), vec!["C--Windows"]); - // missing metadata → `unknown`; missing capture time → the import time - p.meta.camera.clear(); - p.captured = None; - assert_eq!(expand_folder("{camera}//{date:%Y%m%d}", &p, 1), vec!["unknown", "20260120"]); - for bad in ["", " ", "/abs/{date}", "\\\\server\\x", "C:/x", "c:x", "~/x", "../{date}", "{date}/../x", "a/./b", "{date:%Y}/.."] { - assert!(folder_template_error(bad).is_some(), "{bad:?} should be rejected"); - } - for ok in ["{date:%Y}/{date:%Y%m%d}", "Trips/{camera}", "{date}", "a//b"] { - assert_eq!(folder_template_error(ok), None, "{ok:?}"); - } - // even unchecked, rejected levels are skipped rather than followed - assert_eq!(expand_folder("/../{date:%Y}/./x", &p, 1), vec!["2026", "x"]); - } - - /// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is - /// replaced, never left literal, and the help's examples match the documented behaviour. - #[test] - fn token_help_matches_the_implementation() { - let p = sample_photo(); - for t in TOKENS { - for tag in std::iter::once(&t.tag).chain(t.aliases) { - let v = expand_tokens(tag, &p, 1, 1); - assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})"); - assert!(!v.is_empty(), "{tag}: the sample photo should give an example"); - } - } - assert_eq!(token_example("{seq:3}"), "001"); - assert_eq!(token_example("{date}"), "20260114"); - assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848"); - assert_eq!(token_example("{ext}"), "CR3"); - assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3"); - for (d, _) in DATE_DIRECTIVES { - let v = format_date("2026-01-14T05:58:48", d); - assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive"); - } - assert_eq!(format_date("2026-09-30T14:05:00", "%B %b"), "September Sep"); - assert_eq!(format_date("2026-12-01T00:00:00", "%d %B %Y"), "01 December 2026"); - // a month that isn't 01–12 gives no name instead of panicking - for bad in ["2026-00-01", "2026-13-01", "2026-x", "", "2026-é1-01"] { - assert_eq!(format_date(bad, "%B|%b"), "|", "{bad:?}"); - } - assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]); - assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty()); - let json = token_help_json(); - assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len()); - assert_eq!(json["tokens"][0]["example"], "IMG_0042"); - // the command descriptions that list the tokens list all of them - for id in ["photo.rename", "photo.renamePreview", "library.import"] { - let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap(); - for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) { - assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag); - } - } - } - - #[test] - fn templates() { - let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00"); - p.captured = Some("2026-09-30T14:05:09".into()); - p.meta.camera = "Model X/2".into(); - p.meta.title = "Sunset: beach".into(); - assert_eq!(expand("{name}", &p, 1), "IMG_0042.CR2"); - assert_eq!(expand("Trip-{seq:3}", &p, 7), "Trip-007.CR2"); - assert_eq!(expand("{date}_{name}", &p, 1), "20260930_IMG_0042.CR2"); - assert_eq!(expand("{date:%Y-%m-%d %H.%M.%S}", &p, 1), "2026-09-30 14.05.09.CR2"); - assert_eq!(expand("{camera} {title}", &p, 1), "Model X-2 Sunset- beach.CR2"); - assert_eq!(expand("{unknown}-{seq}", &p, 12), "{unknown}-12.CR2"); - assert_eq!(expand(" ", &p, 1), "IMG_0042.CR2", "empty template keeps the name"); - assert_eq!(expand("../{name}", &p, 1), "-IMG_0042.CR2", "no path components"); - } - - #[test] - fn number_folder_and_metadata_tokens() { - let path = std::path::Path::new("shoots").join("2026-09 Coast").join("DSC_0815.NEF"); - let mut p = Photo::new(PhotoId(1), Source::File { path: path.to_string_lossy().into() }, "DSC_0815.NEF", "NEF", 1, 1, "2026-01-01T00:00:00"); - p.meta.lens = "24-70mm F2.8".into(); - p.meta.iso = Some(400); - p.meta.creator = "A. Person".into(); - p.rating = 4; - assert_eq!(expand("{folder}_{num}", &p, 1), "2026-09 Coast_0815.NEF"); - assert_eq!(expand("{rating}star-{iso}-{lens}-{creator}", &p, 1), "4star-400-24-70mm F2.8-A. Person.NEF"); - // no number at the end of the name / no folder: empty - p.file_name = "beach.NEF".into(); - p.source = Source::Demo { scene: 0 }; - assert_eq!(expand("{folder}{name}{num}", &p, 1), "beach.NEF"); - // a bare {seq} is padded to the caller's width - assert_eq!(expand_tokens("{seq}|{seq:2}", &p, 7, 3), "007|07"); - } -} +pub(crate) mod tests; diff --git a/crates/engine/src/rename/tests.rs b/crates/engine/src/rename/tests.rs new file mode 100644 index 000000000..d8ef8de10 --- /dev/null +++ b/crates/engine/src/rename/tests.rs @@ -0,0 +1,590 @@ +use super::*; +use std::cell::RefCell; + +/// Windows has no file id in std: two spellings of one file are the same file whichever is +/// asked about first (a save after a partial rename saw `Trip.XMP` as a collision with the +/// known `Trip.xmp`), and only the spelling on disk is listed. +#[cfg(windows)] +#[test] +fn same_file_spellings_are_symmetric_on_windows() { + let dir = std::env::temp_dir().join(format!("lc-same-file-spelling-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (real, alias, other) = (dir.join("Trip.xmp"), dir.join("Trip.XMP"), dir.join("other.xmp")); + std::fs::write(&real, b"packet").unwrap(); + std::fs::write(&other, b"packet").unwrap(); + assert!(RealFs.same_file(&real, &alias) && RealFs.same_file(&alias, &real)); + assert!(!RealFs.same_file(&real, &other) && !RealFs.same_file(&other, &alias)); + assert!(RealFs.listed(&real) && !RealFs.listed(&alias)); + std::fs::remove_dir_all(&dir).unwrap(); +} + +/// The cross-volume move's check compares files chunk by chunk: equal files across several +/// chunks, a difference in the last chunk, different lengths, a missing file. +#[test] +fn same_contents_compares_in_chunks() { + let dir = std::env::temp_dir().join(format!("lc-same-contents-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let big: Vec = (0..(2usize << 20) + 123).map(|i| (i % 251) as u8).collect(); + let (a, b) = (dir.join("a"), dir.join("b")); + std::fs::write(&a, &big).unwrap(); + std::fs::write(&b, &big).unwrap(); + assert!(same_contents(&a, &b).unwrap()); + let mut other = big.clone(); + *other.last_mut().unwrap() ^= 1; + std::fs::write(&b, &other).unwrap(); + assert!(!same_contents(&a, &b).unwrap()); + std::fs::write(&b, &big[..big.len() - 1]).unwrap(); + assert!(!same_contents(&a, &b).unwrap()); + std::fs::write(&a, b"").unwrap(); + std::fs::write(&b, b"").unwrap(); + assert!(same_contents(&a, &b).unwrap()); + assert!(same_contents(&a, &dir.join("missing")).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or +/// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail. +pub(crate) struct FakeFs { + pub ci: bool, + pub files: RefCell)>>, + pub fail: RefCell>, +} + +impl FakeFs { + pub fn new(ci: bool, files: &[(&str, &str)]) -> FakeFs { + FakeFs { + ci, + files: RefCell::new(files.iter().map(|(p, c)| (p.to_string(), c.as_bytes().to_vec())).collect()), + fail: RefCell::new(Vec::new()), + } + } + /// The model volume spells paths with `/`; the code under test joins them with the host's separator. + fn norm(p: &Path) -> String { + p.to_string_lossy().replace('\\', "/") + } + fn idx(&self, p: &Path) -> Option { + let p = Self::norm(p); + self.files.borrow().iter().position(|(f, _)| { + let f = f.replace('\\', "/"); + if self.ci { folded_path(&f) == folded_path(&p) } else { f == p } + }) + } + /// The listing: (exact path, contents), sorted. + pub fn listing(&self) -> Vec<(String, String)> { + let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.replace('\\', "/"), String::from_utf8_lossy(c).to_string())).collect(); + v.sort(); + v + } +} + +impl MoveFs for FakeFs { + fn packet_version(&self, path: &Path) -> std::io::Result { + let i = self.idx(path).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such packet"))?; + self.files.borrow().get(i).map(|(_, bytes)| PacketVersion::of(bytes)).ok_or_else(|| std::io::Error::other("packet disappeared")) + } + fn exists(&self, p: &Path) -> bool { + self.idx(p).is_some() + } + fn is_file(&self, p: &Path) -> bool { + self.exists(p) + } + fn same_file(&self, a: &Path, b: &Path) -> bool { + self.idx(a).is_some() && self.idx(a) == self.idx(b) + } + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { + return Err(std::io::Error::other("injected failure")); + } + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists")); + } + let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; + self.files.borrow_mut()[i].0 = Self::norm(b); + Ok(()) + } + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { + return Err(std::io::Error::other("injected failure")); + } + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists")); + } + let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; + let c = self.files.borrow()[i].1.clone(); + self.files.borrow_mut().push((Self::norm(b), c)); + Ok(()) + } + fn remove_file(&self, p: &Path) -> std::io::Result<()> { + let i = self.idx(p).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?; + self.files.borrow_mut().remove(i); + Ok(()) + } + fn listed(&self, p: &Path) -> bool { + let p = Self::norm(p); + self.files.borrow().iter().any(|(f, _)| f.replace('\\', "/") == p) + } + fn stem_sibling(&self, sidecar: &Path) -> Option { + let stem = Self::norm(&sidecar.with_extension("")); + self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| { + let p = Path::new(f); + Self::norm(&p.with_extension("")) == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp")) + }) + } +} + +fn file_photo(id: u64, path: &str) -> Photo { + let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // the host's separator, so `dir.join(name)` in the planner spells the same path the same way + let path = path.replace('/', std::path::MAIN_SEPARATOR_STR); + Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00") +} + +/// Compare the unfinished operation and every observed packet after partial progress. +/// Companion order is incidental; paths and recorded content versions are exact. +pub(crate) fn assert_repair_packets(actual: &SidecarMove, unfinished: &SidecarMove, packets: &[(PathBuf, &[u8])]) { + let mut expected = unfinished.clone(); + expected.companions = + packets.iter().map(|(path, bytes)| RetainedPacket { path: path.clone(), version: Some(PacketVersion::of(bytes)) }).collect(); + let mut actual = actual.clone(); + actual.companions.sort_by(|a, b| a.path.cmp(&b.path)); + expected.companions.sort_by(|a, b| a.path.cmp(&b.path)); + assert_eq!(actual, expected); +} + +#[test] +fn folder_moves_repair_retained_sidecars_before_changing_paths() { + for command in ["folder.rename", "folder.move"] { + for queue in ["pending", "undo", "redo"] { + for (naming, copied) in [(SidecarNaming::Stem, false), (SidecarNaming::Full, false), (SidecarNaming::Stem, true)] { + let root = crate::tests_xmp::temp_dir("folder-sidecar-repair"); + let from = root.join("Trip"); + std::fs::create_dir_all(&from).unwrap(); + let photo = from.join("b.jpg"); + std::fs::write(&photo, b"Photo bytes").unwrap(); + let old_sidecar = crate::sidecar::sidecar_path(&from.join("a.jpg").to_string_lossy(), naming); + let sidecar = crate::sidecar::sidecar_path(&photo.to_string_lossy(), naming); + std::fs::write(&old_sidecar, b"Retained edits").unwrap(); + // An ordinary occupied destination must block repair without replacing its bytes. + std::fs::write(&sidecar, b"Other edits").unwrap(); + let mut s = Session::new(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, &photo.to_string_lossy())) }).unwrap(); + if copied { + let sibling = from.join("a.raw"); + std::fs::write(&sibling, b"Shared original").unwrap(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(2, &sibling.to_string_lossy())) }).unwrap(); + } + s.commit("Rating", Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap(); + if queue == "redo" { + s.undo_step().unwrap(); + } + let repair = SidecarMove { + from: old_sidecar.clone(), + to: sidecar.clone(), + copied, + companions: vec![RetainedPacket { path: old_sidecar.clone(), version: Some(RealFs.packet_version(&old_sidecar).unwrap()) }], + }; + match queue { + "undo" => s.undo.last_mut().unwrap().retry_sidecars.push(repair), + "redo" => s.redo.last_mut().unwrap().retry_sidecars.push(repair), + _ => s.pending_sidecar_repairs.push(repair), + } + let params = if command == "folder.rename" { + serde_json::json!({"path": from, "name": "Holiday"}) + } else { + serde_json::json!({"path": from, "into": root.join("Archive")}) + }; + let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive").join("Trip") }; + let before = s.catalog.to_snapshot(); + let history = (s.undo.len(), s.redo.len()); + assert!(s.execute(command, ¶ms).is_err()); + assert!(from.is_dir() && !to.exists()); + assert_eq!(s.catalog.to_snapshot(), before); + assert_eq!((s.undo.len(), s.redo.len()), history); + assert_eq!(std::fs::read(&old_sidecar).unwrap(), b"Retained edits"); + assert_eq!(std::fs::read(&sidecar).unwrap(), b"Other edits"); + std::fs::remove_file(&sidecar).unwrap(); + s.execute(command, ¶ms).unwrap(); + assert!(s.pending_sidecar_repairs.is_empty()); + assert!(s.undo.iter().chain(&s.redo).all(|e| e.retry_sidecars.is_empty())); + let moved_sidecar = to.join(sidecar.file_name().unwrap()); + assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits"); + assert_eq!(to.join(old_sidecar.file_name().unwrap()).exists(), copied); + for (step, current) in [("edit.undo", &from), ("edit.redo", &to)] { + s.execute(step, &serde_json::json!({})).unwrap(); + assert_eq!( + s.catalog.photo(PhotoId(1)).unwrap().source, + Source::File { path: current.join("b.jpg").to_string_lossy().into_owned() } + ); + assert_eq!(std::fs::read(current.join(sidecar.file_name().unwrap())).unwrap(), b"Retained edits"); + assert_eq!(current.join(old_sidecar.file_name().unwrap()).exists(), copied); + } + // Explicit IDs choose targets after the command's selection gate. + s.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); + s.execute("photo.rename", &serde_json::json!({"ids": [1], "template": "Latest"})).unwrap(); + s.execute("edit.undo", &serde_json::json!({})).unwrap(); + assert!(to.join("b.jpg").is_file()); + assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits"); + s.execute("edit.redo", &serde_json::json!({})).unwrap(); + assert!(to.join("Latest.jpg").is_file()); + let final_sidecar = crate::sidecar::sidecar_path(&to.join("Latest.jpg").to_string_lossy(), naming); + assert_eq!(std::fs::read(final_sidecar).unwrap(), b"Retained edits"); + std::fs::remove_dir_all(root).unwrap(); + } + } + } +} + +#[cfg(windows)] +#[test] +fn mixed_separator_noop_rename_preserves_files_catalog_and_undo() { + let fs = FakeFs::new(true, &[("N:/photos/IMG.jpg", "image"), ("N:/photos/IMG.xmp", "edits"), ("N:/photos/other.jpg", "other")]); + // Any attempt to move the unchanged photo (including rollback) must fail. + fs.fail.borrow_mut().push("IMG".into()); + let mut s = Session::new(); + // spelled with `/` on purpose (not `file_photo`, which uses the host's separator) + for (id, path) in [(1, "N:/photos/IMG.jpg"), (2, "N:/photos/other.jpg")] { + let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + } + let before = fs.listing(); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "{name}", 1); + assert_ne!(plans[0].from_path, plans[0].to_path, "fixture exercises different separator spelling"); + assert_eq!(s.apply_rename_with(&fs, &plans).unwrap(), 0); + assert_eq!(fs.listing(), before); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "N:/photos/IMG.jpg".into() }); + assert!(s.undo.is_empty()); + move_file_with(&fs, "N:/photos/IMG.jpg", "N:/photos\\IMG.jpg").unwrap(); + let separator_only = Op::SetFile { id: PhotoId(1), file_name: "IMG.jpg".into(), source: Source::File { path: "N:/photos\\IMG.jpg".into() } }; + assert!(s.file_moves(&separator_only).is_empty()); + + // A batch may combine that no-op with a genuine rename, without touching IMG or its sidecar. + let mut batch = plans; + batch.extend(s.plan_rename_with(&fs, &[PhotoId(2)], "renamed", 1)); + assert_eq!(s.apply_rename_with(&fs, &batch).unwrap(), 1); + assert_eq!( + fs.listing(), + vec![ + ("N:/photos/IMG.jpg".into(), "image".into()), + ("N:/photos/IMG.xmp".into(), "edits".into()), + ("N:/photos/renamed.jpg".into(), "other".into()) + ] + ); + let undo = &s.undo.last().unwrap().op; + let moves = s.file_moves(undo); + assert_eq!(moves.len(), 1); + assert_eq!(Path::new(&moves[0].1), Path::new("N:/photos/other.jpg")); + move_all(&fs, &moves).unwrap(); + s.catalog.apply(undo.clone()).unwrap(); + assert_eq!(fs.listing(), before); +} + +/// Issue #95: a case-only rename is a real change on a case-insensitive volume and never +/// replaces a different file that differs only by case on a case-sensitive one. +#[test] +fn case_only_renames_never_replace_another_file() { + // case-sensitive: IMG_1.JPG and img_1.JPG are two photos + let fs = FakeFs::new(false, &[("/p/IMG_1.JPG", "first"), ("/p/img_1.JPG", "second"), ("/p/IMG_1.xmp", "sc1"), ("/p/img_1.xmp", "sc2")]); + assert!(move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap_err().message.contains("already exists")); + assert_eq!(fs.listing().len(), 4); + assert!(fs.listing().contains(&("/p/img_1.JPG".into(), "second".into()))); + // the planner picks a free name instead + let mut s = Session::new(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap(); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1); + assert_eq!(plans[0].to, "img_1-1.JPG"); + move_file_with(&fs, "/p/IMG_1.JPG", plans[0].to_path.as_deref().unwrap()).unwrap(); + assert_eq!( + fs.listing(), + vec![ + ("/p/img_1-1.JPG".into(), "first".into()), + ("/p/img_1-1.xmp".into(), "sc1".into()), + ("/p/img_1.JPG".into(), "second".into()), + ("/p/img_1.xmp".into(), "sc2".into()) + ] + ); + // case-sensitive, target free: a plain rename + let fs = FakeFs::new(false, &[("/p/IMG_2.JPG", "x")]); + move_file_with(&fs, "/p/IMG_2.JPG", "/p/img_2.JPG").unwrap(); + assert_eq!(fs.listing(), vec![("/p/img_2.JPG".into(), "x".into())]); + + // case-insensitive: the "existing" target is the photo itself → renamed via a temp name + let fs = FakeFs::new(true, &[("/p/IMG_1.JPG", "first"), ("/p/IMG_1.xmp", "sc1")]); + let mut s = Session::new(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap(); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1); + assert_eq!(plans[0].to, "img_1.JPG", "not a collision with itself"); + move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap(); + assert_eq!(fs.listing(), vec![("/p/img_1.JPG".into(), "first".into()), ("/p/img_1.xmp".into(), "sc1".into())]); + // case-insensitive, another file under another name: still a collision + let fs = FakeFs::new(true, &[("/p/A.JPG", "a"), ("/p/b.JPG", "b")]); + assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err()); +} + +/// On a case-insensitive volume (Windows, macOS) `a.XMP` answers for `a.xmp`: a renamed +/// photo's sidecars keep the spelling they have on disk, and a failure on the real spelling +/// is the one reported and queued. +#[test] +fn case_insensitive_renames_keep_each_sidecars_spelling() { + let fs = FakeFs::new(true, &[("/p/a.jpg", "A"), ("/p/a.xmp", "stem"), ("/p/a.jpg.XMP", "full"), ("/p/b.jpg", "B"), ("/p/b.Xmp", "mixed")]); + move_file_with(&fs, "/p/a.jpg", "/p/c.jpg").unwrap(); + move_file_with(&fs, "/p/b.jpg", "/p/d.jpg").unwrap(); + assert_eq!( + fs.listing(), + vec![ + ("/p/c.jpg".into(), "A".into()), + ("/p/c.jpg.XMP".into(), "full".into()), + ("/p/c.xmp".into(), "stem".into()), + ("/p/d.jpg".into(), "B".into()), + ("/p/d.xmp".into(), "mixed".into()) + ] + ); + // the move of the sidecar's real spelling is the one attempted (and here fails): the photo + // goes back and nothing changed + let before = fs.listing(); + *fs.fail.borrow_mut() = vec!["/p/c.xmp".into()]; + let e = move_file_with(&fs, "/p/c.jpg", "/p/a.jpg").unwrap_err(); + assert!(e.message.replace('\\', "/").contains("rename /p/c.xmp: injected failure"), "{e}"); + assert!(!e.moved && e.pending_sidecars.is_empty()); + assert_eq!(fs.listing(), before); +} + +/// Issue #105: a rollback that fails is reported, and the library follows the files that kept +/// their new names (an undoable partial rename), so nothing goes missing. +#[test] +fn failed_rollbacks_are_reported_and_followed() { + let fs = FakeFs::new(false, &[("/p/a.jpg", "A"), ("/p/a.xmp", "As"), ("/p/b.jpg", "B"), ("/p/c.jpg", "C")]); + let mut s = Session::new(); + for (i, f) in ["/p/a.jpg", "/p/b.jpg", "/p/c.jpg"].iter().enumerate() { + s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(i as u64 + 1, f)) }).unwrap(); + } + let ids = [PhotoId(1), PhotoId(2), PhotoId(3)]; + let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1); + // c can't be renamed (the share went away), and neither can Trip-1 be moved back + fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]); + let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string().replace('\\', "/"); + assert!(err.contains("rename /p/c.jpg"), "{err}"); + assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}"); + assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}"); + assert_eq!( + fs.listing(), + vec![ + ("/p/Trip-1.jpg".into(), "A".into()), + ("/p/Trip-1.xmp".into(), "As".into()), + ("/p/b.jpg".into(), "B".into()), + ("/p/c.jpg".into(), "C".into()) + ] + ); + // the catalog matches the disk + let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source { + Source::File { path } => path.replace('\\', "/"), + Source::Demo { .. } => String::new(), + }; + assert_eq!(path(&s, 1), "/p/Trip-1.jpg"); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().file_name, "Trip-1.jpg"); + assert_eq!((path(&s, 2), path(&s, 3)), ("/p/b.jpg".into(), "/p/c.jpg".into())); + assert_eq!(s.undo.last().map(|e| e.label.as_str()), Some("Rename 1 Photo (partly)")); + + // a sidecar that won't move, and a file that won't go back: the file keeps its new name + // with the sidecars that did move, and says so + let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]); + fs.fail.borrow_mut().extend(["d.jpg.xmp".to_string(), "/p/e.jpg".to_string()]); + let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err(); + assert!(e.moved, "{e}"); + assert!(e.message.replace('\\', "/").contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}"); + assert_eq!(fs.listing(), vec![("/p/d.jpg.xmp".into(), "Df".into()), ("/p/e.jpg".into(), "D".into()), ("/p/e.xmp".into(), "Ds".into())]); + // … and when the file does go back, its sidecars follow it + let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]); + fs.fail.borrow_mut().push("d.jpg.xmp".to_string()); + let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err(); + assert!(!e.moved); + assert_eq!(fs.listing().len(), 3); + assert!(fs.listing().iter().all(|(p, _)| p.starts_with("/p/d."))); +} + +#[test] +fn pending_sidecars_preserve_shared_copies_and_do_not_repeat_case_aliases() { + // These are synthetic volume semantics; no host driver or OS runtime is involved. + for ci in [false, true] { + let fs = FakeFs::new(ci, &[("/p/a.CR3", "Raw"), ("/p/a.JPG", "Jpeg"), ("/p/a.XMP", "Shared edits"), ("/p/a.CR3.XMP", "Raw edits")]); + fs.fail.borrow_mut().extend(["/p/a.XMP".into(), "/p/b.CR3".into()]); + let mut error = move_file_with(&fs, "/p/a.CR3", "/p/b.CR3").unwrap_err(); + assert!(error.moved); + assert_eq!(error.pending_sidecars.len(), 2); + assert!(error.pending_sidecars[0].copied); + assert!(!error.pending_sidecars[1].copied); + fs.fail.borrow_mut().clear(); + retry_sidecars(&fs, &mut error.pending_sidecars).unwrap(); + assert!(error.pending_sidecars.is_empty()); + assert_eq!( + fs.listing(), + vec![ + ("/p/a.JPG".into(), "Jpeg".into()), + ("/p/a.XMP".into(), "Shared edits".into()), + ("/p/b.CR3".into(), "Raw".into()), + ("/p/b.CR3.XMP".into(), "Raw edits".into()), + ("/p/b.XMP".into(), "Shared edits".into()), + ] + ); + } +} + +/// Issue #92 (comment): renaming one of `IMG_1.CR3` + `IMG_1.JPG` copies their shared +/// `IMG_1.xmp` instead of taking it away from the other; the last one renamed takes it. +#[test] +fn shared_stem_sidecar_is_copied_not_taken() { + let fs = FakeFs::new(false, &[("/p/IMG_1.CR3", "raw"), ("/p/IMG_1.JPG", "jpg"), ("/p/IMG_1.xmp", "shared"), ("/p/IMG_1.CR3.xmp", "own")]); + move_file_with(&fs, "/p/IMG_1.CR3", "/p/Trip_1.CR3").unwrap(); + assert_eq!( + fs.listing(), + vec![ + ("/p/IMG_1.JPG".into(), "jpg".into()), + ("/p/IMG_1.xmp".into(), "shared".into()), + ("/p/Trip_1.CR3".into(), "raw".into()), + ("/p/Trip_1.CR3.xmp".into(), "own".into()), + ("/p/Trip_1.xmp".into(), "shared".into()), + ] + ); + // the JPEG is the last one using it: it moves + move_file_with(&fs, "/p/IMG_1.JPG", "/p/Trip_2.JPG").unwrap(); + assert!(fs.listing().contains(&("/p/Trip_2.xmp".into(), "shared".into()))); + assert!(!fs.exists(Path::new("/p/IMG_1.xmp"))); + // a failure after the copy removes the copy and keeps the original + let fs = FakeFs::new(false, &[("/p/A.CR3", "raw"), ("/p/A.JPG", "jpg"), ("/p/A.xmp", "shared"), ("/p/A.CR3.xmp", "own")]); + fs.fail.borrow_mut().push("A.CR3.xmp".into()); + assert!(move_file_with(&fs, "/p/A.CR3", "/p/B.CR3").is_err()); + assert_eq!(fs.listing().len(), 4); + assert!(fs.listing().contains(&("/p/A.xmp".into(), "shared".into())) && !fs.exists(Path::new("/p/B.xmp"))); +} + +/// Issue #95 on the real disk, whichever kind of volume the temp folder is on. +#[test] +fn case_only_rename_on_disk() { + let dir = std::env::temp_dir().join(format!("lc-rename-case-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (upper, lower) = (dir.join("IMG_1.JPG"), dir.join("img_1.JPG")); + std::fs::write(&upper, b"first").unwrap(); + let case_sensitive = !lower.exists(); + let p = |q: &Path| q.to_string_lossy().to_string(); + if case_sensitive { + std::fs::write(&lower, b"second").unwrap(); + assert!(move_file(&p(&upper), &p(&lower)).is_err()); + assert_eq!(std::fs::read(&lower).unwrap(), b"second"); + assert_eq!(std::fs::read(&upper).unwrap(), b"first"); + } else { + move_file(&p(&upper), &p(&lower)).unwrap(); + let names: Vec = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert_eq!(names, vec!["img_1.JPG".to_string()]); + assert_eq!(std::fs::read(&lower).unwrap(), b"first"); + } + // a plain rename never replaces + std::fs::write(dir.join("other.JPG"), b"other").unwrap(); + assert!(move_file(&p(&dir.join("other.JPG")), &p(&lower)).is_err()); + assert_eq!(std::fs::read(dir.join("other.JPG")).unwrap(), b"other"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn folder_templates_stay_inside_the_destination() { + let mut p = sample_photo(); + assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]); + assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too"); + assert_eq!(expand_folder("{date:%Y}/{date:%B}", &p, 1), vec!["2026", "January"]); + assert_eq!(expand_folder("{date:%Y}/{date:%m %b}", &p, 1), vec!["2026", "01 Jan"]); + // metadata can't add levels or climb out + p.meta.camera = "../../etc/x".into(); + p.meta.title = "..".into(); + assert_eq!(expand_folder("{camera}/{title}/{date:%Y}", &p, 1), vec!["-..-etc-x", "unknown", "2026"]); + p.meta.camera = "C:\\Windows".into(); + assert_eq!(expand_folder("{camera}", &p, 1), vec!["C--Windows"]); + // missing metadata → `unknown`; missing capture time → the import time + p.meta.camera.clear(); + p.captured = None; + assert_eq!(expand_folder("{camera}//{date:%Y%m%d}", &p, 1), vec!["unknown", "20260120"]); + for bad in ["", " ", "/abs/{date}", "\\\\server\\x", "C:/x", "c:x", "~/x", "../{date}", "{date}/../x", "a/./b", "{date:%Y}/.."] { + assert!(folder_template_error(bad).is_some(), "{bad:?} should be rejected"); + } + for ok in ["{date:%Y}/{date:%Y%m%d}", "Trips/{camera}", "{date}", "a//b"] { + assert_eq!(folder_template_error(ok), None, "{ok:?}"); + } + // even unchecked, rejected levels are skipped rather than followed + assert_eq!(expand_folder("/../{date:%Y}/./x", &p, 1), vec!["2026", "x"]); +} + +/// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is +/// replaced, never left literal, and the help's examples match the documented behaviour. +#[test] +fn token_help_matches_the_implementation() { + let p = sample_photo(); + for t in TOKENS { + for tag in std::iter::once(&t.tag).chain(t.aliases) { + let v = expand_tokens(tag, &p, 1, 1); + assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})"); + assert!(!v.is_empty(), "{tag}: the sample photo should give an example"); + } + } + assert_eq!(token_example("{seq:3}"), "001"); + assert_eq!(token_example("{date}"), "20260114"); + assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848"); + assert_eq!(token_example("{ext}"), "CR3"); + assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3"); + for (d, _) in DATE_DIRECTIVES { + let v = format_date("2026-01-14T05:58:48", d); + assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive"); + } + assert_eq!(format_date("2026-09-30T14:05:00", "%B %b"), "September Sep"); + assert_eq!(format_date("2026-12-01T00:00:00", "%d %B %Y"), "01 December 2026"); + // a month that isn't 01–12 gives no name instead of panicking + for bad in ["2026-00-01", "2026-13-01", "2026-x", "", "2026-é1-01"] { + assert_eq!(format_date(bad, "%B|%b"), "|", "{bad:?}"); + } + assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]); + assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty()); + let json = token_help_json(); + assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len()); + assert_eq!(json["tokens"][0]["example"], "IMG_0042"); + // the command descriptions that list the tokens list all of them + for id in ["photo.rename", "photo.renamePreview", "library.import"] { + let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap(); + for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) { + assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag); + } + } +} + +#[test] +fn templates() { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00"); + p.captured = Some("2026-09-30T14:05:09".into()); + p.meta.camera = "Model X/2".into(); + p.meta.title = "Sunset: beach".into(); + assert_eq!(expand("{name}", &p, 1), "IMG_0042.CR2"); + assert_eq!(expand("Trip-{seq:3}", &p, 7), "Trip-007.CR2"); + assert_eq!(expand("{date}_{name}", &p, 1), "20260930_IMG_0042.CR2"); + assert_eq!(expand("{date:%Y-%m-%d %H.%M.%S}", &p, 1), "2026-09-30 14.05.09.CR2"); + assert_eq!(expand("{camera} {title}", &p, 1), "Model X-2 Sunset- beach.CR2"); + assert_eq!(expand("{unknown}-{seq}", &p, 12), "{unknown}-12.CR2"); + assert_eq!(expand(" ", &p, 1), "IMG_0042.CR2", "empty template keeps the name"); + assert_eq!(expand("../{name}", &p, 1), "-IMG_0042.CR2", "no path components"); +} + +#[test] +fn number_folder_and_metadata_tokens() { + let path = std::path::Path::new("shoots").join("2026-09 Coast").join("DSC_0815.NEF"); + let mut p = Photo::new(PhotoId(1), Source::File { path: path.to_string_lossy().into() }, "DSC_0815.NEF", "NEF", 1, 1, "2026-01-01T00:00:00"); + p.meta.lens = "24-70mm F2.8".into(); + p.meta.iso = Some(400); + p.meta.creator = "A. Person".into(); + p.rating = 4; + assert_eq!(expand("{folder}_{num}", &p, 1), "2026-09 Coast_0815.NEF"); + assert_eq!(expand("{rating}star-{iso}-{lens}-{creator}", &p, 1), "4star-400-24-70mm F2.8-A. Person.NEF"); + // no number at the end of the name / no folder: empty + p.file_name = "beach.NEF".into(); + p.source = Source::Demo { scene: 0 }; + assert_eq!(expand("{folder}{name}{num}", &p, 1), "beach.NEF"); + // a bare {seq} is padded to the caller's width + assert_eq!(expand_tokens("{seq}|{seq:2}", &p, 7, 3), "007|07"); +} From 41064fabb5ecadb0eef50ad42ccfa4e770070f07 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:57 +0000 Subject: [PATCH 049/191] refactor(lightcraft-engine): move crs.rs tests to crates/engine/src/crs/tests.rs --- crates/engine/src/crs.rs | 358 +-------------------------------- crates/engine/src/crs/tests.rs | 355 ++++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+), 357 deletions(-) create mode 100644 crates/engine/src/crs/tests.rs diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 093f3a84f..b5d2c856c 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -552,360 +552,4 @@ pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_develop::{DevelopSettings, Upright, VignetteStyle, WbMode, apply_partial}; - - /// A hand-written sidecar in attribute form (as many tools write it). - const SIDECAR: &str = r#" - - - - 0, 064, 52192, 205255, 255 - - - 0, 0255, 255 - - - -"#; - - fn props(x: &str) -> Props { - lightcraft_meta::parse_xmp(x).unwrap().properties - } - - #[test] - fn maps_common_fields() { - let p = props(SIDECAR); - assert!(has_adjustments(&p)); - let partial = to_partial(&p, Target::RawAbsolute); - let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); - assert_eq!(s.wb.mode, WbMode::Custom); - assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0)); - assert_eq!(s.light.exposure, 0.65); - assert_eq!((s.light.contrast, s.light.highlights, s.light.shadows, s.light.whites, s.light.blacks), (-14.0, -58.0, 41.0, 9.0, -17.0)); - assert_eq!((s.effects.texture, s.effects.clarity, s.effects.dehaze), (6.0, 11.0, 4.0)); - assert_eq!((s.color.vibrance, s.color.saturation), (19.0, -3.0)); - assert_eq!(s.mixer.orange.hue, -7.0); - assert_eq!(s.mixer.blue.sat, -22.0); - assert_eq!(s.mixer.aqua.lum, 13.0); - assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0)); - assert_eq!(s.curve.master.len(), 4); - assert_eq!(s.curve.refine_saturation, 40.0); - assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); - assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); - assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); - assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0)); - assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0)); - assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0)); - assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0)); - assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0)); - assert_eq!(s.vignette.amount, -21.0); - assert_eq!(s.vignette.style, VignetteStyle::ColorPriority); - assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0)); - assert_eq!(s.geometry.upright, Upright::Level); - assert_eq!(s.geometry.vertical, -10.0); - assert!(s.optics.remove_ca); - assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); - let cal = s.calibration; - assert_eq!( - (cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat), - (-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0) - ); - let c = s.crop.geometry; - assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5)); - } - - /// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve: - /// it is left out, rather than stored with a point the curve editor can't place. - #[test] - fn curves_with_non_finite_points_are_left_out() { - for bad in ["NaN, 0", "64, inf", "-inf, 3"] { - let x = SIDECAR.replace("64, 52", &format!("{bad}")); - let partial = to_partial(&props(&x), Target::RawAbsolute); - let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); - assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master); - assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite())); - } - } - - #[test] - fn only_present_fields_are_in_the_partial() { - let x = r#""#; - let partial = to_partial(&props(x), Target::Any); - assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}})); - } - - #[test] - fn hdr_edit_mode_turns_hdr_editing_on_or_off() { - let x = |v: &str| { - format!( - r#""# - ) - }; - let on = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("1")), Target::RawAbsolute), 1.0); - assert!(on.hdr.enabled); - assert_eq!(on.light.exposure, 1.5); - assert_eq!(on.hdr.max_ev, lightcraft_develop::Hdr::DEFAULT_MAX_EV, "the rest of the HDR settings keep their defaults"); - let true_form = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("True")), Target::RawAbsolute), 1.0); - assert!(true_form.hdr.enabled); - let mut hdr = DevelopSettings::default(); - hdr.hdr.enabled = true; - assert!(!apply_partial(&hdr, &to_partial(&props(&x("0")), Target::RawAbsolute), 1.0).hdr.enabled, "0 turns it off"); - // without the field nothing about HDR changes - let plain = r#""#; - assert!(to_partial(&props(plain), Target::RawAbsolute).get("hdr").is_none()); - } - - #[test] - fn element_form_bw_and_incremental_wb() { - let x = r#" - - True - -35 - 25 - -6 - 4800 - "#; - let p = props(x); - let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0); - assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw); - assert_eq!(rendered.bw_mix.blue, -35.0); - assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0); - assert_eq!(rendered.wb.tint, -6.0); - let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0); - assert_eq!(raw.wb.temp, 4800.0); - } - - #[test] - fn named_wb_and_already_applied() { - let x = r#""#; - let p = props(x); - assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}})); - assert!(!has_adjustments(&p)); - let bookkeeping = r#""#; - assert!(!has_adjustments(&props(bookkeeping))); - } - - #[test] - fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() { - // Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white - // of 3650 K / −2 (written for this test from the numbers in the issue). - let x = r#""#; - let p = props(x); - let rel = to_partial(&p, Target::RawRelative); - // 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale - let t = rel["wb"]["temp"].as_f64().unwrap(); - assert!((t - 6275.0).abs() < 5.0, "{t}"); - assert_eq!(rel["wb"]["tint"], -3.0); - assert_eq!(rel["wb"]["mode"], "custom"); - // a rendered file without `Incremental*` fields shifts the same way; a raw with a measured - // illuminant (and a preset) keeps the Kelvin value - assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]); - assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); - assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0); - // Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not - assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin"); - assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin"); - assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none()); - // the as-shot fields are a reference, not an adjustment: nothing to report - assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty()); - // no tint next to the as-shot tint: the custom tint is taken as the shift itself - let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, "")); - assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0); - } - - #[test] - fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() { - // The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos: - // 3578 K read on the relative scale would be a large blue shift (issue #510) - let x = r#""#; - let p = props(x); - let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5); - assert_eq!(partial["wb"], json!({"mode": "asShot"})); - assert_eq!(partial["light"]["exposure"], 0.3); - assert_eq!(unmapped.len(), 1, "{unmapped:?}"); - assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}"); - // a raw with a measured illuminant and a rendered file read it as before, and report nothing - assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); - assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0); - assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty()); - // an unusable reference (zero) counts as none - let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#)); - assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"})); - } - - #[test] - fn target_follows_the_file() { - use lightcraft_catalog::MediaKind; - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative); - assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative); - // Lightroom's `fileFormat` name for proprietary raws - assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative); - assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute); - // a raw shown from its embedded preview develops like a rendered file - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered); - assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered); - // a camera with measured matrices edits in Kelvin (issue #730), under any of its names - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute); - assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute); - assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute); - assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute); - let mut p = - lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, ""); - p.kind = MediaKind::Raw; - assert_eq!(Target::for_photo(&p), Target::RawRelative); - p.meta.camera = "SONY ILCE-7M3".into(); - assert_eq!(Target::for_photo(&p), Target::RawAbsolute); - } - - /// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value - /// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on - /// a raw without matrices still takes the #510 shift. - #[test] - fn kelvin_is_direct_on_raws_whose_camera_has_matrices() { - use lightcraft_catalog::MediaKind; - let x = r#""#; - let p = props(x); - let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"); - assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"})); - let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2"); - let shifted = to_partial(&p, a7m2); - assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}"); - assert_eq!(shifted["wb"]["tint"], 4.0); - assert!(shifted["wb"].get("scale").is_none()); - // without the reference the Kelvin camera still reads it, the other stays As Shot - let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, "")); - assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0); - assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"})); - // a named preset carries no numbers either way - let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#)); - assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"})); - } - - #[test] - fn xmp_preset_with_name_and_group() { - // Written for this test: a minimal preset-shaped packet with localized name/group. - let x = r#" - - Punchy Test - My Looks - "#; - let p = preset_from_xmp(x, "file").unwrap(); - assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Punchy Test", "My Looks", "user.xmp.0123abcd")); - assert_eq!(p.settings, json!({"light": {"contrast": 25.0}})); - assert!(!p.builtin); - assert!(preset_from_xmp("", "f").is_none()); - } - - /// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry. - fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String { - format!( - r#" - - - - {extra_elems} - "# - ) - } - - #[test] - fn channel_curves_need_the_master_and_all_channel_curves() { - // Lightroom applies red / green / blue curves only when the master and all three are present - let seq = |name: &str, pts: &str| format!("{pts}"); - let identity = "0, 0255, 255"; - let bent = "0, 064, 40255, 255"; - let packet = |curves: &[String]| { - format!( - r#" - {} - "#, - curves.concat() - ) - }; - let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute); - let (master, red, green, blue) = ( - seq("ToneCurvePV2012", identity), - seq("ToneCurvePV2012Red", identity), - seq("ToneCurvePV2012Green", bent), - seq("ToneCurvePV2012Blue", identity), - ); - // a channel curve alone, or with the master but without its two siblings: ignored - let alone = partial(std::slice::from_ref(&green)); - assert!(alone.pointer("/curve/green").is_none(), "{alone}"); - let master_and_green = partial(&[master.clone(), green.clone()]); - assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}"); - // all four: read - let all = partial(&[master, red, green, blue]); - assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}"); - } - - fn unmapped(x: &str) -> Vec { - to_partial_report(&props(x), None, Target::Any, 1.5).1 - } - - #[test] - fn preset_bookkeeping_is_not_reported_but_real_gaps_are() { - let empty_points = "-1.000000, -1.000000, -1.000000, -1.000000"; - assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::::new()); - // a used Point Color slot and an unknown adjustment still are (an HDR edit is carried over) - let used_points = "0.5, 0.2, 0.1, 10, 0, 0, 0, 0"; - let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); - assert_eq!(got, ["FutureSlider", "PointColors"]); - } - - #[test] - fn lightroom_profile_bookkeeping_is_not_reported() { - // what Lightroom Classic writes next to every raw's edit: none of it changes the rendering - let bookkeeping = r#"crs:CompatibleVersion="268435456" crs:CameraProfileDigest="0A1B" crs:LensProfileDigest="2C3D" - crs:LensProfileName="Sony FE 24-70mm" crs:LensProfileFilename="x.lcp" crs:LensProfileIsEmbedded="False" - crs:LensProfileSetup="LensDefaults" crs:LensProfileDistortionScale="100" crs:LensProfileVignettingScale="100" - crs:CropConstrainToUnitSquare="1""#; - let look = r#"Vivid"#; - assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::::new()); - assert_eq!(unmapped(&preset_packet(bookkeeping, look)), ["Look"], "the Look is a real gap"); - // a lens profile's strength away from 100 % is an adjustment we don't carry over - let scaled = preset_packet(r#"crs:LensProfileDistortionScale="60""#, ""); - assert_eq!(unmapped(&scaled), ["LensProfileDistortionScale"]); - } - - #[test] - fn disabled_lens_profile_switch_keeps_embedded_corrections() { - // Lightroom applies a file's built-in lens corrections regardless of its profile switch, - // and ours are only those: a preset's `LensProfileEnable=0` must not turn them off - let p = props(&preset_packet("", "")); - let mut d = DevelopSettings::default(); - d.optics.lens_profile = true; - assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile); - let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#)); - assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile); - } -} +mod tests; diff --git a/crates/engine/src/crs/tests.rs b/crates/engine/src/crs/tests.rs new file mode 100644 index 000000000..2779194c1 --- /dev/null +++ b/crates/engine/src/crs/tests.rs @@ -0,0 +1,355 @@ +use super::*; +use lightcraft_develop::{DevelopSettings, Upright, VignetteStyle, WbMode, apply_partial}; + +/// A hand-written sidecar in attribute form (as many tools write it). +const SIDECAR: &str = r#" + + + + 0, 064, 52192, 205255, 255 + + + 0, 0255, 255 + + + +"#; + +fn props(x: &str) -> Props { + lightcraft_meta::parse_xmp(x).unwrap().properties +} + +#[test] +fn maps_common_fields() { + let p = props(SIDECAR); + assert!(has_adjustments(&p)); + let partial = to_partial(&p, Target::RawAbsolute); + let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); + assert_eq!(s.wb.mode, WbMode::Custom); + assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0)); + assert_eq!(s.light.exposure, 0.65); + assert_eq!((s.light.contrast, s.light.highlights, s.light.shadows, s.light.whites, s.light.blacks), (-14.0, -58.0, 41.0, 9.0, -17.0)); + assert_eq!((s.effects.texture, s.effects.clarity, s.effects.dehaze), (6.0, 11.0, 4.0)); + assert_eq!((s.color.vibrance, s.color.saturation), (19.0, -3.0)); + assert_eq!(s.mixer.orange.hue, -7.0); + assert_eq!(s.mixer.blue.sat, -22.0); + assert_eq!(s.mixer.aqua.lum, 13.0); + assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0)); + assert_eq!(s.curve.master.len(), 4); + assert_eq!(s.curve.refine_saturation, 40.0); + assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); + assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); + assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); + assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0)); + assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0)); + assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0)); + assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0)); + assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0)); + assert_eq!(s.vignette.amount, -21.0); + assert_eq!(s.vignette.style, VignetteStyle::ColorPriority); + assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0)); + assert_eq!(s.geometry.upright, Upright::Level); + assert_eq!(s.geometry.vertical, -10.0); + assert!(s.optics.remove_ca); + assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); + let cal = s.calibration; + assert_eq!( + (cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat), + (-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0) + ); + let c = s.crop.geometry; + assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5)); +} + +/// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve: +/// it is left out, rather than stored with a point the curve editor can't place. +#[test] +fn curves_with_non_finite_points_are_left_out() { + for bad in ["NaN, 0", "64, inf", "-inf, 3"] { + let x = SIDECAR.replace("64, 52", &format!("{bad}")); + let partial = to_partial(&props(&x), Target::RawAbsolute); + let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); + assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master); + assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + } +} + +#[test] +fn only_present_fields_are_in_the_partial() { + let x = r#""#; + let partial = to_partial(&props(x), Target::Any); + assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}})); +} + +#[test] +fn hdr_edit_mode_turns_hdr_editing_on_or_off() { + let x = |v: &str| { + format!( + r#""# + ) + }; + let on = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("1")), Target::RawAbsolute), 1.0); + assert!(on.hdr.enabled); + assert_eq!(on.light.exposure, 1.5); + assert_eq!(on.hdr.max_ev, lightcraft_develop::Hdr::DEFAULT_MAX_EV, "the rest of the HDR settings keep their defaults"); + let true_form = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("True")), Target::RawAbsolute), 1.0); + assert!(true_form.hdr.enabled); + let mut hdr = DevelopSettings::default(); + hdr.hdr.enabled = true; + assert!(!apply_partial(&hdr, &to_partial(&props(&x("0")), Target::RawAbsolute), 1.0).hdr.enabled, "0 turns it off"); + // without the field nothing about HDR changes + let plain = r#""#; + assert!(to_partial(&props(plain), Target::RawAbsolute).get("hdr").is_none()); +} + +#[test] +fn element_form_bw_and_incremental_wb() { + let x = r#" + + True + -35 + 25 + -6 + 4800 + "#; + let p = props(x); + let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0); + assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw); + assert_eq!(rendered.bw_mix.blue, -35.0); + assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0); + assert_eq!(rendered.wb.tint, -6.0); + let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0); + assert_eq!(raw.wb.temp, 4800.0); +} + +#[test] +fn named_wb_and_already_applied() { + let x = r#""#; + let p = props(x); + assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}})); + assert!(!has_adjustments(&p)); + let bookkeeping = r#""#; + assert!(!has_adjustments(&props(bookkeeping))); +} + +#[test] +fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() { + // Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white + // of 3650 K / −2 (written for this test from the numbers in the issue). + let x = r#""#; + let p = props(x); + let rel = to_partial(&p, Target::RawRelative); + // 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale + let t = rel["wb"]["temp"].as_f64().unwrap(); + assert!((t - 6275.0).abs() < 5.0, "{t}"); + assert_eq!(rel["wb"]["tint"], -3.0); + assert_eq!(rel["wb"]["mode"], "custom"); + // a rendered file without `Incremental*` fields shifts the same way; a raw with a measured + // illuminant (and a preset) keeps the Kelvin value + assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]); + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); + assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0); + // Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin"); + assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin"); + assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none()); + // the as-shot fields are a reference, not an adjustment: nothing to report + assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty()); + // no tint next to the as-shot tint: the custom tint is taken as the shift itself + let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, "")); + assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0); +} + +#[test] +fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() { + // The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos: + // 3578 K read on the relative scale would be a large blue shift (issue #510) + let x = r#""#; + let p = props(x); + let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5); + assert_eq!(partial["wb"], json!({"mode": "asShot"})); + assert_eq!(partial["light"]["exposure"], 0.3); + assert_eq!(unmapped.len(), 1, "{unmapped:?}"); + assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}"); + // a raw with a measured illuminant and a rendered file read it as before, and report nothing + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); + assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0); + assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty()); + // an unusable reference (zero) counts as none + let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#)); + assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"})); +} + +#[test] +fn target_follows_the_file() { + use lightcraft_catalog::MediaKind; + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative); + // Lightroom's `fileFormat` name for proprietary raws + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute); + // a raw shown from its embedded preview develops like a rendered file + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered); + assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered); + // a camera with measured matrices edits in Kelvin (issue #730), under any of its names + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute); + let mut p = + lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, ""); + p.kind = MediaKind::Raw; + assert_eq!(Target::for_photo(&p), Target::RawRelative); + p.meta.camera = "SONY ILCE-7M3".into(); + assert_eq!(Target::for_photo(&p), Target::RawAbsolute); +} + +/// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value +/// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on +/// a raw without matrices still takes the #510 shift. +#[test] +fn kelvin_is_direct_on_raws_whose_camera_has_matrices() { + use lightcraft_catalog::MediaKind; + let x = r#""#; + let p = props(x); + let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"); + assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"})); + let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2"); + let shifted = to_partial(&p, a7m2); + assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}"); + assert_eq!(shifted["wb"]["tint"], 4.0); + assert!(shifted["wb"].get("scale").is_none()); + // without the reference the Kelvin camera still reads it, the other stays As Shot + let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, "")); + assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0); + assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"})); + // a named preset carries no numbers either way + let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#)); + assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"})); +} + +#[test] +fn xmp_preset_with_name_and_group() { + // Written for this test: a minimal preset-shaped packet with localized name/group. + let x = r#" + + Punchy Test + My Looks + "#; + let p = preset_from_xmp(x, "file").unwrap(); + assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Punchy Test", "My Looks", "user.xmp.0123abcd")); + assert_eq!(p.settings, json!({"light": {"contrast": 25.0}})); + assert!(!p.builtin); + assert!(preset_from_xmp("", "f").is_none()); +} + +/// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry. +fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String { + format!( + r#" + + + + {extra_elems} + "# + ) +} + +#[test] +fn channel_curves_need_the_master_and_all_channel_curves() { + // Lightroom applies red / green / blue curves only when the master and all three are present + let seq = |name: &str, pts: &str| format!("{pts}"); + let identity = "0, 0255, 255"; + let bent = "0, 064, 40255, 255"; + let packet = |curves: &[String]| { + format!( + r#" + {} + "#, + curves.concat() + ) + }; + let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute); + let (master, red, green, blue) = ( + seq("ToneCurvePV2012", identity), + seq("ToneCurvePV2012Red", identity), + seq("ToneCurvePV2012Green", bent), + seq("ToneCurvePV2012Blue", identity), + ); + // a channel curve alone, or with the master but without its two siblings: ignored + let alone = partial(std::slice::from_ref(&green)); + assert!(alone.pointer("/curve/green").is_none(), "{alone}"); + let master_and_green = partial(&[master.clone(), green.clone()]); + assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}"); + // all four: read + let all = partial(&[master, red, green, blue]); + assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}"); +} + +fn unmapped(x: &str) -> Vec { + to_partial_report(&props(x), None, Target::Any, 1.5).1 +} + +#[test] +fn preset_bookkeeping_is_not_reported_but_real_gaps_are() { + let empty_points = "-1.000000, -1.000000, -1.000000, -1.000000"; + assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::::new()); + // a used Point Color slot and an unknown adjustment still are (an HDR edit is carried over) + let used_points = "0.5, 0.2, 0.1, 10, 0, 0, 0, 0"; + let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); + assert_eq!(got, ["FutureSlider", "PointColors"]); +} + +#[test] +fn lightroom_profile_bookkeeping_is_not_reported() { + // what Lightroom Classic writes next to every raw's edit: none of it changes the rendering + let bookkeeping = r#"crs:CompatibleVersion="268435456" crs:CameraProfileDigest="0A1B" crs:LensProfileDigest="2C3D" + crs:LensProfileName="Sony FE 24-70mm" crs:LensProfileFilename="x.lcp" crs:LensProfileIsEmbedded="False" + crs:LensProfileSetup="LensDefaults" crs:LensProfileDistortionScale="100" crs:LensProfileVignettingScale="100" + crs:CropConstrainToUnitSquare="1""#; + let look = r#"Vivid"#; + assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::::new()); + assert_eq!(unmapped(&preset_packet(bookkeeping, look)), ["Look"], "the Look is a real gap"); + // a lens profile's strength away from 100 % is an adjustment we don't carry over + let scaled = preset_packet(r#"crs:LensProfileDistortionScale="60""#, ""); + assert_eq!(unmapped(&scaled), ["LensProfileDistortionScale"]); +} + +#[test] +fn disabled_lens_profile_switch_keeps_embedded_corrections() { + // Lightroom applies a file's built-in lens corrections regardless of its profile switch, + // and ours are only those: a preset's `LensProfileEnable=0` must not turn them off + let p = props(&preset_packet("", "")); + let mut d = DevelopSettings::default(); + d.optics.lens_profile = true; + assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile); + let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#)); + assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile); +} From e6b0b386a7d4d185a859c18d85dbfb67438e4cf9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:57 +0000 Subject: [PATCH 050/191] refactor(lightcraft-engine): move print/layout.rs tests to crates/engine/src/print/layout/tests.rs --- crates/engine/src/print/layout.rs | 493 +----------------------- crates/engine/src/print/layout/tests.rs | 490 +++++++++++++++++++++++ 2 files changed, 491 insertions(+), 492 deletions(-) create mode 100644 crates/engine/src/print/layout/tests.rs diff --git a/crates/engine/src/print/layout.rs b/crates/engine/src/print/layout.rs index be4438424..046ff952c 100644 --- a/crates/engine/src/print/layout.rs +++ b/crates/engine/src/print/layout.rs @@ -961,495 +961,4 @@ impl Layout { } #[cfg(test)] -mod tests { - use super::*; - - fn package() -> Layout { - Layout { style: Style::PicturePackage, ..Default::default() } - } - - fn assert_clean(l: &Layout) { - let area = l.printable().unwrap(); - for c in &l.cells { - assert!(area.contains(&c.rect), "{c:?} outside {area:?}"); - assert!(c.page < l.pages); - } - assert!(!l.has_overlaps(), "{:#?}", l.cells); - } - - #[test] - fn papers_and_orientation() { - let mut p = PageSetup::default(); - assert_eq!(p.size().unwrap(), (8.5, 11.0)); - p.orientation = Orientation::Landscape; - assert_eq!(p.size().unwrap(), (11.0, 8.5)); - assert_eq!(p.size_points().unwrap(), (792.0, 612.0)); - p.paper = "A4".into(); - let (w, h) = p.size().unwrap(); - assert!((w - 11.693).abs() < 1e-3 && (h - 8.268).abs() < 1e-3); - for n in paper_names() { - p.paper = n.into(); - assert!(p.printable().is_ok(), "{n}"); - } - p.paper = "custom".into(); - p.custom_width = 20.0; - p.custom_height = 30.0; - assert_eq!(p.size().unwrap(), (30.0, 20.0)); - p.paper = "poster".into(); - assert!(p.size().is_err()); - } - - /// Japanese photo and postcard papers: exact metric sizes to 0.1 pt, the customary names as - /// aliases, and the ISO/US papers around them unchanged. - #[test] - fn japanese_photo_and_postcard_papers() { - let pt = |name: &str| { - let p = PageSetup { paper: name.into(), ..Default::default() }; - p.size_points().unwrap_or_else(|e| panic!("{name}: {e}")) - }; - for (name, w_mm, h_mm) in [ - ("L判", 89.0, 127.0), - ("2L判", 127.0, 178.0), - ("KG判", 102.0, 152.0), - ("はがき", 100.0, 148.0), - ("往復はがき", 148.0, 200.0), - ("六切", 203.0, 254.0), - ("四切", 254.0, 305.0), - ("名刺", 55.0, 91.0), - ] { - let (w, h) = pt(name); - let want = (w_mm / 25.4 * 72.0, h_mm / 25.4 * 72.0); - assert!((w - want.0).abs() < 0.1 && (h - want.1).abs() < 0.1, "{name}: {w} x {h} pt, wanted {want:?}"); - assert!((w - h).abs() > 1e-6, "{name} is portrait"); - } - // The customary and English spellings resolve to the same paper, including spaces and case. - for (a, b) in [ - ("L判", "l"), - ("L判", " L "), - ("L判", "L-size"), - ("2L判", "2l"), - ("KG判", "kg"), - ("はがき", "postcard"), - ("はがき", "ハガキ"), - ("往復はがき", "double-postcard"), - ("六切", "6cut"), - ("六切", "6切"), - ("四切", "4cut"), - ("名刺", "business-card"), - ("A4", "a 4"), - ("4 × 6", "4x6"), - ] { - assert_eq!(paper_size(a), paper_size(b), "{a} vs {b}"); - } - // Punctuation is ignored, including the periods and Japanese marks a pasted name may carry. - for (name, canonical) in [("L判。", "l"), ("L判、", "l"), ("(L判)", "l"), ("l-size.", "l"), ("はがき。", "postcard"), ("6切。", "6cut")] - { - assert_eq!(paper_name(name), Some(canonical), "{name}"); - } - assert_eq!(paper_name("custom"), None); - assert!(paper_size("poster").is_none()); - // The papers that were already there keep their sizes. - assert_eq!(pt("letter"), (612.0, 792.0)); - assert_eq!(pt("4x6"), (288.0, 432.0)); - assert_eq!(pt("5x7"), (360.0, 504.0)); - assert_eq!(pt("8x10"), (576.0, 720.0)); - assert_eq!(pt("13x19"), (936.0, 1368.0)); - let (w, h) = pt("A4"); - assert!((w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01, "A4 {w} x {h}"); - } - - #[test] - fn hostile_numbers_are_rejected() { - for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] { - let p = PageSetup { paper: "custom".into(), custom_width: bad, ..Default::default() }; - assert!(p.size().is_err(), "{bad}"); - let p = PageSetup { margins: Margins { left: bad, ..Margins::default() }, ..Default::default() }; - assert!(p.printable().is_err(), "{bad}"); - let mut l = package(); - assert!(l.add_cell(bad, 2.0).is_err(), "{bad}"); - assert!(l.add_cell(2.0, bad).is_err()); - let id = l.add_cell(2.0, 2.0).unwrap(); - assert!(bad.is_finite() || l.move_cell(id, bad, 0.0).is_err()); - assert!(l.resize_cell(id, Rect::new(0.0, 0.0, bad, 1.0)).is_err()); - assert!(bad.is_finite() || l.snap_move(id, Rect::new(bad, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); - let mut g = Layout { spacing: bad, ..package() }; - assert!(g.validate().is_err()); - assert!(pack(&[(1.0, 1.0)], l.printable().unwrap(), bad, true).is_err()); - assert!(pack(&[(bad, 1.0)], l.printable().unwrap(), 0.0, true).is_err()); - } - // Margins that leave nothing. - let p = PageSetup { margins: Margins::uniform(4.5), ..Default::default() }; - assert!(p.printable().is_err()); - let mut l = Layout { grid: GridSpec { columns: 0, ..Default::default() }, ..Default::default() }; - assert!(l.validate().is_err()); - l.grid.columns = 21; - assert!(l.validate().is_err()); - } - - #[test] - fn units_convert() { - for u in [Unit::Inches, Unit::Centimeters, Unit::Millimeters, Unit::Points] { - assert!((u.to_inches(u.from_inches(3.5)) - 3.5).abs() < 1e-12); - } - assert!((Unit::Millimeters.from_inches(1.0) - 25.4).abs() < 1e-12); - assert_eq!(Unit::Points.from_inches(2.0), 144.0); - assert_eq!(serde_json::to_string(&Unit::Centimeters).unwrap(), "\"cm\""); - } - - #[test] - fn contact_grid_fills_the_printable_area() { - let l = Layout { - style: Style::ContactSheet, - grid: GridSpec { columns: 3, rows: 4, spacing_x: 0.2, spacing_y: 0.1, keep_square: false }, - ..Default::default() - }; - let area = l.printable().unwrap(); - let cells = l.grid_rects().unwrap(); - assert_eq!(cells.len(), 12); - let first = cells.first().unwrap(); - let last = cells.last().unwrap(); - assert!((first.x - area.x).abs() < 1e-9 && (first.y - area.y).abs() < 1e-9); - assert!((last.right() - area.right()).abs() < 1e-9 && (last.bottom() - area.bottom()).abs() < 1e-9); - for (i, a) in cells.iter().enumerate() { - assert!((a.w - first.w).abs() < 1e-12 && (a.h - first.h).abs() < 1e-12); - for b in cells.iter().skip(i + 1) { - assert!(!a.overlaps(b)); - } - } - // Square cells are centred. - let sq = Layout { grid: GridSpec { keep_square: true, ..l.grid }, ..l.clone() }; - let cells = sq.grid_rects().unwrap(); - assert!(cells.iter().all(|c| (c.w - c.h).abs() < 1e-12)); - let (lo, hi) = (cells.iter().map(|c| c.x).fold(f64::MAX, f64::min), cells.iter().map(|c| c.right()).fold(f64::MIN, f64::max)); - assert!(((lo - area.x) - (area.right() - hi)).abs() < 1e-9); - // Single image ignores the grid. - let one = Layout { style: Style::SingleImage, ..l }; - assert_eq!(one.grid_rects().unwrap(), vec![area]); - } - - #[test] - fn page_counts() { - let mut l = Layout { style: Style::ContactSheet, grid: GridSpec { columns: 2, rows: 2, ..Default::default() }, ..Default::default() }; - assert_eq!(l.page_count(0), 1); - assert_eq!(l.page_count(4), 1); - assert_eq!(l.page_count(5), 2); - l.style = Style::SingleImage; - assert_eq!(l.page_count(3), 3); - l.style = Style::PicturePackage; - l.pages = 4; - assert_eq!(l.page_count(99), 4); - } - - #[test] - fn packer_is_deterministic_and_clean() { - let area = Layout::default().printable().unwrap(); - let sizes: Vec<(f64, f64)> = - [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (3.5, 5.0), (8.0, 10.0), (4.0, 6.0), (2.0, 2.0), (1.0, 1.5), (5.0, 7.0), (4.0, 6.0)] - .to_vec(); - let a = pack(&sizes, area, 0.1, true).unwrap(); - assert_eq!(a, pack(&sizes, area, 0.1, true).unwrap()); - assert_eq!(a.len(), sizes.len()); - for (i, (p, r, turned)) in a.iter().enumerate() { - assert!(area.contains(r), "{r:?}"); - let (w, h) = sizes[i]; - let expect = if *turned { (h, w) } else { (w, h) }; - assert!((r.w - expect.0).abs() < 1e-9 && (r.h - expect.1).abs() < 1e-9); - for (j, (q, s, _)) in a.iter().enumerate().skip(i + 1) { - if p == q { - // The spacing is kept between neighbours. - let grown = Rect::new(r.x, r.y, r.w + 0.1 - 2e-6, r.h + 0.1 - 2e-6); - assert!(!grown.overlaps(s), "{i} and {j}: {r:?} {s:?}"); - } - } - } - // The input order does not hide a block that needs a page of its own. - assert!(a.iter().map(|p| p.0).max().unwrap() >= 1); - } - - #[test] - fn packer_rotates_only_when_needed_and_refuses_oversize() { - let area = Rect::new(0.25, 0.25, 8.0, 10.5); - // 10 x 5 only fits a portrait page turned. - let r = pack(&[(10.0, 5.0)], area, 0.0, true).unwrap(); - assert!(r[0].2 && (r[0].1.w - 5.0).abs() < 1e-9 && (r[0].1.h - 10.0).abs() < 1e-9); - assert!(pack(&[(10.0, 5.0)], area, 0.0, false).is_err()); - let r = pack(&[(4.0, 6.0)], area, 0.0, true).unwrap(); - assert!(!r[0].2, "a block that fits as given is not turned"); - assert!(pack(&[(9.0, 11.0)], area, 0.0, true).is_err()); - // A block that exactly fills the area fits despite the spacing. - assert!(pack(&[(8.0, 10.5)], area, 0.2, true).is_ok()); - assert!(pack(&vec![(1.0, 1.0); MAX_CELLS + 1], area, 0.0, true).is_err()); - // More pages than allowed. - assert!(pack(&vec![(8.0, 10.5); 51], area, 0.0, true).is_err()); - assert_eq!(pack(&vec![(8.0, 10.5); 50], area, 0.0, true).unwrap().iter().map(|p| p.0).max(), Some(49)); - } - - #[test] - fn packing_is_dense() { - // Four 4x5.25 cells tile a letter page's 8 x 10.5 area exactly. - let area = Rect::new(0.25, 0.25, 8.0, 10.5); - let r = pack(&[(4.0, 5.25); 4], area, 0.0, false).unwrap(); - assert!(r.iter().all(|p| p.0 == 0)); - // Eight 4x6 photos fit two pages (four per page with the rotation trick is impossible: 4x6 x2 per column). - let r = pack(&[(4.0, 6.0); 4], area, 0.0, true).unwrap(); - assert_eq!(r.iter().map(|p| p.0).max(), Some(1)); - } - - #[test] - fn add_cell_fills_gaps_then_opens_pages() { - let mut l = package(); - let a = l.add_cell(3.9, 6.0).unwrap(); - let b = l.add_cell(3.9, 6.0).unwrap(); - assert_eq!(l.cell(a).unwrap().rect.x, 0.25); - assert_ne!(l.cell(a).unwrap().rect, l.cell(b).unwrap().rect); - assert_clean(&l); - // Keep adding until a second page appears. - for _ in 0..6 { - l.add_cell(3.9, 6.0).unwrap(); - } - assert!(l.pages >= 2); - assert_clean(&l); - assert_eq!(l.cells.len(), 8); - assert!(l.add_cell(9.0, 9.0).is_err(), "wider than the printable area"); - let mut grid = Layout::default(); - assert!(grid.add_cell(1.0, 1.0).is_err()); - } - - #[test] - fn cell_cap() { - let mut l = package(); - for _ in 0..MAX_CELLS { - if l.add_cell(0.5, 0.5).is_err() { - break; - } - } - assert!(l.cells.len() <= MAX_CELLS); - let n = l.cells.len(); - if n == MAX_CELLS { - assert!(l.add_cell(0.5, 0.5).is_err()); - } - } - - #[test] - fn move_and_resize_stay_inside() { - let mut l = package(); - let id = l.add_cell(2.0, 3.0).unwrap(); - let r = l.move_cell(id, 100.0, -5.0).unwrap(); - let area = l.printable().unwrap(); - assert!((r.right() - area.right()).abs() < 1e-9 && (r.y - area.y).abs() < 1e-9); - let r = l.resize_cell(id, Rect::new(7.0, 9.0, 50.0, 50.0)).unwrap(); - assert!(area.contains(&r)); - assert!(l.resize_cell(id, Rect::new(0.0, 0.0, 0.0, 1.0)).is_err()); - assert!(l.move_cell(77, 0.0, 0.0).is_err()); - l.delete_cell(id).unwrap(); - assert!(l.delete_cell(id).is_err()); - } - - #[test] - fn stacking_order() { - let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; - let ids: Vec = (0..4).map(|_| l.add_cell(1.0, 1.0).unwrap()).collect(); - let order = |l: &Layout| l.cells.iter().map(|c| c.id).collect::>(); - assert_eq!(order(&l), ids); - l.restack(ids[0], Stack::ToFront).unwrap(); - assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); - l.restack(ids[0], Stack::Backward).unwrap(); - assert_eq!(order(&l), vec![ids[1], ids[2], ids[0], ids[3]]); - l.restack(ids[0], Stack::Forward).unwrap(); - assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); - l.restack(ids[0], Stack::Forward).unwrap(); - assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); - l.restack(ids[0], Stack::ToBack).unwrap(); - assert_eq!(order(&l), ids); - l.restack(ids[0], Stack::Backward).unwrap(); - assert_eq!(order(&l), ids); - assert!(l.restack(99, Stack::ToBack).is_err()); - } - - #[test] - fn stacking_ignores_other_pages() { - let mut l = Layout { style: Style::CustomPackage, pages: 2, ..Default::default() }; - let a = l.add_cell(1.0, 1.0).unwrap(); - let b = l.add_cell(1.0, 1.0).unwrap(); - let c = l.add_cell(1.0, 1.0).unwrap(); - l.set_cell_page(b, 1).unwrap(); - l.restack(a, Stack::ToFront).unwrap(); - // `a` goes past `c` (same page) and not necessarily past `b`. - let pos = |id| l.cells.iter().position(|x| x.id == id).unwrap(); - assert!(pos(a) > pos(c)); - } - - #[test] - fn overlap_is_allowed_by_move_and_reported() { - let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; - let a = l.add_cell(3.0, 3.0).unwrap(); - let b = l.add_cell(3.0, 3.0).unwrap(); - assert!(!l.has_overlaps()); - let r = l.cell(a).unwrap().rect; - l.move_cell(b, r.x + 1.0, r.y + 1.0).unwrap(); - assert!(l.has_overlaps()); - // Touching edges are not an overlap. - l.move_cell(b, r.right(), r.y).unwrap(); - assert!(!l.has_overlaps()); - } - - #[test] - fn auto_layout_repacks_cleanly_and_keeps_photos() { - let mut l = package(); - for (w, h) in [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (8.0, 10.0), (4.0, 6.0), (3.5, 5.0)] { - l.add_cell(w, h).unwrap(); - } - let first = l.cells[0].id; - l.set_cell_photo(first, Some(42)).unwrap(); - // Scramble positions to overlap, then auto layout. - for c in &mut l.cells { - c.rect.x = 0.25; - c.rect.y = 0.25; - c.page = 0; - } - l.pages = 1; - assert!(l.has_overlaps()); - l.auto_layout().unwrap(); - assert_clean(&l); - assert_eq!(l.cell(first).unwrap().photo, Some(42)); - assert_eq!(l.cells.len(), 6); - let again = l.clone(); - let mut l2 = again.clone(); - l2.auto_layout().unwrap(); - l.auto_layout().unwrap(); - assert_eq!(l, l2); - } - - #[test] - fn auto_layout_failure_changes_nothing() { - let mut l = package(); - l.add_cell(2.0, 2.0).unwrap(); - l.cells.push(Cell { id: 50, page: 0, rect: Rect::new(0.25, 0.25, 40.0, 40.0), photo: None }); - let before = l.clone(); - assert!(l.auto_layout().is_err()); - assert_eq!(l, before); - } - - #[test] - fn clear_and_new_page() { - let mut l = package(); - l.add_cell(2.0, 2.0).unwrap(); - assert_eq!(l.new_page().unwrap(), 1); - assert_eq!(l.pages, 2); - l.clear(); - assert_eq!((l.cells.len(), l.pages), (0, 1)); - for _ in 1..MAX_PAGES { - l.new_page().unwrap(); - } - assert!(l.new_page().is_err()); - let mut g = Layout::default(); - assert!(g.new_page().is_err()); - } - - #[test] - fn validate_catches_bad_cells() { - let mut l = package(); - l.add_cell(2.0, 2.0).unwrap(); - let ok = l.clone(); - let mut bad = ok.clone(); - bad.cells[0].rect.w = f64::NAN; - assert!(bad.validate().is_err()); - let mut bad = ok.clone(); - bad.cells[0].page = 3; - assert!(bad.validate().is_err()); - let mut bad = ok.clone(); - bad.cells[0].rect.x = 50.0; - assert!(bad.validate().is_err()); - let mut bad = ok.clone(); - bad.cells.push(bad.cells[0].clone()); - assert!(bad.validate().is_err()); - let mut bad = ok.clone(); - bad.pages = 0; - assert!(bad.validate().is_err()); - let mut bad = ok.clone(); - bad.next_id = 0; - bad.validate().unwrap(); - assert!(bad.next_id > bad.cells[0].id); - assert!(serde_json::from_str::(r#"{"surprise":1}"#).is_err()); - let mut round: Layout = serde_json::from_str(&serde_json::to_string(&ok).unwrap()).unwrap(); - round.validate().unwrap(); - assert_eq!(round, ok); - } - - #[test] - fn snapping_to_cells_grid_and_margins() { - let mut l = Layout { style: Style::CustomPackage, spacing: 0.0, ..Default::default() }; - let a = l.add_cell(3.0, 3.0).unwrap(); - let b = l.add_cell(2.0, 2.0).unwrap(); - let ra = l.cell(a).unwrap().rect; - // Left edge of b near the right edge of a. - let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); - assert!((m.x - ra.right()).abs() < 1e-9); - assert_eq!(m.y, 5.0); - // Too far: untouched. - let m = l.snap_move(b, Rect::new(ra.right() + 0.5, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); - assert!((m.x - (ra.right() + 0.5)).abs() < 1e-9); - // Right edge snaps to the printable margin. - let area = l.printable().unwrap(); - let m = l.snap_move(b, Rect::new(area.right() - 2.0 - 0.03, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); - assert!((m.right() - area.right()).abs() < 1e-9); - // Off leaves everything. - let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Off, 0.25, 0.1).unwrap(); - assert!((m.x - (ra.right() + 0.04)).abs() < 1e-9); - // Grid snapping lands on multiples from the printable corner. - let m = l.snap_move(b, Rect::new(1.37, 2.62, 2.0, 2.0), Snap::Grid, 0.5, 0.2).unwrap(); - assert!(((m.x - area.x) / 0.5).fract().abs() < 1e-9 || (((m.x - area.x) / 0.5).fract() - 1.0).abs() < 1e-9); - // Resize snaps the dragged corner only. - let r = l.snap_resize(b, Rect::new(5.0, 5.0, 2.1, 1.9), Snap::Grid, 0.5, 0.2).unwrap(); - assert_eq!((r.x, r.y), (5.0, 5.0)); - assert!(((r.right() - area.x) / 0.5 - ((r.right() - area.x) / 0.5).round()).abs() < 1e-9); - // Never below the minimum size. - let r = l.snap_resize(b, Rect::new(5.0, 5.0, 0.0, 0.0), Snap::Grid, 0.5, 0.2).unwrap(); - assert!(r.w >= MIN_CELL_IN && r.h >= MIN_CELL_IN); - assert!(l.snap_move(99, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); - assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, 0.0, 0.1).is_err()); - assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, f64::NAN, 0.1).is_err()); - } - - #[test] - fn fit_cells_follows_the_margins() { - let mut l = package(); - let id = l.add_cell(7.0, 10.0).unwrap(); - l.page.margins = Margins::uniform(1.0); - assert!(l.validate().is_err(), "the cell no longer fits"); - l.fit_cells().unwrap(); - l.validate().unwrap(); - let r = l.cell(id).unwrap().rect; - assert!(l.printable().unwrap().contains(&r)); - assert!((r.w - 6.5).abs() < 1e-9 && (r.h - 9.0).abs() < 1e-9); - l.page.paper = "poster".into(); - assert!(l.fit_cells().is_err()); - } - - #[test] - fn cell_aspect_survives_auto_layout() { - let mut l = package(); - let id = l.add_cell(2.5, 3.5).unwrap(); - l.auto_layout().unwrap(); - let r = l.cell(id).unwrap().rect; - assert!((r.w / r.h - 2.5 / 3.5).abs() < 1e-9 || (r.h / r.w - 2.5 / 3.5).abs() < 1e-9); - } - - #[test] - fn guides_validate() { - assert!(Guides::default().validate().is_ok()); - for bad in [f64::NAN, 0.0, -1.0, 1e9] { - assert!(Guides { grid_size: bad, ..Default::default() }.validate().is_err()); - } - } - - #[test] - fn rect_helpers() { - let a = Rect::new(0.0, 0.0, 2.0, 2.0); - assert!(a.overlaps(&Rect::new(1.0, 1.0, 2.0, 2.0))); - assert!(!a.overlaps(&Rect::new(2.0, 0.0, 1.0, 1.0))); - assert!(a.contains(&Rect::new(0.5, 0.5, 1.0, 1.0))); - assert!(!a.contains(&Rect::new(1.5, 0.5, 1.0, 1.0))); - assert_eq!(a.area(), 4.0); - } -} +mod tests; diff --git a/crates/engine/src/print/layout/tests.rs b/crates/engine/src/print/layout/tests.rs new file mode 100644 index 000000000..59f3c5845 --- /dev/null +++ b/crates/engine/src/print/layout/tests.rs @@ -0,0 +1,490 @@ +use super::*; + +fn package() -> Layout { + Layout { style: Style::PicturePackage, ..Default::default() } +} + +fn assert_clean(l: &Layout) { + let area = l.printable().unwrap(); + for c in &l.cells { + assert!(area.contains(&c.rect), "{c:?} outside {area:?}"); + assert!(c.page < l.pages); + } + assert!(!l.has_overlaps(), "{:#?}", l.cells); +} + +#[test] +fn papers_and_orientation() { + let mut p = PageSetup::default(); + assert_eq!(p.size().unwrap(), (8.5, 11.0)); + p.orientation = Orientation::Landscape; + assert_eq!(p.size().unwrap(), (11.0, 8.5)); + assert_eq!(p.size_points().unwrap(), (792.0, 612.0)); + p.paper = "A4".into(); + let (w, h) = p.size().unwrap(); + assert!((w - 11.693).abs() < 1e-3 && (h - 8.268).abs() < 1e-3); + for n in paper_names() { + p.paper = n.into(); + assert!(p.printable().is_ok(), "{n}"); + } + p.paper = "custom".into(); + p.custom_width = 20.0; + p.custom_height = 30.0; + assert_eq!(p.size().unwrap(), (30.0, 20.0)); + p.paper = "poster".into(); + assert!(p.size().is_err()); +} + +/// Japanese photo and postcard papers: exact metric sizes to 0.1 pt, the customary names as +/// aliases, and the ISO/US papers around them unchanged. +#[test] +fn japanese_photo_and_postcard_papers() { + let pt = |name: &str| { + let p = PageSetup { paper: name.into(), ..Default::default() }; + p.size_points().unwrap_or_else(|e| panic!("{name}: {e}")) + }; + for (name, w_mm, h_mm) in [ + ("L判", 89.0, 127.0), + ("2L判", 127.0, 178.0), + ("KG判", 102.0, 152.0), + ("はがき", 100.0, 148.0), + ("往復はがき", 148.0, 200.0), + ("六切", 203.0, 254.0), + ("四切", 254.0, 305.0), + ("名刺", 55.0, 91.0), + ] { + let (w, h) = pt(name); + let want = (w_mm / 25.4 * 72.0, h_mm / 25.4 * 72.0); + assert!((w - want.0).abs() < 0.1 && (h - want.1).abs() < 0.1, "{name}: {w} x {h} pt, wanted {want:?}"); + assert!((w - h).abs() > 1e-6, "{name} is portrait"); + } + // The customary and English spellings resolve to the same paper, including spaces and case. + for (a, b) in [ + ("L判", "l"), + ("L判", " L "), + ("L判", "L-size"), + ("2L判", "2l"), + ("KG判", "kg"), + ("はがき", "postcard"), + ("はがき", "ハガキ"), + ("往復はがき", "double-postcard"), + ("六切", "6cut"), + ("六切", "6切"), + ("四切", "4cut"), + ("名刺", "business-card"), + ("A4", "a 4"), + ("4 × 6", "4x6"), + ] { + assert_eq!(paper_size(a), paper_size(b), "{a} vs {b}"); + } + // Punctuation is ignored, including the periods and Japanese marks a pasted name may carry. + for (name, canonical) in [("L判。", "l"), ("L判、", "l"), ("(L判)", "l"), ("l-size.", "l"), ("はがき。", "postcard"), ("6切。", "6cut")] + { + assert_eq!(paper_name(name), Some(canonical), "{name}"); + } + assert_eq!(paper_name("custom"), None); + assert!(paper_size("poster").is_none()); + // The papers that were already there keep their sizes. + assert_eq!(pt("letter"), (612.0, 792.0)); + assert_eq!(pt("4x6"), (288.0, 432.0)); + assert_eq!(pt("5x7"), (360.0, 504.0)); + assert_eq!(pt("8x10"), (576.0, 720.0)); + assert_eq!(pt("13x19"), (936.0, 1368.0)); + let (w, h) = pt("A4"); + assert!((w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01, "A4 {w} x {h}"); +} + +#[test] +fn hostile_numbers_are_rejected() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] { + let p = PageSetup { paper: "custom".into(), custom_width: bad, ..Default::default() }; + assert!(p.size().is_err(), "{bad}"); + let p = PageSetup { margins: Margins { left: bad, ..Margins::default() }, ..Default::default() }; + assert!(p.printable().is_err(), "{bad}"); + let mut l = package(); + assert!(l.add_cell(bad, 2.0).is_err(), "{bad}"); + assert!(l.add_cell(2.0, bad).is_err()); + let id = l.add_cell(2.0, 2.0).unwrap(); + assert!(bad.is_finite() || l.move_cell(id, bad, 0.0).is_err()); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, bad, 1.0)).is_err()); + assert!(bad.is_finite() || l.snap_move(id, Rect::new(bad, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + let mut g = Layout { spacing: bad, ..package() }; + assert!(g.validate().is_err()); + assert!(pack(&[(1.0, 1.0)], l.printable().unwrap(), bad, true).is_err()); + assert!(pack(&[(bad, 1.0)], l.printable().unwrap(), 0.0, true).is_err()); + } + // Margins that leave nothing. + let p = PageSetup { margins: Margins::uniform(4.5), ..Default::default() }; + assert!(p.printable().is_err()); + let mut l = Layout { grid: GridSpec { columns: 0, ..Default::default() }, ..Default::default() }; + assert!(l.validate().is_err()); + l.grid.columns = 21; + assert!(l.validate().is_err()); +} + +#[test] +fn units_convert() { + for u in [Unit::Inches, Unit::Centimeters, Unit::Millimeters, Unit::Points] { + assert!((u.to_inches(u.from_inches(3.5)) - 3.5).abs() < 1e-12); + } + assert!((Unit::Millimeters.from_inches(1.0) - 25.4).abs() < 1e-12); + assert_eq!(Unit::Points.from_inches(2.0), 144.0); + assert_eq!(serde_json::to_string(&Unit::Centimeters).unwrap(), "\"cm\""); +} + +#[test] +fn contact_grid_fills_the_printable_area() { + let l = Layout { + style: Style::ContactSheet, + grid: GridSpec { columns: 3, rows: 4, spacing_x: 0.2, spacing_y: 0.1, keep_square: false }, + ..Default::default() + }; + let area = l.printable().unwrap(); + let cells = l.grid_rects().unwrap(); + assert_eq!(cells.len(), 12); + let first = cells.first().unwrap(); + let last = cells.last().unwrap(); + assert!((first.x - area.x).abs() < 1e-9 && (first.y - area.y).abs() < 1e-9); + assert!((last.right() - area.right()).abs() < 1e-9 && (last.bottom() - area.bottom()).abs() < 1e-9); + for (i, a) in cells.iter().enumerate() { + assert!((a.w - first.w).abs() < 1e-12 && (a.h - first.h).abs() < 1e-12); + for b in cells.iter().skip(i + 1) { + assert!(!a.overlaps(b)); + } + } + // Square cells are centred. + let sq = Layout { grid: GridSpec { keep_square: true, ..l.grid }, ..l.clone() }; + let cells = sq.grid_rects().unwrap(); + assert!(cells.iter().all(|c| (c.w - c.h).abs() < 1e-12)); + let (lo, hi) = (cells.iter().map(|c| c.x).fold(f64::MAX, f64::min), cells.iter().map(|c| c.right()).fold(f64::MIN, f64::max)); + assert!(((lo - area.x) - (area.right() - hi)).abs() < 1e-9); + // Single image ignores the grid. + let one = Layout { style: Style::SingleImage, ..l }; + assert_eq!(one.grid_rects().unwrap(), vec![area]); +} + +#[test] +fn page_counts() { + let mut l = Layout { style: Style::ContactSheet, grid: GridSpec { columns: 2, rows: 2, ..Default::default() }, ..Default::default() }; + assert_eq!(l.page_count(0), 1); + assert_eq!(l.page_count(4), 1); + assert_eq!(l.page_count(5), 2); + l.style = Style::SingleImage; + assert_eq!(l.page_count(3), 3); + l.style = Style::PicturePackage; + l.pages = 4; + assert_eq!(l.page_count(99), 4); +} + +#[test] +fn packer_is_deterministic_and_clean() { + let area = Layout::default().printable().unwrap(); + let sizes: Vec<(f64, f64)> = + [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (3.5, 5.0), (8.0, 10.0), (4.0, 6.0), (2.0, 2.0), (1.0, 1.5), (5.0, 7.0), (4.0, 6.0)] + .to_vec(); + let a = pack(&sizes, area, 0.1, true).unwrap(); + assert_eq!(a, pack(&sizes, area, 0.1, true).unwrap()); + assert_eq!(a.len(), sizes.len()); + for (i, (p, r, turned)) in a.iter().enumerate() { + assert!(area.contains(r), "{r:?}"); + let (w, h) = sizes[i]; + let expect = if *turned { (h, w) } else { (w, h) }; + assert!((r.w - expect.0).abs() < 1e-9 && (r.h - expect.1).abs() < 1e-9); + for (j, (q, s, _)) in a.iter().enumerate().skip(i + 1) { + if p == q { + // The spacing is kept between neighbours. + let grown = Rect::new(r.x, r.y, r.w + 0.1 - 2e-6, r.h + 0.1 - 2e-6); + assert!(!grown.overlaps(s), "{i} and {j}: {r:?} {s:?}"); + } + } + } + // The input order does not hide a block that needs a page of its own. + assert!(a.iter().map(|p| p.0).max().unwrap() >= 1); +} + +#[test] +fn packer_rotates_only_when_needed_and_refuses_oversize() { + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + // 10 x 5 only fits a portrait page turned. + let r = pack(&[(10.0, 5.0)], area, 0.0, true).unwrap(); + assert!(r[0].2 && (r[0].1.w - 5.0).abs() < 1e-9 && (r[0].1.h - 10.0).abs() < 1e-9); + assert!(pack(&[(10.0, 5.0)], area, 0.0, false).is_err()); + let r = pack(&[(4.0, 6.0)], area, 0.0, true).unwrap(); + assert!(!r[0].2, "a block that fits as given is not turned"); + assert!(pack(&[(9.0, 11.0)], area, 0.0, true).is_err()); + // A block that exactly fills the area fits despite the spacing. + assert!(pack(&[(8.0, 10.5)], area, 0.2, true).is_ok()); + assert!(pack(&vec![(1.0, 1.0); MAX_CELLS + 1], area, 0.0, true).is_err()); + // More pages than allowed. + assert!(pack(&vec![(8.0, 10.5); 51], area, 0.0, true).is_err()); + assert_eq!(pack(&vec![(8.0, 10.5); 50], area, 0.0, true).unwrap().iter().map(|p| p.0).max(), Some(49)); +} + +#[test] +fn packing_is_dense() { + // Four 4x5.25 cells tile a letter page's 8 x 10.5 area exactly. + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + let r = pack(&[(4.0, 5.25); 4], area, 0.0, false).unwrap(); + assert!(r.iter().all(|p| p.0 == 0)); + // Eight 4x6 photos fit two pages (four per page with the rotation trick is impossible: 4x6 x2 per column). + let r = pack(&[(4.0, 6.0); 4], area, 0.0, true).unwrap(); + assert_eq!(r.iter().map(|p| p.0).max(), Some(1)); +} + +#[test] +fn add_cell_fills_gaps_then_opens_pages() { + let mut l = package(); + let a = l.add_cell(3.9, 6.0).unwrap(); + let b = l.add_cell(3.9, 6.0).unwrap(); + assert_eq!(l.cell(a).unwrap().rect.x, 0.25); + assert_ne!(l.cell(a).unwrap().rect, l.cell(b).unwrap().rect); + assert_clean(&l); + // Keep adding until a second page appears. + for _ in 0..6 { + l.add_cell(3.9, 6.0).unwrap(); + } + assert!(l.pages >= 2); + assert_clean(&l); + assert_eq!(l.cells.len(), 8); + assert!(l.add_cell(9.0, 9.0).is_err(), "wider than the printable area"); + let mut grid = Layout::default(); + assert!(grid.add_cell(1.0, 1.0).is_err()); +} + +#[test] +fn cell_cap() { + let mut l = package(); + for _ in 0..MAX_CELLS { + if l.add_cell(0.5, 0.5).is_err() { + break; + } + } + assert!(l.cells.len() <= MAX_CELLS); + let n = l.cells.len(); + if n == MAX_CELLS { + assert!(l.add_cell(0.5, 0.5).is_err()); + } +} + +#[test] +fn move_and_resize_stay_inside() { + let mut l = package(); + let id = l.add_cell(2.0, 3.0).unwrap(); + let r = l.move_cell(id, 100.0, -5.0).unwrap(); + let area = l.printable().unwrap(); + assert!((r.right() - area.right()).abs() < 1e-9 && (r.y - area.y).abs() < 1e-9); + let r = l.resize_cell(id, Rect::new(7.0, 9.0, 50.0, 50.0)).unwrap(); + assert!(area.contains(&r)); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, 0.0, 1.0)).is_err()); + assert!(l.move_cell(77, 0.0, 0.0).is_err()); + l.delete_cell(id).unwrap(); + assert!(l.delete_cell(id).is_err()); +} + +#[test] +fn stacking_order() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let ids: Vec = (0..4).map(|_| l.add_cell(1.0, 1.0).unwrap()).collect(); + let order = |l: &Layout| l.cells.iter().map(|c| c.id).collect::>(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::ToFront).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[0], ids[3]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::ToBack).unwrap(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), ids); + assert!(l.restack(99, Stack::ToBack).is_err()); +} + +#[test] +fn stacking_ignores_other_pages() { + let mut l = Layout { style: Style::CustomPackage, pages: 2, ..Default::default() }; + let a = l.add_cell(1.0, 1.0).unwrap(); + let b = l.add_cell(1.0, 1.0).unwrap(); + let c = l.add_cell(1.0, 1.0).unwrap(); + l.set_cell_page(b, 1).unwrap(); + l.restack(a, Stack::ToFront).unwrap(); + // `a` goes past `c` (same page) and not necessarily past `b`. + let pos = |id| l.cells.iter().position(|x| x.id == id).unwrap(); + assert!(pos(a) > pos(c)); +} + +#[test] +fn overlap_is_allowed_by_move_and_reported() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(3.0, 3.0).unwrap(); + assert!(!l.has_overlaps()); + let r = l.cell(a).unwrap().rect; + l.move_cell(b, r.x + 1.0, r.y + 1.0).unwrap(); + assert!(l.has_overlaps()); + // Touching edges are not an overlap. + l.move_cell(b, r.right(), r.y).unwrap(); + assert!(!l.has_overlaps()); +} + +#[test] +fn auto_layout_repacks_cleanly_and_keeps_photos() { + let mut l = package(); + for (w, h) in [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (8.0, 10.0), (4.0, 6.0), (3.5, 5.0)] { + l.add_cell(w, h).unwrap(); + } + let first = l.cells[0].id; + l.set_cell_photo(first, Some(42)).unwrap(); + // Scramble positions to overlap, then auto layout. + for c in &mut l.cells { + c.rect.x = 0.25; + c.rect.y = 0.25; + c.page = 0; + } + l.pages = 1; + assert!(l.has_overlaps()); + l.auto_layout().unwrap(); + assert_clean(&l); + assert_eq!(l.cell(first).unwrap().photo, Some(42)); + assert_eq!(l.cells.len(), 6); + let again = l.clone(); + let mut l2 = again.clone(); + l2.auto_layout().unwrap(); + l.auto_layout().unwrap(); + assert_eq!(l, l2); +} + +#[test] +fn auto_layout_failure_changes_nothing() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + l.cells.push(Cell { id: 50, page: 0, rect: Rect::new(0.25, 0.25, 40.0, 40.0), photo: None }); + let before = l.clone(); + assert!(l.auto_layout().is_err()); + assert_eq!(l, before); +} + +#[test] +fn clear_and_new_page() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + assert_eq!(l.new_page().unwrap(), 1); + assert_eq!(l.pages, 2); + l.clear(); + assert_eq!((l.cells.len(), l.pages), (0, 1)); + for _ in 1..MAX_PAGES { + l.new_page().unwrap(); + } + assert!(l.new_page().is_err()); + let mut g = Layout::default(); + assert!(g.new_page().is_err()); +} + +#[test] +fn validate_catches_bad_cells() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + let ok = l.clone(); + let mut bad = ok.clone(); + bad.cells[0].rect.w = f64::NAN; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].page = 3; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].rect.x = 50.0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells.push(bad.cells[0].clone()); + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.pages = 0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.next_id = 0; + bad.validate().unwrap(); + assert!(bad.next_id > bad.cells[0].id); + assert!(serde_json::from_str::(r#"{"surprise":1}"#).is_err()); + let mut round: Layout = serde_json::from_str(&serde_json::to_string(&ok).unwrap()).unwrap(); + round.validate().unwrap(); + assert_eq!(round, ok); +} + +#[test] +fn snapping_to_cells_grid_and_margins() { + let mut l = Layout { style: Style::CustomPackage, spacing: 0.0, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(2.0, 2.0).unwrap(); + let ra = l.cell(a).unwrap().rect; + // Left edge of b near the right edge of a. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - ra.right()).abs() < 1e-9); + assert_eq!(m.y, 5.0); + // Too far: untouched. + let m = l.snap_move(b, Rect::new(ra.right() + 0.5, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.5)).abs() < 1e-9); + // Right edge snaps to the printable margin. + let area = l.printable().unwrap(); + let m = l.snap_move(b, Rect::new(area.right() - 2.0 - 0.03, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.right() - area.right()).abs() < 1e-9); + // Off leaves everything. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Off, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.04)).abs() < 1e-9); + // Grid snapping lands on multiples from the printable corner. + let m = l.snap_move(b, Rect::new(1.37, 2.62, 2.0, 2.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(((m.x - area.x) / 0.5).fract().abs() < 1e-9 || (((m.x - area.x) / 0.5).fract() - 1.0).abs() < 1e-9); + // Resize snaps the dragged corner only. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 2.1, 1.9), Snap::Grid, 0.5, 0.2).unwrap(); + assert_eq!((r.x, r.y), (5.0, 5.0)); + assert!(((r.right() - area.x) / 0.5 - ((r.right() - area.x) / 0.5).round()).abs() < 1e-9); + // Never below the minimum size. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 0.0, 0.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(r.w >= MIN_CELL_IN && r.h >= MIN_CELL_IN); + assert!(l.snap_move(99, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, 0.0, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, f64::NAN, 0.1).is_err()); +} + +#[test] +fn fit_cells_follows_the_margins() { + let mut l = package(); + let id = l.add_cell(7.0, 10.0).unwrap(); + l.page.margins = Margins::uniform(1.0); + assert!(l.validate().is_err(), "the cell no longer fits"); + l.fit_cells().unwrap(); + l.validate().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!(l.printable().unwrap().contains(&r)); + assert!((r.w - 6.5).abs() < 1e-9 && (r.h - 9.0).abs() < 1e-9); + l.page.paper = "poster".into(); + assert!(l.fit_cells().is_err()); +} + +#[test] +fn cell_aspect_survives_auto_layout() { + let mut l = package(); + let id = l.add_cell(2.5, 3.5).unwrap(); + l.auto_layout().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!((r.w / r.h - 2.5 / 3.5).abs() < 1e-9 || (r.h / r.w - 2.5 / 3.5).abs() < 1e-9); +} + +#[test] +fn guides_validate() { + assert!(Guides::default().validate().is_ok()); + for bad in [f64::NAN, 0.0, -1.0, 1e9] { + assert!(Guides { grid_size: bad, ..Default::default() }.validate().is_err()); + } +} + +#[test] +fn rect_helpers() { + let a = Rect::new(0.0, 0.0, 2.0, 2.0); + assert!(a.overlaps(&Rect::new(1.0, 1.0, 2.0, 2.0))); + assert!(!a.overlaps(&Rect::new(2.0, 0.0, 1.0, 1.0))); + assert!(a.contains(&Rect::new(0.5, 0.5, 1.0, 1.0))); + assert!(!a.contains(&Rect::new(1.5, 0.5, 1.0, 1.0))); + assert_eq!(a.area(), 4.0); +} From c579f226abfde3a0879fcfac3f68e92bdebe4c3c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:58 +0000 Subject: [PATCH 051/191] refactor(lightcraft-engine): move print/compose.rs tests to crates/engine/src/print/compose/tests.rs --- crates/engine/src/print/compose.rs | 430 +---------------------- crates/engine/src/print/compose/tests.rs | 427 ++++++++++++++++++++++ 2 files changed, 428 insertions(+), 429 deletions(-) create mode 100644 crates/engine/src/print/compose/tests.rs diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs index f4e50703b..d8e1eeb87 100644 --- a/crates/engine/src/print/compose.rs +++ b/crates/engine/src/print/compose.rs @@ -665,432 +665,4 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI } #[cfg(test)] -mod tests { - use super::*; - use crate::print::layout::{GridSpec, Margins}; - - const RED: [u16; 3] = [60000, 1000, 1000]; - const BLUE: [u16; 3] = [1000, 1000, 60000]; - const WHITE: [u16; 3] = [65535; 3]; - - /// A `w` x `h` image whose left half is red and right half blue, with a green top row. - fn halves(w: usize, h: usize) -> Image16 { - let mut img = Image16::filled(w, h, RED).unwrap(); - img.fill_rect(w / 2, 0, w - w / 2, h, BLUE); - img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]); - img - } - - fn style() -> CellStyle { - CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] } - } - - fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] { - img.px(x, y).unwrap() - } - - fn page() -> Image16 { - Image16::filled(100, 100, WHITE).unwrap() - } - - #[test] - fn placement_fit_fill_and_rotation() { - // 4:3 photo into a 2:1 area. - let fit = place(400.0, 300.0, 200, 100, false, false).unwrap(); - assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false)); - let fill = place(400.0, 300.0, 200, 100, true, false).unwrap(); - assert_eq!((fill.w, fill.h), (200, 150)); - // A portrait photo in a landscape area: turning prints it larger. - assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 }); - let turned = place(300.0, 400.0, 400, 300, false, true).unwrap(); - assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300)); - assert_eq!(turned.unturned(), (300, 400)); - // Filling a landscape cell with a portrait photo crops less when turned. - let f = place(300.0, 400.0, 400, 300, true, true).unwrap(); - assert!(f.rotated && f.w >= 400 && f.h >= 300); - // Matching orientation never turns. - assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated); - assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated); - // Hostile input. - for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] { - assert!(place(bad, 10.0, 10, 10, false, false).is_none()); - assert!(place(10.0, bad, 10, 10, true, true).is_none()); - } - assert!(place(10.0, 10.0, 0, 10, false, false).is_none()); - } - - #[test] - fn fit_centres_the_photo() { - let mut p = page(); - draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap(); - // 40x20 scaled to 80x40, centred vertically at y 30..70. - assert_eq!(at(&p, 10, 50), RED); - assert_eq!(at(&p, 89, 50), BLUE); - assert_eq!(at(&p, 50, 20), WHITE, "above the photo"); - assert_eq!(at(&p, 50, 75), WHITE, "below the photo"); - assert_eq!(at(&p, 5, 50), WHITE, "outside the cell"); - } - - #[test] - fn zoom_to_fill_crops_the_centre() { - let mut p = page(); - let s = CellStyle { fill: true, ..style() }; - draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap(); - // The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell. - for y in [10, 50, 89] { - assert_ne!(at(&p, 10, y), WHITE); - assert_ne!(at(&p, 89, y), WHITE); - } - assert_eq!(at(&p, 20, 50), RED); - assert_eq!(at(&p, 80, 50), BLUE); - // Nothing leaks outside the cell. - assert_eq!(at(&p, 9, 50), WHITE); - assert_eq!(at(&p, 90, 50), WHITE); - assert_eq!(at(&p, 50, 9), WHITE); - assert_eq!(at(&p, 50, 90), WHITE); - } - - #[test] - fn rotate_to_fit_turns_clockwise() { - // A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell. - let mut p = page(); - let cell = PxRect { x: 10, y: 10, w: 80, h: 40 }; - draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap(); - assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle"); - assert_eq!(at(&p, 45, 30), RED); - let mut q = page(); - draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap(); - // Turned clockwise the left half (red) becomes the top half and the photo spans the cell. - assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12)); - assert_eq!(at(&q, 12, 48), BLUE); - assert_eq!(at(&q, 50, 20), RED); - assert_eq!(at(&q, 50, 40), BLUE); - assert_eq!(at(&q, 11, 30), at(&q, 11, 30)); - } - - #[test] - fn border_frames_the_photo_inside_the_cell() { - let mut p = page(); - let green = [100, 60000, 100]; - let s = CellStyle { border_px: 5, border: green, fill: true, ..style() }; - draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap(); - assert_eq!(at(&p, 10, 40), green); - assert_eq!(at(&p, 14, 40), green); - assert_eq!(at(&p, 15, 40), RED); - assert_eq!(at(&p, 69, 40), green); - assert_eq!(at(&p, 64, 40), BLUE); - assert_eq!(at(&p, 40, 10), green); - assert_eq!(at(&p, 40, 69), green); - // A fitted photo gets the border hugging it, not the whole cell. - let mut q = page(); - let s = CellStyle { border_px: 5, border: green, ..style() }; - draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap(); - // The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick. - assert_eq!(at(&q, 50, 23), green); - assert_eq!(at(&q, 50, 22), green); - assert_eq!(at(&q, 50, 21), WHITE); - // A border wider than the cell leaves the photo visible. - let mut r = page(); - let s = CellStyle { border_px: 500, border: green, ..style() }; - draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap(); - assert_ne!(at(&r, 20, 20), WHITE); - } - - #[test] - fn stroke_is_drawn_inside_the_photo_edge() { - let mut p = page(); - let black = [0, 0, 0]; - let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() }; - draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap(); - for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] { - assert_eq!(at(&p, x, y), black, "({x}, {y})"); - } - assert_eq!(at(&p, 23, 40), RED); - assert_eq!(at(&p, 76, 40), BLUE); - assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo"); - // Corners are covered exactly once and a huge stroke stays within the photo. - let mut q = page(); - let s = CellStyle { stroke_px: 1000, stroke: black, ..style() }; - draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap(); - assert_eq!(at(&q, 40, 40), black); - assert_eq!(at(&q, 19, 40), WHITE); - assert_eq!(at(&q, 60, 40), WHITE); - } - - #[test] - fn colours_are_rec2020_encoded() { - assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]); - assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]); - let grey = rec2020_from_srgb8([128; 3]); - assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3); - // sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero. - let red = rec2020_from_srgb8([255, 0, 0]); - assert!(red[0] < 65535 && red[1] > 0); - } - - #[test] - fn resize_preserves_flat_colour_and_edges() { - let img = halves(64, 32); - for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] { - let r = img.resized(w, h).unwrap(); - assert_eq!((r.w, r.h), (w, h)); - assert_eq!(r.rgb.len(), w * h * 3); - } - let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap(); - assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999])); - assert!(img.resized(0, 5).is_err()); - let r = img.resized(32, 16).unwrap(); - assert_eq!(at(&r, 4, 8), RED); - assert_eq!(at(&r, 28, 8), BLUE); - } - - #[test] - fn rotate_and_blit_are_clipped() { - let img = halves(3, 2); - let r = img.rotated_cw().unwrap(); - assert_eq!((r.w, r.h), (2, 3)); - let mut p = page(); - p.blit(&img, 0, 0, 99, 99, 10, 10); - assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); - p.blit(&img, 5, 5, 0, 0, 3, 3); - p.fill_rect(95, 95, 50, 50, RED); - assert_eq!(at(&p, 99, 99), RED); - p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED); - } - - fn settings(style: Style) -> PrintSettings { - let mut s = PrintSettings::default(); - s.layout.style = style; - s.layout.page.paper = "4x6".into(); - s.layout.page.margins = Margins::uniform(0.5); - s.job.ppi = 100.0; - s - } - - #[test] - fn plans_follow_the_layout_style() { - let ids: Vec = (1..=5).map(PhotoId).collect(); - let mut s = settings(Style::ContactSheet); - s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() }; - let plan = plan_cells(&s.layout, &ids).unwrap(); - assert_eq!(plan.iter().map(Vec::len).collect::>(), vec![4, 1]); - assert_eq!(plan[1][0].1, PhotoId(5)); - let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap(); - assert_eq!(one.len(), 5); - assert!(plan_cells(&s.layout, &[]).is_err()); - assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err()); - - let mut pkg = settings(Style::PicturePackage); - assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet"); - pkg.layout.add_cell(1.0, 1.0).unwrap(); - pkg.layout.add_cell(2.0, 2.0).unwrap(); - let plan = plan_cells(&pkg.layout, &ids).unwrap(); - assert_eq!(plan.len(), 1); - assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo"); - - let mut custom = settings(Style::CustomPackage); - let a = custom.layout.add_cell(1.0, 1.0).unwrap(); - custom.layout.add_cell(1.0, 1.0).unwrap(); - custom.layout.set_cell_photo(a, Some(3)).unwrap(); - let plan = plan_cells(&custom.layout, &ids).unwrap(); - assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1))); - let blank_page = { - custom.layout.new_page().unwrap(); - plan_cells(&custom.layout, &ids).unwrap() - }; - assert_eq!(blank_page.iter().map(Vec::len).collect::>(), vec![2, 0]); - } - - fn first_ids(s: &Session, n: usize) -> Vec { - s.catalog.photos().take(n).map(|p| p.id).collect() - } - - #[test] - fn composes_pages_from_real_photos() { - let mut s = Session::with_demo(); - let ids = first_ids(&s, 3); - let mut set = settings(Style::ContactSheet); - set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false }; - set.page.background = [200, 30, 30]; - set.image.zoom_to_fill = true; - set.image.stroke_enabled = true; - set.image.stroke_width_pt = 2.0; - set.image.stroke_color = [0, 0, 0]; - let work = prepare(&mut s, &set, &ids).unwrap(); - assert_eq!((work.len(), work.pages()), (3, 2)); - let mut steps = Vec::new(); - let pages = work - .run(&mut |i, name| { - steps.push((i, name.to_string())); - true - }) - .unwrap(); - assert_eq!(steps.iter().map(|s| s.0).collect::>(), vec![0, 1, 2, 3]); - assert_eq!(pages.len(), 2); - let first = &pages[0]; - assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0)); - assert_eq!(first.rgb.len(), 400 * 600 * 3); - let bg = rec2020_from_srgb8([200, 30, 30]); - let px = |p: &PageRaster, x: usize, y: usize| { - let i = (y * p.width as usize + x) * 3; - [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] - }; - assert_eq!(px(first, 5, 5), bg, "margin shows the page background"); - assert_eq!(px(first, 395, 595), bg); - // Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour. - assert_eq!(px(first, 50, 50), [0, 0, 0]); - let centre = px(first, 200, 150); - assert_ne!(centre, bg); - assert_ne!(centre, [0, 0, 0]); - // The second page holds the third photo in the first cell only: the second cell is bare. - let second = &pages[1]; - assert_ne!(px(second, 200, 150), bg); - assert_eq!(px(second, 200, 450), bg); - // Cancelling gives no pages. - let work = prepare(&mut s, &set, &ids).unwrap(); - assert!(work.run(&mut |i, _| i < 1).is_err()); - } - - #[test] - fn package_renders_each_size_and_border() { - let mut s = Session::with_demo(); - let ids = first_ids(&s, 1); - let mut set = settings(Style::PicturePackage); - set.layout.add_cell(1.5, 1.0).unwrap(); - set.layout.add_cell(1.5, 1.0).unwrap(); - set.image.border_enabled = true; - set.image.border_width = 0.1; - set.image.border_color = [10, 200, 10]; - let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap(); - assert_eq!(pages.len(), 1); - let green = rec2020_from_srgb8([10, 200, 10]); - let cell = set.layout.cells[0].rect; - let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize); - let at = |x: usize, y: usize| { - let i = (y * 400 + x) * 3; - pages[0].rgb[i..i + 3].to_vec() - }; - // The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round. - let mid = (x0 + 75, y0 + 50); - assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle"); - let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count(); - assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); - } - - /// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the - /// margins are bare background unless something draws there). - fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec { - let ids = first_ids(s, 2); - let mut set = settings(style); - if style.is_package() { - set.layout.add_cell(3.0, 5.0).unwrap(); - set.layout.new_page().unwrap(); - let id = set.layout.add_cell(3.0, 5.0).unwrap(); - set.layout.set_cell_page(id, 1).unwrap(); - } - set.image.zoom_to_fill = true; - set.page = page; - prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap() - } - - fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] { - let i = (y * p.width as usize + x) * 3; - [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] - } - - /// The pixels of `x0..x1` x `y0..y1` that are not the white background. - fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize { - (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count() - } - - #[test] - fn crop_marks_sit_outside_each_photo_corner() { - let mut s = Session::with_demo(); - let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default()); - assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked"); - let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() }); - // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner, - // along each edge of the photo. - for p in &pages { - for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] { - assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})"); - } - for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] { - assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})"); - } - } - // a dark page gets white marks - let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() }); - assert_eq!(rgb(&dark[0], 30, 50), WHITE); - } - - #[test] - fn page_numbers_and_info_print_in_the_bottom_margin() { - let mut s = Session::with_demo(); - let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() }); - // bottom right: the number, different on each page; nothing at the bottom left - let (right, left) = ((200, 400), (0, 200)); - for p in &numbered { - assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number"); - assert_eq!(inked(p, left.0, left.1, 550, 600), 0); - assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top"); - } - let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::>(); - assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently"); - let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() }); - for p in &info { - assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line"); - } - // the packages have no Page Options in Lightroom: nothing is drawn - let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() }; - let package = page_options_pages(&mut s, Style::PicturePackage, all); - assert_eq!(package.len(), 2); - for p in &package { - assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0); - } - } - - #[test] - fn page_info_names_the_sharpening_profile_and_resolution() { - use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; - let mut set = PrintSettings::default(); - let line = page_info_text(&set); - assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi"); - set.job.color.sharpen = PrintSharpen::Off; - set.job.color.media = PrintMedia::Matte; - set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() }; - set.job.ppi = 300.0; - assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi"); - set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb }; - assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)")); - } - - #[test] - fn caps_and_hostile_settings_are_refused() { - let mut s = Session::with_demo(); - let ids = first_ids(&s, 1); - let mut set = settings(Style::SingleImage); - set.layout.page.paper = "custom".into(); - set.layout.page.custom_width = 60.0; - set.layout.page.custom_height = 60.0; - set.job.ppi = 720.0; - assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap"); - let mut set = settings(Style::SingleImage); - set.job.ppi = f32::NAN; - assert!(prepare(&mut s, &set, &ids).is_err()); - let mut set = settings(Style::SingleImage); - set.image.border_width = f64::NAN; - assert!(prepare(&mut s, &set, &ids).is_err()); - set.image.border_width = 0.1; - set.image.stroke_width_pt = -1.0; - assert!(prepare(&mut s, &set, &ids).is_err()); - assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err()); - assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err()); - let set = settings(Style::SingleImage); - assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages"); - // 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap. - let mut big = settings(Style::SingleImage); - big.job.ppi = 720.0; - assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err()); - } -} +mod tests; diff --git a/crates/engine/src/print/compose/tests.rs b/crates/engine/src/print/compose/tests.rs new file mode 100644 index 000000000..cdec92799 --- /dev/null +++ b/crates/engine/src/print/compose/tests.rs @@ -0,0 +1,427 @@ +use super::*; +use crate::print::layout::{GridSpec, Margins}; + +const RED: [u16; 3] = [60000, 1000, 1000]; +const BLUE: [u16; 3] = [1000, 1000, 60000]; +const WHITE: [u16; 3] = [65535; 3]; + +/// A `w` x `h` image whose left half is red and right half blue, with a green top row. +fn halves(w: usize, h: usize) -> Image16 { + let mut img = Image16::filled(w, h, RED).unwrap(); + img.fill_rect(w / 2, 0, w - w / 2, h, BLUE); + img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]); + img +} + +fn style() -> CellStyle { + CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] } +} + +fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] { + img.px(x, y).unwrap() +} + +fn page() -> Image16 { + Image16::filled(100, 100, WHITE).unwrap() +} + +#[test] +fn placement_fit_fill_and_rotation() { + // 4:3 photo into a 2:1 area. + let fit = place(400.0, 300.0, 200, 100, false, false).unwrap(); + assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false)); + let fill = place(400.0, 300.0, 200, 100, true, false).unwrap(); + assert_eq!((fill.w, fill.h), (200, 150)); + // A portrait photo in a landscape area: turning prints it larger. + assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 }); + let turned = place(300.0, 400.0, 400, 300, false, true).unwrap(); + assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300)); + assert_eq!(turned.unturned(), (300, 400)); + // Filling a landscape cell with a portrait photo crops less when turned. + let f = place(300.0, 400.0, 400, 300, true, true).unwrap(); + assert!(f.rotated && f.w >= 400 && f.h >= 300); + // Matching orientation never turns. + assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated); + assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated); + // Hostile input. + for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] { + assert!(place(bad, 10.0, 10, 10, false, false).is_none()); + assert!(place(10.0, bad, 10, 10, true, true).is_none()); + } + assert!(place(10.0, 10.0, 0, 10, false, false).is_none()); +} + +#[test] +fn fit_centres_the_photo() { + let mut p = page(); + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap(); + // 40x20 scaled to 80x40, centred vertically at y 30..70. + assert_eq!(at(&p, 10, 50), RED); + assert_eq!(at(&p, 89, 50), BLUE); + assert_eq!(at(&p, 50, 20), WHITE, "above the photo"); + assert_eq!(at(&p, 50, 75), WHITE, "below the photo"); + assert_eq!(at(&p, 5, 50), WHITE, "outside the cell"); +} + +#[test] +fn zoom_to_fill_crops_the_centre() { + let mut p = page(); + let s = CellStyle { fill: true, ..style() }; + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap(); + // The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell. + for y in [10, 50, 89] { + assert_ne!(at(&p, 10, y), WHITE); + assert_ne!(at(&p, 89, y), WHITE); + } + assert_eq!(at(&p, 20, 50), RED); + assert_eq!(at(&p, 80, 50), BLUE); + // Nothing leaks outside the cell. + assert_eq!(at(&p, 9, 50), WHITE); + assert_eq!(at(&p, 90, 50), WHITE); + assert_eq!(at(&p, 50, 9), WHITE); + assert_eq!(at(&p, 50, 90), WHITE); +} + +#[test] +fn rotate_to_fit_turns_clockwise() { + // A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell. + let mut p = page(); + let cell = PxRect { x: 10, y: 10, w: 80, h: 40 }; + draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap(); + assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle"); + assert_eq!(at(&p, 45, 30), RED); + let mut q = page(); + draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap(); + // Turned clockwise the left half (red) becomes the top half and the photo spans the cell. + assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12)); + assert_eq!(at(&q, 12, 48), BLUE); + assert_eq!(at(&q, 50, 20), RED); + assert_eq!(at(&q, 50, 40), BLUE); + assert_eq!(at(&q, 11, 30), at(&q, 11, 30)); +} + +#[test] +fn border_frames_the_photo_inside_the_cell() { + let mut p = page(); + let green = [100, 60000, 100]; + let s = CellStyle { border_px: 5, border: green, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap(); + assert_eq!(at(&p, 10, 40), green); + assert_eq!(at(&p, 14, 40), green); + assert_eq!(at(&p, 15, 40), RED); + assert_eq!(at(&p, 69, 40), green); + assert_eq!(at(&p, 64, 40), BLUE); + assert_eq!(at(&p, 40, 10), green); + assert_eq!(at(&p, 40, 69), green); + // A fitted photo gets the border hugging it, not the whole cell. + let mut q = page(); + let s = CellStyle { border_px: 5, border: green, ..style() }; + draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap(); + // The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick. + assert_eq!(at(&q, 50, 23), green); + assert_eq!(at(&q, 50, 22), green); + assert_eq!(at(&q, 50, 21), WHITE); + // A border wider than the cell leaves the photo visible. + let mut r = page(); + let s = CellStyle { border_px: 500, border: green, ..style() }; + draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap(); + assert_ne!(at(&r, 20, 20), WHITE); +} + +#[test] +fn stroke_is_drawn_inside_the_photo_edge() { + let mut p = page(); + let black = [0, 0, 0]; + let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap(); + for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] { + assert_eq!(at(&p, x, y), black, "({x}, {y})"); + } + assert_eq!(at(&p, 23, 40), RED); + assert_eq!(at(&p, 76, 40), BLUE); + assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo"); + // Corners are covered exactly once and a huge stroke stays within the photo. + let mut q = page(); + let s = CellStyle { stroke_px: 1000, stroke: black, ..style() }; + draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap(); + assert_eq!(at(&q, 40, 40), black); + assert_eq!(at(&q, 19, 40), WHITE); + assert_eq!(at(&q, 60, 40), WHITE); +} + +#[test] +fn colours_are_rec2020_encoded() { + assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]); + assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]); + let grey = rec2020_from_srgb8([128; 3]); + assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3); + // sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero. + let red = rec2020_from_srgb8([255, 0, 0]); + assert!(red[0] < 65535 && red[1] > 0); +} + +#[test] +fn resize_preserves_flat_colour_and_edges() { + let img = halves(64, 32); + for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] { + let r = img.resized(w, h).unwrap(); + assert_eq!((r.w, r.h), (w, h)); + assert_eq!(r.rgb.len(), w * h * 3); + } + let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap(); + assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999])); + assert!(img.resized(0, 5).is_err()); + let r = img.resized(32, 16).unwrap(); + assert_eq!(at(&r, 4, 8), RED); + assert_eq!(at(&r, 28, 8), BLUE); +} + +#[test] +fn rotate_and_blit_are_clipped() { + let img = halves(3, 2); + let r = img.rotated_cw().unwrap(); + assert_eq!((r.w, r.h), (2, 3)); + let mut p = page(); + p.blit(&img, 0, 0, 99, 99, 10, 10); + assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); + p.blit(&img, 5, 5, 0, 0, 3, 3); + p.fill_rect(95, 95, 50, 50, RED); + assert_eq!(at(&p, 99, 99), RED); + p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED); +} + +fn settings(style: Style) -> PrintSettings { + let mut s = PrintSettings::default(); + s.layout.style = style; + s.layout.page.paper = "4x6".into(); + s.layout.page.margins = Margins::uniform(0.5); + s.job.ppi = 100.0; + s +} + +#[test] +fn plans_follow_the_layout_style() { + let ids: Vec = (1..=5).map(PhotoId).collect(); + let mut s = settings(Style::ContactSheet); + s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() }; + let plan = plan_cells(&s.layout, &ids).unwrap(); + assert_eq!(plan.iter().map(Vec::len).collect::>(), vec![4, 1]); + assert_eq!(plan[1][0].1, PhotoId(5)); + let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap(); + assert_eq!(one.len(), 5); + assert!(plan_cells(&s.layout, &[]).is_err()); + assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err()); + + let mut pkg = settings(Style::PicturePackage); + assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet"); + pkg.layout.add_cell(1.0, 1.0).unwrap(); + pkg.layout.add_cell(2.0, 2.0).unwrap(); + let plan = plan_cells(&pkg.layout, &ids).unwrap(); + assert_eq!(plan.len(), 1); + assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo"); + + let mut custom = settings(Style::CustomPackage); + let a = custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.set_cell_photo(a, Some(3)).unwrap(); + let plan = plan_cells(&custom.layout, &ids).unwrap(); + assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1))); + let blank_page = { + custom.layout.new_page().unwrap(); + plan_cells(&custom.layout, &ids).unwrap() + }; + assert_eq!(blank_page.iter().map(Vec::len).collect::>(), vec![2, 0]); +} + +fn first_ids(s: &Session, n: usize) -> Vec { + s.catalog.photos().take(n).map(|p| p.id).collect() +} + +#[test] +fn composes_pages_from_real_photos() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 3); + let mut set = settings(Style::ContactSheet); + set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false }; + set.page.background = [200, 30, 30]; + set.image.zoom_to_fill = true; + set.image.stroke_enabled = true; + set.image.stroke_width_pt = 2.0; + set.image.stroke_color = [0, 0, 0]; + let work = prepare(&mut s, &set, &ids).unwrap(); + assert_eq!((work.len(), work.pages()), (3, 2)); + let mut steps = Vec::new(); + let pages = work + .run(&mut |i, name| { + steps.push((i, name.to_string())); + true + }) + .unwrap(); + assert_eq!(steps.iter().map(|s| s.0).collect::>(), vec![0, 1, 2, 3]); + assert_eq!(pages.len(), 2); + let first = &pages[0]; + assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0)); + assert_eq!(first.rgb.len(), 400 * 600 * 3); + let bg = rec2020_from_srgb8([200, 30, 30]); + let px = |p: &PageRaster, x: usize, y: usize| { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] + }; + assert_eq!(px(first, 5, 5), bg, "margin shows the page background"); + assert_eq!(px(first, 395, 595), bg); + // Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour. + assert_eq!(px(first, 50, 50), [0, 0, 0]); + let centre = px(first, 200, 150); + assert_ne!(centre, bg); + assert_ne!(centre, [0, 0, 0]); + // The second page holds the third photo in the first cell only: the second cell is bare. + let second = &pages[1]; + assert_ne!(px(second, 200, 150), bg); + assert_eq!(px(second, 200, 450), bg); + // Cancelling gives no pages. + let work = prepare(&mut s, &set, &ids).unwrap(); + assert!(work.run(&mut |i, _| i < 1).is_err()); +} + +#[test] +fn package_renders_each_size_and_border() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::PicturePackage); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.image.border_enabled = true; + set.image.border_width = 0.1; + set.image.border_color = [10, 200, 10]; + let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap(); + assert_eq!(pages.len(), 1); + let green = rec2020_from_srgb8([10, 200, 10]); + let cell = set.layout.cells[0].rect; + let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize); + let at = |x: usize, y: usize| { + let i = (y * 400 + x) * 3; + pages[0].rgb[i..i + 3].to_vec() + }; + // The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round. + let mid = (x0 + 75, y0 + 50); + assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle"); + let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count(); + assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); +} + +/// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the +/// margins are bare background unless something draws there). +fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec { + let ids = first_ids(s, 2); + let mut set = settings(style); + if style.is_package() { + set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.new_page().unwrap(); + let id = set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.set_cell_page(id, 1).unwrap(); + } + set.image.zoom_to_fill = true; + set.page = page; + prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap() +} + +fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] +} + +/// The pixels of `x0..x1` x `y0..y1` that are not the white background. +fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize { + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count() +} + +#[test] +fn crop_marks_sit_outside_each_photo_corner() { + let mut s = Session::with_demo(); + let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default()); + assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked"); + let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() }); + // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner, + // along each edge of the photo. + for p in &pages { + for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] { + assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})"); + } + for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] { + assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})"); + } + } + // a dark page gets white marks + let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() }); + assert_eq!(rgb(&dark[0], 30, 50), WHITE); +} + +#[test] +fn page_numbers_and_info_print_in_the_bottom_margin() { + let mut s = Session::with_demo(); + let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() }); + // bottom right: the number, different on each page; nothing at the bottom left + let (right, left) = ((200, 400), (0, 200)); + for p in &numbered { + assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number"); + assert_eq!(inked(p, left.0, left.1, 550, 600), 0); + assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top"); + } + let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::>(); + assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently"); + let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() }); + for p in &info { + assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line"); + } + // the packages have no Page Options in Lightroom: nothing is drawn + let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() }; + let package = page_options_pages(&mut s, Style::PicturePackage, all); + assert_eq!(package.len(), 2); + for p in &package { + assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0); + } +} + +#[test] +fn page_info_names_the_sharpening_profile_and_resolution() { + use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; + let mut set = PrintSettings::default(); + let line = page_info_text(&set); + assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi"); + set.job.color.sharpen = PrintSharpen::Off; + set.job.color.media = PrintMedia::Matte; + set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() }; + set.job.ppi = 300.0; + assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi"); + set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb }; + assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)")); +} + +#[test] +fn caps_and_hostile_settings_are_refused() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::SingleImage); + set.layout.page.paper = "custom".into(); + set.layout.page.custom_width = 60.0; + set.layout.page.custom_height = 60.0; + set.job.ppi = 720.0; + assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap"); + let mut set = settings(Style::SingleImage); + set.job.ppi = f32::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + let mut set = settings(Style::SingleImage); + set.image.border_width = f64::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + set.image.border_width = 0.1; + set.image.stroke_width_pt = -1.0; + assert!(prepare(&mut s, &set, &ids).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err()); + let set = settings(Style::SingleImage); + assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages"); + // 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap. + let mut big = settings(Style::SingleImage); + big.job.ppi = 720.0; + assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err()); +} From 5d1053ffe6eddad44799907e077ce0fbafd57917 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:58 +0000 Subject: [PATCH 052/191] refactor(lightcraft-geom): move crop_drag.rs tests to crates/geom/src/crop_drag/tests.rs --- crates/geom/src/crop_drag.rs | 306 +---------------------------- crates/geom/src/crop_drag/tests.rs | 303 ++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+), 305 deletions(-) create mode 100644 crates/geom/src/crop_drag/tests.rs diff --git a/crates/geom/src/crop_drag.rs b/crates/geom/src/crop_drag.rs index 3e8f2484f..cdffa7cce 100644 --- a/crates/geom/src/crop_drag.rs +++ b/crates/geom/src/crop_drag.rs @@ -333,308 +333,4 @@ fn bisect(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 { } #[cfg(test)] -mod tests { - use super::*; - use CropHandle::*; - - const W: f64 = 600.0; - const H: f64 = 400.0; - const EPS: f64 = 1e-6; - - fn geo(x0: f64, y0: f64, x1: f64, y1: f64) -> CropGeometry { - CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle: 0.0 } - } - fn p(x: f64, y: f64) -> Point { - Point::new(x, y) - } - fn drag(g: CropGeometry, hd: CropHandle, from: Point, to: Point, aspect: Option) -> CropGeometry { - drag_crop(g, hd, from, to, W, H, aspect) - } - fn px_aspect(g: &CropGeometry) -> f64 { - g.rect.width() * W / (g.rect.height() * H) - } - fn near(a: f64, b: f64) -> bool { - (a - b).abs() < EPS - } - - // Feature: dragging a crop handle - // Scenario: a free corner drag inside the image - #[test] - fn given_free_crop_when_corner_dragged_inside_then_corner_follows_and_opposite_corner_stays() { - let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(0.9, 0.7), None); - assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2)); - assert!(near(g.rect.x1, 0.9) && near(g.rect.y1, 0.7)); - } - - // Scenario: dragging past the right edge (the weird behaviour of issue 295) - #[test] - fn given_corner_dragged_past_image_edge_then_it_stops_at_the_edge_and_anchor_stays() { - let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(1.3, 0.7), None); - assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "anchor moved: {:?}", g.rect); - assert!(near(g.rect.x1, 1.0), "stops at the edge: {:?}", g.rect); - assert!(near(g.rect.y1, 0.7), "other axis still follows the pointer: {:?}", g.rect); - } - - #[test] - fn given_locked_aspect_when_corner_dragged_past_edge_then_ratio_and_anchor_hold() { - let a = 1.5; - let g = drag(geo(0.2, 0.2, 0.8, 0.2 + 0.6 * W / H / a), BottomRight, p(0.8, 0.6), p(1.4, 0.9), Some(a)); - assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "{:?}", g.rect); - assert!(near(px_aspect(&g), a), "{}", px_aspect(&g)); - assert!(g.rect.x1 <= 1.0 + EPS && g.rect.y1 <= 1.0 + EPS); - assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "grows until it touches an edge: {:?}", g.rect); - } - - #[test] - fn given_locked_aspect_when_edge_dragged_then_ratio_holds_and_frame_grows_about_its_midline() { - let a = 2.0; - let start = geo(0.3, 0.3, 0.7, 0.3 + 0.4 * W / H / a); - let g = drag(start, Bottom, p(0.5, start.rect.y1), p(0.5, start.rect.y1 + 0.1), Some(a)); - assert!(near(g.rect.y0, start.rect.y0), "opposite edge stays"); - assert!(near(g.rect.center().x, start.rect.center().x), "grows symmetrically sideways"); - assert!(near(px_aspect(&g), a)); - assert!(g.rect.height() > start.rect.height()); - } - - #[test] - fn given_free_crop_when_edge_dragged_then_only_that_edge_moves() { - let g = drag(geo(0.2, 0.2, 0.8, 0.8), Left, p(0.2, 0.5), p(0.1, 0.9), None); - assert!(near(g.rect.x0, 0.1) && near(g.rect.y0, 0.2) && near(g.rect.x1, 0.8) && near(g.rect.y1, 0.8)); - } - - #[test] - fn given_move_past_edge_then_size_is_kept_and_frame_slides_along_the_edge() { - let start = geo(0.5, 0.25, 0.9, 0.65); - let g = drag(start, Move, p(0.7, 0.45), p(1.3, 0.55), None); - assert!(near(g.rect.width(), 0.4) && near(g.rect.height(), 0.4)); - assert!(near(g.rect.x1, 1.0), "flush with the right edge: {:?}", g.rect); - assert!(near(g.rect.y0, 0.35), "still follows the pointer vertically: {:?}", g.rect); - } - - #[test] - fn given_edge_dragged_across_the_opposite_edge_then_crop_keeps_a_minimum_size() { - let g = drag(geo(0.2, 0.2, 0.8, 0.8), Right, p(0.8, 0.5), p(-0.5, 0.5), None); - assert!(g.rect.width() * W >= MIN_CROP_FRACTION * H - EPS && g.rect.x1 > g.rect.x0); - assert!(near(g.rect.x0, 0.2)); - } - - #[test] - fn given_straightened_image_when_dragged_outside_then_crop_stays_inside_the_rotated_image() { - let start = crate::crop_fit_angle(W, H, 12.0, Some(1.5)); - let c = start.rect.center(); - for hd in [TopLeft, TopRight, BottomRight, BottomLeft, Top, Right, Bottom, Left, Move] { - for aspect in [None, Some(1.5)] { - let g = drag(start, hd, c, p(3.0, -3.0), aspect); - assert!(g.is_within_image(W, H), "{hd:?} {aspect:?}: {:?}", g.rect); - assert_eq!(g.angle, 12.0); - } - } - } - - #[test] - fn given_hostile_numbers_then_the_start_crop_is_returned_not_a_panic() { - let s = geo(0.2, 0.2, 0.8, 0.8); - for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(bad, 0.5), None), s); - } - assert_eq!(drag_crop(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), 0.0, 400.0, None), s); - assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(f64::NAN)), s); - assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(-1.0)), s); - } - - // Scenario: a locked edge drag when the frame already touches the image edge (review finding) - #[test] - fn given_locked_frame_flush_with_the_top_when_right_edge_dragged_out_then_it_grows_sliding_down() { - let a = 1.5; - let start = geo(0.2, 0.0, 0.5, 0.3 * W / H * 0.0 + 0.3 * W / (a * H)); - let g = drag(start, Right, p(0.5, 0.2), p(0.9, 0.2), Some(a)); - assert!(near(g.rect.x0, 0.2), "anchor edge stays: {:?}", g.rect); - assert!(g.rect.width() > start.rect.width() + 0.05, "it must grow: {:?}", g.rect); - assert!(near(px_aspect(&g), a)); - assert!(g.is_within_image(W, H)); - } - - #[test] - fn given_full_fit_straightened_crop_when_locked_edge_dragged_then_it_still_respects_the_image() { - let start = crate::crop_fit_angle(W, H, 20.0, Some(1.5)); - let g = drag(start, Right, p(0.9, 0.5), p(1.5, 0.5), Some(1.5)); - assert!(g.is_within_image(W, H) && near(px_aspect(&g), 1.5)); - } - - // Scenario: extreme or hostile magnitudes - #[test] - fn given_an_extreme_locked_ratio_then_the_frame_can_still_be_moved() { - let start = geo(0.1, 0.4, 0.9, 0.4 + 0.8 * W / (60.0 * H)); - let g = drag(start, Move, p(0.5, 0.4), p(0.5, 0.7), Some(60.0)); - assert!(g.rect.y0 > 0.5, "moved: {:?}", g.rect); - } - - #[test] - fn given_a_huge_finite_pointer_then_a_locked_corner_still_grows_to_the_edge() { - let a = 1.5; - let start = geo(0.2, 0.2, 0.5, 0.2 + 0.3 * W / (a * H)); - let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), Some(a)); - assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "{:?}", g.rect); - let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), None); - assert!(near(g.rect.x1, 1.0) && near(g.rect.y1, 1.0), "{:?}", g.rect); - } - - #[test] - fn given_an_unrotated_image_then_results_never_leave_the_unit_square() { - let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(5.0, 5.0), None); - assert!(g.rect.x1 <= 1.0 && g.rect.y1 <= 1.0 && g.rect.x0 >= 0.0 && g.rect.y0 >= 0.0, "{:?}", g.rect); - } - - #[test] - fn handle_numbering_matches_the_overlay() { - assert_eq!(CropHandle::from_index(0), Some(TopLeft)); - assert_eq!(CropHandle::from_index(6), Some(Bottom)); - assert_eq!(CropHandle::from_index(8), Some(Move)); - assert_eq!(CropHandle::from_index(9), None); - assert_eq!(CropHandle::from_name("bottomLeft"), Some(BottomLeft)); - assert_eq!(CropHandle::from_name("nope"), None); - } - - mod props { - use super::*; - use proptest::prelude::*; - - fn any_handle() -> impl Strategy { - (0u8..9).prop_map(|i| CropHandle::from_index(i).unwrap_or(Move)) - } - - proptest! { - /// Whatever the pointer does, the crop stays valid; the anchor and the ratio hold. - #[test] - fn drag_is_always_inside_anchored_and_shaped( - angle in -45.0f64..45.0, - ratio in prop::option::of(0.3f64..4.0), - hd in any_handle(), - tx in -4.0f64..5.0, ty in -4.0f64..5.0, - shrink in 0.2f64..1.0, - ) { - let base = crate::crop_fit_angle(W, H, angle, ratio); - // a smaller start crop so there is room to move - let c = base.rect.center(); - let start = CropGeometry { rect: Rect::from_center(c, base.rect.width() * shrink, base.rect.height() * shrink), angle }; - prop_assume!(start.is_within_image(W, H)); - let g = drag_crop(start, hd, c, p(tx, ty), W, H, ratio); - prop_assert!(g.is_within_image(W, H), "{:?} -> {:?}", start.rect, g.rect); - prop_assert!(g.rect.width() > 0.0 && g.rect.height() > 0.0); - let min = MIN_CROP_FRACTION * W.min(H) - 1e-6; - prop_assert!(g.rect.width() * W >= min.min(start.rect.width() * W) && g.rect.height() * H >= min.min(start.rect.height() * H), "min size {:?}", g.rect); - if let Some(a) = ratio { - prop_assert!((px_aspect(&g) - a).abs() < 1e-6 * a.max(1.0), "ratio {a}: {}", px_aspect(&g)); - } - // anchors: edges opposite the handle do not move - let (s, r) = (start.rect, g.rect); - let keep_l = matches!(hd, TopRight | BottomRight | Right); - let keep_r = matches!(hd, TopLeft | BottomLeft | Left); - let keep_t = matches!(hd, BottomLeft | BottomRight | Bottom); - let keep_b = matches!(hd, TopLeft | TopRight | Top); - { - if keep_l { prop_assert!((r.x0 - s.x0).abs() < 1e-6, "x0"); } - if keep_r { prop_assert!((r.x1 - s.x1).abs() < 1e-6, "x1"); } - if keep_t { prop_assert!((r.y0 - s.y0).abs() < 1e-6, "y0"); } - if keep_b { prop_assert!((r.y1 - s.y1).abs() < 1e-6, "y1"); } - } - if hd == Move { - prop_assert!((r.width() - s.width()).abs() < 1e-9 && (r.height() - s.height()).abs() < 1e-9); - } - } - - #[test] - fn drag_never_panics_on_arbitrary_numbers( - x0 in -2.0f64..3.0, y0 in -2.0f64..3.0, x1 in -2.0f64..3.0, y1 in -2.0f64..3.0, - angle in -90.0f64..90.0, tx in -1e9f64..1e9, ty in -1e9f64..1e9, - ratio in prop::option::of(-1.0f64..10.0), hd in any_handle(), - ) { - let g = drag_crop(CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle }, hd, p(0.5, 0.5), p(tx, ty), W, H, ratio); - prop_assert!(g.rect.x0.is_finite() && g.rect.y1.is_finite()); - } - } - } - - // Scenario: moving a frame that fills a straightened photo's height (issue #2299) - #[test] - fn given_largest_locked_frame_on_a_straightened_photo_when_moved_sideways_then_it_slides_along_the_tilt() { - for angle in [3.0, -10.0] { - let a = 2.0 / 3.0; - let start = crate::crop_fit_angle(W, H, angle, Some(a)); - let c = start.rect.center(); - let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a)); - assert!(g.is_within_image(W, H), "{angle}: {:?}", g.rect); - assert!(near(g.rect.width(), start.rect.width()) && near(g.rect.height(), start.rect.height()), "a move keeps the size"); - assert!(g.rect.x0 < start.rect.x0 - 0.2, "{angle}: stuck at {:?} (from {:?})", g.rect, start.rect); - let gc = g.rect.center(); - let further = drag(g, Move, gc, p(gc.x - 0.5, gc.y), Some(a)); - assert!((further.rect.x0 - g.rect.x0).abs() < 1e-4, "{angle}: not at the left side: {:?} → {:?}", g.rect, further.rect); - let back = drag(g, Move, c, p(c.x + 0.5, c.y), Some(a)); - assert!(back.rect.x1 > start.rect.x1 + 0.2, "{angle}: {:?}", back.rect); - } - } - - // Scenario: a straightened move that fits goes exactly where the pointer says - #[test] - fn given_straightened_photo_when_a_small_frame_is_moved_inside_then_it_follows_the_pointer() { - let start = CropGeometry { rect: Rect::new(0.4, 0.4, 0.5, 0.5), angle: 8.0 }; - let g = drag(start, Move, p(0.45, 0.45), p(0.5, 0.42), None); - assert!(near(g.rect.x0, 0.45) && near(g.rect.y0, 0.37), "{:?}", g.rect); - } - - // Scenario: a frame a handle drag left within the tolerance past a tilted edge (review of #2299) - #[test] - fn given_a_frame_just_past_a_tilted_edge_when_moved_then_it_still_slides_and_a_click_changes_nothing() { - let a = 2.0 / 3.0; - let fit = crate::crop_fit_angle(W, H, 3.0, Some(a)); - let k = 1.0 + 5e-7 / fit.rect.height(); - let start = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * k, fit.rect.height() * k), angle: 3.0 }; - assert!(start.is_within_image(W, H)); - let c = start.rect.center(); - let z = drag(start, Move, c, c, Some(a)).rect; - let s = start.rect; - assert!(near(z.x0, s.x0) && near(z.y0, s.y0) && near(z.x1, s.x1) && near(z.y1, s.y1), "a click inside the frame moves nothing: {z:?}"); - let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a)); - assert!(g.rect.x0 < start.rect.x0 - 0.2, "stuck at {:?}", g.rect); - assert!(g.is_within_image(W, H)); - } - - // Scenario: a frame a handle drag enlarged to a tilted edge (review of #2299) - #[test] - fn given_a_frame_enlarged_to_a_tilted_edge_by_a_handle_when_moved_then_it_still_slides() { - let a = 2.0 / 3.0; - for angle in [3.0, 6.0, -10.0, 12.0] { - let fit = crate::crop_fit_angle(W, H, angle, Some(a)); - let small = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * 0.6, fit.rect.height() * 0.6), angle }; - // the second fills the photo's height, at the tolerance's edge top and bottom - let shrunk = drag(fit, BottomRight, p(0.5, 0.5), p(0.35, 0.35), Some(a)); - for start in [small, shrunk] { - let big = drag(start, BottomRight, p(0.5, 0.5), p(1.5, 1.5), Some(a)); - let c = big.rect.center(); - let g = drag(big, Move, c, p(c.x - 0.5, c.y), Some(a)); - assert!(g.rect.x0 < big.rect.x0 - 0.2, "{angle}: stuck at {:?}", g.rect); - assert!( - g.is_within_image(W, H) - && (g.rect.width() / big.rect.width() - 1.0).abs() < 1e-5 - && (g.rect.height() / big.rect.height() - 1.0).abs() < 1e-5, - "{angle}: {:?}", - g.rect - ); - } - } - } - - // Scenario: a smaller frame pushed sideways past a tilted edge slides along it (#2299) - #[test] - fn given_a_small_frame_pushed_past_a_tilted_edge_then_it_stops_on_the_edge_and_slides_along_it() { - let start = CropGeometry { rect: Rect::new(0.35, 0.35, 0.55, 0.65), angle: 6.0 }; - let c = start.rect.center(); - let g = drag(start, Move, c, p(c.x - 0.2, c.y), None); - assert!(near(g.rect.x0, 0.15) && near(g.rect.y0, 0.35), "{:?}", g.rect); - let (a, b) = (drag(start, Move, c, p(c.x - 0.6, c.y), None), drag(start, Move, c, p(c.x - 1.0, c.y), None)); - for g in [a, b] { - assert!(g.is_within_image(W, H) && g.rect.x0 < 0.01, "{:?}", g.rect); - } - assert!(b.rect.y0 > a.rect.y0 + 0.01 && a.rect.y0 > start.rect.y0, "{:?} then {:?}", a.rect, b.rect); - } -} +mod tests; diff --git a/crates/geom/src/crop_drag/tests.rs b/crates/geom/src/crop_drag/tests.rs new file mode 100644 index 000000000..cf57c67cb --- /dev/null +++ b/crates/geom/src/crop_drag/tests.rs @@ -0,0 +1,303 @@ +use super::*; +use CropHandle::*; + +const W: f64 = 600.0; +const H: f64 = 400.0; +const EPS: f64 = 1e-6; + +fn geo(x0: f64, y0: f64, x1: f64, y1: f64) -> CropGeometry { + CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle: 0.0 } +} +fn p(x: f64, y: f64) -> Point { + Point::new(x, y) +} +fn drag(g: CropGeometry, hd: CropHandle, from: Point, to: Point, aspect: Option) -> CropGeometry { + drag_crop(g, hd, from, to, W, H, aspect) +} +fn px_aspect(g: &CropGeometry) -> f64 { + g.rect.width() * W / (g.rect.height() * H) +} +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < EPS +} + +// Feature: dragging a crop handle +// Scenario: a free corner drag inside the image +#[test] +fn given_free_crop_when_corner_dragged_inside_then_corner_follows_and_opposite_corner_stays() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(0.9, 0.7), None); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2)); + assert!(near(g.rect.x1, 0.9) && near(g.rect.y1, 0.7)); +} + +// Scenario: dragging past the right edge (the weird behaviour of issue 295) +#[test] +fn given_corner_dragged_past_image_edge_then_it_stops_at_the_edge_and_anchor_stays() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(1.3, 0.7), None); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "anchor moved: {:?}", g.rect); + assert!(near(g.rect.x1, 1.0), "stops at the edge: {:?}", g.rect); + assert!(near(g.rect.y1, 0.7), "other axis still follows the pointer: {:?}", g.rect); +} + +#[test] +fn given_locked_aspect_when_corner_dragged_past_edge_then_ratio_and_anchor_hold() { + let a = 1.5; + let g = drag(geo(0.2, 0.2, 0.8, 0.2 + 0.6 * W / H / a), BottomRight, p(0.8, 0.6), p(1.4, 0.9), Some(a)); + assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "{:?}", g.rect); + assert!(near(px_aspect(&g), a), "{}", px_aspect(&g)); + assert!(g.rect.x1 <= 1.0 + EPS && g.rect.y1 <= 1.0 + EPS); + assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "grows until it touches an edge: {:?}", g.rect); +} + +#[test] +fn given_locked_aspect_when_edge_dragged_then_ratio_holds_and_frame_grows_about_its_midline() { + let a = 2.0; + let start = geo(0.3, 0.3, 0.7, 0.3 + 0.4 * W / H / a); + let g = drag(start, Bottom, p(0.5, start.rect.y1), p(0.5, start.rect.y1 + 0.1), Some(a)); + assert!(near(g.rect.y0, start.rect.y0), "opposite edge stays"); + assert!(near(g.rect.center().x, start.rect.center().x), "grows symmetrically sideways"); + assert!(near(px_aspect(&g), a)); + assert!(g.rect.height() > start.rect.height()); +} + +#[test] +fn given_free_crop_when_edge_dragged_then_only_that_edge_moves() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), Left, p(0.2, 0.5), p(0.1, 0.9), None); + assert!(near(g.rect.x0, 0.1) && near(g.rect.y0, 0.2) && near(g.rect.x1, 0.8) && near(g.rect.y1, 0.8)); +} + +#[test] +fn given_move_past_edge_then_size_is_kept_and_frame_slides_along_the_edge() { + let start = geo(0.5, 0.25, 0.9, 0.65); + let g = drag(start, Move, p(0.7, 0.45), p(1.3, 0.55), None); + assert!(near(g.rect.width(), 0.4) && near(g.rect.height(), 0.4)); + assert!(near(g.rect.x1, 1.0), "flush with the right edge: {:?}", g.rect); + assert!(near(g.rect.y0, 0.35), "still follows the pointer vertically: {:?}", g.rect); +} + +#[test] +fn given_edge_dragged_across_the_opposite_edge_then_crop_keeps_a_minimum_size() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), Right, p(0.8, 0.5), p(-0.5, 0.5), None); + assert!(g.rect.width() * W >= MIN_CROP_FRACTION * H - EPS && g.rect.x1 > g.rect.x0); + assert!(near(g.rect.x0, 0.2)); +} + +#[test] +fn given_straightened_image_when_dragged_outside_then_crop_stays_inside_the_rotated_image() { + let start = crate::crop_fit_angle(W, H, 12.0, Some(1.5)); + let c = start.rect.center(); + for hd in [TopLeft, TopRight, BottomRight, BottomLeft, Top, Right, Bottom, Left, Move] { + for aspect in [None, Some(1.5)] { + let g = drag(start, hd, c, p(3.0, -3.0), aspect); + assert!(g.is_within_image(W, H), "{hd:?} {aspect:?}: {:?}", g.rect); + assert_eq!(g.angle, 12.0); + } + } +} + +#[test] +fn given_hostile_numbers_then_the_start_crop_is_returned_not_a_panic() { + let s = geo(0.2, 0.2, 0.8, 0.8); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(bad, 0.5), None), s); + } + assert_eq!(drag_crop(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), 0.0, 400.0, None), s); + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(f64::NAN)), s); + assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(-1.0)), s); +} + +// Scenario: a locked edge drag when the frame already touches the image edge (review finding) +#[test] +fn given_locked_frame_flush_with_the_top_when_right_edge_dragged_out_then_it_grows_sliding_down() { + let a = 1.5; + let start = geo(0.2, 0.0, 0.5, 0.3 * W / H * 0.0 + 0.3 * W / (a * H)); + let g = drag(start, Right, p(0.5, 0.2), p(0.9, 0.2), Some(a)); + assert!(near(g.rect.x0, 0.2), "anchor edge stays: {:?}", g.rect); + assert!(g.rect.width() > start.rect.width() + 0.05, "it must grow: {:?}", g.rect); + assert!(near(px_aspect(&g), a)); + assert!(g.is_within_image(W, H)); +} + +#[test] +fn given_full_fit_straightened_crop_when_locked_edge_dragged_then_it_still_respects_the_image() { + let start = crate::crop_fit_angle(W, H, 20.0, Some(1.5)); + let g = drag(start, Right, p(0.9, 0.5), p(1.5, 0.5), Some(1.5)); + assert!(g.is_within_image(W, H) && near(px_aspect(&g), 1.5)); +} + +// Scenario: extreme or hostile magnitudes +#[test] +fn given_an_extreme_locked_ratio_then_the_frame_can_still_be_moved() { + let start = geo(0.1, 0.4, 0.9, 0.4 + 0.8 * W / (60.0 * H)); + let g = drag(start, Move, p(0.5, 0.4), p(0.5, 0.7), Some(60.0)); + assert!(g.rect.y0 > 0.5, "moved: {:?}", g.rect); +} + +#[test] +fn given_a_huge_finite_pointer_then_a_locked_corner_still_grows_to_the_edge() { + let a = 1.5; + let start = geo(0.2, 0.2, 0.5, 0.2 + 0.3 * W / (a * H)); + let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), Some(a)); + assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "{:?}", g.rect); + let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), None); + assert!(near(g.rect.x1, 1.0) && near(g.rect.y1, 1.0), "{:?}", g.rect); +} + +#[test] +fn given_an_unrotated_image_then_results_never_leave_the_unit_square() { + let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(5.0, 5.0), None); + assert!(g.rect.x1 <= 1.0 && g.rect.y1 <= 1.0 && g.rect.x0 >= 0.0 && g.rect.y0 >= 0.0, "{:?}", g.rect); +} + +#[test] +fn handle_numbering_matches_the_overlay() { + assert_eq!(CropHandle::from_index(0), Some(TopLeft)); + assert_eq!(CropHandle::from_index(6), Some(Bottom)); + assert_eq!(CropHandle::from_index(8), Some(Move)); + assert_eq!(CropHandle::from_index(9), None); + assert_eq!(CropHandle::from_name("bottomLeft"), Some(BottomLeft)); + assert_eq!(CropHandle::from_name("nope"), None); +} + +mod props { + use super::*; + use proptest::prelude::*; + + fn any_handle() -> impl Strategy { + (0u8..9).prop_map(|i| CropHandle::from_index(i).unwrap_or(Move)) + } + + proptest! { + /// Whatever the pointer does, the crop stays valid; the anchor and the ratio hold. + #[test] + fn drag_is_always_inside_anchored_and_shaped( + angle in -45.0f64..45.0, + ratio in prop::option::of(0.3f64..4.0), + hd in any_handle(), + tx in -4.0f64..5.0, ty in -4.0f64..5.0, + shrink in 0.2f64..1.0, + ) { + let base = crate::crop_fit_angle(W, H, angle, ratio); + // a smaller start crop so there is room to move + let c = base.rect.center(); + let start = CropGeometry { rect: Rect::from_center(c, base.rect.width() * shrink, base.rect.height() * shrink), angle }; + prop_assume!(start.is_within_image(W, H)); + let g = drag_crop(start, hd, c, p(tx, ty), W, H, ratio); + prop_assert!(g.is_within_image(W, H), "{:?} -> {:?}", start.rect, g.rect); + prop_assert!(g.rect.width() > 0.0 && g.rect.height() > 0.0); + let min = MIN_CROP_FRACTION * W.min(H) - 1e-6; + prop_assert!(g.rect.width() * W >= min.min(start.rect.width() * W) && g.rect.height() * H >= min.min(start.rect.height() * H), "min size {:?}", g.rect); + if let Some(a) = ratio { + prop_assert!((px_aspect(&g) - a).abs() < 1e-6 * a.max(1.0), "ratio {a}: {}", px_aspect(&g)); + } + // anchors: edges opposite the handle do not move + let (s, r) = (start.rect, g.rect); + let keep_l = matches!(hd, TopRight | BottomRight | Right); + let keep_r = matches!(hd, TopLeft | BottomLeft | Left); + let keep_t = matches!(hd, BottomLeft | BottomRight | Bottom); + let keep_b = matches!(hd, TopLeft | TopRight | Top); + { + if keep_l { prop_assert!((r.x0 - s.x0).abs() < 1e-6, "x0"); } + if keep_r { prop_assert!((r.x1 - s.x1).abs() < 1e-6, "x1"); } + if keep_t { prop_assert!((r.y0 - s.y0).abs() < 1e-6, "y0"); } + if keep_b { prop_assert!((r.y1 - s.y1).abs() < 1e-6, "y1"); } + } + if hd == Move { + prop_assert!((r.width() - s.width()).abs() < 1e-9 && (r.height() - s.height()).abs() < 1e-9); + } + } + + #[test] + fn drag_never_panics_on_arbitrary_numbers( + x0 in -2.0f64..3.0, y0 in -2.0f64..3.0, x1 in -2.0f64..3.0, y1 in -2.0f64..3.0, + angle in -90.0f64..90.0, tx in -1e9f64..1e9, ty in -1e9f64..1e9, + ratio in prop::option::of(-1.0f64..10.0), hd in any_handle(), + ) { + let g = drag_crop(CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle }, hd, p(0.5, 0.5), p(tx, ty), W, H, ratio); + prop_assert!(g.rect.x0.is_finite() && g.rect.y1.is_finite()); + } + } +} + +// Scenario: moving a frame that fills a straightened photo's height (issue #2299) +#[test] +fn given_largest_locked_frame_on_a_straightened_photo_when_moved_sideways_then_it_slides_along_the_tilt() { + for angle in [3.0, -10.0] { + let a = 2.0 / 3.0; + let start = crate::crop_fit_angle(W, H, angle, Some(a)); + let c = start.rect.center(); + let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a)); + assert!(g.is_within_image(W, H), "{angle}: {:?}", g.rect); + assert!(near(g.rect.width(), start.rect.width()) && near(g.rect.height(), start.rect.height()), "a move keeps the size"); + assert!(g.rect.x0 < start.rect.x0 - 0.2, "{angle}: stuck at {:?} (from {:?})", g.rect, start.rect); + let gc = g.rect.center(); + let further = drag(g, Move, gc, p(gc.x - 0.5, gc.y), Some(a)); + assert!((further.rect.x0 - g.rect.x0).abs() < 1e-4, "{angle}: not at the left side: {:?} → {:?}", g.rect, further.rect); + let back = drag(g, Move, c, p(c.x + 0.5, c.y), Some(a)); + assert!(back.rect.x1 > start.rect.x1 + 0.2, "{angle}: {:?}", back.rect); + } +} + +// Scenario: a straightened move that fits goes exactly where the pointer says +#[test] +fn given_straightened_photo_when_a_small_frame_is_moved_inside_then_it_follows_the_pointer() { + let start = CropGeometry { rect: Rect::new(0.4, 0.4, 0.5, 0.5), angle: 8.0 }; + let g = drag(start, Move, p(0.45, 0.45), p(0.5, 0.42), None); + assert!(near(g.rect.x0, 0.45) && near(g.rect.y0, 0.37), "{:?}", g.rect); +} + +// Scenario: a frame a handle drag left within the tolerance past a tilted edge (review of #2299) +#[test] +fn given_a_frame_just_past_a_tilted_edge_when_moved_then_it_still_slides_and_a_click_changes_nothing() { + let a = 2.0 / 3.0; + let fit = crate::crop_fit_angle(W, H, 3.0, Some(a)); + let k = 1.0 + 5e-7 / fit.rect.height(); + let start = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * k, fit.rect.height() * k), angle: 3.0 }; + assert!(start.is_within_image(W, H)); + let c = start.rect.center(); + let z = drag(start, Move, c, c, Some(a)).rect; + let s = start.rect; + assert!(near(z.x0, s.x0) && near(z.y0, s.y0) && near(z.x1, s.x1) && near(z.y1, s.y1), "a click inside the frame moves nothing: {z:?}"); + let g = drag(start, Move, c, p(c.x - 0.5, c.y), Some(a)); + assert!(g.rect.x0 < start.rect.x0 - 0.2, "stuck at {:?}", g.rect); + assert!(g.is_within_image(W, H)); +} + +// Scenario: a frame a handle drag enlarged to a tilted edge (review of #2299) +#[test] +fn given_a_frame_enlarged_to_a_tilted_edge_by_a_handle_when_moved_then_it_still_slides() { + let a = 2.0 / 3.0; + for angle in [3.0, 6.0, -10.0, 12.0] { + let fit = crate::crop_fit_angle(W, H, angle, Some(a)); + let small = CropGeometry { rect: Rect::from_center(fit.rect.center(), fit.rect.width() * 0.6, fit.rect.height() * 0.6), angle }; + // the second fills the photo's height, at the tolerance's edge top and bottom + let shrunk = drag(fit, BottomRight, p(0.5, 0.5), p(0.35, 0.35), Some(a)); + for start in [small, shrunk] { + let big = drag(start, BottomRight, p(0.5, 0.5), p(1.5, 1.5), Some(a)); + let c = big.rect.center(); + let g = drag(big, Move, c, p(c.x - 0.5, c.y), Some(a)); + assert!(g.rect.x0 < big.rect.x0 - 0.2, "{angle}: stuck at {:?}", g.rect); + assert!( + g.is_within_image(W, H) + && (g.rect.width() / big.rect.width() - 1.0).abs() < 1e-5 + && (g.rect.height() / big.rect.height() - 1.0).abs() < 1e-5, + "{angle}: {:?}", + g.rect + ); + } + } +} + +// Scenario: a smaller frame pushed sideways past a tilted edge slides along it (#2299) +#[test] +fn given_a_small_frame_pushed_past_a_tilted_edge_then_it_stops_on_the_edge_and_slides_along_it() { + let start = CropGeometry { rect: Rect::new(0.35, 0.35, 0.55, 0.65), angle: 6.0 }; + let c = start.rect.center(); + let g = drag(start, Move, c, p(c.x - 0.2, c.y), None); + assert!(near(g.rect.x0, 0.15) && near(g.rect.y0, 0.35), "{:?}", g.rect); + let (a, b) = (drag(start, Move, c, p(c.x - 0.6, c.y), None), drag(start, Move, c, p(c.x - 1.0, c.y), None)); + for g in [a, b] { + assert!(g.is_within_image(W, H) && g.rect.x0 < 0.01, "{:?}", g.rect); + } + assert!(b.rect.y0 > a.rect.y0 + 0.01 && a.rect.y0 > start.rect.y0, "{:?} then {:?}", a.rect, b.rect); +} From 5b5f860150fbb1f6738d14fe25551d21d3c70bb5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:59 +0000 Subject: [PATCH 053/191] refactor(lightcraft-faces): move net.rs tests to crates/faces/src/net/tests.rs --- crates/faces/src/net.rs | 394 +--------------------------------- crates/faces/src/net/tests.rs | 391 +++++++++++++++++++++++++++++++++ 2 files changed, 392 insertions(+), 393 deletions(-) create mode 100644 crates/faces/src/net/tests.rs diff --git a/crates/faces/src/net.rs b/crates/faces/src/net.rs index 430335840..bd093c495 100644 --- a/crates/faces/src/net.rs +++ b/crates/faces/src/net.rs @@ -620,399 +620,7 @@ fn reshape(node: &Node, x: &Tensor, g: &Graph) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use crate::graph::{Attr, Weight}; - - fn node(op: &str, inputs: &[&str], attrs: &[(&str, Attr)]) -> Node { - Node { - op: op.into(), - inputs: inputs.iter().map(|s| s.to_string()).collect(), - outputs: vec!["y".into()], - attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(), - } - } - fn graph(nodes: Vec, weights: Vec<(&str, Weight)>) -> Graph { - Graph { - nodes, - weights: weights.into_iter().map(|(k, v)| (k.to_string(), v)).collect(), - inputs: vec![("x".into(), vec![1, 1, 3, 3])], - outputs: vec!["y".into()], - } - } - fn w(dims: &[usize], v: Vec) -> Weight { - Weight { dims: dims.to_vec(), data: Data::F32(v) } - } - fn input(c: usize, h: usize, wd: usize, f: impl Fn(usize) -> f32) -> Tensor { - Tensor::new(vec![1, c, h, wd], (0..c * h * wd).map(f).collect()).unwrap() - } - fn run(g: Graph, x: Tensor) -> Result { - Ok(Net::new(g)?.run(x)?.remove(0).1) - } - - /// A direct, slow reference convolution to check the fast one against. - fn reference_conv(x: &Tensor, wd: &[f32], dims: [usize; 4], bias: &[f32], s: usize, p: usize, groups: usize) -> Tensor { - let (c, h, wi) = (x.shape[1], x.shape[2], x.shape[3]); - let [co_n, cin_g, kh, kw] = dims; - let (oh, ow) = ((h + 2 * p - kh) / s + 1, (wi + 2 * p - kw) / s + 1); - let mut out = vec![0.0f32; co_n * oh * ow]; - for co in 0..co_n { - let g = co / (co_n / groups); - for oy in 0..oh { - for ox in 0..ow { - let mut acc = bias[co]; - for cl in 0..cin_g { - for ky in 0..kh { - for kx in 0..kw { - let (iy, ix) = ((oy * s + ky) as isize - p as isize, (ox * s + kx) as isize - p as isize); - if iy >= 0 && ix >= 0 && (iy as usize) < h && (ix as usize) < wi { - acc += - x.data[((g * cin_g + cl) * h + iy as usize) * wi + ix as usize] * wd[((co * cin_g + cl) * kh + ky) * kw + kx]; - } - } - } - } - out[(co * oh + oy) * ow + ox] = acc; - } - } - } - let _ = c; - Tensor::new(vec![1, co_n, oh, ow], out).unwrap() - } - - #[test] - fn convolutions_match_a_slow_reference() { - // (cin, cout, groups, kernel, stride, pad, h, w) - for (cin, cout, groups, k, s, p, h, wi) in [ - (3, 4, 1, 3, 1, 1, 7, 9), - (3, 4, 1, 3, 2, 1, 8, 8), - (4, 4, 4, 3, 1, 1, 6, 5), - (2, 6, 1, 1, 1, 0, 5, 5), - (4, 8, 2, 3, 1, 1, 5, 6), - (1, 1, 1, 3, 1, 0, 3, 3), - ] { - let cin_g = cin / groups; - let wd: Vec = (0..cout * cin_g * k * k).map(|i| ((i * 37 % 11) as f32 - 5.0) * 0.1).collect(); - let bias: Vec = (0..cout).map(|i| i as f32 * 0.25 - 0.5).collect(); - let x = input(cin, h, wi, |i| ((i * 13 % 17) as f32 - 8.0) * 0.2); - let attrs = [("group", Attr::Int(groups as i64)), ("strides", Attr::Ints(vec![s as i64; 2])), ("pads", Attr::Ints(vec![p as i64; 4]))]; - let g = graph( - vec![node("Conv", &["x", "w", "b"], &attrs)], - vec![("w", w(&[cout, cin_g, k, k], wd.clone())), ("b", w(&[cout], bias.clone()))], - ); - let got = run(g, x.clone()).unwrap(); - let want = reference_conv(&x, &wd, [cout, cin_g, k, k], &bias, s, p, groups); - assert_eq!(got.shape, want.shape, "{cin} {cout} {groups} {k} {s} {p}"); - for (a, b) in got.data.iter().zip(&want.data) { - assert!((a - b).abs() < 1e-4, "{cin} {cout} {groups} {k} {s} {p}: {a} vs {b}"); - } - } - } - - #[test] - fn the_small_operators() { - let x = input(1, 4, 4, |i| i as f32 - 6.0); - // Relu, Sigmoid - let r = run(graph(vec![node("Relu", &["x"], &[])], vec![]), x.clone()).unwrap(); - assert!(r.data.iter().all(|v| *v >= 0.0) && r.data.get(7) == Some(&1.0)); - let s = run(graph(vec![node("Sigmoid", &["x"], &[])], vec![]), x.clone()).unwrap(); - assert!((s.data[6] - 0.5).abs() < 1e-6 && s.data.iter().all(|v| (0.0..=1.0).contains(v))); - // MaxPool 2x2/2 - let m = run( - graph(vec![node("MaxPool", &["x"], &[("kernel_shape", Attr::Ints(vec![2, 2])), ("strides", Attr::Ints(vec![2, 2]))])], vec![]), - x.clone(), - ) - .unwrap(); - assert_eq!((m.shape.clone(), m.data.clone()), (vec![1, 1, 2, 2], vec![-1.0, 1.0, 7.0, 9.0])); - // Resize x2 nearest - let z = run(graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 2.0, 2.0]))]), input(1, 2, 2, |i| i as f32)) - .unwrap(); - assert_eq!(z.shape, vec![1, 1, 4, 4]); - assert_eq!(&z.data[..8], &[0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0]); - // Add - let a = run(graph(vec![node("Add", &["x", "x"], &[])], vec![]), x.clone()).unwrap(); - assert_eq!(a.data[5], 2.0 * x.data[5]); - // Transpose [0,2,3,1] then Reshape [1,-1,2] - let t = run(graph(vec![node("Transpose", &["x"], &[("perm", Attr::Ints(vec![0, 2, 3, 1]))])], vec![]), input(2, 2, 3, |i| i as f32)).unwrap(); - assert_eq!(t.shape, vec![1, 2, 3, 2]); - assert_eq!(t.data, vec![0.0, 6.0, 1.0, 7.0, 2.0, 8.0, 3.0, 9.0, 4.0, 10.0, 5.0, 11.0]); - let rs = run( - graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![3], data: Data::I64(vec![1, -1, 2]) })]), - input(2, 2, 3, |i| i as f32), - ) - .unwrap(); - assert_eq!(rs.shape, vec![1, 6, 2]); - } - - /// An accelerator built on the slow reference convolution, optionally wrong or failing. - struct Fake { - skew: f32, - fail_after: std::sync::atomic::AtomicUsize, - } - impl Accelerator for Fake { - fn device(&self) -> String { - "test".into() - } - fn conv(&self, j: &ConvJob) -> std::result::Result>, String> { - if self.fail_after.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) == 0 { - return Err("device lost".into()); - } - assert!(j.pt == j.pl && j.sh == j.sw); - let x = Tensor::new(vec![1, j.cin, j.h, j.w], j.input.to_vec()).unwrap(); - let zero = vec![0.0; j.cout]; - let dims = [j.cout, j.cin / j.groups, j.kh, j.kw]; - let out = reference_conv(&x, j.weights, dims, j.bias.unwrap_or(&zero), j.sh, j.pt, j.groups); - Ok(Some(out.data.iter().map(|v| v + self.skew).collect())) - } - } - - fn two_convs() -> (Graph, Tensor) { - let attrs = [("strides", Attr::Ints(vec![2, 2])), ("pads", Attr::Ints(vec![1; 4]))]; - let mut a = node("Conv", &["x", "w1", "b1"], &attrs); - a.outputs = vec!["h".into()]; - let b = node("Conv", &["h", "w2"], &[("group", Attr::Int(2)), ("pads", Attr::Ints(vec![1; 4]))]); - let mut g = graph( - vec![a, b], - vec![ - ("w1", w(&[4, 3, 3, 3], (0..108).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.05).collect())), - ("b1", w(&[4], vec![0.1, -0.2, 0.3, 0.0])), - ("w2", w(&[4, 2, 3, 3], (0..72).map(|i| ((i * 5 % 11) as f32 - 5.0) * 0.05).collect())), - ], - ); - g.inputs = vec![("x".into(), vec![1, 3, 9, 8])]; - (g, input(3, 9, 8, |i| ((i * 13 % 17) as f32 - 8.0) * 0.1)) - } - - #[test] - fn an_accelerator_is_used_only_when_it_agrees_with_the_cpu() { - let (g, x) = two_convs(); - let net = Net::new(g).unwrap(); - let cpu = net.run(x.clone()).unwrap(); - let good = Arc::new(Fake { skew: 0.0, fail_after: usize::MAX.into() }); - net.attach_checked(good, &x, 1e-4, false).unwrap(); - assert!(net.accelerator().is_some()); - assert_eq!(net.run(x.clone()).unwrap()[0].1.shape, cpu[0].1.shape); - let wrong = Arc::new(Fake { skew: 0.5, fail_after: usize::MAX.into() }); - assert!(net.attach_checked(wrong, &x, 1e-4, false).is_err()); - assert!(net.accelerator().is_none(), "a device that disagrees is not used"); - assert_eq!(net.run(x).unwrap(), cpu); - } - - #[test] - fn a_failing_accelerator_falls_back_to_the_cpu_mid_run() { - let (g, x) = two_convs(); - let net = Net::new(g).unwrap(); - let cpu = net.run(x.clone()).unwrap(); - // agrees on the probe (two convs), then fails on the second conv of the next run - net.attach_checked(Arc::new(Fake { skew: 0.0, fail_after: 3.into() }), &x, 1e-4, false).unwrap(); - let out = net.run(x).unwrap(); - assert!(net.accelerator().is_none()); - for (a, b) in out[0].1.data.iter().zip(&cpu[0].1.data) { - assert!((a - b).abs() < 1e-4); - } - } - - /// An accelerator that runs whole programs with plain loops (the definition of each step), counting its calls; - /// `poison` makes it give a value that is not finite. - struct ProgramFake { - calls: std::sync::atomic::AtomicUsize, - poison: bool, - } - - fn run_steps(p: &program::Program, input: &[f32]) -> Vec> { - use program::{Arg, Binary, Op, Unary}; - let mut values: Vec> = vec![Vec::new(); p.lens.len()]; - values[program::INPUT] = input.to_vec(); - for step in &p.steps { - let arg = |i: usize| -> Vec { - match &step.args[i] { - Arg::Value(v) => values[*v].clone(), - Arg::Const { data, .. } => data.to_vec(), - } - }; - let len = p.lens[step.out]; - let x = arg(0); - let out: Vec = match &step.op { - Op::Conv(j) => { - let (m, cg) = (j.cout / j.groups, j.cin / j.groups); - let mut out = vec![0f32; len]; - for co in 0..j.cout { - for oy in 0..j.oh { - for ox in 0..j.ow { - let mut v = j.bias.map_or(0.0, |b| b[co]); - for ci in 0..cg { - for ky in 0..j.kh { - for kx in 0..j.kw { - let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize); - if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w { - v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx] - * x[((co / m * cg + ci) * j.h + iy as usize) * j.w + ix as usize]; - } - } - } - } - out[(co * j.oh + oy) * j.ow + ox] = v; - } - } - } - out - } - Op::Unary(Unary::Relu) => x.iter().map(|v| v.max(0.0)).collect(), - Op::Unary(Unary::Sigmoid) => x.iter().map(|v| 1.0 / (1.0 + (-v).exp())).collect(), - Op::ChannelAffine { channels, plane } => { - let (s, o) = (arg(1), arg(2)); - x.iter().enumerate().map(|(i, v)| v * s[(i / plane) % channels] + o[(i / plane) % channels]).collect() - } - Op::ChannelPrelu { channels, plane } => { - let a = arg(1); - x.iter().enumerate().map(|(i, v)| if *v < 0.0 { v * a[(i / plane) % channels] } else { *v }).collect() - } - Op::Binary { op, dims, a, b } => { - let y = arg(1); - (0..len) - .map(|i| { - let (mut rest, mut ia, mut ib) = (i, 0, 0); - for d in (0..4).rev() { - let at = rest % dims[d]; - rest /= dims[d]; - ia += at * a[d]; - ib += at * b[d]; - } - match op { - Binary::Add => x[ia] + y[ib], - Binary::Sub => x[ia] - y[ib], - Binary::Mul => x[ia] * y[ib], - } - }) - .collect() - } - Op::Transpose { dims, strides } => (0..len) - .map(|i| { - let (mut rest, mut from) = (i, 0); - for d in (0..4).rev() { - from += (rest % dims[d]) * strides[d]; - rest /= dims[d]; - } - x[from] - }) - .collect(), - Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => (0..len) - .map(|i| { - let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); - let mut m = f32::NEG_INFINITY; - for ky in 0..*kh { - for kx in 0..*kw { - m = m.max(x[(c * h + oy * sh + ky) * w + ox * sw + kx]); - } - } - m - }) - .collect(), - Op::Resize { h, w, sh, sw, .. } => (0..len) - .map(|i| { - let (ow, oh) = (w * sw, h * sh); - let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); - x[(c * h + oy / sh) * w + ox / sw] - }) - .collect(), - Op::GlobalAverage { plane, .. } => x.chunks(*plane).map(|c| c.iter().sum::() / *plane as f32).collect(), - Op::Gemv { k, n, trans_b, alpha } => { - let (wt, bias) = (arg(1), arg(2)); - (0..*n).map(|o| alpha * (0..*k).map(|j| x[j] * wt[if *trans_b { o * k + j } else { j * n + o }]).sum::() + bias[o]).collect() - } - }; - assert_eq!(out.len(), len, "{:?}", step.op); - values[step.out] = out; - for f in &step.free { - values[*f] = Vec::new(); - } - } - p.outputs.iter().map(|(_, v, _)| values[*v].clone()).collect() - } - - impl Accelerator for ProgramFake { - fn device(&self) -> String { - "program test".into() - } - fn conv(&self, _: &ConvJob) -> std::result::Result>, String> { - Ok(None) - } - fn run_program(&self, p: &program::Program, inputs: &[&[f32]]) -> std::result::Result>>>, String> { - self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let mut out: Vec>> = inputs.iter().map(|x| run_steps(p, x)).collect(); - if self.poison { - out[0][0][0] = f32::NAN; - } - Ok(Some(out)) - } - } - - fn every_operator_input(seed: usize) -> Tensor { - input(4, 8, 8, |i| (((i * 31 + seed * 7) % 37) as f32 - 18.0) * 0.05) - } - - #[test] - fn a_program_of_every_operator_gives_what_the_interpreter_gives() { - let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); - let x = every_operator_input(0); - let cpu = net.run(x.clone()).unwrap(); - let fake = Arc::new(ProgramFake { calls: 0.into(), poison: false }); - net.attach_checked(fake.clone(), &x, 1e-5, false).unwrap(); - let calls = fake.calls.load(std::sync::atomic::Ordering::Relaxed); - assert!(calls > 0, "the whole network ran as one program"); - // several inputs go to the device together, and each gets its own outputs - let inputs: Vec = (0..3).map(every_operator_input).collect(); - let together = net.run_batch(inputs.clone()).unwrap(); - assert_eq!(fake.calls.load(std::sync::atomic::Ordering::Relaxed), calls + 1, "one dispatch for the batch"); - net.detach(); - for (x, got) in inputs.into_iter().zip(&together) { - let want = net.run(x).unwrap(); - agree(&want, got, 1e-5).unwrap(); - } - assert_eq!(cpu.len(), 2); - } - - #[test] - fn a_device_that_gives_values_that_are_not_finite_is_dropped() { - let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); - let x = every_operator_input(1); - let cpu = net.run(x.clone()).unwrap(); - assert!(net.attach_checked(Arc::new(ProgramFake { calls: 0.into(), poison: true }), &x, 1e-5, false).is_err()); - assert!(net.accelerator().is_none()); - assert_eq!(net.run(x).unwrap(), cpu); - } - - #[test] - fn unsupported_and_inconsistent_models_are_errors() { - let x = input(1, 3, 3, |i| i as f32); - for (what, g) in [ - ("unknown op", graph(vec![node("Softmax", &["x"], &[])], vec![])), - ( - "dilated conv", - graph(vec![node("Conv", &["x", "w"], &[("dilations", Attr::Ints(vec![2, 2]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))]), - ), - ("conv without weights", graph(vec![node("Conv", &["x", "w"], &[])], vec![])), - ("channels do not match", graph(vec![node("Conv", &["x", "w"], &[])], vec![("w", w(&[1, 5, 1, 1], vec![1.0; 5]))])), - ("kernel too big", graph(vec![node("Conv", &["x", "w"], &[])], vec![("w", w(&[1, 1, 5, 5], vec![1.0; 25]))])), - ("zero group", graph(vec![node("Conv", &["x", "w"], &[("group", Attr::Int(0))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))])), - ("zero stride", graph(vec![node("Conv", &["x", "w"], &[("strides", Attr::Ints(vec![0, 0]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))])), - ( - "negative pad", - graph(vec![node("Conv", &["x", "w"], &[("pads", Attr::Ints(vec![-1, 0, 0, 0]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))]), - ), - ("bad perm", graph(vec![node("Transpose", &["x"], &[("perm", Attr::Ints(vec![0, 0, 1, 2]))])], vec![])), - ( - "huge reshape", - graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![i64::MAX, 1]) })]), - ), - ("reshape mismatch", graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![4, 4]) })])), - ("non-integer resize", graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 1.5, 1.5]))])), - ("add that broadcasts", graph(vec![node("Add", &["x", "c"], &[])], vec![])), - ] { - assert!(run(g, x.clone()).is_err(), "{what}"); - } - } -} +mod tests; #[cfg(test)] mod recognition_tests; diff --git a/crates/faces/src/net/tests.rs b/crates/faces/src/net/tests.rs new file mode 100644 index 000000000..964d1f3ae --- /dev/null +++ b/crates/faces/src/net/tests.rs @@ -0,0 +1,391 @@ +use super::*; +use crate::graph::{Attr, Weight}; + +fn node(op: &str, inputs: &[&str], attrs: &[(&str, Attr)]) -> Node { + Node { + op: op.into(), + inputs: inputs.iter().map(|s| s.to_string()).collect(), + outputs: vec!["y".into()], + attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(), + } +} +fn graph(nodes: Vec, weights: Vec<(&str, Weight)>) -> Graph { + Graph { + nodes, + weights: weights.into_iter().map(|(k, v)| (k.to_string(), v)).collect(), + inputs: vec![("x".into(), vec![1, 1, 3, 3])], + outputs: vec!["y".into()], + } +} +fn w(dims: &[usize], v: Vec) -> Weight { + Weight { dims: dims.to_vec(), data: Data::F32(v) } +} +fn input(c: usize, h: usize, wd: usize, f: impl Fn(usize) -> f32) -> Tensor { + Tensor::new(vec![1, c, h, wd], (0..c * h * wd).map(f).collect()).unwrap() +} +fn run(g: Graph, x: Tensor) -> Result { + Ok(Net::new(g)?.run(x)?.remove(0).1) +} + +/// A direct, slow reference convolution to check the fast one against. +fn reference_conv(x: &Tensor, wd: &[f32], dims: [usize; 4], bias: &[f32], s: usize, p: usize, groups: usize) -> Tensor { + let (c, h, wi) = (x.shape[1], x.shape[2], x.shape[3]); + let [co_n, cin_g, kh, kw] = dims; + let (oh, ow) = ((h + 2 * p - kh) / s + 1, (wi + 2 * p - kw) / s + 1); + let mut out = vec![0.0f32; co_n * oh * ow]; + for co in 0..co_n { + let g = co / (co_n / groups); + for oy in 0..oh { + for ox in 0..ow { + let mut acc = bias[co]; + for cl in 0..cin_g { + for ky in 0..kh { + for kx in 0..kw { + let (iy, ix) = ((oy * s + ky) as isize - p as isize, (ox * s + kx) as isize - p as isize); + if iy >= 0 && ix >= 0 && (iy as usize) < h && (ix as usize) < wi { + acc += + x.data[((g * cin_g + cl) * h + iy as usize) * wi + ix as usize] * wd[((co * cin_g + cl) * kh + ky) * kw + kx]; + } + } + } + } + out[(co * oh + oy) * ow + ox] = acc; + } + } + } + let _ = c; + Tensor::new(vec![1, co_n, oh, ow], out).unwrap() +} + +#[test] +fn convolutions_match_a_slow_reference() { + // (cin, cout, groups, kernel, stride, pad, h, w) + for (cin, cout, groups, k, s, p, h, wi) in [ + (3, 4, 1, 3, 1, 1, 7, 9), + (3, 4, 1, 3, 2, 1, 8, 8), + (4, 4, 4, 3, 1, 1, 6, 5), + (2, 6, 1, 1, 1, 0, 5, 5), + (4, 8, 2, 3, 1, 1, 5, 6), + (1, 1, 1, 3, 1, 0, 3, 3), + ] { + let cin_g = cin / groups; + let wd: Vec = (0..cout * cin_g * k * k).map(|i| ((i * 37 % 11) as f32 - 5.0) * 0.1).collect(); + let bias: Vec = (0..cout).map(|i| i as f32 * 0.25 - 0.5).collect(); + let x = input(cin, h, wi, |i| ((i * 13 % 17) as f32 - 8.0) * 0.2); + let attrs = [("group", Attr::Int(groups as i64)), ("strides", Attr::Ints(vec![s as i64; 2])), ("pads", Attr::Ints(vec![p as i64; 4]))]; + let g = graph( + vec![node("Conv", &["x", "w", "b"], &attrs)], + vec![("w", w(&[cout, cin_g, k, k], wd.clone())), ("b", w(&[cout], bias.clone()))], + ); + let got = run(g, x.clone()).unwrap(); + let want = reference_conv(&x, &wd, [cout, cin_g, k, k], &bias, s, p, groups); + assert_eq!(got.shape, want.shape, "{cin} {cout} {groups} {k} {s} {p}"); + for (a, b) in got.data.iter().zip(&want.data) { + assert!((a - b).abs() < 1e-4, "{cin} {cout} {groups} {k} {s} {p}: {a} vs {b}"); + } + } +} + +#[test] +fn the_small_operators() { + let x = input(1, 4, 4, |i| i as f32 - 6.0); + // Relu, Sigmoid + let r = run(graph(vec![node("Relu", &["x"], &[])], vec![]), x.clone()).unwrap(); + assert!(r.data.iter().all(|v| *v >= 0.0) && r.data.get(7) == Some(&1.0)); + let s = run(graph(vec![node("Sigmoid", &["x"], &[])], vec![]), x.clone()).unwrap(); + assert!((s.data[6] - 0.5).abs() < 1e-6 && s.data.iter().all(|v| (0.0..=1.0).contains(v))); + // MaxPool 2x2/2 + let m = run( + graph(vec![node("MaxPool", &["x"], &[("kernel_shape", Attr::Ints(vec![2, 2])), ("strides", Attr::Ints(vec![2, 2]))])], vec![]), + x.clone(), + ) + .unwrap(); + assert_eq!((m.shape.clone(), m.data.clone()), (vec![1, 1, 2, 2], vec![-1.0, 1.0, 7.0, 9.0])); + // Resize x2 nearest + let z = run(graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 2.0, 2.0]))]), input(1, 2, 2, |i| i as f32)) + .unwrap(); + assert_eq!(z.shape, vec![1, 1, 4, 4]); + assert_eq!(&z.data[..8], &[0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0]); + // Add + let a = run(graph(vec![node("Add", &["x", "x"], &[])], vec![]), x.clone()).unwrap(); + assert_eq!(a.data[5], 2.0 * x.data[5]); + // Transpose [0,2,3,1] then Reshape [1,-1,2] + let t = run(graph(vec![node("Transpose", &["x"], &[("perm", Attr::Ints(vec![0, 2, 3, 1]))])], vec![]), input(2, 2, 3, |i| i as f32)).unwrap(); + assert_eq!(t.shape, vec![1, 2, 3, 2]); + assert_eq!(t.data, vec![0.0, 6.0, 1.0, 7.0, 2.0, 8.0, 3.0, 9.0, 4.0, 10.0, 5.0, 11.0]); + let rs = run( + graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![3], data: Data::I64(vec![1, -1, 2]) })]), + input(2, 2, 3, |i| i as f32), + ) + .unwrap(); + assert_eq!(rs.shape, vec![1, 6, 2]); +} + +/// An accelerator built on the slow reference convolution, optionally wrong or failing. +struct Fake { + skew: f32, + fail_after: std::sync::atomic::AtomicUsize, +} +impl Accelerator for Fake { + fn device(&self) -> String { + "test".into() + } + fn conv(&self, j: &ConvJob) -> std::result::Result>, String> { + if self.fail_after.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) == 0 { + return Err("device lost".into()); + } + assert!(j.pt == j.pl && j.sh == j.sw); + let x = Tensor::new(vec![1, j.cin, j.h, j.w], j.input.to_vec()).unwrap(); + let zero = vec![0.0; j.cout]; + let dims = [j.cout, j.cin / j.groups, j.kh, j.kw]; + let out = reference_conv(&x, j.weights, dims, j.bias.unwrap_or(&zero), j.sh, j.pt, j.groups); + Ok(Some(out.data.iter().map(|v| v + self.skew).collect())) + } +} + +fn two_convs() -> (Graph, Tensor) { + let attrs = [("strides", Attr::Ints(vec![2, 2])), ("pads", Attr::Ints(vec![1; 4]))]; + let mut a = node("Conv", &["x", "w1", "b1"], &attrs); + a.outputs = vec!["h".into()]; + let b = node("Conv", &["h", "w2"], &[("group", Attr::Int(2)), ("pads", Attr::Ints(vec![1; 4]))]); + let mut g = graph( + vec![a, b], + vec![ + ("w1", w(&[4, 3, 3, 3], (0..108).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.05).collect())), + ("b1", w(&[4], vec![0.1, -0.2, 0.3, 0.0])), + ("w2", w(&[4, 2, 3, 3], (0..72).map(|i| ((i * 5 % 11) as f32 - 5.0) * 0.05).collect())), + ], + ); + g.inputs = vec![("x".into(), vec![1, 3, 9, 8])]; + (g, input(3, 9, 8, |i| ((i * 13 % 17) as f32 - 8.0) * 0.1)) +} + +#[test] +fn an_accelerator_is_used_only_when_it_agrees_with_the_cpu() { + let (g, x) = two_convs(); + let net = Net::new(g).unwrap(); + let cpu = net.run(x.clone()).unwrap(); + let good = Arc::new(Fake { skew: 0.0, fail_after: usize::MAX.into() }); + net.attach_checked(good, &x, 1e-4, false).unwrap(); + assert!(net.accelerator().is_some()); + assert_eq!(net.run(x.clone()).unwrap()[0].1.shape, cpu[0].1.shape); + let wrong = Arc::new(Fake { skew: 0.5, fail_after: usize::MAX.into() }); + assert!(net.attach_checked(wrong, &x, 1e-4, false).is_err()); + assert!(net.accelerator().is_none(), "a device that disagrees is not used"); + assert_eq!(net.run(x).unwrap(), cpu); +} + +#[test] +fn a_failing_accelerator_falls_back_to_the_cpu_mid_run() { + let (g, x) = two_convs(); + let net = Net::new(g).unwrap(); + let cpu = net.run(x.clone()).unwrap(); + // agrees on the probe (two convs), then fails on the second conv of the next run + net.attach_checked(Arc::new(Fake { skew: 0.0, fail_after: 3.into() }), &x, 1e-4, false).unwrap(); + let out = net.run(x).unwrap(); + assert!(net.accelerator().is_none()); + for (a, b) in out[0].1.data.iter().zip(&cpu[0].1.data) { + assert!((a - b).abs() < 1e-4); + } +} + +/// An accelerator that runs whole programs with plain loops (the definition of each step), counting its calls; +/// `poison` makes it give a value that is not finite. +struct ProgramFake { + calls: std::sync::atomic::AtomicUsize, + poison: bool, +} + +fn run_steps(p: &program::Program, input: &[f32]) -> Vec> { + use program::{Arg, Binary, Op, Unary}; + let mut values: Vec> = vec![Vec::new(); p.lens.len()]; + values[program::INPUT] = input.to_vec(); + for step in &p.steps { + let arg = |i: usize| -> Vec { + match &step.args[i] { + Arg::Value(v) => values[*v].clone(), + Arg::Const { data, .. } => data.to_vec(), + } + }; + let len = p.lens[step.out]; + let x = arg(0); + let out: Vec = match &step.op { + Op::Conv(j) => { + let (m, cg) = (j.cout / j.groups, j.cin / j.groups); + let mut out = vec![0f32; len]; + for co in 0..j.cout { + for oy in 0..j.oh { + for ox in 0..j.ow { + let mut v = j.bias.map_or(0.0, |b| b[co]); + for ci in 0..cg { + for ky in 0..j.kh { + for kx in 0..j.kw { + let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize); + if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w { + v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx] + * x[((co / m * cg + ci) * j.h + iy as usize) * j.w + ix as usize]; + } + } + } + } + out[(co * j.oh + oy) * j.ow + ox] = v; + } + } + } + out + } + Op::Unary(Unary::Relu) => x.iter().map(|v| v.max(0.0)).collect(), + Op::Unary(Unary::Sigmoid) => x.iter().map(|v| 1.0 / (1.0 + (-v).exp())).collect(), + Op::ChannelAffine { channels, plane } => { + let (s, o) = (arg(1), arg(2)); + x.iter().enumerate().map(|(i, v)| v * s[(i / plane) % channels] + o[(i / plane) % channels]).collect() + } + Op::ChannelPrelu { channels, plane } => { + let a = arg(1); + x.iter().enumerate().map(|(i, v)| if *v < 0.0 { v * a[(i / plane) % channels] } else { *v }).collect() + } + Op::Binary { op, dims, a, b } => { + let y = arg(1); + (0..len) + .map(|i| { + let (mut rest, mut ia, mut ib) = (i, 0, 0); + for d in (0..4).rev() { + let at = rest % dims[d]; + rest /= dims[d]; + ia += at * a[d]; + ib += at * b[d]; + } + match op { + Binary::Add => x[ia] + y[ib], + Binary::Sub => x[ia] - y[ib], + Binary::Mul => x[ia] * y[ib], + } + }) + .collect() + } + Op::Transpose { dims, strides } => (0..len) + .map(|i| { + let (mut rest, mut from) = (i, 0); + for d in (0..4).rev() { + from += (rest % dims[d]) * strides[d]; + rest /= dims[d]; + } + x[from] + }) + .collect(), + Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => (0..len) + .map(|i| { + let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); + let mut m = f32::NEG_INFINITY; + for ky in 0..*kh { + for kx in 0..*kw { + m = m.max(x[(c * h + oy * sh + ky) * w + ox * sw + kx]); + } + } + m + }) + .collect(), + Op::Resize { h, w, sh, sw, .. } => (0..len) + .map(|i| { + let (ow, oh) = (w * sw, h * sh); + let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); + x[(c * h + oy / sh) * w + ox / sw] + }) + .collect(), + Op::GlobalAverage { plane, .. } => x.chunks(*plane).map(|c| c.iter().sum::() / *plane as f32).collect(), + Op::Gemv { k, n, trans_b, alpha } => { + let (wt, bias) = (arg(1), arg(2)); + (0..*n).map(|o| alpha * (0..*k).map(|j| x[j] * wt[if *trans_b { o * k + j } else { j * n + o }]).sum::() + bias[o]).collect() + } + }; + assert_eq!(out.len(), len, "{:?}", step.op); + values[step.out] = out; + for f in &step.free { + values[*f] = Vec::new(); + } + } + p.outputs.iter().map(|(_, v, _)| values[*v].clone()).collect() +} + +impl Accelerator for ProgramFake { + fn device(&self) -> String { + "program test".into() + } + fn conv(&self, _: &ConvJob) -> std::result::Result>, String> { + Ok(None) + } + fn run_program(&self, p: &program::Program, inputs: &[&[f32]]) -> std::result::Result>>>, String> { + self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut out: Vec>> = inputs.iter().map(|x| run_steps(p, x)).collect(); + if self.poison { + out[0][0][0] = f32::NAN; + } + Ok(Some(out)) + } +} + +fn every_operator_input(seed: usize) -> Tensor { + input(4, 8, 8, |i| (((i * 31 + seed * 7) % 37) as f32 - 18.0) * 0.05) +} + +#[test] +fn a_program_of_every_operator_gives_what_the_interpreter_gives() { + let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); + let x = every_operator_input(0); + let cpu = net.run(x.clone()).unwrap(); + let fake = Arc::new(ProgramFake { calls: 0.into(), poison: false }); + net.attach_checked(fake.clone(), &x, 1e-5, false).unwrap(); + let calls = fake.calls.load(std::sync::atomic::Ordering::Relaxed); + assert!(calls > 0, "the whole network ran as one program"); + // several inputs go to the device together, and each gets its own outputs + let inputs: Vec = (0..3).map(every_operator_input).collect(); + let together = net.run_batch(inputs.clone()).unwrap(); + assert_eq!(fake.calls.load(std::sync::atomic::Ordering::Relaxed), calls + 1, "one dispatch for the batch"); + net.detach(); + for (x, got) in inputs.into_iter().zip(&together) { + let want = net.run(x).unwrap(); + agree(&want, got, 1e-5).unwrap(); + } + assert_eq!(cpu.len(), 2); +} + +#[test] +fn a_device_that_gives_values_that_are_not_finite_is_dropped() { + let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); + let x = every_operator_input(1); + let cpu = net.run(x.clone()).unwrap(); + assert!(net.attach_checked(Arc::new(ProgramFake { calls: 0.into(), poison: true }), &x, 1e-5, false).is_err()); + assert!(net.accelerator().is_none()); + assert_eq!(net.run(x).unwrap(), cpu); +} + +#[test] +fn unsupported_and_inconsistent_models_are_errors() { + let x = input(1, 3, 3, |i| i as f32); + for (what, g) in [ + ("unknown op", graph(vec![node("Softmax", &["x"], &[])], vec![])), + ( + "dilated conv", + graph(vec![node("Conv", &["x", "w"], &[("dilations", Attr::Ints(vec![2, 2]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))]), + ), + ("conv without weights", graph(vec![node("Conv", &["x", "w"], &[])], vec![])), + ("channels do not match", graph(vec![node("Conv", &["x", "w"], &[])], vec![("w", w(&[1, 5, 1, 1], vec![1.0; 5]))])), + ("kernel too big", graph(vec![node("Conv", &["x", "w"], &[])], vec![("w", w(&[1, 1, 5, 5], vec![1.0; 25]))])), + ("zero group", graph(vec![node("Conv", &["x", "w"], &[("group", Attr::Int(0))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))])), + ("zero stride", graph(vec![node("Conv", &["x", "w"], &[("strides", Attr::Ints(vec![0, 0]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))])), + ( + "negative pad", + graph(vec![node("Conv", &["x", "w"], &[("pads", Attr::Ints(vec![-1, 0, 0, 0]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))]), + ), + ("bad perm", graph(vec![node("Transpose", &["x"], &[("perm", Attr::Ints(vec![0, 0, 1, 2]))])], vec![])), + ( + "huge reshape", + graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![i64::MAX, 1]) })]), + ), + ("reshape mismatch", graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![4, 4]) })])), + ("non-integer resize", graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 1.5, 1.5]))])), + ("add that broadcasts", graph(vec![node("Add", &["x", "c"], &[])], vec![])), + ] { + assert!(run(g, x.clone()).is_err(), "{what}"); + } +} From 581555e248c4dc21264ce39475858b368dcb5470 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:59 +0000 Subject: [PATCH 054/191] refactor(lightcraft-pipeline): move optics.rs tests to crates/pipeline/src/optics/tests.rs --- crates/pipeline/src/optics.rs | 486 +--------------------------- crates/pipeline/src/optics/tests.rs | 483 +++++++++++++++++++++++++++ 2 files changed, 484 insertions(+), 485 deletions(-) create mode 100644 crates/pipeline/src/optics/tests.rs diff --git a/crates/pipeline/src/optics.rs b/crates/pipeline/src/optics.rs index 31e8d1351..b0d7cc7f9 100644 --- a/crates/pipeline/src/optics.rs +++ b/crates/pipeline/src/optics.rs @@ -650,488 +650,4 @@ fn min_filter(p: &Plane, r: usize, max: bool) -> Plane { } #[cfg(test)] -mod tests { - use super::*; - - // An empty image (no pixel to sample) has a fingerprint, its dimensions' (indexing its first pixel panicked) - #[test] - fn an_empty_image_has_a_fingerprint() { - assert_eq!(fingerprint(&Rgb32f::new(0, 0)), fingerprint(&Rgb32f::new(0, 0))); - assert_ne!(fingerprint(&Rgb32f::new(0, 3)), fingerprint(&Rgb32f::new(3, 0))); - } - - /// splitmix64: deterministic pseudo-random numbers for the warp tests. - struct Rng(u64); - - impl Rng { - fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - } - /// Uniform in `lo..hi`. - fn range(&mut self, lo: f64, hi: f64) -> f64 { - lo + (hi - lo) * ((self.next() >> 11) as f64 / (1u64 << 53) as f64) - } - /// `range(lo, hi)` or, one time in four, exactly 0 (to take the "no correction" branches). - fn maybe(&mut self, lo: f64, hi: f64) -> f64 { - if self.next().is_multiple_of(4) { 0.0 } else { self.range(lo, hi) } - } - } - - /// A pseudo-random warp: perspective, manual distortion, CA and an embedded lens warp, each sometimes off. - /// Sizes are Pythagorean (exact `hypot`), so results don't depend on the platform's libm. - fn random_warp(r: &mut Rng) -> Warp { - const SIZES: [(f64, f64); 6] = [(4000.0, 3000.0), (3000.0, 4000.0), (1200.0, 900.0), (1200.0, 500.0), (56.0, 33.0), (6000.0, 4500.0)]; - let (w, h) = SIZES[(r.next() % SIZES.len() as u64) as usize]; - let mut wp = Warp::identity(w, h); - if !r.next().is_multiple_of(4) { - let m = [ - 1.0 + r.range(-0.2, 0.2), - r.range(-0.2, 0.2), - r.range(-0.1, 0.1), - r.range(-0.2, 0.2), - 1.0 + r.range(-0.2, 0.2), - r.range(-0.1, 0.1), - r.range(-0.4, 0.4), - r.range(-0.4, 0.4), - 1.0 + r.range(-0.1, 0.1), - ]; - wp.persp_inv = Homography(m); - wp.persp = wp.persp_inv.inverse().unwrap_or(Homography::IDENTITY); - } - wp.k1 = r.maybe(-0.2, 0.2); - wp.ca = [r.maybe(-0.003, 0.003), r.maybe(-0.003, 0.003), r.maybe(-0.003, 0.003)]; - if !r.next().is_multiple_of(4) { - let mut plane = || { - [ - 1.0 + r.range(-0.05, 0.05), - r.range(-0.08, 0.08), - r.range(-0.05, 0.05), - r.range(-0.02, 0.02), - r.range(-0.003, 0.003), - r.range(-0.003, 0.003), - ] - }; - let planes = [plane(), plane(), plane()]; - let warp = EmbeddedWarp { planes, center: Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), radius: r.range(0.5, 0.8) }; - wp.lens = Some(EmbeddedLens { warp: Some(warp), vignette: None, ..Default::default() }); - wp.lens_dist = r.maybe(0.0, 2.0); - } - wp - } - - /// FNV-1a over the bits of the f64 warp results (`Affine::apply`, `Homography::apply`, `to_corrected`, - /// `from_corrected`, `corrected_to_source`, `to_source`, `embedded_warp`) for pseudo-random warps and points. - /// The expected value was captured before these formulas became generic over `Real`: the f64 path must keep - /// the same operations in the same order, bit for bit. To recompute it after changing the inputs, run this - /// test (printing `hash`) on a scratch copy of 419261c (thumb-split, pre-generic formulas) and paste it here. - #[test] - fn warp_f64_results_are_bit_identical_to_the_pre_generic_formulas() { - let mut hash = 0xCBF2_9CE4_8422_2325u64; - let mut eat = |p: Point| { - for v in [p.x, p.y] { - for b in v.to_bits().to_le_bytes() { - hash = (hash ^ b as u64).wrapping_mul(0x0000_0100_0000_01B3); - } - } - }; - let mut r = Rng(42); - for _ in 0..2000 { - let wp = random_warp(&mut r); - let a = lightcraft_geom::Affine([ - r.range(-2.0, 2.0), - r.range(-2.0, 2.0), - r.range(-2.0, 2.0), - r.range(-2.0, 2.0), - r.range(-500.0, 500.0), - r.range(-500.0, 500.0), - ]); - for _ in 0..40 { - let p = Point::new(r.range(-0.2, 1.2) * wp.w, r.range(-0.2, 1.2) * wp.h); - eat(a.apply(p)); - eat(wp.persp_inv.apply(Point::new(p.x / wp.w - 0.5, p.y / wp.h - 0.5))); - eat(wp.to_corrected(p)); - eat(wp.from_corrected(p)); - for ch in 0..3 { - eat(wp.corrected_to_source(p, ch)); - eat(wp.to_source(p, ch)); - } - if let Some(ew) = wp.lens.and_then(|l| l.warp) { - for ch in 0..4 { - eat(embedded_warp(&ew, p, ch, wp.w, wp.h)); - } - } - } - } - // `Homography::apply`'s near-zero denominator clamp, including subnormal and signed-zero denominators and - // ±1e-300 exactly (not clamped: `<`, not `<=`) - let hz = Homography([1.0, 0.5, 0.25, -0.5, 1.0, 0.125, 1.0, 0.0, 0.0]); - for x in [0.0, -0.0, 1e-310, -1e-310, 1e-300, -1e-300, 1e-299, -1e-299, 0.5] { - eat(hz.apply(Point::new(x, 0.75))); - } - assert_eq!(hash, 0x3FA5_7473_D795_9E32, "f64 warp results changed"); - } - - /// The interval evaluation of `framing` bounds the f64 evaluation of every point of the box. - #[test] - fn interval_framing_encloses_every_f64_result() { - let mut r = Rng(7); - let (mut decided, mut total) = (0, 0); - for _ in 0..500 { - let wp = random_warp(&mut r); - let a = lightcraft_geom::Affine([ - r.range(0.5, 2.0), - r.range(-0.2, 0.2), - r.range(-0.2, 0.2), - r.range(0.5, 2.0), - r.range(-300.0, 300.0), - r.range(-300.0, 300.0), - ]); - for _ in 0..20 { - let (x0, y0) = (r.range(-0.2, 1.1) * wp.w, r.range(-0.2, 1.1) * wp.h); - let (bw, bh) = (r.range(0.0, 40.0), r.range(0.0, 40.0)); - let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh)); - let (_, (sx, sy)) = wp.framing(&a, ix, iy); - total += 1; - if !(sx.is_decided() && sy.is_decided()) { - continue; - } - decided += 1; - for i in 0..=6 { - for j in 0..=6 { - let (x, y) = if i == 6 && j == 6 { - (r.range(x0, x0 + bw), r.range(y0, y0 + bh)) - } else { - (x0 + bw * i as f64 / 5.0, y0 + bh * j as f64 / 5.0) - }; - let (x, y) = (x.clamp(ix.lo(), ix.hi()), y.clamp(iy.lo(), iy.hi())); - let (_, (fx, fy)) = wp.framing(&a, x, y); - assert!( - sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), - "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}" - ); - } - } - } - } - assert!(decided * 10 > total * 9, "{decided}/{total} boxes bounded"); - } - - #[test] - fn block_coverage_agrees_with_every_pixel() { - let lens = EmbeddedLens { - warp: Some(EmbeddedWarp { - planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002], [1.0, -0.028, 0.01, 0.0, 0.001, -0.002], [1.0, -0.026, 0.01, 0.0, 0.001, -0.002]], - center: Point::new(0.52, 0.48), - radius: 0.6, - }), - vignette: None, - ..Default::default() - }; - let mut cases: Vec = Vec::new(); - let mut s = DevelopSettings::default(); - s.optics.lens_profile = true; - s.optics.ca_red = 30.0; - s.geometry.horizontal = -15.0; - cases.push(s.clone()); - s.orientation = Orientation::Rotate270; - cases.push(s.clone()); - let mut s = DevelopSettings::default(); - s.optics.distortion = -60.0; - s.optics.ca_blue = 50.0; - s.geometry.vertical = 25.0; - s.geometry.rotate = 7.0; - cases.push(s.clone()); - s.optics.distortion = 80.0; - s.geometry.horizontal = 30.0; - s.orientation = Orientation::Rotate90; - cases.push(s); - for (i, s) in cases.iter().enumerate() { - let f = crate::geometry::Frame::with_lens(900, 600, s, true, Some(&lens)); - let wp = f.warp.as_ref().expect("warp"); - let (w, h) = f.fit(700, 700); - let o2t = f.out_to_oriented(w, h); - let (mut decided, mut tiles, mut blank) = (0, 0, 0); - for (tw, th) in [(32, 8), (7, 5), (64, 64)] { - for y0 in (0..h).step_by(th) { - for x0 in (0..w).step_by(tw) { - let (x1, y1) = ((x0 + tw).min(w), (y0 + th).min(h)); - tiles += 1; - let Some(all) = wp.block_coverage(&o2t, x0, x1, y0, y1) else { continue }; - decided += 1; - for y in y0..y1 { - for x in x0..x1 { - assert_eq!(wp.covers(&o2t, x, y), all, "case {i}: pixel {x},{y} in block {x0}..{x1} × {y0}..{y1}"); - blank += !all as usize; - } - } - } - } - } - // most blocks are decided wholesale, including blank ones away from the edge - assert!(decided * 10 > tiles * 8, "case {i}: {decided}/{tiles} blocks decided"); - // strong distortion leaves blank margins wide enough for whole blocks - assert!(blank > 0 || i < 2, "case {i}: no blank block decided"); - assert_eq!(wp.block_coverage(&o2t, 5, 5, 0, 1), None, "empty block"); - } - } - - /// A grid of dark lines on white with lateral CA: red magnified by `ar`, blue by `ab` (about the centre). - fn ca_grid(w: usize, h: usize, ar: f64, ab: f64) -> Rgb32f { - let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0); - let val = |x: f64, y: f64| -> f32 { - // smooth-edged discs on a grid (edges in every direction) - let cell = w as f64 / 10.0; - let (fx, fy) = ((x / cell).fract() - 0.5, (y / cell).fract() - 0.5); - let d = fx.hypot(fy) * cell - cell * 0.28; - (0.05 + 0.85 * (d / 1.2).clamp(-0.5, 0.5) + 0.425) as f32 - }; - Rgb32f::from_fn(w, h, |x, y| { - let (x, y) = (x as f64 + 0.5, y as f64 + 0.5); - let at = |a: f64| val(cx + (x - cx) / (1.0 + a), cy + (y - cy) / (1.0 + a)); - [at(ar), at(0.0), at(ab)] - }) - } - - #[test] - fn ca_estimate_recovers_scale() { - let img = ca_grid(600, 400, 0.004, -0.003); - let [r, b] = estimate_lateral_ca_uncached(&img); - assert!((r - 0.004).abs() < 0.0008, "red {r}"); - assert!((b + 0.003).abs() < 0.0008, "blue {b}"); - let clean = ca_grid(600, 400, 0.0, 0.0); - let [r, b] = estimate_lateral_ca_uncached(&clean); - assert!(r.abs() < 0.0005 && b.abs() < 0.0005, "{r} {b}"); - } - - #[test] - fn warp_identity_and_distortion_direction() { - let s = DevelopSettings::default(); - let wp = Warp::from_settings(300.0, 200.0, &s, None); - assert!(wp.is_identity()); - let mut s = s; - s.optics.distortion = 50.0; // corrects barrel: corners sample from further in - let wp = Warp::from_settings(300.0, 200.0, &s, None); - let q = wp.to_source(Point::new(0.0, 0.0), 1); - assert!(q.x > 0.0 && q.y > 0.0); - let c = wp.to_source(Point::new(150.0, 100.0), 1); - assert!(c.dist(Point::new(150.0, 100.0)) < 1e-9); - } - - #[test] - fn manual_vignette_brightens_corners() { - let mut s = DevelopSettings::default(); - s.optics.vignetting = 100.0; - let wp = Warp::from_settings(300.0, 200.0, &s, None); - assert!((wp.gain(Point::new(150.0, 100.0)) - 1.0).abs() < 1e-6); - assert!(wp.gain(Point::new(0.0, 0.0)) > 2.0); - } - - #[test] - fn reorient_keeps_centre_mapping() { - let lens = EmbeddedLens { - warp: Some(EmbeddedWarp { planes: [[1.0, 0.01, 0.0, 0.0, 0.001, 0.002]; 3], center: Point::new(0.4, 0.5), radius: 0.6 }), - vignette: Some(EmbeddedVignette { k: [0.2, 0.0, 0.0, 0.0, 0.0], center: Point::new(0.4, 0.5), radius: 0.6 }), - ..Default::default() - }; - let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); - // rotate 90° cw: (x, y) → (h − y, x) - let c = r.warp.unwrap().center; - assert!((c.x - 0.5).abs() < 1e-9 && (c.y - 0.4).abs() < 1e-9, "{c:?}"); - // a point's warped position transforms like the point itself - let p = Point::new(50.0, 30.0); - let s0 = embedded_warp(&lens.warp.unwrap(), p, 0, 300.0, 200.0); - let m = |q: Point| { - let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0); - Point::new(x, y) - }; - let s1 = embedded_warp(&r.warp.unwrap(), m(p), 0, 200.0, 300.0); - assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); - } - - /// A `WarpRectilinear2` stage with the same coefficients on every plane. - fn stage(kr: &[(usize, f64)], center: Point, radius: f64, reciprocal: bool) -> EmbeddedWarpStage { - let mut k = [0.0; 19]; - for &(i, v) in kr { - k[i] = v; - } - k[18] = 1.0; - EmbeddedWarpStage { planes: [k; 3], center, radius, reciprocal } - } - - fn chain(stages: &[EmbeddedWarpStage]) -> Warp { - let mut wp = Warp::identity(300.0, 200.0); - let mut lens = EmbeddedLens::default(); - for (slot, st) in lens.stages.iter_mut().zip(stages) { - *slot = Some(*st); - } - wp.lens = Some(lens); - wp.lens_dist = 1.0; - wp - } - - #[test] - fn warp_rectilinear2_stages_identity_chain_order_and_reciprocal() { - let c = Point::new(0.5, 0.5); - let p = Point::new(40.0, 25.0); - // identity coefficients (kr0 = 1), plain and reciprocal, leave positions alone and don't move pixels - for reciprocal in [false, true] { - let wp = chain(&[stage(&[(0, 1.0)], c, 0.6, reciprocal)]); - assert!(wp.moves_pixels() && !wp.per_channel()); - assert!(wp.corrected_to_source(p, 1).dist(p) < 1e-9); - } - // the radial factor at r is f(r); with kr0 = 1, kr1 = 0.5: s = d·(1 + 0.5 r) (and d / (1 + 0.5 r) reciprocal) - let m = 0.6 * 300.0; - let d = Point::new((p.x - 150.0) / m, (p.y - 100.0) / m); - let r = d.x.hypot(d.y); - let fwd = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, false)]).corrected_to_source(p, 0); - assert!((fwd.x - (150.0 + m * d.x * (1.0 + 0.5 * r))).abs() < 1e-9); - let rec = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, true)]).corrected_to_source(p, 0); - assert!((rec.y - (100.0 + m * d.y / (1.0 + 0.5 * r))).abs() < 1e-9); - // list order: [A, B] maps an output position through B first, then A - let a = stage(&[(0, 1.0), (2, -0.3)], c, 0.6, false); - let b = stage(&[(0, 1.1), (1, 0.2)], Point::new(0.45, 0.5), 0.6, false); - let ab = chain(&[a, b]); - let by_hand = chain(&[a]).corrected_to_source(chain(&[b]).corrected_to_source(p, 1), 1); - assert!(ab.corrected_to_source(p, 1).dist(by_hand) < 1e-9); - assert!(ab.corrected_to_source(p, 1).dist(chain(&[b, a]).corrected_to_source(p, 1)) > 1e-3, "order matters"); - // strength scales the correction; a lens without a chain or warp does not move pixels - let mut half = ab.clone(); - half.lens_dist = 0.5; - let (full, mid) = (ab.corrected_to_source(p, 1), half.corrected_to_source(p, 1)); - assert!(mid.dist(Point::new((p.x + full.x) / 2.0, (p.y + full.y) / 2.0)) < 1e-9); - assert!(ab.needs_f64() && !chain(&[]).moves_pixels() && !Warp::identity(3.0, 3.0).needs_f64()); - // planes that differ (lateral CA) are sampled separately - let mut st = stage(&[(0, 1.0)], c, 0.6, false); - st.planes[0][0] = 1.01; - assert!(chain(&[st]).per_channel()); - } - - #[test] - fn warp_rectilinear2_valid_radius_clamps_the_polynomial() { - let c = Point::new(0.5, 0.5); - let mut st = stage(&[(0, 1.0), (1, 1.0)], c, 0.5, false); - st.planes.iter_mut().for_each(|k| (k[17], k[18]) = (0.2, 0.4)); - let wp = chain(&[st]); - let m = 0.5 * 300.0; - // far out (r > 0.4): f stays f(0.4) = 1.4 - let p = Point::new(10.0, 100.0); - let d = (p.x - 150.0) / m; - assert!(d.abs() > 0.4); - assert!((wp.corrected_to_source(p, 0).x - (150.0 + m * d * 1.4)).abs() < 1e-9); - // near the centre (r < 0.2): f stays f(0.2) = 1.2 - let p = Point::new(150.0 + 0.1 * m, 100.0); - assert!((wp.corrected_to_source(p, 0).x - (150.0 + 0.1 * m * 1.2)).abs() < 1e-9); - } - - #[test] - fn warp_rectilinear2_interval_framing_encloses_every_f64_result() { - let mut r = Rng(11); - for _ in 0..200 { - let mut stages = Vec::new(); - for _ in 0..2 { - let mut st = stage(&[], Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), r.range(0.5, 0.8), r.next().is_multiple_of(3)); - for k in st.planes.iter_mut() { - k[0] = 1.0 + r.range(-0.05, 0.05); - for v in k[1..15].iter_mut() { - *v = r.maybe(-0.5, 0.5); - } - (k[15], k[16]) = (r.range(-0.003, 0.003), r.range(-0.003, 0.003)); - (k[17], k[18]) = (r.maybe(0.0, 0.3), r.range(0.5, 1.0)); - } - stages.push(st); - } - let wp = chain(&stages); - let a = lightcraft_geom::Affine([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); - for _ in 0..20 { - let (x0, y0) = (r.range(0.0, 1.0) * wp.w, r.range(0.0, 1.0) * wp.h); - let (bw, bh) = (r.range(0.0, 20.0), r.range(0.0, 20.0)); - let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh)); - let (_, (sx, sy)) = wp.framing(&a, ix, iy); - if !(sx.is_decided() && sy.is_decided()) { - continue; - } - for i in 0..=4 { - for j in 0..=4 { - let (x, y) = ((x0 + bw * i as f64 / 4.0).clamp(ix.lo(), ix.hi()), (y0 + bh * j as f64 / 4.0).clamp(iy.lo(), iy.hi())); - let (_, (fx, fy)) = wp.framing(&a, x, y); - assert!( - sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), - "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}" - ); - } - } - } - } - } - - #[test] - fn reorient_rotates_a_warp_rectilinear2_stage_like_the_point() { - let mut st = stage(&[(0, 1.0), (1, 0.05), (2, -0.1)], Point::new(0.4, 0.5), 0.6, false); - st.planes.iter_mut().for_each(|k| (k[15], k[16]) = (0.001, 0.002)); - let mut lens = EmbeddedLens::default(); - lens.stages[0] = Some(st); - let p = Point::new(50.0, 30.0); - let m = |q: Point| { - let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0); - Point::new(x, y) - }; - let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); - let s0 = embedded_stage(&st, p, 0, 300.0, 200.0); - let s1 = embedded_stage(&r.stages[0].unwrap(), m(p), 0, 200.0, 300.0); - assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); - assert_eq!(reorient_lens(&lens, Orientation::Normal, 300.0, 200.0), lens); - } - - #[test] - fn defringe_removes_purple_at_edges_only() { - let mut s = DevelopSettings::default(); - s.optics.defringe_purple_amount = 10.0; - // white | black edge with a purple column next to it, and a purple patch far from edges - let purple = [0.25f32, 0.05, 0.45]; - let mut img = Rgb32f::from_fn(40, 20, |x, _| if x < 20 { [0.9; 3] } else { [0.02; 3] }); - for y in 0..20 { - img.set(20, y, purple); - } - let flat = Rgb32f::filled(40, 20, purple); - let mut flat2 = flat.clone(); - defringe(&mut img, &s, 1.0); - defringe(&mut flat2, &s, 1.0); - let chroma = |p: [f32; 3]| { - let l = oklab_from_2020(p); - l[1].hypot(l[2]) - }; - assert!(chroma(img.get(20, 10)) < chroma(purple) * 0.3); - assert!((chroma(flat2.get(20, 10)) - chroma(purple)).abs() < 1e-4); - } - - /// Found by fuzzing catalog snapshots: a defringe amount far past the sliders' 20 (from a - /// damaged catalog or sidecar) made the filter's radius overflow (`x + r`), which panics. - /// It acts as the sliders' end. - #[test] - fn defringe_amounts_past_the_sliders_act_as_their_end() { - let purple = [0.25f32, 0.05, 0.45]; - let scene = Rgb32f::from_fn(40, 20, |x, _| { - if x < 20 { - [0.9; 3] - } else if x == 20 { - purple - } else { - [0.02; 3] - } - }); - let at = |amount: f64, scale: f64| { - let mut s = DevelopSettings::default(); - s.optics.defringe_purple_amount = amount; - s.optics.defringe_green_amount = amount / 2.0; - let mut img = scene.clone(); - defringe(&mut img, &s, scale); - img - }; - assert_eq!(at(1e300, 1.0).data, at(20.0, 1.0).data); - assert_eq!(at(f64::MAX, 1e300).data.len(), 800, "a huge scale too"); - } -} +mod tests; diff --git a/crates/pipeline/src/optics/tests.rs b/crates/pipeline/src/optics/tests.rs new file mode 100644 index 000000000..228a99f4e --- /dev/null +++ b/crates/pipeline/src/optics/tests.rs @@ -0,0 +1,483 @@ +use super::*; + +// An empty image (no pixel to sample) has a fingerprint, its dimensions' (indexing its first pixel panicked) +#[test] +fn an_empty_image_has_a_fingerprint() { + assert_eq!(fingerprint(&Rgb32f::new(0, 0)), fingerprint(&Rgb32f::new(0, 0))); + assert_ne!(fingerprint(&Rgb32f::new(0, 3)), fingerprint(&Rgb32f::new(3, 0))); +} + +/// splitmix64: deterministic pseudo-random numbers for the warp tests. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + /// Uniform in `lo..hi`. + fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + (hi - lo) * ((self.next() >> 11) as f64 / (1u64 << 53) as f64) + } + /// `range(lo, hi)` or, one time in four, exactly 0 (to take the "no correction" branches). + fn maybe(&mut self, lo: f64, hi: f64) -> f64 { + if self.next().is_multiple_of(4) { 0.0 } else { self.range(lo, hi) } + } +} + +/// A pseudo-random warp: perspective, manual distortion, CA and an embedded lens warp, each sometimes off. +/// Sizes are Pythagorean (exact `hypot`), so results don't depend on the platform's libm. +fn random_warp(r: &mut Rng) -> Warp { + const SIZES: [(f64, f64); 6] = [(4000.0, 3000.0), (3000.0, 4000.0), (1200.0, 900.0), (1200.0, 500.0), (56.0, 33.0), (6000.0, 4500.0)]; + let (w, h) = SIZES[(r.next() % SIZES.len() as u64) as usize]; + let mut wp = Warp::identity(w, h); + if !r.next().is_multiple_of(4) { + let m = [ + 1.0 + r.range(-0.2, 0.2), + r.range(-0.2, 0.2), + r.range(-0.1, 0.1), + r.range(-0.2, 0.2), + 1.0 + r.range(-0.2, 0.2), + r.range(-0.1, 0.1), + r.range(-0.4, 0.4), + r.range(-0.4, 0.4), + 1.0 + r.range(-0.1, 0.1), + ]; + wp.persp_inv = Homography(m); + wp.persp = wp.persp_inv.inverse().unwrap_or(Homography::IDENTITY); + } + wp.k1 = r.maybe(-0.2, 0.2); + wp.ca = [r.maybe(-0.003, 0.003), r.maybe(-0.003, 0.003), r.maybe(-0.003, 0.003)]; + if !r.next().is_multiple_of(4) { + let mut plane = || { + [ + 1.0 + r.range(-0.05, 0.05), + r.range(-0.08, 0.08), + r.range(-0.05, 0.05), + r.range(-0.02, 0.02), + r.range(-0.003, 0.003), + r.range(-0.003, 0.003), + ] + }; + let planes = [plane(), plane(), plane()]; + let warp = EmbeddedWarp { planes, center: Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), radius: r.range(0.5, 0.8) }; + wp.lens = Some(EmbeddedLens { warp: Some(warp), vignette: None, ..Default::default() }); + wp.lens_dist = r.maybe(0.0, 2.0); + } + wp +} + +/// FNV-1a over the bits of the f64 warp results (`Affine::apply`, `Homography::apply`, `to_corrected`, +/// `from_corrected`, `corrected_to_source`, `to_source`, `embedded_warp`) for pseudo-random warps and points. +/// The expected value was captured before these formulas became generic over `Real`: the f64 path must keep +/// the same operations in the same order, bit for bit. To recompute it after changing the inputs, run this +/// test (printing `hash`) on a scratch copy of 419261c (thumb-split, pre-generic formulas) and paste it here. +#[test] +fn warp_f64_results_are_bit_identical_to_the_pre_generic_formulas() { + let mut hash = 0xCBF2_9CE4_8422_2325u64; + let mut eat = |p: Point| { + for v in [p.x, p.y] { + for b in v.to_bits().to_le_bytes() { + hash = (hash ^ b as u64).wrapping_mul(0x0000_0100_0000_01B3); + } + } + }; + let mut r = Rng(42); + for _ in 0..2000 { + let wp = random_warp(&mut r); + let a = lightcraft_geom::Affine([ + r.range(-2.0, 2.0), + r.range(-2.0, 2.0), + r.range(-2.0, 2.0), + r.range(-2.0, 2.0), + r.range(-500.0, 500.0), + r.range(-500.0, 500.0), + ]); + for _ in 0..40 { + let p = Point::new(r.range(-0.2, 1.2) * wp.w, r.range(-0.2, 1.2) * wp.h); + eat(a.apply(p)); + eat(wp.persp_inv.apply(Point::new(p.x / wp.w - 0.5, p.y / wp.h - 0.5))); + eat(wp.to_corrected(p)); + eat(wp.from_corrected(p)); + for ch in 0..3 { + eat(wp.corrected_to_source(p, ch)); + eat(wp.to_source(p, ch)); + } + if let Some(ew) = wp.lens.and_then(|l| l.warp) { + for ch in 0..4 { + eat(embedded_warp(&ew, p, ch, wp.w, wp.h)); + } + } + } + } + // `Homography::apply`'s near-zero denominator clamp, including subnormal and signed-zero denominators and + // ±1e-300 exactly (not clamped: `<`, not `<=`) + let hz = Homography([1.0, 0.5, 0.25, -0.5, 1.0, 0.125, 1.0, 0.0, 0.0]); + for x in [0.0, -0.0, 1e-310, -1e-310, 1e-300, -1e-300, 1e-299, -1e-299, 0.5] { + eat(hz.apply(Point::new(x, 0.75))); + } + assert_eq!(hash, 0x3FA5_7473_D795_9E32, "f64 warp results changed"); +} + +/// The interval evaluation of `framing` bounds the f64 evaluation of every point of the box. +#[test] +fn interval_framing_encloses_every_f64_result() { + let mut r = Rng(7); + let (mut decided, mut total) = (0, 0); + for _ in 0..500 { + let wp = random_warp(&mut r); + let a = lightcraft_geom::Affine([ + r.range(0.5, 2.0), + r.range(-0.2, 0.2), + r.range(-0.2, 0.2), + r.range(0.5, 2.0), + r.range(-300.0, 300.0), + r.range(-300.0, 300.0), + ]); + for _ in 0..20 { + let (x0, y0) = (r.range(-0.2, 1.1) * wp.w, r.range(-0.2, 1.1) * wp.h); + let (bw, bh) = (r.range(0.0, 40.0), r.range(0.0, 40.0)); + let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh)); + let (_, (sx, sy)) = wp.framing(&a, ix, iy); + total += 1; + if !(sx.is_decided() && sy.is_decided()) { + continue; + } + decided += 1; + for i in 0..=6 { + for j in 0..=6 { + let (x, y) = if i == 6 && j == 6 { + (r.range(x0, x0 + bw), r.range(y0, y0 + bh)) + } else { + (x0 + bw * i as f64 / 5.0, y0 + bh * j as f64 / 5.0) + }; + let (x, y) = (x.clamp(ix.lo(), ix.hi()), y.clamp(iy.lo(), iy.hi())); + let (_, (fx, fy)) = wp.framing(&a, x, y); + assert!( + sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), + "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}" + ); + } + } + } + } + assert!(decided * 10 > total * 9, "{decided}/{total} boxes bounded"); +} + +#[test] +fn block_coverage_agrees_with_every_pixel() { + let lens = EmbeddedLens { + warp: Some(EmbeddedWarp { + planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002], [1.0, -0.028, 0.01, 0.0, 0.001, -0.002], [1.0, -0.026, 0.01, 0.0, 0.001, -0.002]], + center: Point::new(0.52, 0.48), + radius: 0.6, + }), + vignette: None, + ..Default::default() + }; + let mut cases: Vec = Vec::new(); + let mut s = DevelopSettings::default(); + s.optics.lens_profile = true; + s.optics.ca_red = 30.0; + s.geometry.horizontal = -15.0; + cases.push(s.clone()); + s.orientation = Orientation::Rotate270; + cases.push(s.clone()); + let mut s = DevelopSettings::default(); + s.optics.distortion = -60.0; + s.optics.ca_blue = 50.0; + s.geometry.vertical = 25.0; + s.geometry.rotate = 7.0; + cases.push(s.clone()); + s.optics.distortion = 80.0; + s.geometry.horizontal = 30.0; + s.orientation = Orientation::Rotate90; + cases.push(s); + for (i, s) in cases.iter().enumerate() { + let f = crate::geometry::Frame::with_lens(900, 600, s, true, Some(&lens)); + let wp = f.warp.as_ref().expect("warp"); + let (w, h) = f.fit(700, 700); + let o2t = f.out_to_oriented(w, h); + let (mut decided, mut tiles, mut blank) = (0, 0, 0); + for (tw, th) in [(32, 8), (7, 5), (64, 64)] { + for y0 in (0..h).step_by(th) { + for x0 in (0..w).step_by(tw) { + let (x1, y1) = ((x0 + tw).min(w), (y0 + th).min(h)); + tiles += 1; + let Some(all) = wp.block_coverage(&o2t, x0, x1, y0, y1) else { continue }; + decided += 1; + for y in y0..y1 { + for x in x0..x1 { + assert_eq!(wp.covers(&o2t, x, y), all, "case {i}: pixel {x},{y} in block {x0}..{x1} × {y0}..{y1}"); + blank += !all as usize; + } + } + } + } + } + // most blocks are decided wholesale, including blank ones away from the edge + assert!(decided * 10 > tiles * 8, "case {i}: {decided}/{tiles} blocks decided"); + // strong distortion leaves blank margins wide enough for whole blocks + assert!(blank > 0 || i < 2, "case {i}: no blank block decided"); + assert_eq!(wp.block_coverage(&o2t, 5, 5, 0, 1), None, "empty block"); + } +} + +/// A grid of dark lines on white with lateral CA: red magnified by `ar`, blue by `ab` (about the centre). +fn ca_grid(w: usize, h: usize, ar: f64, ab: f64) -> Rgb32f { + let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0); + let val = |x: f64, y: f64| -> f32 { + // smooth-edged discs on a grid (edges in every direction) + let cell = w as f64 / 10.0; + let (fx, fy) = ((x / cell).fract() - 0.5, (y / cell).fract() - 0.5); + let d = fx.hypot(fy) * cell - cell * 0.28; + (0.05 + 0.85 * (d / 1.2).clamp(-0.5, 0.5) + 0.425) as f32 + }; + Rgb32f::from_fn(w, h, |x, y| { + let (x, y) = (x as f64 + 0.5, y as f64 + 0.5); + let at = |a: f64| val(cx + (x - cx) / (1.0 + a), cy + (y - cy) / (1.0 + a)); + [at(ar), at(0.0), at(ab)] + }) +} + +#[test] +fn ca_estimate_recovers_scale() { + let img = ca_grid(600, 400, 0.004, -0.003); + let [r, b] = estimate_lateral_ca_uncached(&img); + assert!((r - 0.004).abs() < 0.0008, "red {r}"); + assert!((b + 0.003).abs() < 0.0008, "blue {b}"); + let clean = ca_grid(600, 400, 0.0, 0.0); + let [r, b] = estimate_lateral_ca_uncached(&clean); + assert!(r.abs() < 0.0005 && b.abs() < 0.0005, "{r} {b}"); +} + +#[test] +fn warp_identity_and_distortion_direction() { + let s = DevelopSettings::default(); + let wp = Warp::from_settings(300.0, 200.0, &s, None); + assert!(wp.is_identity()); + let mut s = s; + s.optics.distortion = 50.0; // corrects barrel: corners sample from further in + let wp = Warp::from_settings(300.0, 200.0, &s, None); + let q = wp.to_source(Point::new(0.0, 0.0), 1); + assert!(q.x > 0.0 && q.y > 0.0); + let c = wp.to_source(Point::new(150.0, 100.0), 1); + assert!(c.dist(Point::new(150.0, 100.0)) < 1e-9); +} + +#[test] +fn manual_vignette_brightens_corners() { + let mut s = DevelopSettings::default(); + s.optics.vignetting = 100.0; + let wp = Warp::from_settings(300.0, 200.0, &s, None); + assert!((wp.gain(Point::new(150.0, 100.0)) - 1.0).abs() < 1e-6); + assert!(wp.gain(Point::new(0.0, 0.0)) > 2.0); +} + +#[test] +fn reorient_keeps_centre_mapping() { + let lens = EmbeddedLens { + warp: Some(EmbeddedWarp { planes: [[1.0, 0.01, 0.0, 0.0, 0.001, 0.002]; 3], center: Point::new(0.4, 0.5), radius: 0.6 }), + vignette: Some(EmbeddedVignette { k: [0.2, 0.0, 0.0, 0.0, 0.0], center: Point::new(0.4, 0.5), radius: 0.6 }), + ..Default::default() + }; + let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); + // rotate 90° cw: (x, y) → (h − y, x) + let c = r.warp.unwrap().center; + assert!((c.x - 0.5).abs() < 1e-9 && (c.y - 0.4).abs() < 1e-9, "{c:?}"); + // a point's warped position transforms like the point itself + let p = Point::new(50.0, 30.0); + let s0 = embedded_warp(&lens.warp.unwrap(), p, 0, 300.0, 200.0); + let m = |q: Point| { + let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0); + Point::new(x, y) + }; + let s1 = embedded_warp(&r.warp.unwrap(), m(p), 0, 200.0, 300.0); + assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); +} + +/// A `WarpRectilinear2` stage with the same coefficients on every plane. +fn stage(kr: &[(usize, f64)], center: Point, radius: f64, reciprocal: bool) -> EmbeddedWarpStage { + let mut k = [0.0; 19]; + for &(i, v) in kr { + k[i] = v; + } + k[18] = 1.0; + EmbeddedWarpStage { planes: [k; 3], center, radius, reciprocal } +} + +fn chain(stages: &[EmbeddedWarpStage]) -> Warp { + let mut wp = Warp::identity(300.0, 200.0); + let mut lens = EmbeddedLens::default(); + for (slot, st) in lens.stages.iter_mut().zip(stages) { + *slot = Some(*st); + } + wp.lens = Some(lens); + wp.lens_dist = 1.0; + wp +} + +#[test] +fn warp_rectilinear2_stages_identity_chain_order_and_reciprocal() { + let c = Point::new(0.5, 0.5); + let p = Point::new(40.0, 25.0); + // identity coefficients (kr0 = 1), plain and reciprocal, leave positions alone and don't move pixels + for reciprocal in [false, true] { + let wp = chain(&[stage(&[(0, 1.0)], c, 0.6, reciprocal)]); + assert!(wp.moves_pixels() && !wp.per_channel()); + assert!(wp.corrected_to_source(p, 1).dist(p) < 1e-9); + } + // the radial factor at r is f(r); with kr0 = 1, kr1 = 0.5: s = d·(1 + 0.5 r) (and d / (1 + 0.5 r) reciprocal) + let m = 0.6 * 300.0; + let d = Point::new((p.x - 150.0) / m, (p.y - 100.0) / m); + let r = d.x.hypot(d.y); + let fwd = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, false)]).corrected_to_source(p, 0); + assert!((fwd.x - (150.0 + m * d.x * (1.0 + 0.5 * r))).abs() < 1e-9); + let rec = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, true)]).corrected_to_source(p, 0); + assert!((rec.y - (100.0 + m * d.y / (1.0 + 0.5 * r))).abs() < 1e-9); + // list order: [A, B] maps an output position through B first, then A + let a = stage(&[(0, 1.0), (2, -0.3)], c, 0.6, false); + let b = stage(&[(0, 1.1), (1, 0.2)], Point::new(0.45, 0.5), 0.6, false); + let ab = chain(&[a, b]); + let by_hand = chain(&[a]).corrected_to_source(chain(&[b]).corrected_to_source(p, 1), 1); + assert!(ab.corrected_to_source(p, 1).dist(by_hand) < 1e-9); + assert!(ab.corrected_to_source(p, 1).dist(chain(&[b, a]).corrected_to_source(p, 1)) > 1e-3, "order matters"); + // strength scales the correction; a lens without a chain or warp does not move pixels + let mut half = ab.clone(); + half.lens_dist = 0.5; + let (full, mid) = (ab.corrected_to_source(p, 1), half.corrected_to_source(p, 1)); + assert!(mid.dist(Point::new((p.x + full.x) / 2.0, (p.y + full.y) / 2.0)) < 1e-9); + assert!(ab.needs_f64() && !chain(&[]).moves_pixels() && !Warp::identity(3.0, 3.0).needs_f64()); + // planes that differ (lateral CA) are sampled separately + let mut st = stage(&[(0, 1.0)], c, 0.6, false); + st.planes[0][0] = 1.01; + assert!(chain(&[st]).per_channel()); +} + +#[test] +fn warp_rectilinear2_valid_radius_clamps_the_polynomial() { + let c = Point::new(0.5, 0.5); + let mut st = stage(&[(0, 1.0), (1, 1.0)], c, 0.5, false); + st.planes.iter_mut().for_each(|k| (k[17], k[18]) = (0.2, 0.4)); + let wp = chain(&[st]); + let m = 0.5 * 300.0; + // far out (r > 0.4): f stays f(0.4) = 1.4 + let p = Point::new(10.0, 100.0); + let d = (p.x - 150.0) / m; + assert!(d.abs() > 0.4); + assert!((wp.corrected_to_source(p, 0).x - (150.0 + m * d * 1.4)).abs() < 1e-9); + // near the centre (r < 0.2): f stays f(0.2) = 1.2 + let p = Point::new(150.0 + 0.1 * m, 100.0); + assert!((wp.corrected_to_source(p, 0).x - (150.0 + 0.1 * m * 1.2)).abs() < 1e-9); +} + +#[test] +fn warp_rectilinear2_interval_framing_encloses_every_f64_result() { + let mut r = Rng(11); + for _ in 0..200 { + let mut stages = Vec::new(); + for _ in 0..2 { + let mut st = stage(&[], Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), r.range(0.5, 0.8), r.next().is_multiple_of(3)); + for k in st.planes.iter_mut() { + k[0] = 1.0 + r.range(-0.05, 0.05); + for v in k[1..15].iter_mut() { + *v = r.maybe(-0.5, 0.5); + } + (k[15], k[16]) = (r.range(-0.003, 0.003), r.range(-0.003, 0.003)); + (k[17], k[18]) = (r.maybe(0.0, 0.3), r.range(0.5, 1.0)); + } + stages.push(st); + } + let wp = chain(&stages); + let a = lightcraft_geom::Affine([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + for _ in 0..20 { + let (x0, y0) = (r.range(0.0, 1.0) * wp.w, r.range(0.0, 1.0) * wp.h); + let (bw, bh) = (r.range(0.0, 20.0), r.range(0.0, 20.0)); + let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh)); + let (_, (sx, sy)) = wp.framing(&a, ix, iy); + if !(sx.is_decided() && sy.is_decided()) { + continue; + } + for i in 0..=4 { + for j in 0..=4 { + let (x, y) = ((x0 + bw * i as f64 / 4.0).clamp(ix.lo(), ix.hi()), (y0 + bh * j as f64 / 4.0).clamp(iy.lo(), iy.hi())); + let (_, (fx, fy)) = wp.framing(&a, x, y); + assert!( + sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), + "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}" + ); + } + } + } + } +} + +#[test] +fn reorient_rotates_a_warp_rectilinear2_stage_like_the_point() { + let mut st = stage(&[(0, 1.0), (1, 0.05), (2, -0.1)], Point::new(0.4, 0.5), 0.6, false); + st.planes.iter_mut().for_each(|k| (k[15], k[16]) = (0.001, 0.002)); + let mut lens = EmbeddedLens::default(); + lens.stages[0] = Some(st); + let p = Point::new(50.0, 30.0); + let m = |q: Point| { + let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0); + Point::new(x, y) + }; + let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); + let s0 = embedded_stage(&st, p, 0, 300.0, 200.0); + let s1 = embedded_stage(&r.stages[0].unwrap(), m(p), 0, 200.0, 300.0); + assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); + assert_eq!(reorient_lens(&lens, Orientation::Normal, 300.0, 200.0), lens); +} + +#[test] +fn defringe_removes_purple_at_edges_only() { + let mut s = DevelopSettings::default(); + s.optics.defringe_purple_amount = 10.0; + // white | black edge with a purple column next to it, and a purple patch far from edges + let purple = [0.25f32, 0.05, 0.45]; + let mut img = Rgb32f::from_fn(40, 20, |x, _| if x < 20 { [0.9; 3] } else { [0.02; 3] }); + for y in 0..20 { + img.set(20, y, purple); + } + let flat = Rgb32f::filled(40, 20, purple); + let mut flat2 = flat.clone(); + defringe(&mut img, &s, 1.0); + defringe(&mut flat2, &s, 1.0); + let chroma = |p: [f32; 3]| { + let l = oklab_from_2020(p); + l[1].hypot(l[2]) + }; + assert!(chroma(img.get(20, 10)) < chroma(purple) * 0.3); + assert!((chroma(flat2.get(20, 10)) - chroma(purple)).abs() < 1e-4); +} + +/// Found by fuzzing catalog snapshots: a defringe amount far past the sliders' 20 (from a +/// damaged catalog or sidecar) made the filter's radius overflow (`x + r`), which panics. +/// It acts as the sliders' end. +#[test] +fn defringe_amounts_past_the_sliders_act_as_their_end() { + let purple = [0.25f32, 0.05, 0.45]; + let scene = Rgb32f::from_fn(40, 20, |x, _| { + if x < 20 { + [0.9; 3] + } else if x == 20 { + purple + } else { + [0.02; 3] + } + }); + let at = |amount: f64, scale: f64| { + let mut s = DevelopSettings::default(); + s.optics.defringe_purple_amount = amount; + s.optics.defringe_green_amount = amount / 2.0; + let mut img = scene.clone(); + defringe(&mut img, &s, scale); + img + }; + assert_eq!(at(1e300, 1.0).data, at(20.0, 1.0).data); + assert_eq!(at(f64::MAX, 1e300).data.len(), 800, "a huge scale too"); +} From bf573029bba2260bad05e44307d48f1b3f05d4db Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:59 +0000 Subject: [PATCH 055/191] refactor(lightcraft-pipeline): move masks.rs tests to crates/pipeline/src/masks/tests.rs --- crates/pipeline/src/masks.rs | 455 +---------------------------- crates/pipeline/src/masks/tests.rs | 452 ++++++++++++++++++++++++++++ 2 files changed, 453 insertions(+), 454 deletions(-) create mode 100644 crates/pipeline/src/masks/tests.rs diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 36cc29192..73383d1d4 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -738,457 +738,4 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_develop::{DevelopSettings, MaskComponent}; - - fn frame(w: usize, h: usize) -> Frame { - Frame::new(w, h, &DevelopSettings::default(), true) - } - - #[test] - fn linear_gradient_ramps() { - let f = frame(100, 50); - let img = Rgb32f::new(100, 50); - let l = Plane::new(100, 50); - let a = shape_alpha(&MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, &f, 100, 50, &img, &l, 0.0, None); - assert!(a.get(1, 25) > 0.99 && a.get(98, 25) < 0.01); - assert!((a.get(50, 25) - 0.5).abs() < 0.05); - } - - #[test] - fn stored_segmentations_render_at_any_size_and_follow_the_crop() { - // the left half selected, on a 16² logit grid - let side = 16; - let logits: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); - let seg = SegMask::from_logits(side, &logits); - for shape in [ - MaskShape::Object { hint: vec![Point::new(0.25, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, - MaskShape::Prompt { text: "left".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, - ] { - for (w, h) in [(40, 20), (400, 200)] { - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(1, h / 2) > 0.99 && a.get(w - 2, h / 2) < 0.01, "{w}×{h}"); - if w >= 400 { - let soft = (0..w).filter(|x| (0.05..0.95).contains(&a.get(*x, h / 2))).count(); - assert!((2..30).contains(&soft), "an anti-aliased edge, not a ramp: {soft} px"); - } - } - } - // cropped to the right half: nothing left selected - let mut d = DevelopSettings::default(); - d.crop.geometry.rect = lightcraft_geom::Rect::from_center(Point::new(0.75, 0.5), 0.5, 1.0); - let f = Frame::new(40, 20, &d, true); - let shape = MaskShape::Prompt { text: "left".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; - let a = shape_alpha(&shape, &f, 20, 20, &Rgb32f::new(20, 20), &Plane::new(20, 20), 0.0, None); - assert!(a.data.iter().all(|v| *v < 0.05)); - } - - #[test] - fn detail_patches_replace_the_coarse_mask_inside_their_rectangle() { - // coarse: nothing; a patch over the right half selects its own left half (x 0.5..0.75) - let coarse = SegMask::from_logits(4, &[-12.0; 16]); - let side = 16; - let l: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); - let patch = SegMask::from_logits_in(side, &l, [0.5, 0.0, 1.0, 1.0]); - let shape = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(coarse), detail: vec![patch], edge: 0.0 }; - let (w, h) = (200, 100); - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(20, 50) < 0.01, "outside the patch: the coarse mask"); - assert!(a.get(120, 50) > 0.99, "inside the patch, its selected half"); - assert!(a.get(190, 50) < 0.01, "inside the patch, its unselected half"); - } - - #[test] - fn a_hostile_number_of_detail_patches_is_capped() { - // built in memory (a deserialized document is capped already): only the first - // MAX_DETAIL are decoded and sampled, so this needs 4 × 4 MB, not 20 000 × 4 MB - let side = 1024; - let l: Vec = vec![12.0; side * side]; - let patch = SegMask::from_logits_in(side, &l, [0.0, 0.0, 0.5, 1.0]); - let shape = MaskShape::Object { hint: vec![Point::new(0.2, 0.5)], exclude: vec![], seg: None, detail: vec![patch; 20_000], edge: 0.0 }; - let (w, h) = (64, 32); - let t = std::time::Instant::now(); - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(5, 16) > 0.99 && a.get(60, 16) < 0.01); - assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); - } - - #[test] - fn the_edge_setting_hardens_or_feathers_the_transition() { - let side = 16; - let l: Vec = (0..side * side).map(|i| ((i % side) as f32 - 7.5) * -0.6).collect(); // a gentle ramp - let seg = SegMask::from_logits(side, &l); - let (w, h) = (400, 40); - let soft_px = |edge: f64| { - let shape = MaskShape::Prompt { text: "x".into(), seg: Some(seg.clone()), detail: vec![], edge }; - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(2, h / 2) > 0.9 && a.get(w - 3, h / 2) < 0.1, "still the same selection at {edge}"); - (0..w).filter(|x| (0.1..0.9).contains(&a.get(*x, h / 2))).count() - }; - let (hard, plain, soft) = (soft_px(-100.0), soft_px(0.0), soft_px(100.0)); - assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}"); - } - - #[test] - fn ai_shapes_without_a_segmentation_select_nothing() { - let f = frame(30, 30); - let (img, l) = (Rgb32f::new(30, 30), Plane::new(30, 30)); - for shape in [ - MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }, - MaskShape::Prompt { text: "sky".into(), seg: None, detail: vec![], edge: 0.0 }, - MaskShape::Prompt { text: "sky".into(), seg: Some(SegMask { side: 4, data: "damaged!".into(), rect: None }), detail: vec![], edge: 0.0 }, - ] { - assert!(shape_alpha(&shape, &f, 30, 30, &img, &l, 0.0, None).data.iter().all(|v| *v == 0.0)); - } - } - - #[test] - fn radial_inside_and_invert() { - let f = frame(100, 100); - let img = Rgb32f::new(100, 100); - let l = Plane::new(100, 100); - let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false }; - let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0, None); - assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01); - let m = Mask { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape }], ..Default::default() }; - let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0, None); - assert!(e[0].alpha.get(50, 50) < 0.01); - } - - /// Issue #517: dabs along a long segment (a fast stroke: two pointer samples far apart) are at - /// most [`dab_spacing`] apart, painting dab by dab (the live stroke) or the whole path at once - /// (the committed one). - #[test] - fn a_long_segment_gets_dabs_at_most_the_spacing_apart() { - let r = 12.0; - let pts = [Point::new(10.0, 20.0), Point::new(610.0, 260.0), Point::new(611.0, 260.0), Point::new(40.0, 400.0)]; - let mut live = Vec::new(); - let mut prev = None; - for q in pts { - if push_dabs(&mut live, prev, q, r) { - prev = Some(q); - } - } - assert!(live.len() > 3 * 600 / 12, "{} dabs", live.len()); - assert!(live.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(r) + 1e-9)); - assert_eq!(live.first(), pts.first()); - assert_eq!(live.last(), pts.last()); - // the committed stroke places the same dabs (in output pixels; this frame maps 1:1 scaled) - let (w, h) = (1000, 500); - let f = frame(w, h); - let stroke = BrushStroke { points: pts.map(|p| Point::new(p.x / w as f64, p.y / h as f64)).to_vec(), ..Default::default() }; - let committed = brush_dabs(&stroke, &f, w, h); - let mut again = Vec::new(); - let mut prev = None; - for p in &stroke.points { - let q = f.norm_to_out(w, h).apply(*p); - if push_dabs(&mut again, prev, q, committed.r) { - prev = Some(q); - } - } - assert_eq!(committed.dabs, again); - assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); - // many huge segments stop at the stroke budget instead of adding up without bound - let mut big = Vec::new(); - let mut at = Point::new(0.0, 0.0); - for i in 0..5 { - let q = Point::new(if i % 2 == 0 { 1e12 } else { 0.0 }, 0.0); - if push_dabs(&mut big, Some(at), q, 1.0) { - at = q; - } - } - assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len()); - assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); - // a hostile segment places no in-between dabs - let mut v = Vec::new(); - assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); - assert!(!push_dabs(&mut v, None, Point::new(f64::INFINITY, 1.0), r)); - push_dabs(&mut v, Some(Point::new(0.0, 0.0)), Point::new(1e300, 1.0), 0.0); - assert!(v.len() <= 1_000_002, "{}", v.len()); - } - - /// A temporary output buffer must spend the same stroke budget as a retained one. - #[test] - fn incremental_dab_buffers_share_the_stroke_budget() { - let points = [Point::new(20.0, 20.0), Point::new(80.0, 20.0), Point::new(200.0, 80.0), Point::new(21.0, 20.0)]; - let mut retained = Vec::new(); - let mut streamed = Vec::new(); - let mut retained_count = MAX_STROKE_DABS - 3; - let mut streamed_count = retained_count; - let mut prev = None; - for q in points { - let mut chunk = Vec::new(); - let accepted = push_dabs_counted(&mut retained, &mut retained_count, prev, q, 10.0); - assert_eq!(accepted, push_dabs_counted(&mut chunk, &mut streamed_count, prev, q, 10.0)); - streamed.extend(chunk); - if accepted { - prev = Some(q); - } - } - assert_eq!(retained, streamed); - assert_eq!(retained.len(), 3); - assert_eq!(retained_count, MAX_STROKE_DABS); - assert_eq!(streamed_count, retained_count); - assert_eq!(prev, Some(points[1])); - assert!(!retained.contains(&points[2])); - assert!(!retained.contains(&points[3])); - } - - /// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile - /// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. - #[test] - fn pen_pressure_scales_dab_size_and_flow() { - let (w, h) = (400, 100); - let f = frame(w, h); - let img = Rgb32f::new(w, h); - let l = Plane::new(w, h); - let line = vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)]; - let alpha = |pressure: Vec| { - let s = BrushStroke { points: line.clone(), size: 0.05, feather: 0.0, flow: 100.0, pressure, ..Default::default() }; - shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) - }; - let full = alpha(vec![]); - assert_eq!(full.data, alpha(vec![1.0, 1.0]).data, "full pressure is no pressure"); - // light at the start, full at the end: thin and faint on the left, as wide as the brush on the right - let ramp = alpha(vec![0.1, 1.0]); - let width = |a: &Plane, x: usize| (0..h).filter(|y| a.get(x, *y) > 0.01).count(); - assert!(width(&ramp, 60) < width(&full, 60) / 2, "{} vs {}", width(&ramp, 60), width(&full, 60)); - assert!(width(&ramp, 350) + 2 >= width(&full, 350)); - assert!(ramp.get(60, 50) < full.get(60, 50) + 1e-6); - let faint = alpha(vec![0.2]); - assert!(faint.data.iter().zip(&full.data).all(|(a, b)| *a <= *b + 1e-6), "never more than full pressure"); - assert!(faint.get(200, 50) > 0.0 && faint.get(200, 50) < full.get(200, 50)); - for hostile in [vec![f32::NAN, f32::INFINITY], vec![-3.0], vec![0.5; 10_000]] { - let a = alpha(hostile); - assert!(a.data.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); - } - assert_eq!(pressure_radius(10.0, f32::NAN), 10.0); - assert_eq!(pressure_radius(10.0, 0.0), 1.0); - assert_eq!(pressure_flow(0.8, 2.0), 0.8); - } - - #[test] - fn brush_and_subtract() { - let f = frame(200, 100); - let img = Rgb32f::new(200, 100); - let l = Plane::new(200, 100); - let stroke = BrushStroke { points: vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)], size: 0.05, ..Default::default() }; - let erase = BrushStroke { points: vec![Point::new(0.5, 0.5)], size: 0.05, erase: true, feather: 0.0, ..Default::default() }; - let m = Mask { - components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }], - ..Default::default() - }; - let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0, None); - let a = &e[0].alpha; - assert!(a.get(40, 50) > 0.9, "{}", a.get(40, 50)); - assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50)); - assert!(a.get(40, 5) < 0.01); - } - - /// #518: holding the brush still adds no coverage. A press plus any number of stationary - /// pointer samples paints exactly the single dab of the press, whatever the frame rate. - #[test] - fn holding_the_brush_still_adds_no_coverage() { - let (w, h) = (200, 100); - let f = frame(w, h); - let img = Rgb32f::new(w, h); - let l = Plane::new(w, h); - let p = Point::new(0.5, 0.5); - let spot = |samples: usize, flow: f64| { - let s = BrushStroke { points: vec![p; samples], size: 0.1, feather: 80.0, flow, ..Default::default() }; - shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) - }; - for flow in [100.0, 40.0] { - let press = spot(1, flow); - // the feathered edge is soft to begin with - let edge = press.get(100 + 14, 50); - assert!(edge > 0.05 && edge < 0.6, "flow {flow}: edge {edge}"); - for n in [2, 5, 60, 600] { - let held = spot(n, flow); - assert!(held.data == press.data, "flow {flow}: {n} stationary samples hardened the edge {edge} → {}", held.get(100 + 14, 50)); - } - } - let s = BrushStroke { points: vec![p; 60], size: 0.1, ..Default::default() }; - assert_eq!(brush_dabs(&s, &f, w, h).dabs.len(), 1, "one dab for the press"); - // a stroke that pauses partway lays the same dabs as one that doesn't - let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); - let paused = BrushStroke { points: vec![a, p, p, p, p, b], flow: 30.0, ..Default::default() }; - let smooth = BrushStroke { points: vec![a, p, b], flow: 30.0, ..Default::default() }; - assert_eq!(brush_dabs(&paused, &f, w, h).dabs, brush_dabs(&smooth, &f, w, h).dabs); - } - - /// Flow builds up by passes: going back over an area in the same stroke adds coverage. - #[test] - fn a_second_pass_builds_up_with_low_flow() { - let (w, h) = (200, 100); - let f = frame(w, h); - let img = Rgb32f::new(w, h); - let l = Plane::new(w, h); - let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); - let paint = |points: Vec| { - let s = BrushStroke { points, size: 0.05, feather: 50.0, flow: 20.0, ..Default::default() }; - shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) - }; - let once = paint(vec![a, b]); - let twice = paint(vec![a, b, a]); - let thrice = paint(vec![a, b, a, b]); - let (o, t, th) = (once.get(100, 50), twice.get(100, 50), thrice.get(100, 50)); - assert!(o > 0.05 && o < 0.99, "one pass {o}"); - assert!(t > o + 0.02 && th > t + 0.01, "passes build up: {o} → {t} → {th}"); - } - - #[test] - fn auto_mask_stops_at_edges() { - // dark left half, bright right half; a stroke along the boundary, centred on the dark side - let (w, h) = (200, 100); - let f = frame(w, h); - let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] }); - let l = img.map(crate::local::log_lum); - let stroke = |auto_mask| BrushStroke { - points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)], - size: 0.08, - feather: 0.0, - auto_mask, - ..Default::default() - }; - let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] }; - let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0, None); - let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0, None); - // without Auto Mask the brush spills over the edge; with it, it stays on the dark side - assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50)); - assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50)); - assert!(auto.get(110, 50) < 0.02); - assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50)); - assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50)); - // on a flat area Auto Mask paints like the plain brush - let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]); - let fl = flat.map(crate::local::log_lum); - let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0, None); - let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0, None); - assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50)); - } - - /// Refine Edges: a soft mask over a hard edge in the photo snaps to that edge. - #[test] - fn refine_edges_follows_the_photo() { - let (w, h) = (120usize, 80usize); - // left half dark, right half bright - let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { [0.05; 3] } else { [0.6; 3] }); - let log_l = Plane::from_fn(w, h, |x, _| if x < 60 { (0.05f32).log2() } else { (0.6f32).log2() }); - let f = Frame::new(w, h, &Default::default(), true); - let soft = Mask { - components: vec![MaskComponent { - name: None, - op: MaskOp::Add, - invert: false, - shape: MaskShape::Linear { start: Point::new(0.75, 0.5), end: Point::new(0.25, 0.5) }, - }], - ..Default::default() - }; - let refined = Mask { refine: 100.0, ..soft.clone() }; - let step = |m: &Mask| { - let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0, None); - a.data[40 * w + 63] - a.data[40 * w + 56] - }; - let (plain, sharp) = (step(&soft), step(&refined)); - assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}"); - } - - /// Issue #157: a colour-range sample of one flat patch must not select a clearly different - /// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195), - /// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab). - #[test] - fn color_range_keeps_to_the_sampled_colour() { - use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8}; - let (w, h) = (120usize, 40usize); - let m = SRGB.to_space(&REC2020); - let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8)); - let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195])); - let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right }); - let log_l = img.map(crate::local::log_lum); - let f = Frame::new(w, h, &Default::default(), true); - let mut sample = [0f64; 3]; - for y in 19..=21 { - for x in 29..=31 { - let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x])); - for k in 0..3 { - sample[k] += lab[k] as f64 / 9.0; - } - } - } - let alpha = |refine| { - let shape = MaskShape::ColorRange { samples: vec![sample], refine }; - let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0, None); - (a.get(30, 20), a.get(90, 20)) - }; - for refine in [0.0, 50.0, 85.0] { - let (l, r) = alpha(refine); - assert!(l > 0.95, "refine {refine}: sampled patch {l}"); - assert!(r < 0.05, "refine {refine}: other patch {r}"); - } - // Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the - // other patch stays mostly out - let (l, r) = alpha(100.0); - assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); - } - - /// A half-resolution matte selecting the left `frac` of a `w × h` source. - fn left_matte(w: usize, h: usize, frac: f64) -> Image { - Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 }) - } - - /// The Sky mask follows the source's sky matte where it has one (here: the bottom-left of a - /// uniformly bright blue photo, where the heuristic sees no sky, and not its top right, where - /// it does), through user orientation, at any output size; without one it keeps the heuristic. - #[test] - fn sky_uses_the_sources_matte() { - let (w, h) = (200, 100); - let img = Rgb32f::filled(w, h, [0.5, 0.7, 1.4]); - let l = img.map(crate::local::log_lum); - let mut mattes = Mattes::default(); - mattes.push(MatteKind::Sky, left_matte(w, h, 0.5)); - let f = frame(w, h); - let heuristic = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, None); - let matte = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&mattes)); - assert!(heuristic.get(150, 5) > 0.9 && heuristic.get(25, 90) < 0.1, "{} {}", heuristic.get(150, 5), heuristic.get(25, 90)); - assert!(matte.get(150, 5) < 0.01 && matte.get(25, 90) > 0.99, "{} {}", matte.get(150, 5), matte.get(25, 90)); - // mattes of other kinds leave the heuristic alone - let mut hair = Mattes::default(); - hair.push(MatteKind::Hair, left_matte(w, h, 0.5)); - assert_eq!(shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&hair)), heuristic); - // rotated 90° clockwise, the source's left half is the top half; a preview and a larger - // render agree - let s = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() }; - let f = Frame::new(w, h, &s, true); - for (ow, oh) in [(100, 200), (25, 50)] { - let small = Rgb32f::filled(ow, oh, [0.5, 0.7, 1.4]); - let a = shape_alpha(&MaskShape::Sky, &f, ow, oh, &small, &small.map(crate::local::log_lum), 0.0, Some(&mattes)); - let at = |x: f64, y: f64| a.get((x * ow as f64) as usize, (y * oh as f64) as usize); - assert!(at(0.5, 0.2) > 0.99 && at(0.5, 0.8) < 0.01, "{ow}×{oh}: {} {}", at(0.5, 0.2), at(0.5, 0.8)); - } - } - - /// Subject, Background and People use person mattes: the whole person is the union of the - /// parts; a People part without a matte keeps the heuristic. - #[test] - fn people_and_subject_use_person_mattes() { - let (w, h) = (200, 100); - let img = Rgb32f::filled(w, h, [0.2; 3]); - let l = img.map(crate::local::log_lum); - let mut mattes = Mattes::default(); - mattes.push(MatteKind::Skin, left_matte(w, h, 0.25)); - let right: Image = Image::from_fn(w / 2, h / 2, |x, _| if x >= 75 { 255 } else { 0 }); - mattes.push(MatteKind::Hair, right); - let f = frame(w, h); - let eval = |shape: &MaskShape| shape_alpha(shape, &f, w, h, &img, &l, 0.0, Some(&mattes)); - let subject = eval(&MaskShape::Subject); - assert!(subject.get(10, 50) > 0.99 && subject.get(190, 50) > 0.99 && subject.get(100, 50) < 0.01); - let background = eval(&MaskShape::Background); - assert!(background.get(100, 50) > 0.99 && background.get(10, 50) < 0.01); - let hair = eval(&MaskShape::People { person: 0, parts: vec!["Hair".into()] }); - assert!(hair.get(190, 50) > 0.99 && hair.get(10, 50) < 0.01); - let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; - assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); - } -} +mod tests; diff --git a/crates/pipeline/src/masks/tests.rs b/crates/pipeline/src/masks/tests.rs new file mode 100644 index 000000000..a3f776d0d --- /dev/null +++ b/crates/pipeline/src/masks/tests.rs @@ -0,0 +1,452 @@ +use super::*; +use lightcraft_develop::{DevelopSettings, MaskComponent}; + +fn frame(w: usize, h: usize) -> Frame { + Frame::new(w, h, &DevelopSettings::default(), true) +} + +#[test] +fn linear_gradient_ramps() { + let f = frame(100, 50); + let img = Rgb32f::new(100, 50); + let l = Plane::new(100, 50); + let a = shape_alpha(&MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, &f, 100, 50, &img, &l, 0.0, None); + assert!(a.get(1, 25) > 0.99 && a.get(98, 25) < 0.01); + assert!((a.get(50, 25) - 0.5).abs() < 0.05); +} + +#[test] +fn stored_segmentations_render_at_any_size_and_follow_the_crop() { + // the left half selected, on a 16² logit grid + let side = 16; + let logits: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); + let seg = SegMask::from_logits(side, &logits); + for shape in [ + MaskShape::Object { hint: vec![Point::new(0.25, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, + MaskShape::Prompt { text: "left".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, + ] { + for (w, h) in [(40, 20), (400, 200)] { + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(1, h / 2) > 0.99 && a.get(w - 2, h / 2) < 0.01, "{w}×{h}"); + if w >= 400 { + let soft = (0..w).filter(|x| (0.05..0.95).contains(&a.get(*x, h / 2))).count(); + assert!((2..30).contains(&soft), "an anti-aliased edge, not a ramp: {soft} px"); + } + } + } + // cropped to the right half: nothing left selected + let mut d = DevelopSettings::default(); + d.crop.geometry.rect = lightcraft_geom::Rect::from_center(Point::new(0.75, 0.5), 0.5, 1.0); + let f = Frame::new(40, 20, &d, true); + let shape = MaskShape::Prompt { text: "left".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; + let a = shape_alpha(&shape, &f, 20, 20, &Rgb32f::new(20, 20), &Plane::new(20, 20), 0.0, None); + assert!(a.data.iter().all(|v| *v < 0.05)); +} + +#[test] +fn detail_patches_replace_the_coarse_mask_inside_their_rectangle() { + // coarse: nothing; a patch over the right half selects its own left half (x 0.5..0.75) + let coarse = SegMask::from_logits(4, &[-12.0; 16]); + let side = 16; + let l: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); + let patch = SegMask::from_logits_in(side, &l, [0.5, 0.0, 1.0, 1.0]); + let shape = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(coarse), detail: vec![patch], edge: 0.0 }; + let (w, h) = (200, 100); + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(20, 50) < 0.01, "outside the patch: the coarse mask"); + assert!(a.get(120, 50) > 0.99, "inside the patch, its selected half"); + assert!(a.get(190, 50) < 0.01, "inside the patch, its unselected half"); +} + +#[test] +fn a_hostile_number_of_detail_patches_is_capped() { + // built in memory (a deserialized document is capped already): only the first + // MAX_DETAIL are decoded and sampled, so this needs 4 × 4 MB, not 20 000 × 4 MB + let side = 1024; + let l: Vec = vec![12.0; side * side]; + let patch = SegMask::from_logits_in(side, &l, [0.0, 0.0, 0.5, 1.0]); + let shape = MaskShape::Object { hint: vec![Point::new(0.2, 0.5)], exclude: vec![], seg: None, detail: vec![patch; 20_000], edge: 0.0 }; + let (w, h) = (64, 32); + let t = std::time::Instant::now(); + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(5, 16) > 0.99 && a.get(60, 16) < 0.01); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); +} + +#[test] +fn the_edge_setting_hardens_or_feathers_the_transition() { + let side = 16; + let l: Vec = (0..side * side).map(|i| ((i % side) as f32 - 7.5) * -0.6).collect(); // a gentle ramp + let seg = SegMask::from_logits(side, &l); + let (w, h) = (400, 40); + let soft_px = |edge: f64| { + let shape = MaskShape::Prompt { text: "x".into(), seg: Some(seg.clone()), detail: vec![], edge }; + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(2, h / 2) > 0.9 && a.get(w - 3, h / 2) < 0.1, "still the same selection at {edge}"); + (0..w).filter(|x| (0.1..0.9).contains(&a.get(*x, h / 2))).count() + }; + let (hard, plain, soft) = (soft_px(-100.0), soft_px(0.0), soft_px(100.0)); + assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}"); +} + +#[test] +fn ai_shapes_without_a_segmentation_select_nothing() { + let f = frame(30, 30); + let (img, l) = (Rgb32f::new(30, 30), Plane::new(30, 30)); + for shape in [ + MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }, + MaskShape::Prompt { text: "sky".into(), seg: None, detail: vec![], edge: 0.0 }, + MaskShape::Prompt { text: "sky".into(), seg: Some(SegMask { side: 4, data: "damaged!".into(), rect: None }), detail: vec![], edge: 0.0 }, + ] { + assert!(shape_alpha(&shape, &f, 30, 30, &img, &l, 0.0, None).data.iter().all(|v| *v == 0.0)); + } +} + +#[test] +fn radial_inside_and_invert() { + let f = frame(100, 100); + let img = Rgb32f::new(100, 100); + let l = Plane::new(100, 100); + let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false }; + let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0, None); + assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01); + let m = Mask { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape }], ..Default::default() }; + let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0, None); + assert!(e[0].alpha.get(50, 50) < 0.01); +} + +/// Issue #517: dabs along a long segment (a fast stroke: two pointer samples far apart) are at +/// most [`dab_spacing`] apart, painting dab by dab (the live stroke) or the whole path at once +/// (the committed one). +#[test] +fn a_long_segment_gets_dabs_at_most_the_spacing_apart() { + let r = 12.0; + let pts = [Point::new(10.0, 20.0), Point::new(610.0, 260.0), Point::new(611.0, 260.0), Point::new(40.0, 400.0)]; + let mut live = Vec::new(); + let mut prev = None; + for q in pts { + if push_dabs(&mut live, prev, q, r) { + prev = Some(q); + } + } + assert!(live.len() > 3 * 600 / 12, "{} dabs", live.len()); + assert!(live.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(r) + 1e-9)); + assert_eq!(live.first(), pts.first()); + assert_eq!(live.last(), pts.last()); + // the committed stroke places the same dabs (in output pixels; this frame maps 1:1 scaled) + let (w, h) = (1000, 500); + let f = frame(w, h); + let stroke = BrushStroke { points: pts.map(|p| Point::new(p.x / w as f64, p.y / h as f64)).to_vec(), ..Default::default() }; + let committed = brush_dabs(&stroke, &f, w, h); + let mut again = Vec::new(); + let mut prev = None; + for p in &stroke.points { + let q = f.norm_to_out(w, h).apply(*p); + if push_dabs(&mut again, prev, q, committed.r) { + prev = Some(q); + } + } + assert_eq!(committed.dabs, again); + assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); + // many huge segments stop at the stroke budget instead of adding up without bound + let mut big = Vec::new(); + let mut at = Point::new(0.0, 0.0); + for i in 0..5 { + let q = Point::new(if i % 2 == 0 { 1e12 } else { 0.0 }, 0.0); + if push_dabs(&mut big, Some(at), q, 1.0) { + at = q; + } + } + assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len()); + assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); + // a hostile segment places no in-between dabs + let mut v = Vec::new(); + assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); + assert!(!push_dabs(&mut v, None, Point::new(f64::INFINITY, 1.0), r)); + push_dabs(&mut v, Some(Point::new(0.0, 0.0)), Point::new(1e300, 1.0), 0.0); + assert!(v.len() <= 1_000_002, "{}", v.len()); +} + +/// A temporary output buffer must spend the same stroke budget as a retained one. +#[test] +fn incremental_dab_buffers_share_the_stroke_budget() { + let points = [Point::new(20.0, 20.0), Point::new(80.0, 20.0), Point::new(200.0, 80.0), Point::new(21.0, 20.0)]; + let mut retained = Vec::new(); + let mut streamed = Vec::new(); + let mut retained_count = MAX_STROKE_DABS - 3; + let mut streamed_count = retained_count; + let mut prev = None; + for q in points { + let mut chunk = Vec::new(); + let accepted = push_dabs_counted(&mut retained, &mut retained_count, prev, q, 10.0); + assert_eq!(accepted, push_dabs_counted(&mut chunk, &mut streamed_count, prev, q, 10.0)); + streamed.extend(chunk); + if accepted { + prev = Some(q); + } + } + assert_eq!(retained, streamed); + assert_eq!(retained.len(), 3); + assert_eq!(retained_count, MAX_STROKE_DABS); + assert_eq!(streamed_count, retained_count); + assert_eq!(prev, Some(points[1])); + assert!(!retained.contains(&points[2])); + assert!(!retained.contains(&points[3])); +} + +/// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile +/// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. +#[test] +fn pen_pressure_scales_dab_size_and_flow() { + let (w, h) = (400, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let line = vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)]; + let alpha = |pressure: Vec| { + let s = BrushStroke { points: line.clone(), size: 0.05, feather: 0.0, flow: 100.0, pressure, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + let full = alpha(vec![]); + assert_eq!(full.data, alpha(vec![1.0, 1.0]).data, "full pressure is no pressure"); + // light at the start, full at the end: thin and faint on the left, as wide as the brush on the right + let ramp = alpha(vec![0.1, 1.0]); + let width = |a: &Plane, x: usize| (0..h).filter(|y| a.get(x, *y) > 0.01).count(); + assert!(width(&ramp, 60) < width(&full, 60) / 2, "{} vs {}", width(&ramp, 60), width(&full, 60)); + assert!(width(&ramp, 350) + 2 >= width(&full, 350)); + assert!(ramp.get(60, 50) < full.get(60, 50) + 1e-6); + let faint = alpha(vec![0.2]); + assert!(faint.data.iter().zip(&full.data).all(|(a, b)| *a <= *b + 1e-6), "never more than full pressure"); + assert!(faint.get(200, 50) > 0.0 && faint.get(200, 50) < full.get(200, 50)); + for hostile in [vec![f32::NAN, f32::INFINITY], vec![-3.0], vec![0.5; 10_000]] { + let a = alpha(hostile); + assert!(a.data.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + } + assert_eq!(pressure_radius(10.0, f32::NAN), 10.0); + assert_eq!(pressure_radius(10.0, 0.0), 1.0); + assert_eq!(pressure_flow(0.8, 2.0), 0.8); +} + +#[test] +fn brush_and_subtract() { + let f = frame(200, 100); + let img = Rgb32f::new(200, 100); + let l = Plane::new(200, 100); + let stroke = BrushStroke { points: vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)], size: 0.05, ..Default::default() }; + let erase = BrushStroke { points: vec![Point::new(0.5, 0.5)], size: 0.05, erase: true, feather: 0.0, ..Default::default() }; + let m = Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }], + ..Default::default() + }; + let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0, None); + let a = &e[0].alpha; + assert!(a.get(40, 50) > 0.9, "{}", a.get(40, 50)); + assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50)); + assert!(a.get(40, 5) < 0.01); +} + +/// #518: holding the brush still adds no coverage. A press plus any number of stationary +/// pointer samples paints exactly the single dab of the press, whatever the frame rate. +#[test] +fn holding_the_brush_still_adds_no_coverage() { + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let p = Point::new(0.5, 0.5); + let spot = |samples: usize, flow: f64| { + let s = BrushStroke { points: vec![p; samples], size: 0.1, feather: 80.0, flow, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + for flow in [100.0, 40.0] { + let press = spot(1, flow); + // the feathered edge is soft to begin with + let edge = press.get(100 + 14, 50); + assert!(edge > 0.05 && edge < 0.6, "flow {flow}: edge {edge}"); + for n in [2, 5, 60, 600] { + let held = spot(n, flow); + assert!(held.data == press.data, "flow {flow}: {n} stationary samples hardened the edge {edge} → {}", held.get(100 + 14, 50)); + } + } + let s = BrushStroke { points: vec![p; 60], size: 0.1, ..Default::default() }; + assert_eq!(brush_dabs(&s, &f, w, h).dabs.len(), 1, "one dab for the press"); + // a stroke that pauses partway lays the same dabs as one that doesn't + let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); + let paused = BrushStroke { points: vec![a, p, p, p, p, b], flow: 30.0, ..Default::default() }; + let smooth = BrushStroke { points: vec![a, p, b], flow: 30.0, ..Default::default() }; + assert_eq!(brush_dabs(&paused, &f, w, h).dabs, brush_dabs(&smooth, &f, w, h).dabs); +} + +/// Flow builds up by passes: going back over an area in the same stroke adds coverage. +#[test] +fn a_second_pass_builds_up_with_low_flow() { + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); + let paint = |points: Vec| { + let s = BrushStroke { points, size: 0.05, feather: 50.0, flow: 20.0, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + let once = paint(vec![a, b]); + let twice = paint(vec![a, b, a]); + let thrice = paint(vec![a, b, a, b]); + let (o, t, th) = (once.get(100, 50), twice.get(100, 50), thrice.get(100, 50)); + assert!(o > 0.05 && o < 0.99, "one pass {o}"); + assert!(t > o + 0.02 && th > t + 0.01, "passes build up: {o} → {t} → {th}"); +} + +#[test] +fn auto_mask_stops_at_edges() { + // dark left half, bright right half; a stroke along the boundary, centred on the dark side + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] }); + let l = img.map(crate::local::log_lum); + let stroke = |auto_mask| BrushStroke { + points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)], + size: 0.08, + feather: 0.0, + auto_mask, + ..Default::default() + }; + let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] }; + let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0, None); + let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0, None); + // without Auto Mask the brush spills over the edge; with it, it stays on the dark side + assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50)); + assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50)); + assert!(auto.get(110, 50) < 0.02); + assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50)); + assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50)); + // on a flat area Auto Mask paints like the plain brush + let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]); + let fl = flat.map(crate::local::log_lum); + let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0, None); + let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0, None); + assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50)); +} + +/// Refine Edges: a soft mask over a hard edge in the photo snaps to that edge. +#[test] +fn refine_edges_follows_the_photo() { + let (w, h) = (120usize, 80usize); + // left half dark, right half bright + let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { [0.05; 3] } else { [0.6; 3] }); + let log_l = Plane::from_fn(w, h, |x, _| if x < 60 { (0.05f32).log2() } else { (0.6f32).log2() }); + let f = Frame::new(w, h, &Default::default(), true); + let soft = Mask { + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.75, 0.5), end: Point::new(0.25, 0.5) }, + }], + ..Default::default() + }; + let refined = Mask { refine: 100.0, ..soft.clone() }; + let step = |m: &Mask| { + let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0, None); + a.data[40 * w + 63] - a.data[40 * w + 56] + }; + let (plain, sharp) = (step(&soft), step(&refined)); + assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}"); +} + +/// Issue #157: a colour-range sample of one flat patch must not select a clearly different +/// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195), +/// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab). +#[test] +fn color_range_keeps_to_the_sampled_colour() { + use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8}; + let (w, h) = (120usize, 40usize); + let m = SRGB.to_space(&REC2020); + let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8)); + let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195])); + let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right }); + let log_l = img.map(crate::local::log_lum); + let f = Frame::new(w, h, &Default::default(), true); + let mut sample = [0f64; 3]; + for y in 19..=21 { + for x in 29..=31 { + let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x])); + for k in 0..3 { + sample[k] += lab[k] as f64 / 9.0; + } + } + } + let alpha = |refine| { + let shape = MaskShape::ColorRange { samples: vec![sample], refine }; + let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0, None); + (a.get(30, 20), a.get(90, 20)) + }; + for refine in [0.0, 50.0, 85.0] { + let (l, r) = alpha(refine); + assert!(l > 0.95, "refine {refine}: sampled patch {l}"); + assert!(r < 0.05, "refine {refine}: other patch {r}"); + } + // Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the + // other patch stays mostly out + let (l, r) = alpha(100.0); + assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); +} + +/// A half-resolution matte selecting the left `frac` of a `w × h` source. +fn left_matte(w: usize, h: usize, frac: f64) -> Image { + Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 }) +} + +/// The Sky mask follows the source's sky matte where it has one (here: the bottom-left of a +/// uniformly bright blue photo, where the heuristic sees no sky, and not its top right, where +/// it does), through user orientation, at any output size; without one it keeps the heuristic. +#[test] +fn sky_uses_the_sources_matte() { + let (w, h) = (200, 100); + let img = Rgb32f::filled(w, h, [0.5, 0.7, 1.4]); + let l = img.map(crate::local::log_lum); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Sky, left_matte(w, h, 0.5)); + let f = frame(w, h); + let heuristic = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, None); + let matte = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&mattes)); + assert!(heuristic.get(150, 5) > 0.9 && heuristic.get(25, 90) < 0.1, "{} {}", heuristic.get(150, 5), heuristic.get(25, 90)); + assert!(matte.get(150, 5) < 0.01 && matte.get(25, 90) > 0.99, "{} {}", matte.get(150, 5), matte.get(25, 90)); + // mattes of other kinds leave the heuristic alone + let mut hair = Mattes::default(); + hair.push(MatteKind::Hair, left_matte(w, h, 0.5)); + assert_eq!(shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&hair)), heuristic); + // rotated 90° clockwise, the source's left half is the top half; a preview and a larger + // render agree + let s = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() }; + let f = Frame::new(w, h, &s, true); + for (ow, oh) in [(100, 200), (25, 50)] { + let small = Rgb32f::filled(ow, oh, [0.5, 0.7, 1.4]); + let a = shape_alpha(&MaskShape::Sky, &f, ow, oh, &small, &small.map(crate::local::log_lum), 0.0, Some(&mattes)); + let at = |x: f64, y: f64| a.get((x * ow as f64) as usize, (y * oh as f64) as usize); + assert!(at(0.5, 0.2) > 0.99 && at(0.5, 0.8) < 0.01, "{ow}×{oh}: {} {}", at(0.5, 0.2), at(0.5, 0.8)); + } +} + +/// Subject, Background and People use person mattes: the whole person is the union of the +/// parts; a People part without a matte keeps the heuristic. +#[test] +fn people_and_subject_use_person_mattes() { + let (w, h) = (200, 100); + let img = Rgb32f::filled(w, h, [0.2; 3]); + let l = img.map(crate::local::log_lum); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Skin, left_matte(w, h, 0.25)); + let right: Image = Image::from_fn(w / 2, h / 2, |x, _| if x >= 75 { 255 } else { 0 }); + mattes.push(MatteKind::Hair, right); + let f = frame(w, h); + let eval = |shape: &MaskShape| shape_alpha(shape, &f, w, h, &img, &l, 0.0, Some(&mattes)); + let subject = eval(&MaskShape::Subject); + assert!(subject.get(10, 50) > 0.99 && subject.get(190, 50) > 0.99 && subject.get(100, 50) < 0.01); + let background = eval(&MaskShape::Background); + assert!(background.get(100, 50) > 0.99 && background.get(10, 50) < 0.01); + let hair = eval(&MaskShape::People { person: 0, parts: vec!["Hair".into()] }); + assert!(hair.get(190, 50) > 0.99 && hair.get(10, 50) < 0.01); + let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; + assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); +} From efa52ddd2c9470d71e9909680eb112a32ddba93f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:00 +0000 Subject: [PATCH 056/191] refactor(lightcraft-ui-egui): move menubar.rs tests to crates/ui-egui/src/menubar/tests.rs --- crates/ui-egui/src/menubar.rs | 831 +--------------------------- crates/ui-egui/src/menubar/tests.rs | 828 +++++++++++++++++++++++++++ 2 files changed, 829 insertions(+), 830 deletions(-) create mode 100644 crates/ui-egui/src/menubar/tests.rs diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index ba4a8a7c2..5c1bcb9fc 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -1186,833 +1186,4 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti } #[cfg(test)] -mod tests { - use super::*; - - fn app() -> LightcraftApp { - LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()) - } - - fn find<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> { - nodes.iter().find_map(|n| match n { - MenuNode::Item { id: i, .. } if i == id => Some(n), - MenuNode::Submenu { children, .. } => find(children, id), - _ => None, - }) - } - - /// #2500: a direct-grid binding takes precedence over the borrowed cycle hint. - #[test] - fn grid_menu_shortcut_hints_preserve_direct_bindings_and_clicks() { - let mut a = app(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); - let bar = menu_bar(&a); - let nodes = &bar.iter().find(|(title, _)| title == "View").unwrap().1; - assert!(matches!(find(nodes, "view.photoGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "Cmd+Shift+1")); - assert!(matches!(find(nodes, "view.squareGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "G")); - // A hint never assigns the cycle key to another command. - assert_eq!(crate::shortcuts::shortcut_of(&a.ui.settings.keymap, "view.squareGrid"), None); - run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::PhotoGrid); - run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::PhotoGrid, "clicking the active row does not cycle"); - run_item(&mut a, "view.squareGrid", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::SquareGrid); - run_item(&mut a, "view.gridToggle", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::PhotoGrid); - } - - /// #2500: native hints never install a borrowed modifier/function key as a direct-grid - /// accelerator. Plain G remains in AppKit's shortcut column and is dispatched by egui. - #[test] - fn grid_menu_shortcut_hints_keep_native_toggle_dispatch() { - let mut keymap = crate::shortcuts::Keymap::new(); - for id in ["view.photoGrid", "view.squareGrid"] { - let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some("G")); - assert_eq!(label, "Photo Grid"); - assert_eq!(accelerator, Some("G")); - assert!(!native_menu_delivers(accelerator.unwrap())); - for (key, text) in [("Cmd+Shift+G", "⌘⇧G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] { - crate::shortcuts::assign(&mut keymap, "view.gridToggle", Some(key)).unwrap(); - let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some(key)); - assert_eq!(label, format!("Photo Grid ({text})")); - assert_eq!(accelerator, None, "egui keeps {key}"); - } - } - crate::shortcuts::assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); - let (label, accelerator) = native_item_presentation(&keymap, "view.photoGrid", &Value::Null, "Photo Grid", Some("Cmd+Shift+1")); - assert_eq!(label, "Photo Grid"); - assert_eq!(accelerator, Some("Cmd+Shift+1"), "the row's own shortcut is a real accelerator"); - } - - /// #2500: the control channel runs the real keyboard path exactly once and menu commands - /// continue to select their named grid after the cycle shortcut is remapped. - #[test] - fn grid_menu_shortcut_hints_preserve_control_keyboard_dispatch() { - let mut h = crate::headless::Headless::new(app(), [960.0, 720.0], 1.0); - h.app.ui.view = ViewMode::PhotoGrid; - for _ in 0..3 { - h.step(); - } - let timeout = std::time::Duration::from_secs(20); - let press = |h: &mut crate::headless::Headless, params| { - let reply = h.request("ui.key", params, timeout); - assert_eq!(reply["ok"], true, "{reply}"); - }; - press(&mut h, json!({"key": "G"})); - assert_eq!(h.app.ui.view, ViewMode::SquareGrid); - press(&mut h, json!({"key": "G"})); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - crate::shortcuts::assign(&mut h.app.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); - press(&mut h, json!({"key": "G"})); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); - assert_eq!(h.app.ui.view, ViewMode::SquareGrid); - press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - for _ in 0..2 { - let reply = h.request("ui.menu.invoke", json!({"command": "view.squareGrid"}), timeout); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(h.app.ui.view, ViewMode::SquareGrid); - } - } - - /// #2513 review: moving a direct-grid key to the cycle command leaves the visible model - /// unchanged, but must invalidate the native cache so its old accelerator is removed. - #[test] - fn grid_menu_shortcut_hints_invalidate_native_ownership_cache() { - let mut a = app(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+G")).unwrap(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.squareGrid", Some("Cmd+Shift+2")).unwrap(); - let before = menu_bar(&a); - let direct = native_menu_structure(&before, &a.ui.settings.keymap); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); - let after = menu_bar(&a); - assert_eq!(before, after, "visible hint text does not expose the ownership change"); - assert_ne!(direct, native_menu_structure(&after, &a.ui.settings.keymap), "rebuild removes the stale direct-grid accelerator"); - } - - /// #2500: grid view rows show the separate cycle command's current shortcut. - #[test] - fn grid_menu_shortcut_hints_follow_the_cycle_key() { - let mut a = app(); - let hint = |a: &LightcraftApp, id: &str| { - let bar = menu_bar(a); - let nodes = &bar.iter().find(|(title, _)| title == "View").expect("View menu").1; - match find(nodes, id).expect("grid row") { - MenuNode::Item { shortcut, .. } => shortcut.clone(), - _ => panic!("grid row must be an item"), - } - }; - for id in ["view.photoGrid", "view.squareGrid"] { - assert_eq!(hint(&a, id).as_deref(), Some("G"), "{id}"); - } - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); - for id in ["view.photoGrid", "view.squareGrid"] { - assert_eq!(hint(&a, id).as_deref(), Some("Cmd+Shift+G"), "{id}"); - } - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); - for id in ["view.photoGrid", "view.squareGrid"] { - assert_eq!(hint(&a, id), None, "{id}"); - } - } - - #[test] - fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() { - let mut a = app(); - let view = |a: &LightcraftApp| menu_bar(a).into_iter().find(|(t, _)| t == "View").expect("View menu").1; - let items = view(&a); - assert!(matches!(find(&items, "library.refresh"), Some(MenuNode::Item { enabled: true, .. }))); - assert!(matches!(find(&items, "library.holdView"), Some(MenuNode::Item { checked: Some(false), .. })), "off unless asked for"); - a.session.execute("library.holdView", &json!({"on": true})).expect("hold the view"); - assert!(matches!(find(&view(&a), "library.holdView"), Some(MenuNode::Item { checked: Some(true), .. }))); - } - - /// The Sort submenu is expanded by hand: Reshuffle appears once (not again from the registry), - /// only while sorting at random, and the direction items are off for a shuffle. - #[test] - fn sort_menu_lists_reshuffle_once_and_only_enables_it_for_random() { - fn sort_children(bar: &[(String, Vec)]) -> Vec { - let view = &bar.iter().find(|(t, _)| t == "View").expect("View menu").1; - view.iter() - .find_map(|n| match n { - MenuNode::Submenu { label, children } if label == "Sort" => Some(children.clone()), - _ => None, - }) - .expect("Sort submenu") - } - let count = |nodes: &[MenuNode]| nodes.iter().filter(|n| matches!(n, MenuNode::Item { id, .. } if id == "library.shuffle")).count(); - let mut a = app(); - let kids = sort_children(&menu_bar(&a)); - assert_eq!(count(&kids), 1); - assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: false, .. })), "off until Random is chosen"); - a.session.execute("library.sort", &json!({"key": "random"})).expect("sort at random"); - let kids = sort_children(&menu_bar(&a)); - assert_eq!(count(&kids), 1); - assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: true, .. }))); - // no direction is shown as chosen while shuffling - let dir_checked = |kids: &[MenuNode]| { - kids.iter() - .filter_map(|n| match n { - MenuNode::Item { label, checked, .. } if label == "Ascending" || label == "Descending" => Some(*checked), - _ => None, - }) - .collect::>() - }; - assert_eq!(dir_checked(&kids), vec![None, None]); - a.session.execute("library.sort", &json!({"key": "fileName"})).expect("sort by name"); - assert!(dir_checked(&sort_children(&menu_bar(&a))).iter().all(Option::is_some), "checks come back for other keys"); - } - - /// File opens with the import entry points, worded as importing (not as adding a sidebar - /// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸. - #[test] - fn file_menu_starts_with_the_import_entry_points() { - let bar = menu_bar(&app()); - let file = &bar.iter().find(|(t, _)| t == "File").expect("File menu").1; - let labels: Vec = file - .iter() - .take(3) - .map(|n| match n { - MenuNode::Item { label, shortcut, .. } => format!("{label}{}", shortcut.as_deref().map(|s| format!(" [{s}]")).unwrap_or_default()), - MenuNode::Submenu { label, .. } => format!("{label} ▸"), - MenuNode::Separator => "---".into(), - }) - .collect(); - assert_eq!(labels[0], "Import Photos… [Cmd+Shift+I]"); - assert_eq!(labels[1..], ["Import from Folder…".to_string(), "Import from Device ▸".to_string()]); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - let text = serde_json::to_string(&all).unwrap(); - assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus"); - } - - /// Back Up / Restore Library are the browser build's (its library lives in browser storage): - /// absent from the desktop's menus, present and wired to the host where it provides them. - #[test] - fn library_backup_items_follow_the_host() { - let all = |app: &LightcraftApp| -> Vec { menu_bar(app).into_iter().flat_map(|(_, v)| v).collect() }; - let mut desktop = app(); - assert!(find(&all(&desktop), "file.backupLibrary").is_none() && find(&all(&desktop), "file.restoreLibrary").is_none()); - assert!(run_item(&mut desktop, "file.backupLibrary", Value::Null).is_err(), "not available without the host"); - let calls = std::rc::Rc::new(std::cell::Cell::new(0)); - let c = calls.clone(); - let services = crate::Services { - backup_library: Some(Box::new(move |s: &mut lightcraft_engine::Session| { - c.set(c.get() + 1); - Ok(json!({"photos": s.catalog.len()})) - })), - restore_library: Some(Box::new(|_: &mut lightcraft_engine::Session| Ok(json!({"started": true})))), - ..Default::default() - }; - let mut web = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - let bar = all(&web); - for id in ["file.backupLibrary", "file.restoreLibrary"] { - assert!(matches!(find(&bar, id), Some(MenuNode::Item { enabled: true, .. })), "{id}"); - } - let r = run_item(&mut web, "file.backupLibrary", Value::Null).unwrap(); - assert!(r["photos"].as_u64().unwrap() > 0); - assert_eq!(calls.get(), 1); - } - - #[test] - fn bar_follows_the_registry_and_state() { - let mut app = app(); - let bar = menu_bar(&app); - let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); - assert_eq!(titles, MENUS); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - // every engine command with a menu path is reachable (parameterized ones via submenus; items - // that follow the state, like Restore for deleted photos, when it applies) - for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id) && host_supports(&app, c.id)) { - assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); - } - for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { - assert!(find(&all, id).is_some(), "{id}"); - } - // no doubled or dangling separators - fn check(v: &[MenuNode]) { - assert_ne!(v.first(), Some(&MenuNode::Separator)); - assert_ne!(v.last(), Some(&MenuNode::Separator)); - for w in v.windows(2) { - assert!(!(w[0] == MenuNode::Separator && w[1] == MenuNode::Separator)); - } - for n in v { - if let MenuNode::Submenu { children, .. } = n { - check(children); - } - } - } - bar.iter().for_each(|(_, v)| check(v)); - // live state: undo label, enabled, checked - let Some(MenuNode::Item { enabled, .. }) = find(&all, "edit.undo") else { panic!() }; - assert!(!enabled); - run_item(&mut app, "photo.rate", json!({"rating": 3})).unwrap(); - let bar = menu_bar(&app); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - let Some(MenuNode::Item { label, enabled, .. }) = find(&all, "edit.undo") else { panic!() }; - assert_eq!((label.as_str(), *enabled), ("Undo Set Rating", true)); - let rated: Vec<_> = all - .iter() - .filter_map(|n| match n { - MenuNode::Submenu { label, children } if label == "Set Rating" => Some(children.clone()), - _ => None, - }) - .flatten() - .filter(|n| matches!(n, MenuNode::Item { checked: Some(true), .. })) - .collect(); - assert!(matches!(&rated[..], [MenuNode::Item { params, .. }] if params["rating"] == 3)); - let Some(MenuNode::Item { checked, .. }) = find(&all, "view.filmstrip") else { panic!() }; - assert_eq!(*checked, Some(app.ui.filmstrip)); - } - - #[test] - fn quit_ends_the_file_menu_after_unlisted_entries() { - let app = app(); - // the File menu has registry entries the layout doesn't list (appended as fallbacks) - let listed = LAYOUT.iter().find(|(t, _)| *t == "File").unwrap().1; - let unlisted: Vec = crate::menus::menu_entries(&app) - .into_iter() - .filter(|e| e.menu.first().map(String::as_str) == Some("File") && !listed.contains(&e.id.as_str()) && !HIDDEN.contains(&e.id.as_str())) - .map(|e| e.id) - .collect(); - assert!(!unlisted.is_empty(), "no fallback File entries to order"); - let bar = menu_bar(&app); - let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; - // Quit is the last item, alone in its separator group - assert!(matches!(file.last(), Some(MenuNode::Item { id, .. }) if id == "app.quit"), "{file:?}"); - assert_eq!(file.get(file.len() - 2), Some(&MenuNode::Separator)); - // every fallback entry is still reachable, before Quit - for id in &unlisted { - assert!(find(&file[..file.len() - 1], id).is_some(), "{id} missing before Quit"); - } - } - - #[test] - fn export_with_preset_submenu() { - let mut app = app(); - let bar = menu_bar(&app); - let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; - let Some(MenuNode::Submenu { children, .. }) = - file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Export with Preset")) - else { - panic!("no Export with Preset submenu") - }; - let presets: Vec<_> = children - .iter() - .filter_map(|n| match n { - MenuNode::Item { id, params, .. } if id == "app.export" => params["preset"].as_str().map(str::to_string), - _ => None, - }) - .collect(); - assert_eq!(presets.first().map(String::as_str), Some("JPEG (Small)")); - assert!(find(children, "dialog.export").is_some(), "Custom… opens the dialog"); - // a user preset appears after the built-ins; choosing it exports with its settings - app.session.execute("export.savePreset", &json!({"name": "Tiny PNG", "params": {"format": "png", "width": 40}})).unwrap(); - let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Vec)>::new())); - let w = written.clone(); - app.services.write = Some(Box::new(move |p: &str, b: &[u8]| { - w.lock().unwrap().push((p.to_string(), b.to_vec())); - Ok(()) - })); - let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": "/nonexistent-lc-test"})).unwrap(); - assert_eq!(r["files"][0]["width"], 40, "{r}"); - let w = written.lock().unwrap(); - assert!(w[0].0.starts_with("/nonexistent-lc-test/") && w[0].0.ends_with(".png"), "{}", w[0].0); - assert!(w[0].1.starts_with(b"\x89PNG")); - // remembered (expanded) for Export with Previous, folder included - let last = app.session.last_export.clone().unwrap(); - assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None)); - // a blank folder (the Export dialog's Folder field cleared) is refused with a clear message - // instead of writing into the working directory - let n = w.len(); - drop(w); - let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "})); - assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER); - assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder"); - // "Same folder as original photo" needs no folder; a demo photo has none of its own, so it - // gets its own error rather than a file written elsewhere (issue #956) - let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " ", "destinationMode": "sameAsOriginal"})); - let e = r.unwrap_err(); - assert!(e.contains("no folder on disk"), "{e}"); - assert_eq!(written.lock().unwrap().len(), n, "nothing written for a photo without a folder"); - } - - #[test] - fn select_by_submenu_and_text_prompt() { - let mut app = app(); - let bar = menu_bar(&app); - let edit = &bar.iter().find(|(t, _)| t == "Edit").unwrap().1; - let Some(MenuNode::Submenu { children, .. }) = edit.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Select by")) - else { - panic!("no Select by submenu") - }; - let Some(MenuNode::Item { id, params, .. }) = children.first() else { panic!() }; - let r = run_item(&mut app, id, params.clone()).unwrap(); - assert!(r["selected"].as_u64().is_some_and(|n| n > 0), "{r}"); - // the one-field prompt runs its command with the typed value - app.session.execute("version.create", &json!({"name": "A"})).unwrap(); - crate::panels::dialogs::prompt(&mut app, "Rename Version", "Version name", "A", "version.rename", json!({"index": 0}), "name"); - if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { - *value = " Final ".into(); - } - let d = app.ui.dialog.take().unwrap(); - crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); - let id = app.session.active().unwrap(); - assert_eq!(app.session.catalog.photo(id).unwrap().versions[0].name, "Final"); - } - - #[test] - fn help_opens_the_docs_and_merge_last_needs_photos() { - let mut app = app(); - let opened = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let o = opened.clone(); - app.services.open_url = Some(Box::new(move |u: &str| { - o.lock().unwrap().push(u.to_string()); - Ok(()) - })); - run_item(&mut app, "app.help", Value::Null).unwrap(); - assert_eq!(opened.lock().unwrap().as_slice(), [crate::links::HELP]); - // merging with the last settings needs a selection of two or more - let first = app.session.visible()[0].0; - app.session.execute("library.select", &json!({"ids": [first]})).unwrap(); - assert!(run_item(&mut app, "merge.hdrLast", Value::Null).is_err()); - } - - /// #260: Help ▸ Open Log Folder reveals the log file the host names. Without one (the web, - /// `--memory`) or without a file manager to show it, the item is off and the command says why. - #[test] - fn open_log_folder_reveals_the_hosts_log_file() { - let mut app = app(); - let help = |app: &LightcraftApp| menu_bar(app).into_iter().find(|(t, _)| t == "Help").map(|(_, items)| items).unwrap_or_default(); - assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: false, .. })), "listed in Help, off without a log"); - assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); - assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); - let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let s = shown.clone(); - app.services.reveal = Some(Box::new(move |p: &str| { - s.lock().unwrap().push(p.to_string()); - Ok(()) - })); - // a file manager alone is not enough: this session keeps no log - assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); - assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); - assert!(shown.lock().unwrap().is_empty()); - let log = "/home/a/.config/lightcraft/logs/lightcraft.log"; - app.services.log_file = Some(log.into()); - assert!(crate::menus::ui_enabled(&app, "app.openLogFolder")); - assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: true, .. }))); - let r = run_item(&mut app, "app.openLogFolder", Value::Null).unwrap(); - assert_eq!(r["path"], log); - assert_eq!(shown.lock().unwrap().as_slice(), [log]); - // the file manager's failure is the command's - app.services.reveal = Some(Box::new(|_: &str| Err("no file manager".into()))); - assert_eq!(run_item(&mut app, "app.openLogFolder", Value::Null).unwrap_err(), "no file manager"); - } - - #[test] - fn find_missing_and_locate() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("a")).unwrap(); - std::fs::create_dir_all(dir.join("b")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("a/one.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dir.join("a").to_string_lossy()]})).unwrap(); - std::fs::rename(dir.join("a/one.png"), dir.join("b/one.png")).unwrap(); - let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy(), "wait": true})).unwrap(); - assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "{r}"); - // Locate: an explicit file - std::fs::rename(dir.join("b/one.png"), dir.join("one-renamed.png")).unwrap(); - let id = app.session.catalog.photos().next().unwrap().id; - app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); - run_item(&mut app, "photo.locate", json!({"path": dir.join("one-renamed.png").to_string_lossy()})).unwrap(); - let p = app.session.catalog.photo(id).unwrap(); - assert_eq!(p.file_name, "one-renamed.png"); - let _ = std::fs::remove_dir_all(&dir); - } - - // Regression test for #49: the in-window menu headings (File, Edit, ...) sat only egui's default - // 8 px apart, too close to read or aim at. - #[test] - fn in_window_menu_headings_are_spaced_apart() { - let mut a = app(); - a.native_menu = false; - let mut h = crate::headless::Headless::new(a, [1400.0, 900.0], 1.0); - h.step(); - h.step(); - let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} is on screen")); - let titles: Vec<_> = ["File", "Edit", "View", "Photo", "Window", "Help"].iter().map(|t| rect(&format!("menu:{t}"))).collect(); - for pair in titles.windows(2) { - let gap = pair[1].left() - pair[0].right(); - assert!(gap >= 24.0 - 0.5, "headings {:?} and {:?} are only {gap} px apart", pair[0], pair[1]); - } - } - - /// A backup the host refuses (changes still unsaved, issue #1508) says why when started from - /// the menu, as a refused export does; the refusal used to reach only the control channel. - #[test] - fn a_refused_backup_from_the_menu_shows_why() { - // the host refuses, as the web one does while changes are still unsaved (issue #1508) - let refusal = "Backup not started: 1 change could not be saved to browser storage yet"; - let services = crate::Services { - backup_library: Some(Box::new(move |_: &mut lightcraft_engine::Session| Err(refusal.to_string()))), - ..Default::default() - }; - let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); - let t = std::time::Duration::from_secs(30); - h.settle(t); - for id in ["menu:File", "menuitem:file.backupLibrary"] { - let r = h.request("ui.clickWidget", json!({"id": id}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - h.step(); - } - // (read at once: a toast keeps the UI repainting until it expires, so settling would outlive it) - let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); - assert!(toast.contains(refusal), "the refusal is shown: {toast:?}"); - } - - /// #1528: `file.findMissing` with `wait: true` must propagate folder validation and search - /// failures as errors instead of returning `Ok(null)`. - #[test] - fn find_missing_waited_invalid_folder_returns_error_with_zero_mutations() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-err-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let dummy = dir.join("dummy.png"); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(&dummy, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dummy.to_string_lossy()]})).unwrap(); - // remove the file on disk so the photo is missing - std::fs::remove_file(&dummy).unwrap(); - let id = app.session.catalog.photos().next().unwrap().id; - let original_path = match &app.session.catalog.photo(id).unwrap().source { - lightcraft_catalog::Source::File { path } => path.clone(), - _ => panic!("expected file source"), - }; - let undo_count_before = app.session.undo.len(); - - // 1. Point at a nonexistent directory - let bad_dir = dir.join("nonexistent_subdir"); - let err = run_item(&mut app, "file.findMissing", json!({"folder": bad_dir.to_string_lossy(), "wait": true})).unwrap_err(); - assert!(err.contains("not a folder"), "expected 'not a folder' error, got: {err}"); - assert!(app.ui.toast.as_ref().map(|t| t.0.contains("not a folder")).unwrap_or(false), "toast should reflect failure"); - assert!(app.ui.last_find_missing.is_none(), "last_find_missing must remain None on failure"); - - // 2. Invalid timeout parameters are rejected cleanly - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": -1.0})).unwrap_err(); - assert!(err.contains("non-negative number of seconds"), "{err}"); - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": "invalid"})).unwrap_err(); - assert!(err.contains("number of seconds"), "{err}"); - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 1e300})).unwrap_err(); - assert!(err.contains("too large"), "{err}"); - - // Catalog and photo path must not have been mutated - assert_eq!(app.session.undo.len(), undo_count_before, "no undo steps added on failed search"); - let path_after = match &app.session.catalog.photo(id).unwrap().source { - lightcraft_catalog::Source::File { path } => path.clone(), - _ => panic!("expected file source"), - }; - assert_eq!(path_after, original_path, "missing photo path must not change on failure"); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1528: `file.findMissing` with `wait: true` returns structured results on success, - /// and background mode (`wait: false` or omitted) continues returning `{"background": true}`. - #[test] - fn find_missing_waited_structured_results_and_background_mode() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-bg-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("orig")).unwrap(); - std::fs::create_dir_all(dir.join("found")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); - std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); - - // Background mode (wait omitted): returns {"background": true} immediately - let bg = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy()})).unwrap(); - assert_eq!(bg, json!({"background": true})); - - // Wait for background task to complete - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); - let last = app.ui.last_find_missing.as_ref().unwrap(); - assert_eq!(last["found"].as_array().map(Vec::len), Some(1)); - - // Now move again and run with wait: true -> returns structured results directly - std::fs::create_dir_all(dir.join("found2")).unwrap(); - std::fs::rename(dir.join("found/pic.png"), dir.join("found2/pic.png")).unwrap(); - let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found2").to_string_lossy(), "wait": true})).unwrap(); - assert!(r.get("found").is_some(), "must have 'found' array: {r}"); - assert!(r.get("missing").is_some(), "must have 'missing' count: {r}"); - assert!(r.get("ambiguous").is_some(), "must have 'ambiguous' array: {r}"); - assert_eq!(r["found"].as_array().map(Vec::len), Some(1)); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1528: `file.findMissing` with `wait: true` when timed out returns an actionable error - /// indicating timeout and that the search continues in the background, rather than returning `Ok(null)`. - #[test] - fn find_missing_waited_timeout_behavior() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-to-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - // Injected test-only worker delay so wait times out deterministically - app.ui.find_missing_test_delay = Some(std::time::Duration::from_millis(100)); - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 0.005})).unwrap_err(); - - assert!(err.contains("search timed out"), "error should mention timeout: {err}"); - assert!(err.contains("search continues in the background"), "error should document continuation: {err}"); - - // The background task continues running in app.tasks - assert!(app.tasks.is_running("Find Missing Photos"), "task should continue running in background"); - - // Clean up the running task so no worker hangs - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - assert!(crate::tasks::wait_for(&mut app, &ctx, "Find Missing Photos", std::time::Duration::from_secs(5))); - assert!(!app.tasks.is_running("Find Missing Photos")); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1528: `file.findMissing` with `wait: true` must return when its own search finishes, - /// even if unrelated background tasks remain active in `app.tasks`. - #[test] - fn find_missing_waited_completes_while_unrelated_task_remains_active() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-multi-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("orig")).unwrap(); - std::fs::create_dir_all(dir.join("found")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); - std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); - - // Spawn an unrelated background task that blocks on a channel until released - let (tx_unblock, rx_unblock) = std::sync::mpsc::channel::<()>(); - crate::tasks::spawn( - &mut app, - "Unrelated Long Background Task", - None, - move || { - let _ = rx_unblock.recv(); - }, - |_app, _ctx, _| {}, - ) - .unwrap(); - assert!(app.tasks.is_running("Unrelated Long Background Task")); - - // Run file.findMissing with wait: true: must complete without waiting for the unrelated task - let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy(), "wait": true, "timeout": 5.0})).unwrap(); - assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "Find Missing must complete successfully: {r}"); - - // The unrelated task must STILL be active - assert!(app.tasks.is_running("Unrelated Long Background Task"), "unrelated task must still be running"); - - // Clean up the blocked background task - let _ = tx_unblock.send(()); - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); - assert!(app.tasks.is_empty()); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - - fn auto_tag_from_tracklog() { - let dir = std::env::temp_dir().join(format!("lc-ui-tracklog-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let gpx = dir.join("walk.gpx"); - std::fs::write( - &gpx, - r#" - - - "#, - ) - .unwrap(); - let mut app = app(); - let id = app.session.visible()[0].0; - app.session.execute("library.select", &json!({"ids": [id]})).unwrap(); - app.session.execute("photo.setCaptureTime", &json!({"time": "2026-05-01T12:01:00", "each": true})).unwrap(); - app.session.execute("photo.setMeta", &json!({"gps": null})).unwrap(); - // in the Photo menu; without a file dialog (headless) it is disabled - let photo_menu = menu_bar(&app).into_iter().find(|(t, _)| t == "Photo").unwrap().1; - let Some(MenuNode::Item { enabled, .. }) = find(&photo_menu, "photo.tagFromTracklog") else { panic!("not in the Photo menu") }; - assert!(!enabled); - // a file without an offset asks for the camera's time zone; confirming runs the tagging - run_item(&mut app, "photo.tagFromTracklog", json!({"path": gpx.to_string_lossy()})).unwrap(); - let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog else { panic!("no time-zone prompt") }; - *value = " +02:00 ".into(); - let d = app.ui.dialog.take().unwrap(); - let r = crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); - assert_eq!(r["tagged"], 1, "{r}"); - let p = app.session.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).unwrap(); - assert_eq!(p.meta.gps, Some((46.001, 7.002))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn metadata_preset_menu() { - let mut app = app(); - app.session.execute("photo.setMeta", &json!({"copyright": "© Me", "creator": "Me"})).unwrap(); - run_item(&mut app, "dialog.saveMetadataPreset", Value::Null).unwrap(); - if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { - *value = "Mine".into(); - } - let d = app.ui.dialog.take().unwrap(); - crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); - let bar = menu_bar(&app); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - let Some(MenuNode::Item { id, params, .. }) = find(&all, "metadata.applyPreset") else { panic!("no preset item") }; - assert_eq!(params["name"], "Mine"); - let other = app.session.visible()[1]; - app.session.execute("library.select", &json!({"ids": [other.0]})).unwrap(); - run_item(&mut app, id, params.clone()).unwrap(); - assert_eq!(app.session.catalog.photo(other).unwrap().meta.copyright, "© Me"); - } - - /// Native menu accelerators never pass through `shortcuts::handle`: a command queued by the - /// native menu must obey the same blocking-window/prompt policy. Settings… and About must not - /// open behind the blocking window, and Quit stays available for its existing confirmation. - #[test] - fn native_menu_commands_respect_the_blocking_window_policy() { - use crate::panels::library_problem::LibraryProblem; - use crate::state::Dialog; - - let mut h = crate::headless::Headless::new(app(), [1000.0, 760.0], 1.0); - h.settle(std::time::Duration::from_secs(20)); - h.app.ui.view = ViewMode::PhotoGrid; - h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); - - assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked); - assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked); - assert_eq!(native_command_policy(&h.app, "view.gridToggle"), NativeCommand::Blocked); - assert_eq!(native_command_policy(&h.app, "app.quit"), NativeCommand::Quit, "Quit keeps its own confirmation"); - assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked); - assert!(h.app.ui.dialog.is_none(), "Settings must not open behind the blocking library window"); - assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked); - assert!(h.app.ui.dialog.is_none(), "About must not open behind the blocking library window"); - assert_eq!(run_native_command(&mut h.app, "view.gridToggle", Value::Null), NativeCommand::Blocked); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a menu command did not act on the library behind the window"); - assert_eq!( - run_native_command(&mut h.app, "app.quit", Value::Null), - NativeCommand::Quit, - "Quit stays for the host's existing tasks/unsaved-changes confirmation" - ); - assert!(h.app.ui.dialog.is_none()); - - // The launch list owns input the same way. - let settings = crate::state::AppSettings { - recent_libraries: vec!["/no/such/library-one".into()], - library_path: "/no/such/library-one".into(), - ..Default::default() - }; - h.app.library_problem = Some(LibraryProblem::choose(&settings)); - assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked); - assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked); - assert!(h.app.ui.dialog.is_none(), "About must not open behind the launch list"); - // The launch list's own New Library prompt is not a background barrier: it keeps its - // priority instead of being treated as an unrelated dialog behind the list. - h.app.ui.dialog = Some(Dialog::TextPrompt { - title: "New Library".into(), - hint: "Library name".into(), - value: "New Library".into(), - command: "app.newLibrary".into(), - params: json!({}), - key: "name".into(), - }); - assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run, "the launch list's own prompt is not a background barrier"); - h.app.ui.dialog = None; - - // A quit/settings notice owns input in the same way. - h.app.library_problem = None; - h.app.notices.push("The settings file could not be read and was reset.".into()); - assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked); - assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked); - assert!(h.app.ui.dialog.is_none(), "Settings must not open behind a notice"); - - // Once nothing blocks input, the same queued command runs normally. - h.app.notices.clear(); - assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run); - assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Run); - assert!(matches!(h.app.ui.dialog, Some(Dialog::Settings { .. })), "the command runs when no barrier owns input"); - } - - #[test] - fn shortcut_text_per_platform() { - assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); - assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); - assert_eq!(shortcut_text("Delete", true), "⌫"); - } - - /// Every item of a menu, submenus included. - fn flat(nodes: &[MenuNode]) -> Vec<&MenuNode> { - nodes - .iter() - .flat_map(|n| match n { - MenuNode::Submenu { children, .. } => flat(children), - MenuNode::Item { .. } => vec![n], - MenuNode::Separator => vec![], - }) - .collect() - } - - /// Given the menu bar, Then an item that turns something on or off is named for the thing, - /// with its check mark saying whether it is on: "Filmstrip ✓", never "Show Filmstrip ✓". - /// Show Original is a view (\), not a switch: it keeps Lightroom's name. - #[test] - fn toggles_are_named_for_the_thing_not_show() { - let mut a = app(); - a.ui.view = ViewMode::Detail; - let shows: Vec = menu_bar(&a) - .iter() - .flat_map(|(_, items)| flat(items).into_iter().cloned().collect::>()) - .filter_map(|n| match n { - MenuNode::Item { id, params, label, checked: Some(_), .. } - if params.is_null() && label.starts_with("Show ") && id != "view.showOriginal" => - { - Some(format!("{id}: {label}")) - } - _ => None, - }) - .collect(); - assert!(shows.is_empty(), "toggles named \"Show …\": {shows:#?}"); - } - - /// Given every command with a menu path, Then no two of them share a label, and none is named - /// like a menu: the menus, the keyboard shortcuts list and the control channel tell them apart - /// by name ("Detail" the view and "Detail" the Edit section were two things called the same). - #[test] - fn no_two_menu_commands_share_a_label() { - let entries = crate::menus::menu_entries(&app()); - let mut clashes = Vec::new(); - for (i, e) in entries.iter().enumerate() { - if MENUS.contains(&e.label.as_str()) { - clashes.push(format!("{} is called like the {} menu", e.id, e.label)); - } - if let Some(other) = entries[..i].iter().find(|o| o.label == e.label && o.id != e.id) { - clashes.push(format!("{} and {} are both {:?}", other.id, e.id, e.label)); - } - } - assert!(clashes.is_empty(), "{clashes:#?}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/menubar/tests.rs b/crates/ui-egui/src/menubar/tests.rs new file mode 100644 index 000000000..e5645918a --- /dev/null +++ b/crates/ui-egui/src/menubar/tests.rs @@ -0,0 +1,828 @@ +use super::*; + +fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()) +} + +fn find<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> { + nodes.iter().find_map(|n| match n { + MenuNode::Item { id: i, .. } if i == id => Some(n), + MenuNode::Submenu { children, .. } => find(children, id), + _ => None, + }) +} + +/// #2500: a direct-grid binding takes precedence over the borrowed cycle hint. +#[test] +fn grid_menu_shortcut_hints_preserve_direct_bindings_and_clicks() { + let mut a = app(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + let bar = menu_bar(&a); + let nodes = &bar.iter().find(|(title, _)| title == "View").unwrap().1; + assert!(matches!(find(nodes, "view.photoGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "Cmd+Shift+1")); + assert!(matches!(find(nodes, "view.squareGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "G")); + // A hint never assigns the cycle key to another command. + assert_eq!(crate::shortcuts::shortcut_of(&a.ui.settings.keymap, "view.squareGrid"), None); + run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid); + run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid, "clicking the active row does not cycle"); + run_item(&mut a, "view.squareGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::SquareGrid); + run_item(&mut a, "view.gridToggle", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid); +} + +/// #2500: native hints never install a borrowed modifier/function key as a direct-grid +/// accelerator. Plain G remains in AppKit's shortcut column and is dispatched by egui. +#[test] +fn grid_menu_shortcut_hints_keep_native_toggle_dispatch() { + let mut keymap = crate::shortcuts::Keymap::new(); + for id in ["view.photoGrid", "view.squareGrid"] { + let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some("G")); + assert_eq!(label, "Photo Grid"); + assert_eq!(accelerator, Some("G")); + assert!(!native_menu_delivers(accelerator.unwrap())); + for (key, text) in [("Cmd+Shift+G", "⌘⇧G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] { + crate::shortcuts::assign(&mut keymap, "view.gridToggle", Some(key)).unwrap(); + let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some(key)); + assert_eq!(label, format!("Photo Grid ({text})")); + assert_eq!(accelerator, None, "egui keeps {key}"); + } + } + crate::shortcuts::assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + let (label, accelerator) = native_item_presentation(&keymap, "view.photoGrid", &Value::Null, "Photo Grid", Some("Cmd+Shift+1")); + assert_eq!(label, "Photo Grid"); + assert_eq!(accelerator, Some("Cmd+Shift+1"), "the row's own shortcut is a real accelerator"); +} + +/// #2500: the control channel runs the real keyboard path exactly once and menu commands +/// continue to select their named grid after the cycle shortcut is remapped. +#[test] +fn grid_menu_shortcut_hints_preserve_control_keyboard_dispatch() { + let mut h = crate::headless::Headless::new(app(), [960.0, 720.0], 1.0); + h.app.ui.view = ViewMode::PhotoGrid; + for _ in 0..3 { + h.step(); + } + let timeout = std::time::Duration::from_secs(20); + let press = |h: &mut crate::headless::Headless, params| { + let reply = h.request("ui.key", params, timeout); + assert_eq!(reply["ok"], true, "{reply}"); + }; + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + crate::shortcuts::assign(&mut h.app.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + for _ in 0..2 { + let reply = h.request("ui.menu.invoke", json!({"command": "view.squareGrid"}), timeout); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + } +} + +/// #2513 review: moving a direct-grid key to the cycle command leaves the visible model +/// unchanged, but must invalidate the native cache so its old accelerator is removed. +#[test] +fn grid_menu_shortcut_hints_invalidate_native_ownership_cache() { + let mut a = app(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+G")).unwrap(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.squareGrid", Some("Cmd+Shift+2")).unwrap(); + let before = menu_bar(&a); + let direct = native_menu_structure(&before, &a.ui.settings.keymap); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + let after = menu_bar(&a); + assert_eq!(before, after, "visible hint text does not expose the ownership change"); + assert_ne!(direct, native_menu_structure(&after, &a.ui.settings.keymap), "rebuild removes the stale direct-grid accelerator"); +} + +/// #2500: grid view rows show the separate cycle command's current shortcut. +#[test] +fn grid_menu_shortcut_hints_follow_the_cycle_key() { + let mut a = app(); + let hint = |a: &LightcraftApp, id: &str| { + let bar = menu_bar(a); + let nodes = &bar.iter().find(|(title, _)| title == "View").expect("View menu").1; + match find(nodes, id).expect("grid row") { + MenuNode::Item { shortcut, .. } => shortcut.clone(), + _ => panic!("grid row must be an item"), + } + }; + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id).as_deref(), Some("G"), "{id}"); + } + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id).as_deref(), Some("Cmd+Shift+G"), "{id}"); + } + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id), None, "{id}"); + } +} + +#[test] +fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() { + let mut a = app(); + let view = |a: &LightcraftApp| menu_bar(a).into_iter().find(|(t, _)| t == "View").expect("View menu").1; + let items = view(&a); + assert!(matches!(find(&items, "library.refresh"), Some(MenuNode::Item { enabled: true, .. }))); + assert!(matches!(find(&items, "library.holdView"), Some(MenuNode::Item { checked: Some(false), .. })), "off unless asked for"); + a.session.execute("library.holdView", &json!({"on": true})).expect("hold the view"); + assert!(matches!(find(&view(&a), "library.holdView"), Some(MenuNode::Item { checked: Some(true), .. }))); +} + +/// The Sort submenu is expanded by hand: Reshuffle appears once (not again from the registry), +/// only while sorting at random, and the direction items are off for a shuffle. +#[test] +fn sort_menu_lists_reshuffle_once_and_only_enables_it_for_random() { + fn sort_children(bar: &[(String, Vec)]) -> Vec { + let view = &bar.iter().find(|(t, _)| t == "View").expect("View menu").1; + view.iter() + .find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Sort" => Some(children.clone()), + _ => None, + }) + .expect("Sort submenu") + } + let count = |nodes: &[MenuNode]| nodes.iter().filter(|n| matches!(n, MenuNode::Item { id, .. } if id == "library.shuffle")).count(); + let mut a = app(); + let kids = sort_children(&menu_bar(&a)); + assert_eq!(count(&kids), 1); + assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: false, .. })), "off until Random is chosen"); + a.session.execute("library.sort", &json!({"key": "random"})).expect("sort at random"); + let kids = sort_children(&menu_bar(&a)); + assert_eq!(count(&kids), 1); + assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: true, .. }))); + // no direction is shown as chosen while shuffling + let dir_checked = |kids: &[MenuNode]| { + kids.iter() + .filter_map(|n| match n { + MenuNode::Item { label, checked, .. } if label == "Ascending" || label == "Descending" => Some(*checked), + _ => None, + }) + .collect::>() + }; + assert_eq!(dir_checked(&kids), vec![None, None]); + a.session.execute("library.sort", &json!({"key": "fileName"})).expect("sort by name"); + assert!(dir_checked(&sort_children(&menu_bar(&a))).iter().all(Option::is_some), "checks come back for other keys"); +} + +/// File opens with the import entry points, worded as importing (not as adding a sidebar +/// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸. +#[test] +fn file_menu_starts_with_the_import_entry_points() { + let bar = menu_bar(&app()); + let file = &bar.iter().find(|(t, _)| t == "File").expect("File menu").1; + let labels: Vec = file + .iter() + .take(3) + .map(|n| match n { + MenuNode::Item { label, shortcut, .. } => format!("{label}{}", shortcut.as_deref().map(|s| format!(" [{s}]")).unwrap_or_default()), + MenuNode::Submenu { label, .. } => format!("{label} ▸"), + MenuNode::Separator => "---".into(), + }) + .collect(); + assert_eq!(labels[0], "Import Photos… [Cmd+Shift+I]"); + assert_eq!(labels[1..], ["Import from Folder…".to_string(), "Import from Device ▸".to_string()]); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let text = serde_json::to_string(&all).unwrap(); + assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus"); +} + +/// Back Up / Restore Library are the browser build's (its library lives in browser storage): +/// absent from the desktop's menus, present and wired to the host where it provides them. +#[test] +fn library_backup_items_follow_the_host() { + let all = |app: &LightcraftApp| -> Vec { menu_bar(app).into_iter().flat_map(|(_, v)| v).collect() }; + let mut desktop = app(); + assert!(find(&all(&desktop), "file.backupLibrary").is_none() && find(&all(&desktop), "file.restoreLibrary").is_none()); + assert!(run_item(&mut desktop, "file.backupLibrary", Value::Null).is_err(), "not available without the host"); + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let c = calls.clone(); + let services = crate::Services { + backup_library: Some(Box::new(move |s: &mut lightcraft_engine::Session| { + c.set(c.get() + 1); + Ok(json!({"photos": s.catalog.len()})) + })), + restore_library: Some(Box::new(|_: &mut lightcraft_engine::Session| Ok(json!({"started": true})))), + ..Default::default() + }; + let mut web = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let bar = all(&web); + for id in ["file.backupLibrary", "file.restoreLibrary"] { + assert!(matches!(find(&bar, id), Some(MenuNode::Item { enabled: true, .. })), "{id}"); + } + let r = run_item(&mut web, "file.backupLibrary", Value::Null).unwrap(); + assert!(r["photos"].as_u64().unwrap() > 0); + assert_eq!(calls.get(), 1); +} + +#[test] +fn bar_follows_the_registry_and_state() { + let mut app = app(); + let bar = menu_bar(&app); + let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(titles, MENUS); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + // every engine command with a menu path is reachable (parameterized ones via submenus; items + // that follow the state, like Restore for deleted photos, when it applies) + for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id) && host_supports(&app, c.id)) { + assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); + } + for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { + assert!(find(&all, id).is_some(), "{id}"); + } + // no doubled or dangling separators + fn check(v: &[MenuNode]) { + assert_ne!(v.first(), Some(&MenuNode::Separator)); + assert_ne!(v.last(), Some(&MenuNode::Separator)); + for w in v.windows(2) { + assert!(!(w[0] == MenuNode::Separator && w[1] == MenuNode::Separator)); + } + for n in v { + if let MenuNode::Submenu { children, .. } = n { + check(children); + } + } + } + bar.iter().for_each(|(_, v)| check(v)); + // live state: undo label, enabled, checked + let Some(MenuNode::Item { enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert!(!enabled); + run_item(&mut app, "photo.rate", json!({"rating": 3})).unwrap(); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let Some(MenuNode::Item { label, enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert_eq!((label.as_str(), *enabled), ("Undo Set Rating", true)); + let rated: Vec<_> = all + .iter() + .filter_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Set Rating" => Some(children.clone()), + _ => None, + }) + .flatten() + .filter(|n| matches!(n, MenuNode::Item { checked: Some(true), .. })) + .collect(); + assert!(matches!(&rated[..], [MenuNode::Item { params, .. }] if params["rating"] == 3)); + let Some(MenuNode::Item { checked, .. }) = find(&all, "view.filmstrip") else { panic!() }; + assert_eq!(*checked, Some(app.ui.filmstrip)); +} + +#[test] +fn quit_ends_the_file_menu_after_unlisted_entries() { + let app = app(); + // the File menu has registry entries the layout doesn't list (appended as fallbacks) + let listed = LAYOUT.iter().find(|(t, _)| *t == "File").unwrap().1; + let unlisted: Vec = crate::menus::menu_entries(&app) + .into_iter() + .filter(|e| e.menu.first().map(String::as_str) == Some("File") && !listed.contains(&e.id.as_str()) && !HIDDEN.contains(&e.id.as_str())) + .map(|e| e.id) + .collect(); + assert!(!unlisted.is_empty(), "no fallback File entries to order"); + let bar = menu_bar(&app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + // Quit is the last item, alone in its separator group + assert!(matches!(file.last(), Some(MenuNode::Item { id, .. }) if id == "app.quit"), "{file:?}"); + assert_eq!(file.get(file.len() - 2), Some(&MenuNode::Separator)); + // every fallback entry is still reachable, before Quit + for id in &unlisted { + assert!(find(&file[..file.len() - 1], id).is_some(), "{id} missing before Quit"); + } +} + +#[test] +fn export_with_preset_submenu() { + let mut app = app(); + let bar = menu_bar(&app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + let Some(MenuNode::Submenu { children, .. }) = + file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Export with Preset")) + else { + panic!("no Export with Preset submenu") + }; + let presets: Vec<_> = children + .iter() + .filter_map(|n| match n { + MenuNode::Item { id, params, .. } if id == "app.export" => params["preset"].as_str().map(str::to_string), + _ => None, + }) + .collect(); + assert_eq!(presets.first().map(String::as_str), Some("JPEG (Small)")); + assert!(find(children, "dialog.export").is_some(), "Custom… opens the dialog"); + // a user preset appears after the built-ins; choosing it exports with its settings + app.session.execute("export.savePreset", &json!({"name": "Tiny PNG", "params": {"format": "png", "width": 40}})).unwrap(); + let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Vec)>::new())); + let w = written.clone(); + app.services.write = Some(Box::new(move |p: &str, b: &[u8]| { + w.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": "/nonexistent-lc-test"})).unwrap(); + assert_eq!(r["files"][0]["width"], 40, "{r}"); + let w = written.lock().unwrap(); + assert!(w[0].0.starts_with("/nonexistent-lc-test/") && w[0].0.ends_with(".png"), "{}", w[0].0); + assert!(w[0].1.starts_with(b"\x89PNG")); + // remembered (expanded) for Export with Previous, folder included + let last = app.session.last_export.clone().unwrap(); + assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None)); + // a blank folder (the Export dialog's Folder field cleared) is refused with a clear message + // instead of writing into the working directory + let n = w.len(); + drop(w); + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "})); + assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER); + assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder"); + // "Same folder as original photo" needs no folder; a demo photo has none of its own, so it + // gets its own error rather than a file written elsewhere (issue #956) + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " ", "destinationMode": "sameAsOriginal"})); + let e = r.unwrap_err(); + assert!(e.contains("no folder on disk"), "{e}"); + assert_eq!(written.lock().unwrap().len(), n, "nothing written for a photo without a folder"); +} + +#[test] +fn select_by_submenu_and_text_prompt() { + let mut app = app(); + let bar = menu_bar(&app); + let edit = &bar.iter().find(|(t, _)| t == "Edit").unwrap().1; + let Some(MenuNode::Submenu { children, .. }) = edit.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Select by")) + else { + panic!("no Select by submenu") + }; + let Some(MenuNode::Item { id, params, .. }) = children.first() else { panic!() }; + let r = run_item(&mut app, id, params.clone()).unwrap(); + assert!(r["selected"].as_u64().is_some_and(|n| n > 0), "{r}"); + // the one-field prompt runs its command with the typed value + app.session.execute("version.create", &json!({"name": "A"})).unwrap(); + crate::panels::dialogs::prompt(&mut app, "Rename Version", "Version name", "A", "version.rename", json!({"index": 0}), "name"); + if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { + *value = " Final ".into(); + } + let d = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + let id = app.session.active().unwrap(); + assert_eq!(app.session.catalog.photo(id).unwrap().versions[0].name, "Final"); +} + +#[test] +fn help_opens_the_docs_and_merge_last_needs_photos() { + let mut app = app(); + let opened = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let o = opened.clone(); + app.services.open_url = Some(Box::new(move |u: &str| { + o.lock().unwrap().push(u.to_string()); + Ok(()) + })); + run_item(&mut app, "app.help", Value::Null).unwrap(); + assert_eq!(opened.lock().unwrap().as_slice(), [crate::links::HELP]); + // merging with the last settings needs a selection of two or more + let first = app.session.visible()[0].0; + app.session.execute("library.select", &json!({"ids": [first]})).unwrap(); + assert!(run_item(&mut app, "merge.hdrLast", Value::Null).is_err()); +} + +/// #260: Help ▸ Open Log Folder reveals the log file the host names. Without one (the web, +/// `--memory`) or without a file manager to show it, the item is off and the command says why. +#[test] +fn open_log_folder_reveals_the_hosts_log_file() { + let mut app = app(); + let help = |app: &LightcraftApp| menu_bar(app).into_iter().find(|(t, _)| t == "Help").map(|(_, items)| items).unwrap_or_default(); + assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: false, .. })), "listed in Help, off without a log"); + assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); + assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); + let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let s = shown.clone(); + app.services.reveal = Some(Box::new(move |p: &str| { + s.lock().unwrap().push(p.to_string()); + Ok(()) + })); + // a file manager alone is not enough: this session keeps no log + assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); + assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); + assert!(shown.lock().unwrap().is_empty()); + let log = "/home/a/.config/lightcraft/logs/lightcraft.log"; + app.services.log_file = Some(log.into()); + assert!(crate::menus::ui_enabled(&app, "app.openLogFolder")); + assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: true, .. }))); + let r = run_item(&mut app, "app.openLogFolder", Value::Null).unwrap(); + assert_eq!(r["path"], log); + assert_eq!(shown.lock().unwrap().as_slice(), [log]); + // the file manager's failure is the command's + app.services.reveal = Some(Box::new(|_: &str| Err("no file manager".into()))); + assert_eq!(run_item(&mut app, "app.openLogFolder", Value::Null).unwrap_err(), "no file manager"); +} + +#[test] +fn find_missing_and_locate() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("a")).unwrap(); + std::fs::create_dir_all(dir.join("b")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("a/one.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dir.join("a").to_string_lossy()]})).unwrap(); + std::fs::rename(dir.join("a/one.png"), dir.join("b/one.png")).unwrap(); + let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy(), "wait": true})).unwrap(); + assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "{r}"); + // Locate: an explicit file + std::fs::rename(dir.join("b/one.png"), dir.join("one-renamed.png")).unwrap(); + let id = app.session.catalog.photos().next().unwrap().id; + app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + run_item(&mut app, "photo.locate", json!({"path": dir.join("one-renamed.png").to_string_lossy()})).unwrap(); + let p = app.session.catalog.photo(id).unwrap(); + assert_eq!(p.file_name, "one-renamed.png"); + let _ = std::fs::remove_dir_all(&dir); +} + +// Regression test for #49: the in-window menu headings (File, Edit, ...) sat only egui's default +// 8 px apart, too close to read or aim at. +#[test] +fn in_window_menu_headings_are_spaced_apart() { + let mut a = app(); + a.native_menu = false; + let mut h = crate::headless::Headless::new(a, [1400.0, 900.0], 1.0); + h.step(); + h.step(); + let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} is on screen")); + let titles: Vec<_> = ["File", "Edit", "View", "Photo", "Window", "Help"].iter().map(|t| rect(&format!("menu:{t}"))).collect(); + for pair in titles.windows(2) { + let gap = pair[1].left() - pair[0].right(); + assert!(gap >= 24.0 - 0.5, "headings {:?} and {:?} are only {gap} px apart", pair[0], pair[1]); + } +} + +/// A backup the host refuses (changes still unsaved, issue #1508) says why when started from +/// the menu, as a refused export does; the refusal used to reach only the control channel. +#[test] +fn a_refused_backup_from_the_menu_shows_why() { + // the host refuses, as the web one does while changes are still unsaved (issue #1508) + let refusal = "Backup not started: 1 change could not be saved to browser storage yet"; + let services = crate::Services { + backup_library: Some(Box::new(move |_: &mut lightcraft_engine::Session| Err(refusal.to_string()))), + ..Default::default() + }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); + let t = std::time::Duration::from_secs(30); + h.settle(t); + for id in ["menu:File", "menuitem:file.backupLibrary"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); + } + // (read at once: a toast keeps the UI repainting until it expires, so settling would outlive it) + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains(refusal), "the refusal is shown: {toast:?}"); +} + +/// #1528: `file.findMissing` with `wait: true` must propagate folder validation and search +/// failures as errors instead of returning `Ok(null)`. +#[test] +fn find_missing_waited_invalid_folder_returns_error_with_zero_mutations() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-err-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dummy = dir.join("dummy.png"); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(&dummy, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dummy.to_string_lossy()]})).unwrap(); + // remove the file on disk so the photo is missing + std::fs::remove_file(&dummy).unwrap(); + let id = app.session.catalog.photos().next().unwrap().id; + let original_path = match &app.session.catalog.photo(id).unwrap().source { + lightcraft_catalog::Source::File { path } => path.clone(), + _ => panic!("expected file source"), + }; + let undo_count_before = app.session.undo.len(); + + // 1. Point at a nonexistent directory + let bad_dir = dir.join("nonexistent_subdir"); + let err = run_item(&mut app, "file.findMissing", json!({"folder": bad_dir.to_string_lossy(), "wait": true})).unwrap_err(); + assert!(err.contains("not a folder"), "expected 'not a folder' error, got: {err}"); + assert!(app.ui.toast.as_ref().map(|t| t.0.contains("not a folder")).unwrap_or(false), "toast should reflect failure"); + assert!(app.ui.last_find_missing.is_none(), "last_find_missing must remain None on failure"); + + // 2. Invalid timeout parameters are rejected cleanly + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": -1.0})).unwrap_err(); + assert!(err.contains("non-negative number of seconds"), "{err}"); + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": "invalid"})).unwrap_err(); + assert!(err.contains("number of seconds"), "{err}"); + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 1e300})).unwrap_err(); + assert!(err.contains("too large"), "{err}"); + + // Catalog and photo path must not have been mutated + assert_eq!(app.session.undo.len(), undo_count_before, "no undo steps added on failed search"); + let path_after = match &app.session.catalog.photo(id).unwrap().source { + lightcraft_catalog::Source::File { path } => path.clone(), + _ => panic!("expected file source"), + }; + assert_eq!(path_after, original_path, "missing photo path must not change on failure"); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// #1528: `file.findMissing` with `wait: true` returns structured results on success, +/// and background mode (`wait: false` or omitted) continues returning `{"background": true}`. +#[test] +fn find_missing_waited_structured_results_and_background_mode() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-bg-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("orig")).unwrap(); + std::fs::create_dir_all(dir.join("found")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); + std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); + + // Background mode (wait omitted): returns {"background": true} immediately + let bg = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy()})).unwrap(); + assert_eq!(bg, json!({"background": true})); + + // Wait for background task to complete + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); + let last = app.ui.last_find_missing.as_ref().unwrap(); + assert_eq!(last["found"].as_array().map(Vec::len), Some(1)); + + // Now move again and run with wait: true -> returns structured results directly + std::fs::create_dir_all(dir.join("found2")).unwrap(); + std::fs::rename(dir.join("found/pic.png"), dir.join("found2/pic.png")).unwrap(); + let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found2").to_string_lossy(), "wait": true})).unwrap(); + assert!(r.get("found").is_some(), "must have 'found' array: {r}"); + assert!(r.get("missing").is_some(), "must have 'missing' count: {r}"); + assert!(r.get("ambiguous").is_some(), "must have 'ambiguous' array: {r}"); + assert_eq!(r["found"].as_array().map(Vec::len), Some(1)); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// #1528: `file.findMissing` with `wait: true` when timed out returns an actionable error +/// indicating timeout and that the search continues in the background, rather than returning `Ok(null)`. +#[test] +fn find_missing_waited_timeout_behavior() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-to-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + // Injected test-only worker delay so wait times out deterministically + app.ui.find_missing_test_delay = Some(std::time::Duration::from_millis(100)); + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 0.005})).unwrap_err(); + + assert!(err.contains("search timed out"), "error should mention timeout: {err}"); + assert!(err.contains("search continues in the background"), "error should document continuation: {err}"); + + // The background task continues running in app.tasks + assert!(app.tasks.is_running("Find Missing Photos"), "task should continue running in background"); + + // Clean up the running task so no worker hangs + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + assert!(crate::tasks::wait_for(&mut app, &ctx, "Find Missing Photos", std::time::Duration::from_secs(5))); + assert!(!app.tasks.is_running("Find Missing Photos")); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// #1528: `file.findMissing` with `wait: true` must return when its own search finishes, +/// even if unrelated background tasks remain active in `app.tasks`. +#[test] +fn find_missing_waited_completes_while_unrelated_task_remains_active() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-multi-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("orig")).unwrap(); + std::fs::create_dir_all(dir.join("found")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); + std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); + + // Spawn an unrelated background task that blocks on a channel until released + let (tx_unblock, rx_unblock) = std::sync::mpsc::channel::<()>(); + crate::tasks::spawn( + &mut app, + "Unrelated Long Background Task", + None, + move || { + let _ = rx_unblock.recv(); + }, + |_app, _ctx, _| {}, + ) + .unwrap(); + assert!(app.tasks.is_running("Unrelated Long Background Task")); + + // Run file.findMissing with wait: true: must complete without waiting for the unrelated task + let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy(), "wait": true, "timeout": 5.0})).unwrap(); + assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "Find Missing must complete successfully: {r}"); + + // The unrelated task must STILL be active + assert!(app.tasks.is_running("Unrelated Long Background Task"), "unrelated task must still be running"); + + // Clean up the blocked background task + let _ = tx_unblock.send(()); + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); + assert!(app.tasks.is_empty()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] + +fn auto_tag_from_tracklog() { + let dir = std::env::temp_dir().join(format!("lc-ui-tracklog-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let gpx = dir.join("walk.gpx"); + std::fs::write( + &gpx, + r#" + + + "#, + ) + .unwrap(); + let mut app = app(); + let id = app.session.visible()[0].0; + app.session.execute("library.select", &json!({"ids": [id]})).unwrap(); + app.session.execute("photo.setCaptureTime", &json!({"time": "2026-05-01T12:01:00", "each": true})).unwrap(); + app.session.execute("photo.setMeta", &json!({"gps": null})).unwrap(); + // in the Photo menu; without a file dialog (headless) it is disabled + let photo_menu = menu_bar(&app).into_iter().find(|(t, _)| t == "Photo").unwrap().1; + let Some(MenuNode::Item { enabled, .. }) = find(&photo_menu, "photo.tagFromTracklog") else { panic!("not in the Photo menu") }; + assert!(!enabled); + // a file without an offset asks for the camera's time zone; confirming runs the tagging + run_item(&mut app, "photo.tagFromTracklog", json!({"path": gpx.to_string_lossy()})).unwrap(); + let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog else { panic!("no time-zone prompt") }; + *value = " +02:00 ".into(); + let d = app.ui.dialog.take().unwrap(); + let r = crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + assert_eq!(r["tagged"], 1, "{r}"); + let p = app.session.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).unwrap(); + assert_eq!(p.meta.gps, Some((46.001, 7.002))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn metadata_preset_menu() { + let mut app = app(); + app.session.execute("photo.setMeta", &json!({"copyright": "© Me", "creator": "Me"})).unwrap(); + run_item(&mut app, "dialog.saveMetadataPreset", Value::Null).unwrap(); + if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { + *value = "Mine".into(); + } + let d = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let Some(MenuNode::Item { id, params, .. }) = find(&all, "metadata.applyPreset") else { panic!("no preset item") }; + assert_eq!(params["name"], "Mine"); + let other = app.session.visible()[1]; + app.session.execute("library.select", &json!({"ids": [other.0]})).unwrap(); + run_item(&mut app, id, params.clone()).unwrap(); + assert_eq!(app.session.catalog.photo(other).unwrap().meta.copyright, "© Me"); +} + +/// Native menu accelerators never pass through `shortcuts::handle`: a command queued by the +/// native menu must obey the same blocking-window/prompt policy. Settings… and About must not +/// open behind the blocking window, and Quit stays available for its existing confirmation. +#[test] +fn native_menu_commands_respect_the_blocking_window_policy() { + use crate::panels::library_problem::LibraryProblem; + use crate::state::Dialog; + + let mut h = crate::headless::Headless::new(app(), [1000.0, 760.0], 1.0); + h.settle(std::time::Duration::from_secs(20)); + h.app.ui.view = ViewMode::PhotoGrid; + h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked); + assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked); + assert_eq!(native_command_policy(&h.app, "view.gridToggle"), NativeCommand::Blocked); + assert_eq!(native_command_policy(&h.app, "app.quit"), NativeCommand::Quit, "Quit keeps its own confirmation"); + assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "Settings must not open behind the blocking library window"); + assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "About must not open behind the blocking library window"); + assert_eq!(run_native_command(&mut h.app, "view.gridToggle", Value::Null), NativeCommand::Blocked); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a menu command did not act on the library behind the window"); + assert_eq!( + run_native_command(&mut h.app, "app.quit", Value::Null), + NativeCommand::Quit, + "Quit stays for the host's existing tasks/unsaved-changes confirmation" + ); + assert!(h.app.ui.dialog.is_none()); + + // The launch list owns input the same way. + let settings = crate::state::AppSettings { + recent_libraries: vec!["/no/such/library-one".into()], + library_path: "/no/such/library-one".into(), + ..Default::default() + }; + h.app.library_problem = Some(LibraryProblem::choose(&settings)); + assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked); + assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "About must not open behind the launch list"); + // The launch list's own New Library prompt is not a background barrier: it keeps its + // priority instead of being treated as an unrelated dialog behind the list. + h.app.ui.dialog = Some(Dialog::TextPrompt { + title: "New Library".into(), + hint: "Library name".into(), + value: "New Library".into(), + command: "app.newLibrary".into(), + params: json!({}), + key: "name".into(), + }); + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run, "the launch list's own prompt is not a background barrier"); + h.app.ui.dialog = None; + + // A quit/settings notice owns input in the same way. + h.app.library_problem = None; + h.app.notices.push("The settings file could not be read and was reset.".into()); + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked); + assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "Settings must not open behind a notice"); + + // Once nothing blocks input, the same queued command runs normally. + h.app.notices.clear(); + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run); + assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Run); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Settings { .. })), "the command runs when no barrier owns input"); +} + +#[test] +fn shortcut_text_per_platform() { + assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); + assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); + assert_eq!(shortcut_text("Delete", true), "⌫"); +} + +/// Every item of a menu, submenus included. +fn flat(nodes: &[MenuNode]) -> Vec<&MenuNode> { + nodes + .iter() + .flat_map(|n| match n { + MenuNode::Submenu { children, .. } => flat(children), + MenuNode::Item { .. } => vec![n], + MenuNode::Separator => vec![], + }) + .collect() +} + +/// Given the menu bar, Then an item that turns something on or off is named for the thing, +/// with its check mark saying whether it is on: "Filmstrip ✓", never "Show Filmstrip ✓". +/// Show Original is a view (\), not a switch: it keeps Lightroom's name. +#[test] +fn toggles_are_named_for_the_thing_not_show() { + let mut a = app(); + a.ui.view = ViewMode::Detail; + let shows: Vec = menu_bar(&a) + .iter() + .flat_map(|(_, items)| flat(items).into_iter().cloned().collect::>()) + .filter_map(|n| match n { + MenuNode::Item { id, params, label, checked: Some(_), .. } + if params.is_null() && label.starts_with("Show ") && id != "view.showOriginal" => + { + Some(format!("{id}: {label}")) + } + _ => None, + }) + .collect(); + assert!(shows.is_empty(), "toggles named \"Show …\": {shows:#?}"); +} + +/// Given every command with a menu path, Then no two of them share a label, and none is named +/// like a menu: the menus, the keyboard shortcuts list and the control channel tell them apart +/// by name ("Detail" the view and "Detail" the Edit section were two things called the same). +#[test] +fn no_two_menu_commands_share_a_label() { + let entries = crate::menus::menu_entries(&app()); + let mut clashes = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if MENUS.contains(&e.label.as_str()) { + clashes.push(format!("{} is called like the {} menu", e.id, e.label)); + } + if let Some(other) = entries[..i].iter().find(|o| o.label == e.label && o.id != e.id) { + clashes.push(format!("{} and {} are both {:?}", other.id, e.id, e.label)); + } + } + assert!(clashes.is_empty(), "{clashes:#?}"); +} From 032ffd74d7dcfa21c1df33fd3e199cc4c729f2b5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:00 +0000 Subject: [PATCH 057/191] refactor(lightcraft-ui-egui): move region.rs tests to crates/ui-egui/src/region/tests.rs --- crates/ui-egui/src/region.rs | 639 +---------------------------- crates/ui-egui/src/region/tests.rs | 636 ++++++++++++++++++++++++++++ 2 files changed, 637 insertions(+), 638 deletions(-) create mode 100644 crates/ui-egui/src/region/tests.rs diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 3994a950d..26552313e 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -445,641 +445,4 @@ pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32), m } #[cfg(test)] -mod tests { - use super::*; - use crate::state::AppSettings; - - const BIG: usize = 16384; - - fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { - ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft: None, windows: true } - } - - fn auto() -> AppSettings { - AppSettings::default() - } - - // Given a fit view on a Retina Mac (about 2800 px), the preview is enough: no original decoded - #[test] - fn a_retina_fit_view_needs_no_window() { - let p = plan(&auto(), sizes(2800.0, 2800.0, 6000)); - assert_eq!(p.main_edge, 2560, "the preview source level"); - assert_eq!(p.window_edge, None); - } - - // Given a 4K display, the fit view is 1.5x the preview: the window makes it sharp - #[test] - fn a_large_fit_view_gets_a_window() { - let p = plan(&auto(), sizes(3840.0, 3840.0, 6000)); - assert_eq!((p.main_edge, p.window_edge), (2560, Some(3840))); - } - - // Given 1:1 on a 24 MP photo in a 2800 px canvas, the whole frame stays canvas-sized and the - // window is cut from the photo's own pixels - #[test] - fn one_to_one_renders_the_canvas_and_a_native_window() { - let p = plan(&auto(), sizes(6000.0, 2800.0, 6000)); - assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); - } - - // Given 800 %, the window is still at 100 % (the GPU magnifies it): the cost does not grow - #[test] - fn beyond_one_to_one_the_window_stays_at_native_resolution() { - let p = plan(&auto(), sizes(48000.0, 2800.0, 6000)); - assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); - let q = plan(&auto(), sizes(480.0 * 100.0, 2800.0, 6000)); - assert_eq!(q.window_edge, Some(6000)); - } - - // Given a small photo zoomed in, the whole-frame render already holds all its pixels - #[test] - fn a_small_photo_needs_no_window() { - let p = plan(&auto(), sizes(8000.0, 1400.0, 1000)); - assert_eq!((p.main_edge, p.window_edge), (1000, None)); - } - - // Given a slider drag at 400 % (drafts at 0.6 scale), the window plan does not change: it is - // the whole-frame draft that gets cheaper - #[test] - fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { - let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); - let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(24000.0, 2800.0, 6000) }); - assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); - assert_eq!(drag.window_edge, still.window_edge); - } - - // Given no window (a whole-frame diagnostic), the view at rest renders the - // whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the - // windowed path - #[test] - fn without_a_window_a_drag_drafts_at_the_canvas_size() { - let no_window = |draft: Option| ViewSizes { windows: false, draft, ..sizes(24000.0, 2800.0, 6000) }; - let still = plan(&auto(), no_window(None)); - let drag = plan(&auto(), no_window(Some(0.6))); - assert_eq!(still.window_edge, None); - assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}"); - assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); - } - - // Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) - #[test] - fn a_drag_shrinks_the_draft_only_on_the_cpu() { - use lightcraft_catalog::PhotoId; - let mut gpu = DragScale::default(); - assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); - assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); - let mut cpu = DragScale::default(); - assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); - } - - // Given a drag that started before the GPU was ready, it keeps its size until it ends: the - // resolution never jumps in the middle of a drag - #[test] - fn a_drag_keeps_its_draft_size_until_it_ends() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); - assert_eq!(d.scale(None, true, None, 0.0), None); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); - // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends - assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); - } - - /// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. - fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option { - let scale = d.scale(drag, true, None, now); - for key in 1..=6 { - d.observe(key, ms, None); - } - d.scale(None, true, None, now); - scale - } - - // Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask - // the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on - // that photo drafts small; once could be a busy moment. Other controls and photos keep full size - #[test] - fn a_drag_over_budget_twice_drafts_small_the_next_time() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let exposure = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); - d.observe(7, 400.0, None); // the render from before the drag: not a draft of it - for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { - d.observe(key, ms, None); - d.observe(key, ms, None); // a render is timed once, however many frames show it - } - assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); - assert_eq!(d.scale(None, true, None, 0.0), None); - assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); - assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); - d.scale(None, true, None, 2.0); - assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); - } - - // Given a pair found slow, it drafts small only for a while: then a drag tries full size - // again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict - #[test] - fn a_slow_verdict_expires_and_is_retried() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - timed_drag(&mut d, drag, 25.0, 0.0); - timed_drag(&mut d, drag, 25.0, 0.0); - assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); - assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); - let mut at = RETRY_AFTER_S; - let mut wait = RETRY_AFTER_S * 2.0; - for _ in 0..6 { - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); - at += wait; - wait = (wait * 2.0).min(RETRY_MAX_S); - } - assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); - assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); - assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); - } - - // Given a drag only just over budget, it doesn't count against the pair (it would flip between - // the two sizes); one clearly over does - #[test] - fn only_a_drag_clearly_over_budget_counts() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - for t in 0..4 { - timed_drag(&mut d, drag, 18.0, f64::from(t)); - } - assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); - d.scale(None, true, None, 4.0); - timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); - timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); - assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); - } - - // Given one slow drag while the machine was busy, then fast ones, the slider keeps full size - #[test] - fn a_busy_moment_doesnt_make_a_slider_draft_small() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - timed_drag(&mut d, drag, 40.0, 0.0); - for t in 1..=3 { - assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); - } - timed_drag(&mut d, drag, 40.0, 4.0); - assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); - } - - // Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it - // can't read tries full size - #[test] - fn a_hostile_clock_never_panics() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - timed_drag(&mut d, drag, 25.0, 100.0); - timed_drag(&mut d, drag, 25.0, 100.0); - assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); - d.scale(None, true, None, 50.0); - assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); - d.scale(None, true, None, f64::NAN); - for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { - timed_drag(&mut d, drag, 25.0, now); - } - } - - // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be - // slow), the next drag stays at full size - #[test] - fn a_drag_within_budget_stays_full_size() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None, 0.0); - for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { - d.observe(key, ms, None); - } - d.scale(None, true, None, 0.0); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); - // hostile times are ignored, never a panic - for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { - d.observe(key, ms, None); - } - d.scale(None, true, None, 0.0); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); - } - - // Given renders within budget whose frames (the texture upload) take it over, the drag counts - // as over budget: a render time alone said 231 of 234 drafts fit while none showed in 16 ms - #[test] - fn a_drafts_time_includes_the_frame_that_shows_it() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - for _ in 0..2 { - d.scale(drag, true, None, 0.0); - for key in 1..=5 { - d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft - } - d.observe(5, 8.0, Some(14.0)); - d.scale(None, true, None, 0.0); - } - assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); - // the same renders without the host's frame times (headless) stay within budget - let mut headless = DragScale::default(); - headless.scale(drag, true, None, 0.0); - for _ in 0..2 { - timed_drag(&mut headless, drag, 8.0, 0.0); - } - assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); - // a hostile frame time counts as nothing, never a panic - let mut hostile = DragScale::default(); - hostile.scale(drag, true, None, 0.0); - for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { - hostile.observe(key, 8.0, Some(frame)); - } - hostile.scale(None, true, None, 0.0); - assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); - } - - // Given more slow drags than are remembered, only the one remembered longest ago is forgotten: - // clearing them all would bring back a slow full-size drag for every pair - #[test] - fn a_full_slow_set_forgets_only_the_oldest_drag() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - for photo in 0..=VERDICTS_KEPT as u64 { - d.remember_slow(PhotoId(photo), "Exposure"); - } - assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); - d.scale(None, true, None, 0.0); - for photo in [1, VERDICTS_KEPT as u64] { - assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); - d.scale(None, true, None, 0.0); - } - d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); - } - - // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as - // unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such - // drags, since one slow drag only makes the pair suspect - #[test] - fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - for t in 0..2u32 { - assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); - for key in 1..=6 { - d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); - } - d.scale(None, true, None, f64::from(t)); - } - assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); - } - - // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), - // the drag drops to the small draft after a few of them instead of at its end; after a second - // such drag, the next starts small - #[test] - fn a_drag_far_over_budget_drafts_small_before_it_ends() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); - let mut scales = Vec::new(); - for key in 1..=8 { - d.observe(key, 190.0, None); - scales.push(d.scale(drag, true, None, 0.0)); - } - // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame - assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); - assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); - d.scale(None, true, None, 0.0); - assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); - assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); - } - - // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is - // the small one: at 1:1 on a Retina display it cost 7–9 ms a step for pixels nobody sees - #[test] - fn a_full_size_drag_under_a_window_drafts_the_whole_frame_small() { - let zoomed = sizes(24000.0, 2800.0, 6000); - let small = plan(&auto(), ViewSizes { draft: Some(CPU_DRAFT_SCALE), ..zoomed }); - let full = plan(&auto(), ViewSizes { draft: Some(1.0), ..zoomed }); - assert!(full.window_edge.is_some(), "{full:?}"); - assert_eq!(full, small); - // at fit without a window, full size stays full size - let fit = sizes(2800.0, 2800.0, 6000); - assert_eq!(plan(&auto(), ViewSizes { draft: Some(1.0), ..fit }), plan(&auto(), fit)); - } - - // Given a drag at fit at full size, the draft is as sharp as the render at rest - #[test] - fn a_full_size_drag_at_fit_keeps_the_resolution() { - for drawn in [1400.0, 2800.0] { - let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft: Some(1.0), ..sizes(drawn, drawn, 6000) }); - assert_eq!(drag, still, "{drawn}"); - } - } - - // Given no window and a full-size drag, the draft is still at most the canvas: a drag at 1:1 - // must not render the photo's every pixel each frame - #[test] - fn a_full_size_drag_without_a_window_drafts_at_the_canvas_size() { - let drag = plan(&auto(), ViewSizes { windows: false, draft: Some(1.0), ..sizes(24000.0, 2800.0, 6000) }); - assert!(drag.main_edge <= 2816, "{drag:?}"); - } - - // Given the user capped the preview size, the whole-frame render obeys it, and a fit view - // stays on it (no original decoded); a zoomed view still gets its sharp window - #[test] - fn a_preview_limit_caps_the_whole_frame_render_and_spares_the_fit_view() { - let s = AppSettings { preview_limit: 1600, ..Default::default() }; - let fit = plan(&s, sizes(2800.0, 2800.0, 6000)); - assert_eq!((fit.main_edge, fit.window_edge), (1600, None)); - let zoomed = plan(&s, sizes(6000.0, 2800.0, 6000)); - assert_eq!((zoomed.main_edge, zoomed.window_edge), (1600, Some(6000))); - } - - // Given a GPU with 2048 px textures, the whole-frame render obeys it, the window frame need not - // (a window is cut to the texture side separately) - #[test] - fn the_texture_side_caps_the_whole_frame_render_only() { - let p = plan(&auto(), ViewSizes { texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); - assert_eq!((p.main_edge, p.window_edge), (2048, Some(6000))); - } - - // Given a picture that is not the frame (an embedded JPEG cropped differently), no window - #[test] - fn a_window_needs_a_picture_with_the_frames_aspect() { - assert!(same_aspect(1.5, 1.5) && same_aspect(1.51, 1.5)); - assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0)); - } - - // Given a drag at fit on a 4K or 5K display (where the fit view has a window), the window stays: - // it is only the whole-frame draft that gets smaller - #[test] - fn a_drag_at_fit_on_a_large_display_keeps_the_window() { - for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { - let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(drawn, drawn, 6000) }); - assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); - assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); - } - } - - // Given a mode whose picture can't be a window (a whole-frame diagnostic, or a - // spot read too far for one render), the whole-frame render is as big as the view - #[test] - fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { - let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); - assert_eq!((p.main_edge, p.window_edge), (6000, None)); - let fit = plan(&auto(), ViewSizes { windows: false, ..sizes(2800.0, 2800.0, 6000) }); - assert_eq!((fit.main_edge, fit.window_edge), (2816, None)); - let deep = plan(&auto(), ViewSizes { windows: false, ..sizes(48000.0, 2800.0, 6000) }); - assert_eq!(deep.main_edge, 6000, "never above the photo's own pixels"); - let small = plan(&auto(), ViewSizes { windows: false, texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); - assert_eq!(small.main_edge, 2048); - } - - // Given a user who chose a preview size above the preview source level (3840 or 5120 on a big - // display), the whole-frame render is that big - #[test] - fn a_large_preview_limit_is_honoured() { - for limit in [3840u32, 5120] { - let s = AppSettings { preview_limit: limit, ..Default::default() }; - let p = plan(&s, sizes(5120.0, 5120.0, 6000)); - assert_eq!(p.main_edge, limit as usize, "{limit}"); - assert_eq!(p.window_edge, None, "a fit view at the limit needs no window"); - } - let s = AppSettings { preview_limit: 2560, ..Default::default() }; - assert_eq!(plan(&s, sizes(5120.0, 5120.0, 6000)).main_edge, 2560); - } - - // Given a medium format photo wider than 8192 px, 1:1 is its own pixels - #[test] - fn a_window_frame_can_be_as_big_as_the_photo() { - let p = plan(&auto(), sizes(40000.0, 2800.0, 11648)); - assert_eq!(p.window_edge, Some(11648)); - } - - // Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) - #[test] - fn a_drag_at_fit_does_not_switch_the_window_on() { - let p = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(2800.0, 2800.0, 6000) }); - assert_eq!(p.window_edge, None); - } - - // Given a pinch from fit to 400 %, the sizes of the first frame hold until the gesture is quiet - #[test] - fn a_pinch_holds_the_render_sizes() { - use lightcraft_catalog::PhotoId; - let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); - assert_ne!(fit, zoomed); - let mut hold = SizeHold::default(); - let p = PhotoId(1); - assert_eq!(hold.apply(p, 0.0, false, fit), fit); - // the pinch runs for a second: every frame would plan something else - for k in 1..60 { - assert_eq!(hold.apply(p, k as f64 / 60.0, true, zoomed), fit, "frame {k}"); - } - // it stopped at t = 59/60: still held for a moment (no flicker between two events)… - assert!(hold.holding(1.1)); - assert_eq!(hold.apply(p, 1.1, false, zoomed), fit); - // …then the view is planned for where it ended - assert!(!hold.holding(59.0 / 60.0 + HOLD_SECS + 0.01)); - assert_eq!(hold.apply(p, 1.3, false, zoomed), zoomed); - assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed); - } - - // Given a pinch, the display-fit size holds like the render sizes and is retaken after it - #[test] - fn a_pinch_holds_the_display_fit_size() { - use lightcraft_catalog::PhotoId; - let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); - let mut hold = SizeHold::default(); - let p = PhotoId(1); - hold.apply(p, 0.0, false, fit); - assert_eq!(hold.display_fit(0.0, false, [1000, 800]), [1000, 800]); - for k in 1..60 { - let now = k as f64 / 60.0; - hold.apply(p, now, true, zoomed); - assert_eq!(hold.display_fit(now, true, [1000 + k, 800 + k]), [1000, 800], "frame {k}"); - } - // stopped at t = 59/60: still held within the hold time… - hold.apply(p, 1.1, false, zoomed); - assert_eq!(hold.display_fit(1.1, false, [2560, 2048]), [1000, 800]); - // …then the size it ended at is taken, and kept while nothing moves - hold.apply(p, 1.3, false, zoomed); - assert_eq!(hold.display_fit(1.3, false, [2560, 2048]), [2560, 2048]); - assert_eq!(hold.display_fit(1.4, false, [2561, 2049]), [2561, 2049]); - // another photo forgets the held size - hold.apply(p, 1.5, true, zoomed); - hold.display_fit(1.5, true, [2561, 2049]); - hold.apply(PhotoId(2), 1.6, true, zoomed); - assert_eq!(hold.display_fit(1.6, true, [640, 480]), [640, 480]); - } - - // Given another photo, the held sizes are forgotten - #[test] - fn another_photo_is_planned_afresh() { - use lightcraft_catalog::PhotoId; - let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); - let mut hold = SizeHold::default(); - hold.apply(PhotoId(1), 0.0, false, fit); - assert_eq!(hold.apply(PhotoId(2), 0.1, true, zoomed), zoomed); - } - - // Given side by side, a window is drawn in its pane only; given a wipe, on its side of the line - #[test] - fn a_window_is_clipped_to_its_pane_or_its_side_of_the_wipe() { - use crate::state::BeforeAfter::*; - let canvas = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1000.0, 600.0)); - let (left, right) = ( - egui::Rect::from_min_max(egui::pos2(24.0, 24.0), egui::pos2(494.0, 576.0)), - egui::Rect::from_min_max(egui::pos2(506.0, 24.0), egui::pos2(976.0, 576.0)), - ); - assert_eq!(window_clip(SideBySide, true, left, canvas, left), left); - assert_eq!(window_clip(SideBySide, false, right, canvas, right), right); - let img = egui::Rect::from_min_max(egui::pos2(100.0, 50.0), egui::pos2(900.0, 550.0)); - let before = window_clip(Split, true, canvas, canvas, img); - let after = window_clip(Split, false, canvas, canvas, img); - assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle"); - assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0); - assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0); - // zoomed in, the photo's middle (x = 1600) is off screen: the line is the view's middle - let zoomed = egui::Rect::from_min_max(egui::pos2(-200.0, -900.0), egui::pos2(3400.0, 1500.0)); - assert_eq!(window_clip(Split, false, canvas, canvas, zoomed).min.x, 500.0); - assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, zoomed).max.y, 300.0); - assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas); - assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); - } - - // Given a window asked for while another is rendering and the original isn't decoded yet, it - // waits for the decoded original instead of decoding another copy - #[test] - fn a_window_waits_for_the_original_another_is_decoding() { - assert!(defer_window(true, false)); - assert!(!defer_window(true, true), "the original is in"); - assert!(!defer_window(false, false), "nothing is decoding it"); - } - - // Hostile numbers give a plan, never a panic - #[test] - fn hostile_sizes_give_a_usable_plan() { - for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] { - let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft: Some(v), ..sizes(1.0, 1.0, 6000) }); - assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); - } - let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); - assert!(p.main_edge >= 8); - } - - // Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid - #[test] - fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { - let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); - let m = margin_for(48_000); - assert!(w.x + m <= 20_000 && w.x + w.w >= 21_400 + m, "{w:?}"); - assert!(w.y + m <= 10_000 && w.y + w.h >= 10_900 + m, "{w:?}"); - assert_eq!((w.x % SNAP, w.y % SNAP, w.w % SNAP, w.h % SNAP), (0, 0, 0, 0)); - } - - // Given a small pan, the same window is kept (no re-render); a big pan changes it - #[test] - fn small_pans_keep_the_window() { - let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); - let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0), MAX_SPAN).unwrap(); - assert_eq!(a, b); - let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0), MAX_SPAN).unwrap(); - assert_ne!(a, c); - } - - // Given a frame smaller than the margin, or a view at its edge, the window is clamped into it - #[test] - fn the_window_stays_inside_the_frame() { - let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0), MAX_SPAN).unwrap(); - assert_eq!((w.x, w.y), (0, 0)); - let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0), MAX_SPAN).unwrap(); - assert!(w.x + w.w <= 3000 && w.y + w.h <= 2000, "{w:?}"); - assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000)); - } - - // Given a deeper zoom, the margin grows with the wide stages' kernels (3σ of the base, clarity - // and dehaze planes are 0.045 of the frame), up to a cap that bounds the window - #[test] - fn the_margin_grows_with_the_zoom_up_to_a_cap() { - assert_eq!(margin_for(1000), SNAP, "never less than one grid step"); - assert!(margin_for(8000) > margin_for(3000)); - assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap"); - assert_eq!(margin_for(80_000), MAX_MARGIN); - assert_eq!(margin_for(0), SNAP); - assert!(margin_for(usize::MAX) <= MAX_MARGIN); - for l in [100, 3000, 8000, 48_000, 400_000] { - assert_eq!(margin_for(l) % SNAP, 0); - } - } - - // Given a tile rendered for other settings, another photo or another zoom, it is not drawn - #[test] - fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { - use lightcraft_catalog::PhotoId; - let v = RegionView { - photo: PhotoId(1), - before: false, - key: 7, - full: (6000, 4000), - window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, - settings: 11, - }; - assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false)); - assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo"); - assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom"); - assert!(!v.is_current(PhotoId(1), (6000, 4000), 12, false), "another look (an edit, an undo)"); - assert!(v.is_current(PhotoId(1), (6000, 4000), 12, true), "a drag's drafts follow the value"); - assert!(!v.is_current(PhotoId(2), (6000, 4000), 12, true), "…of this photo only"); - } - - // Given a GPU that allows 2048 px textures, no window is wider than that - #[test] - fn a_window_fits_the_texture_side() { - for tex in [512, 2048, 4096] { - let span = max_span(tex); - assert!(span <= tex.max(SNAP) && span.is_multiple_of(SNAP), "{tex}: {span}"); - let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), span).unwrap(); - assert!(w.w <= span && w.h <= span, "{tex}: {w:?}"); - } - assert_eq!(max_span(100_000), MAX_SPAN); - assert_eq!(max_span(0), SNAP); - } - - // Given nothing visible, or hostile numbers, there is no window (never a panic) - #[test] - fn nothing_visible_or_hostile_input_is_no_window() { - assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0), MAX_SPAN), None); - assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0), MAX_SPAN), None); - assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0), MAX_SPAN), None); - for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { - assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0), MAX_SPAN), None); - } - let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30), MAX_SPAN).unwrap(); - assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN, "{w:?}"); - } - - // Given a view of an enormous area, the window is bounded - #[test] - fn a_window_is_bounded() { - let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0), MAX_SPAN).unwrap(); - assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN); - } -} +mod tests; diff --git a/crates/ui-egui/src/region/tests.rs b/crates/ui-egui/src/region/tests.rs new file mode 100644 index 000000000..c4558c1d8 --- /dev/null +++ b/crates/ui-egui/src/region/tests.rs @@ -0,0 +1,636 @@ +use super::*; +use crate::state::AppSettings; + +const BIG: usize = 16384; + +fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft: None, windows: true } +} + +fn auto() -> AppSettings { + AppSettings::default() +} + +// Given a fit view on a Retina Mac (about 2800 px), the preview is enough: no original decoded +#[test] +fn a_retina_fit_view_needs_no_window() { + let p = plan(&auto(), sizes(2800.0, 2800.0, 6000)); + assert_eq!(p.main_edge, 2560, "the preview source level"); + assert_eq!(p.window_edge, None); +} + +// Given a 4K display, the fit view is 1.5x the preview: the window makes it sharp +#[test] +fn a_large_fit_view_gets_a_window() { + let p = plan(&auto(), sizes(3840.0, 3840.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(3840))); +} + +// Given 1:1 on a 24 MP photo in a 2800 px canvas, the whole frame stays canvas-sized and the +// window is cut from the photo's own pixels +#[test] +fn one_to_one_renders_the_canvas_and_a_native_window() { + let p = plan(&auto(), sizes(6000.0, 2800.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); +} + +// Given 800 %, the window is still at 100 % (the GPU magnifies it): the cost does not grow +#[test] +fn beyond_one_to_one_the_window_stays_at_native_resolution() { + let p = plan(&auto(), sizes(48000.0, 2800.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); + let q = plan(&auto(), sizes(480.0 * 100.0, 2800.0, 6000)); + assert_eq!(q.window_edge, Some(6000)); +} + +// Given a small photo zoomed in, the whole-frame render already holds all its pixels +#[test] +fn a_small_photo_needs_no_window() { + let p = plan(&auto(), sizes(8000.0, 1400.0, 1000)); + assert_eq!((p.main_edge, p.window_edge), (1000, None)); +} + +// Given a slider drag at 400 % (drafts at 0.6 scale), the window plan does not change: it is +// the whole-frame draft that gets cheaper +#[test] +fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { + let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(24000.0, 2800.0, 6000) }); + assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); + assert_eq!(drag.window_edge, still.window_edge); +} + +// Given no window (a whole-frame diagnostic), the view at rest renders the +// whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the +// windowed path +#[test] +fn without_a_window_a_drag_drafts_at_the_canvas_size() { + let no_window = |draft: Option| ViewSizes { windows: false, draft, ..sizes(24000.0, 2800.0, 6000) }; + let still = plan(&auto(), no_window(None)); + let drag = plan(&auto(), no_window(Some(0.6))); + assert_eq!(still.window_edge, None); + assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}"); + assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); +} + +// Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) +#[test] +fn a_drag_shrinks_the_draft_only_on_the_cpu() { + use lightcraft_catalog::PhotoId; + let mut gpu = DragScale::default(); + assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); + let mut cpu = DragScale::default(); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); +} + +// Given a drag that started before the GPU was ready, it keeps its size until it ends: the +// resolution never jumps in the middle of a drag +#[test] +fn a_drag_keeps_its_draft_size_until_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); + // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends + assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); +} + +/// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. +fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option { + let scale = d.scale(drag, true, None, now); + for key in 1..=6 { + d.observe(key, ms, None); + } + d.scale(None, true, None, now); + scale +} + +// Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask +// the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on +// that photo drafts small; once could be a busy moment. Other controls and photos keep full size +#[test] +fn a_drag_over_budget_twice_drafts_small_the_next_time() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let exposure = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); + d.observe(7, 400.0, None); // the render from before the drag: not a draft of it + for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { + d.observe(key, ms, None); + d.observe(key, ms, None); // a render is timed once, however many frames show it + } + assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); + assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 2.0); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); +} + +// Given a pair found slow, it drafts small only for a while: then a drag tries full size +// again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict +#[test] +fn a_slow_verdict_expires_and_is_retried() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 0.0); + timed_drag(&mut d, drag, 25.0, 0.0); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); + let mut at = RETRY_AFTER_S; + let mut wait = RETRY_AFTER_S * 2.0; + for _ in 0..6 { + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); + at += wait; + wait = (wait * 2.0).min(RETRY_MAX_S); + } + assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); + assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); + assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); +} + +// Given a drag only just over budget, it doesn't count against the pair (it would flip between +// the two sizes); one clearly over does +#[test] +fn only_a_drag_clearly_over_budget_counts() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..4 { + timed_drag(&mut d, drag, 18.0, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); + d.scale(None, true, None, 4.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); + assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); +} + +// Given one slow drag while the machine was busy, then fast ones, the slider keeps full size +#[test] +fn a_busy_moment_doesnt_make_a_slider_draft_small() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 40.0, 0.0); + for t in 1..=3 { + assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); + } + timed_drag(&mut d, drag, 40.0, 4.0); + assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); +} + +// Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it +// can't read tries full size +#[test] +fn a_hostile_clock_never_panics() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 100.0); + timed_drag(&mut d, drag, 25.0, 100.0); + assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); + d.scale(None, true, None, 50.0); + assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); + d.scale(None, true, None, f64::NAN); + for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { + timed_drag(&mut d, drag, 25.0, now); + } +} + +// Given full-size drafts within budget (the first one, rebuilding the stage caches, may be +// slow), the next drag stays at full size +#[test] +fn a_drag_within_budget_stays_full_size() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None, 0.0); + for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { + d.observe(key, ms, None); + } + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); + // hostile times are ignored, never a panic + for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { + d.observe(key, ms, None); + } + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); +} + +// Given renders within budget whose frames (the texture upload) take it over, the drag counts +// as over budget: a render time alone said 231 of 234 drafts fit while none showed in 16 ms +#[test] +fn a_drafts_time_includes_the_frame_that_shows_it() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for _ in 0..2 { + d.scale(drag, true, None, 0.0); + for key in 1..=5 { + d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(14.0)); + d.scale(None, true, None, 0.0); + } + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); + // the same renders without the host's frame times (headless) stay within budget + let mut headless = DragScale::default(); + headless.scale(drag, true, None, 0.0); + for _ in 0..2 { + timed_drag(&mut headless, drag, 8.0, 0.0); + } + assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); + // a hostile frame time counts as nothing, never a panic + let mut hostile = DragScale::default(); + hostile.scale(drag, true, None, 0.0); + for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { + hostile.observe(key, 8.0, Some(frame)); + } + hostile.scale(None, true, None, 0.0); + assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); +} + +// Given more slow drags than are remembered, only the one remembered longest ago is forgotten: +// clearing them all would bring back a slow full-size drag for every pair +#[test] +fn a_full_slow_set_forgets_only_the_oldest_drag() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + for photo in 0..=VERDICTS_KEPT as u64 { + d.remember_slow(PhotoId(photo), "Exposure"); + } + assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); + d.scale(None, true, None, 0.0); + for photo in [1, VERDICTS_KEPT as u64] { + assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); + d.scale(None, true, None, 0.0); + } + d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); +} + +// Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as +// unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such +// drags, since one slow drag only makes the pair suspect +#[test] +fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..2u32 { + assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); + for key in 1..=6 { + d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); + } + d.scale(None, true, None, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); +} + +// Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), +// the drag drops to the small draft after a few of them instead of at its end; after a second +// such drag, the next starts small +#[test] +fn a_drag_far_over_budget_drafts_small_before_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); + let mut scales = Vec::new(); + for key in 1..=8 { + d.observe(key, 190.0, None); + scales.push(d.scale(drag, true, None, 0.0)); + } + // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame + assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); + assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); + d.scale(None, true, None, 0.0); + assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); + assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); +} + +// Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is +// the small one: at 1:1 on a Retina display it cost 7–9 ms a step for pixels nobody sees +#[test] +fn a_full_size_drag_under_a_window_drafts_the_whole_frame_small() { + let zoomed = sizes(24000.0, 2800.0, 6000); + let small = plan(&auto(), ViewSizes { draft: Some(CPU_DRAFT_SCALE), ..zoomed }); + let full = plan(&auto(), ViewSizes { draft: Some(1.0), ..zoomed }); + assert!(full.window_edge.is_some(), "{full:?}"); + assert_eq!(full, small); + // at fit without a window, full size stays full size + let fit = sizes(2800.0, 2800.0, 6000); + assert_eq!(plan(&auto(), ViewSizes { draft: Some(1.0), ..fit }), plan(&auto(), fit)); +} + +// Given a drag at fit at full size, the draft is as sharp as the render at rest +#[test] +fn a_full_size_drag_at_fit_keeps_the_resolution() { + for drawn in [1400.0, 2800.0] { + let still = plan(&auto(), sizes(drawn, drawn, 6000)); + let drag = plan(&auto(), ViewSizes { draft: Some(1.0), ..sizes(drawn, drawn, 6000) }); + assert_eq!(drag, still, "{drawn}"); + } +} + +// Given no window and a full-size drag, the draft is still at most the canvas: a drag at 1:1 +// must not render the photo's every pixel each frame +#[test] +fn a_full_size_drag_without_a_window_drafts_at_the_canvas_size() { + let drag = plan(&auto(), ViewSizes { windows: false, draft: Some(1.0), ..sizes(24000.0, 2800.0, 6000) }); + assert!(drag.main_edge <= 2816, "{drag:?}"); +} + +// Given the user capped the preview size, the whole-frame render obeys it, and a fit view +// stays on it (no original decoded); a zoomed view still gets its sharp window +#[test] +fn a_preview_limit_caps_the_whole_frame_render_and_spares_the_fit_view() { + let s = AppSettings { preview_limit: 1600, ..Default::default() }; + let fit = plan(&s, sizes(2800.0, 2800.0, 6000)); + assert_eq!((fit.main_edge, fit.window_edge), (1600, None)); + let zoomed = plan(&s, sizes(6000.0, 2800.0, 6000)); + assert_eq!((zoomed.main_edge, zoomed.window_edge), (1600, Some(6000))); +} + +// Given a GPU with 2048 px textures, the whole-frame render obeys it, the window frame need not +// (a window is cut to the texture side separately) +#[test] +fn the_texture_side_caps_the_whole_frame_render_only() { + let p = plan(&auto(), ViewSizes { texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!((p.main_edge, p.window_edge), (2048, Some(6000))); +} + +// Given a picture that is not the frame (an embedded JPEG cropped differently), no window +#[test] +fn a_window_needs_a_picture_with_the_frames_aspect() { + assert!(same_aspect(1.5, 1.5) && same_aspect(1.51, 1.5)); + assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0)); +} + +// Given a drag at fit on a 4K or 5K display (where the fit view has a window), the window stays: +// it is only the whole-frame draft that gets smaller +#[test] +fn a_drag_at_fit_on_a_large_display_keeps_the_window() { + for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { + let still = plan(&auto(), sizes(drawn, drawn, 6000)); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(drawn, drawn, 6000) }); + assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); + assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); + } +} + +// Given a mode whose picture can't be a window (a whole-frame diagnostic, or a +// spot read too far for one render), the whole-frame render is as big as the view +#[test] +fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { + let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!((p.main_edge, p.window_edge), (6000, None)); + let fit = plan(&auto(), ViewSizes { windows: false, ..sizes(2800.0, 2800.0, 6000) }); + assert_eq!((fit.main_edge, fit.window_edge), (2816, None)); + let deep = plan(&auto(), ViewSizes { windows: false, ..sizes(48000.0, 2800.0, 6000) }); + assert_eq!(deep.main_edge, 6000, "never above the photo's own pixels"); + let small = plan(&auto(), ViewSizes { windows: false, texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!(small.main_edge, 2048); +} + +// Given a user who chose a preview size above the preview source level (3840 or 5120 on a big +// display), the whole-frame render is that big +#[test] +fn a_large_preview_limit_is_honoured() { + for limit in [3840u32, 5120] { + let s = AppSettings { preview_limit: limit, ..Default::default() }; + let p = plan(&s, sizes(5120.0, 5120.0, 6000)); + assert_eq!(p.main_edge, limit as usize, "{limit}"); + assert_eq!(p.window_edge, None, "a fit view at the limit needs no window"); + } + let s = AppSettings { preview_limit: 2560, ..Default::default() }; + assert_eq!(plan(&s, sizes(5120.0, 5120.0, 6000)).main_edge, 2560); +} + +// Given a medium format photo wider than 8192 px, 1:1 is its own pixels +#[test] +fn a_window_frame_can_be_as_big_as_the_photo() { + let p = plan(&auto(), sizes(40000.0, 2800.0, 11648)); + assert_eq!(p.window_edge, Some(11648)); +} + +// Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) +#[test] +fn a_drag_at_fit_does_not_switch_the_window_on() { + let p = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(2800.0, 2800.0, 6000) }); + assert_eq!(p.window_edge, None); +} + +// Given a pinch from fit to 400 %, the sizes of the first frame hold until the gesture is quiet +#[test] +fn a_pinch_holds_the_render_sizes() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + assert_ne!(fit, zoomed); + let mut hold = SizeHold::default(); + let p = PhotoId(1); + assert_eq!(hold.apply(p, 0.0, false, fit), fit); + // the pinch runs for a second: every frame would plan something else + for k in 1..60 { + assert_eq!(hold.apply(p, k as f64 / 60.0, true, zoomed), fit, "frame {k}"); + } + // it stopped at t = 59/60: still held for a moment (no flicker between two events)… + assert!(hold.holding(1.1)); + assert_eq!(hold.apply(p, 1.1, false, zoomed), fit); + // …then the view is planned for where it ended + assert!(!hold.holding(59.0 / 60.0 + HOLD_SECS + 0.01)); + assert_eq!(hold.apply(p, 1.3, false, zoomed), zoomed); + assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed); +} + +// Given a pinch, the display-fit size holds like the render sizes and is retaken after it +#[test] +fn a_pinch_holds_the_display_fit_size() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + let mut hold = SizeHold::default(); + let p = PhotoId(1); + hold.apply(p, 0.0, false, fit); + assert_eq!(hold.display_fit(0.0, false, [1000, 800]), [1000, 800]); + for k in 1..60 { + let now = k as f64 / 60.0; + hold.apply(p, now, true, zoomed); + assert_eq!(hold.display_fit(now, true, [1000 + k, 800 + k]), [1000, 800], "frame {k}"); + } + // stopped at t = 59/60: still held within the hold time… + hold.apply(p, 1.1, false, zoomed); + assert_eq!(hold.display_fit(1.1, false, [2560, 2048]), [1000, 800]); + // …then the size it ended at is taken, and kept while nothing moves + hold.apply(p, 1.3, false, zoomed); + assert_eq!(hold.display_fit(1.3, false, [2560, 2048]), [2560, 2048]); + assert_eq!(hold.display_fit(1.4, false, [2561, 2049]), [2561, 2049]); + // another photo forgets the held size + hold.apply(p, 1.5, true, zoomed); + hold.display_fit(1.5, true, [2561, 2049]); + hold.apply(PhotoId(2), 1.6, true, zoomed); + assert_eq!(hold.display_fit(1.6, true, [640, 480]), [640, 480]); +} + +// Given another photo, the held sizes are forgotten +#[test] +fn another_photo_is_planned_afresh() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + let mut hold = SizeHold::default(); + hold.apply(PhotoId(1), 0.0, false, fit); + assert_eq!(hold.apply(PhotoId(2), 0.1, true, zoomed), zoomed); +} + +// Given side by side, a window is drawn in its pane only; given a wipe, on its side of the line +#[test] +fn a_window_is_clipped_to_its_pane_or_its_side_of_the_wipe() { + use crate::state::BeforeAfter::*; + let canvas = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1000.0, 600.0)); + let (left, right) = ( + egui::Rect::from_min_max(egui::pos2(24.0, 24.0), egui::pos2(494.0, 576.0)), + egui::Rect::from_min_max(egui::pos2(506.0, 24.0), egui::pos2(976.0, 576.0)), + ); + assert_eq!(window_clip(SideBySide, true, left, canvas, left), left); + assert_eq!(window_clip(SideBySide, false, right, canvas, right), right); + let img = egui::Rect::from_min_max(egui::pos2(100.0, 50.0), egui::pos2(900.0, 550.0)); + let before = window_clip(Split, true, canvas, canvas, img); + let after = window_clip(Split, false, canvas, canvas, img); + assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle"); + assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0); + assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0); + // zoomed in, the photo's middle (x = 1600) is off screen: the line is the view's middle + let zoomed = egui::Rect::from_min_max(egui::pos2(-200.0, -900.0), egui::pos2(3400.0, 1500.0)); + assert_eq!(window_clip(Split, false, canvas, canvas, zoomed).min.x, 500.0); + assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, zoomed).max.y, 300.0); + assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas); + assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); +} + +// Given a window asked for while another is rendering and the original isn't decoded yet, it +// waits for the decoded original instead of decoding another copy +#[test] +fn a_window_waits_for_the_original_another_is_decoding() { + assert!(defer_window(true, false)); + assert!(!defer_window(true, true), "the original is in"); + assert!(!defer_window(false, false), "nothing is decoding it"); +} + +// Hostile numbers give a plan, never a panic +#[test] +fn hostile_sizes_give_a_usable_plan() { + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] { + let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft: Some(v), ..sizes(1.0, 1.0, 6000) }); + assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); + } + let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); + assert!(p.main_edge >= 8); +} + +// Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid +#[test] +fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { + let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); + let m = margin_for(48_000); + assert!(w.x + m <= 20_000 && w.x + w.w >= 21_400 + m, "{w:?}"); + assert!(w.y + m <= 10_000 && w.y + w.h >= 10_900 + m, "{w:?}"); + assert_eq!((w.x % SNAP, w.y % SNAP, w.w % SNAP, w.h % SNAP), (0, 0, 0, 0)); +} + +// Given a small pan, the same window is kept (no re-render); a big pan changes it +#[test] +fn small_pans_keep_the_window() { + let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); + let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0), MAX_SPAN).unwrap(); + assert_eq!(a, b); + let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0), MAX_SPAN).unwrap(); + assert_ne!(a, c); +} + +// Given a frame smaller than the margin, or a view at its edge, the window is clamped into it +#[test] +fn the_window_stays_inside_the_frame() { + let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0), MAX_SPAN).unwrap(); + assert_eq!((w.x, w.y), (0, 0)); + let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0), MAX_SPAN).unwrap(); + assert!(w.x + w.w <= 3000 && w.y + w.h <= 2000, "{w:?}"); + assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000)); +} + +// Given a deeper zoom, the margin grows with the wide stages' kernels (3σ of the base, clarity +// and dehaze planes are 0.045 of the frame), up to a cap that bounds the window +#[test] +fn the_margin_grows_with_the_zoom_up_to_a_cap() { + assert_eq!(margin_for(1000), SNAP, "never less than one grid step"); + assert!(margin_for(8000) > margin_for(3000)); + assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap"); + assert_eq!(margin_for(80_000), MAX_MARGIN); + assert_eq!(margin_for(0), SNAP); + assert!(margin_for(usize::MAX) <= MAX_MARGIN); + for l in [100, 3000, 8000, 48_000, 400_000] { + assert_eq!(margin_for(l) % SNAP, 0); + } +} + +// Given a tile rendered for other settings, another photo or another zoom, it is not drawn +#[test] +fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { + use lightcraft_catalog::PhotoId; + let v = RegionView { + photo: PhotoId(1), + before: false, + key: 7, + full: (6000, 4000), + window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, + settings: 11, + }; + assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false)); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo"); + assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom"); + assert!(!v.is_current(PhotoId(1), (6000, 4000), 12, false), "another look (an edit, an undo)"); + assert!(v.is_current(PhotoId(1), (6000, 4000), 12, true), "a drag's drafts follow the value"); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 12, true), "…of this photo only"); +} + +// Given a GPU that allows 2048 px textures, no window is wider than that +#[test] +fn a_window_fits_the_texture_side() { + for tex in [512, 2048, 4096] { + let span = max_span(tex); + assert!(span <= tex.max(SNAP) && span.is_multiple_of(SNAP), "{tex}: {span}"); + let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), span).unwrap(); + assert!(w.w <= span && w.h <= span, "{tex}: {w:?}"); + } + assert_eq!(max_span(100_000), MAX_SPAN); + assert_eq!(max_span(0), SNAP); +} + +// Given nothing visible, or hostile numbers, there is no window (never a panic) +#[test] +fn nothing_visible_or_hostile_input_is_no_window() { + assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0), MAX_SPAN), None); + assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0), MAX_SPAN), None); + assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0), MAX_SPAN), None); + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0), MAX_SPAN), None); + } + let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30), MAX_SPAN).unwrap(); + assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN, "{w:?}"); +} + +// Given a view of an enormous area, the window is bounded +#[test] +fn a_window_is_bounded() { + let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0), MAX_SPAN).unwrap(); + assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN); +} From c2da20d88392bca23a3e73f6d371fbf01d1191e7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:01 +0000 Subject: [PATCH 058/191] refactor(lightcraft-ui-egui): move text_field.rs tests to crates/ui-egui/src/text_field/tests.rs --- crates/ui-egui/src/text_field.rs | 425 +------------------------ crates/ui-egui/src/text_field/tests.rs | 422 ++++++++++++++++++++++++ 2 files changed, 423 insertions(+), 424 deletions(-) create mode 100644 crates/ui-egui/src/text_field/tests.rs diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index f59be23dc..cb150080b 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -407,427 +407,4 @@ fn menu(ui: &mut Ui, widget: &str, id: egui::Id, text: &str, action: &mut Option } #[cfg(test)] -mod tests { - use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect}; - - use super::*; - use crate::headless::HeadlessView; - - const FIELD: &str = "cityField"; - - /// A field alone in a window, with a blank area beside it to click away to. - struct Rig { - view: HeadlessView, - text: String, - select_on_focus: bool, - /// The field is on screen (a dialog that holds it may close). - shown: bool, - /// The app's window has the system's focus (the user may switch to another app). - window_focused: bool, - /// The field said an edit is under way, last frame. - editing: bool, - endings: Vec, - widgets: Vec<(String, Rect)>, - time: f64, - } - - impl Rig { - fn new(text: &str) -> Self { - let mut rig = Rig { - view: HeadlessView::new(), - text: text.to_string(), - select_on_focus: false, - shown: true, - window_focused: true, - editing: false, - endings: vec![], - widgets: vec![], - time: 0.0, - }; - rig.frame(vec![]); - rig - } - - fn frame(&mut self, events: Vec) { - self.frame_with(events, false); - } - - /// A frame that asks for the field's focus after drawing it, as a dialog does, so the - /// focus takes effect on the next frame. - fn frame_with(&mut self, events: Vec, focus_after: bool) { - self.time += 1.0 / 60.0; - let mut raw = HeadlessView::raw_input(egui::vec2(400.0, 300.0), 1.0, self.time, events); - raw.focused = self.window_focused; - let (text, select, shown, endings, editing) = (&mut self.text, self.select_on_focus, self.shown, &mut self.endings, &mut self.editing); - self.view.run(raw, |ui| { - ui.add_space(20.0); - if !shown { - return; - } - let r = TextField::singleline(FIELD, text).width(200.0).select_on_focus(select).show(ui); - endings.extend(r.ending); - *editing = r.editing; - if focus_after { - ui.memory_mut(|m| m.request_focus(id(FIELD))); - } - }); - self.widgets = crate::widgets::take_registry(&self.view.ctx); - } - - /// A few frames, for menus to open and requests to come back. - fn settle(&mut self) { - for _ in 0..4 { - self.frame(vec![]); - } - } - - fn rect(&self, id: &str) -> Option { - self.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) - } - - fn click_at(&mut self, at: Pos2, button: PointerButton) { - // far apart in time from the last click: two clicks never make a double-click - self.time += 1.0; - let press = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Modifiers::NONE }; - self.frame(vec![Event::PointerMoved(at), press(true)]); - self.frame(vec![press(false)]); - self.settle(); - } - - fn click(&mut self, id: &str, button: PointerButton) { - let r = self.rect(id).unwrap_or_else(|| panic!("no {id} on screen: {:?}", self.widgets)); - self.click_at(r.center(), button); - } - - fn key(&mut self, key: Key, modifiers: Modifiers) { - let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; - self.frame(vec![ev(true), ev(false)]); - self.settle(); - } - - fn type_text(&mut self, text: &str) { - self.frame(vec![Event::Text(text.to_string())]); - self.settle(); - } - - fn focused(&self) -> bool { - self.view.ctx.memory(|m| m.focused()).is_some() - } - - /// Click into the field and select all its text with ⌘A. - fn select_all_by_keys(&mut self) { - self.click(FIELD, PointerButton::Primary); - self.key(Key::A, Modifiers::COMMAND); - } - - /// Right-click the field, then pick `item` from its menu. - fn menu(&mut self, item: &str) { - self.click(FIELD, PointerButton::Secondary); - self.click(&format!("{FIELD}:{item}"), PointerButton::Primary); - } - } - - /// Right-click offers Cut, Copy, Paste and Select All. - #[test] - fn right_click_offers_cut_copy_paste_and_select_all() { - let mut rig = Rig::new("Lisbon"); - rig.click(FIELD, PointerButton::Secondary); - for item in ["cut", "copy", "paste", "selectAll"] { - assert!(rig.rect(&format!("{FIELD}:{item}")).is_some(), "{item}: {:?}", rig.widgets); - } - } - - /// Copy in the menu puts the selected text on the clipboard and leaves the field as it was, - /// still being edited. - #[test] - fn copy_in_the_menu_copies_the_selection() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.menu("copy"); - assert_eq!(rig.view.clipboard, "Lisbon"); - assert_eq!(rig.text, "Lisbon"); - assert!(rig.focused(), "still editing"); - assert_eq!(rig.endings, vec![], "using the menu is not leaving the field"); - } - - /// Cut in the menu puts the selected text on the clipboard and takes it out of the field. - #[test] - fn cut_in_the_menu_moves_the_selection_to_the_clipboard() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.menu("cut"); - assert_eq!(rig.view.clipboard, "Lisbon"); - assert_eq!(rig.text, ""); - } - - /// Paste in the menu puts the clipboard's text in place of the selection. - #[test] - fn paste_in_the_menu_replaces_the_selection() { - let mut rig = Rig::new("Lisbon"); - rig.view.clipboard = "Porto".into(); - rig.select_all_by_keys(); - rig.menu("paste"); - assert_eq!(rig.text, "Porto"); - } - - /// Select All in the menu selects the whole text: what is typed next replaces it. - #[test] - fn select_all_in_the_menu_selects_the_whole_text() { - let mut rig = Rig::new("Lisbon"); - rig.click(FIELD, PointerButton::Primary); - rig.menu("selectAll"); - rig.type_text("Kyoto"); - assert_eq!(rig.text, "Kyoto"); - } - - /// #1877: a right-click elsewhere in the field while its menu is open moves the menu there - /// (it flashed at the new spot and closed). - #[test] - fn right_clicking_again_moves_the_menu() { - let mut rig = Rig::new("Lisbon"); - rig.click(FIELD, PointerButton::Secondary); - let before = rig.rect(&format!("{FIELD}:cut")).expect("the menu"); - let rect = rig.rect(FIELD).unwrap(); - let at = egui::pos2(rect.left() + 10.0, rect.center().y); - rig.click_at(at, PointerButton::Secondary); - let after = rig.rect(&format!("{FIELD}:cut")).unwrap_or_else(|| panic!("the menu is still open: {:?}", rig.widgets)); - assert!(after.left() < before.left(), "the menu moved to the pointer: {after:?} (was {before:?})"); - assert!(rig.endings.is_empty(), "the edit goes on"); - } - - #[test] - fn clicking_back_into_field_dismisses_its_menu_and_places_a_new_caret() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.click(FIELD, PointerButton::Secondary); - let rect = rig.rect(FIELD).unwrap(); - rig.click_at(egui::pos2(rect.left() + 2.0, rect.center().y), PointerButton::Primary); - let range = egui::text_edit::TextEditState::load(&rig.view.ctx, id(FIELD)).unwrap().cursor.char_range().unwrap(); - assert!(range.is_empty(), "a click back in the field places a caret: {range:?}"); - rig.type_text("X"); - assert_eq!(rig.text, "XLisbon"); - assert!(rig.endings.is_empty(), "dismissing into the field keeps the edit open"); - } - - /// With nothing selected, Cut and Copy are greyed out: clicking one does nothing, as in a native - /// menu, and neither closes the menu nor ends the edit. - #[test] - fn greyed_out_items_do_nothing() { - let mut rig = Rig::new("Lisbon"); - rig.click(FIELD, PointerButton::Primary); - rig.click(FIELD, PointerButton::Secondary); - for item in ["cut", "copy"] { - rig.click(&format!("{FIELD}:{item}"), PointerButton::Primary); - assert!(rig.rect(&format!("{FIELD}:{item}")).is_some(), "{item}: the menu is still open"); - } - assert_eq!((rig.text.as_str(), rig.view.clipboard.as_str()), ("Lisbon", "")); - assert_eq!(rig.endings, vec![], "still editing"); - rig.menu("selectAll"); - rig.type_text("Kyoto"); - assert_eq!(rig.text, "Kyoto", "and the edit goes on"); - } - - /// Esc gives back the text from before the edit and says the edit was cancelled. - #[test] - fn escape_gives_back_the_text_from_before_the_edit() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - assert_eq!(rig.text, "Rome"); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!(rig.text, "Lisbon"); - assert_eq!(rig.endings, vec![Ending::Escape]); - } - - /// Return ends the edit and keeps the text. - #[test] - fn return_ends_the_edit() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.key(Key::Enter, Modifiers::NONE); - assert_eq!(rig.text, "Rome"); - assert_eq!(rig.endings, vec![Ending::Return]); - } - - /// Return with the menu open ends the edit as Return does, and closes the menu. - #[test] - fn return_with_the_menu_open_ends_the_edit() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.click(FIELD, PointerButton::Secondary); - rig.key(Key::Enter, Modifiers::NONE); - assert_eq!(rig.endings, vec![Ending::Return]); - assert_eq!(rig.text, "Rome"); - assert!(rig.rect(&format!("{FIELD}:cut")).is_none(), "the menu closed"); - } - - /// Esc with the menu open closes only the menu, as in native menus: the edit goes on, and a - /// second Esc gives back the text. - #[test] - fn escape_with_the_menu_open_closes_only_the_menu() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.click(FIELD, PointerButton::Secondary); - rig.key(Key::Escape, Modifiers::NONE); - assert!(rig.rect(&format!("{FIELD}:cut")).is_none(), "the menu closed"); - assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Rome", vec![]), "the edit goes on"); - assert!(rig.focused()); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Lisbon", vec![Ending::Escape])); - } - - /// Esc after Tab took the focus into the open menu also closes only the menu. - #[test] - fn escape_from_inside_the_menu_closes_only_the_menu() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.click(FIELD, PointerButton::Secondary); - rig.key(Key::Tab, Modifiers::NONE); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Rome", vec![]), "the edit goes on"); - assert!(rig.focused()); - } - - /// Clicking elsewhere ends the edit and keeps the text. - #[test] - fn clicking_away_ends_the_edit() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary); - assert_eq!(rig.text, "Rome"); - assert_eq!(rig.endings, vec![Ending::Away]); - } - - /// Opening the menu and closing it without a choice (a click outside) leaves the field. - #[test] - fn dismissing_the_menu_leaves_the_field() { - let mut rig = Rig::new("Lisbon"); - rig.click(FIELD, PointerButton::Primary); - rig.click(FIELD, PointerButton::Secondary); - rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary); - assert!(!rig.focused()); - assert_eq!(rig.endings, vec![Ending::Away]); - } - - /// A field that selects on focus: clicking into it selects everything, so typing replaces it. - #[test] - fn a_field_that_selects_on_focus_is_retyped_whole() { - let mut rig = Rig::new("Lisbon"); - rig.select_on_focus = true; - rig.click(FIELD, PointerButton::Primary); - rig.type_text("Kyoto"); - assert_eq!(rig.text, "Kyoto"); - } - - /// A field focused by the pass before (a dialog asks for focus after drawing it) selects its - /// text before egui handles the first keystroke of the pass the focus takes effect, so that - /// keystroke replaces the text instead of being inserted into it. - #[test] - fn text_typed_on_the_first_focused_pass_replaces_the_whole_text() { - let mut rig = Rig::new("LightCraft Library"); - rig.select_on_focus = true; - // the dialog draws the field, then asks for its focus: effective on the next pass - rig.frame_with(vec![], true); - assert!(rig.view.ctx.memory(|m| m.has_focus(id(FIELD))), "focused for the next pass"); - // the pass the focus takes effect already carries the text - rig.frame(vec![Event::Text("Fresh".into())]); - assert_eq!(rig.text, "Fresh", "the first keystroke replaced the selected text"); - // the text from before the edit is still what Esc gives back - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!(rig.text, "LightCraft Library"); - assert_eq!(rig.endings, vec![Ending::Escape]); - } - - /// Esc after using the menu still gives back the text from before the whole edit. - #[test] - fn escape_after_the_menu_gives_back_the_original_text() { - let mut rig = Rig::new("Lisbon"); - rig.view.clipboard = "Porto".into(); - rig.select_all_by_keys(); - rig.menu("paste"); - assert_eq!(rig.text, "Porto"); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!(rig.text, "Lisbon"); - } - - /// A field that goes away in the middle of an edit (its dialog closed) starts afresh when it - /// comes back: Esc then gives back the text it came back with. - #[test] - fn an_edit_cut_short_is_forgotten() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.shown = false; - rig.settle(); - rig.text = "Porto".into(); - rig.shown = true; - rig.settle(); - rig.select_all_by_keys(); - rig.type_text("Kyoto"); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!(rig.text, "Porto"); - } - - /// Switching to another app and back in the middle of an edit carries on with it: the field - /// is still being edited meanwhile, and Esc still gives back the text from before. - #[test] - fn switching_apps_carries_on_with_the_edit() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.window_focused = false; - rig.settle(); - assert!(rig.editing, "still being edited while the app is in the background"); - assert_eq!(rig.endings, vec![]); - rig.window_focused = true; - rig.settle(); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Lisbon", vec![Ending::Escape])); - } - - /// A field that goes away while its menu is open starts afresh too when it comes back. - #[test] - fn a_field_gone_with_its_menu_open_starts_afresh() { - let mut rig = Rig::new("Lisbon"); - rig.select_all_by_keys(); - rig.type_text("Rome"); - rig.click(FIELD, PointerButton::Secondary); - // a click on the greyed-out Cut takes the focus into the menu - rig.click(&format!("{FIELD}:cut"), PointerButton::Primary); - rig.shown = false; - rig.settle(); - rig.text = "Porto".into(); - rig.shown = true; - rig.settle(); - assert!(!rig.editing, "no edit under way"); - assert_eq!(rig.endings, vec![], "and none ended: it was cut short"); - rig.select_all_by_keys(); - rig.type_text("Kyoto"); - rig.key(Key::Escape, Modifiers::NONE); - assert_eq!(rig.text, "Porto"); - } - - /// A field that selects on focus still lets a drag into it select part of the text, as a - /// browser's address bar does: only a plain click selects everything. - #[test] - fn a_drag_into_a_field_that_selects_on_focus_selects_what_it_covers() { - let mut rig = Rig::new("Lisbon Portugal Europe Atlantic Ocean"); - rig.select_on_focus = true; - let r = rig.rect(FIELD).expect("the field"); - let (from, to) = (r.center(), egui::pos2(r.right() + 40.0, r.center().y)); - let press = |at, pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - rig.frame(vec![Event::PointerMoved(from), press(from, true)]); - for i in 1..=5 { - rig.frame(vec![Event::PointerMoved(from + (to - from) * (i as f32 / 5.0))]); - } - rig.frame(vec![press(to, false)]); - rig.settle(); - rig.type_text("X"); - assert!(rig.text.starts_with("Lisbon") && rig.text.ends_with('X') && rig.text != "X", "{:?}", rig.text); - } -} +mod tests; diff --git a/crates/ui-egui/src/text_field/tests.rs b/crates/ui-egui/src/text_field/tests.rs new file mode 100644 index 000000000..0c4e3c3d3 --- /dev/null +++ b/crates/ui-egui/src/text_field/tests.rs @@ -0,0 +1,422 @@ +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect}; + +use super::*; +use crate::headless::HeadlessView; + +const FIELD: &str = "cityField"; + +/// A field alone in a window, with a blank area beside it to click away to. +struct Rig { + view: HeadlessView, + text: String, + select_on_focus: bool, + /// The field is on screen (a dialog that holds it may close). + shown: bool, + /// The app's window has the system's focus (the user may switch to another app). + window_focused: bool, + /// The field said an edit is under way, last frame. + editing: bool, + endings: Vec, + widgets: Vec<(String, Rect)>, + time: f64, +} + +impl Rig { + fn new(text: &str) -> Self { + let mut rig = Rig { + view: HeadlessView::new(), + text: text.to_string(), + select_on_focus: false, + shown: true, + window_focused: true, + editing: false, + endings: vec![], + widgets: vec![], + time: 0.0, + }; + rig.frame(vec![]); + rig + } + + fn frame(&mut self, events: Vec) { + self.frame_with(events, false); + } + + /// A frame that asks for the field's focus after drawing it, as a dialog does, so the + /// focus takes effect on the next frame. + fn frame_with(&mut self, events: Vec, focus_after: bool) { + self.time += 1.0 / 60.0; + let mut raw = HeadlessView::raw_input(egui::vec2(400.0, 300.0), 1.0, self.time, events); + raw.focused = self.window_focused; + let (text, select, shown, endings, editing) = (&mut self.text, self.select_on_focus, self.shown, &mut self.endings, &mut self.editing); + self.view.run(raw, |ui| { + ui.add_space(20.0); + if !shown { + return; + } + let r = TextField::singleline(FIELD, text).width(200.0).select_on_focus(select).show(ui); + endings.extend(r.ending); + *editing = r.editing; + if focus_after { + ui.memory_mut(|m| m.request_focus(id(FIELD))); + } + }); + self.widgets = crate::widgets::take_registry(&self.view.ctx); + } + + /// A few frames, for menus to open and requests to come back. + fn settle(&mut self) { + for _ in 0..4 { + self.frame(vec![]); + } + } + + fn rect(&self, id: &str) -> Option { + self.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) + } + + fn click_at(&mut self, at: Pos2, button: PointerButton) { + // far apart in time from the last click: two clicks never make a double-click + self.time += 1.0; + let press = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Modifiers::NONE }; + self.frame(vec![Event::PointerMoved(at), press(true)]); + self.frame(vec![press(false)]); + self.settle(); + } + + fn click(&mut self, id: &str, button: PointerButton) { + let r = self.rect(id).unwrap_or_else(|| panic!("no {id} on screen: {:?}", self.widgets)); + self.click_at(r.center(), button); + } + + fn key(&mut self, key: Key, modifiers: Modifiers) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + self.frame(vec![ev(true), ev(false)]); + self.settle(); + } + + fn type_text(&mut self, text: &str) { + self.frame(vec![Event::Text(text.to_string())]); + self.settle(); + } + + fn focused(&self) -> bool { + self.view.ctx.memory(|m| m.focused()).is_some() + } + + /// Click into the field and select all its text with ⌘A. + fn select_all_by_keys(&mut self) { + self.click(FIELD, PointerButton::Primary); + self.key(Key::A, Modifiers::COMMAND); + } + + /// Right-click the field, then pick `item` from its menu. + fn menu(&mut self, item: &str) { + self.click(FIELD, PointerButton::Secondary); + self.click(&format!("{FIELD}:{item}"), PointerButton::Primary); + } +} + +/// Right-click offers Cut, Copy, Paste and Select All. +#[test] +fn right_click_offers_cut_copy_paste_and_select_all() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Secondary); + for item in ["cut", "copy", "paste", "selectAll"] { + assert!(rig.rect(&format!("{FIELD}:{item}")).is_some(), "{item}: {:?}", rig.widgets); + } +} + +/// Copy in the menu puts the selected text on the clipboard and leaves the field as it was, +/// still being edited. +#[test] +fn copy_in_the_menu_copies_the_selection() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.menu("copy"); + assert_eq!(rig.view.clipboard, "Lisbon"); + assert_eq!(rig.text, "Lisbon"); + assert!(rig.focused(), "still editing"); + assert_eq!(rig.endings, vec![], "using the menu is not leaving the field"); +} + +/// Cut in the menu puts the selected text on the clipboard and takes it out of the field. +#[test] +fn cut_in_the_menu_moves_the_selection_to_the_clipboard() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.menu("cut"); + assert_eq!(rig.view.clipboard, "Lisbon"); + assert_eq!(rig.text, ""); +} + +/// Paste in the menu puts the clipboard's text in place of the selection. +#[test] +fn paste_in_the_menu_replaces_the_selection() { + let mut rig = Rig::new("Lisbon"); + rig.view.clipboard = "Porto".into(); + rig.select_all_by_keys(); + rig.menu("paste"); + assert_eq!(rig.text, "Porto"); +} + +/// Select All in the menu selects the whole text: what is typed next replaces it. +#[test] +fn select_all_in_the_menu_selects_the_whole_text() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Primary); + rig.menu("selectAll"); + rig.type_text("Kyoto"); + assert_eq!(rig.text, "Kyoto"); +} + +/// #1877: a right-click elsewhere in the field while its menu is open moves the menu there +/// (it flashed at the new spot and closed). +#[test] +fn right_clicking_again_moves_the_menu() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Secondary); + let before = rig.rect(&format!("{FIELD}:cut")).expect("the menu"); + let rect = rig.rect(FIELD).unwrap(); + let at = egui::pos2(rect.left() + 10.0, rect.center().y); + rig.click_at(at, PointerButton::Secondary); + let after = rig.rect(&format!("{FIELD}:cut")).unwrap_or_else(|| panic!("the menu is still open: {:?}", rig.widgets)); + assert!(after.left() < before.left(), "the menu moved to the pointer: {after:?} (was {before:?})"); + assert!(rig.endings.is_empty(), "the edit goes on"); +} + +#[test] +fn clicking_back_into_field_dismisses_its_menu_and_places_a_new_caret() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.click(FIELD, PointerButton::Secondary); + let rect = rig.rect(FIELD).unwrap(); + rig.click_at(egui::pos2(rect.left() + 2.0, rect.center().y), PointerButton::Primary); + let range = egui::text_edit::TextEditState::load(&rig.view.ctx, id(FIELD)).unwrap().cursor.char_range().unwrap(); + assert!(range.is_empty(), "a click back in the field places a caret: {range:?}"); + rig.type_text("X"); + assert_eq!(rig.text, "XLisbon"); + assert!(rig.endings.is_empty(), "dismissing into the field keeps the edit open"); +} + +/// With nothing selected, Cut and Copy are greyed out: clicking one does nothing, as in a native +/// menu, and neither closes the menu nor ends the edit. +#[test] +fn greyed_out_items_do_nothing() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Primary); + rig.click(FIELD, PointerButton::Secondary); + for item in ["cut", "copy"] { + rig.click(&format!("{FIELD}:{item}"), PointerButton::Primary); + assert!(rig.rect(&format!("{FIELD}:{item}")).is_some(), "{item}: the menu is still open"); + } + assert_eq!((rig.text.as_str(), rig.view.clipboard.as_str()), ("Lisbon", "")); + assert_eq!(rig.endings, vec![], "still editing"); + rig.menu("selectAll"); + rig.type_text("Kyoto"); + assert_eq!(rig.text, "Kyoto", "and the edit goes on"); +} + +/// Esc gives back the text from before the edit and says the edit was cancelled. +#[test] +fn escape_gives_back_the_text_from_before_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + assert_eq!(rig.text, "Rome"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Lisbon"); + assert_eq!(rig.endings, vec![Ending::Escape]); +} + +/// Return ends the edit and keeps the text. +#[test] +fn return_ends_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.key(Key::Enter, Modifiers::NONE); + assert_eq!(rig.text, "Rome"); + assert_eq!(rig.endings, vec![Ending::Return]); +} + +/// Return with the menu open ends the edit as Return does, and closes the menu. +#[test] +fn return_with_the_menu_open_ends_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + rig.key(Key::Enter, Modifiers::NONE); + assert_eq!(rig.endings, vec![Ending::Return]); + assert_eq!(rig.text, "Rome"); + assert!(rig.rect(&format!("{FIELD}:cut")).is_none(), "the menu closed"); +} + +/// Esc with the menu open closes only the menu, as in native menus: the edit goes on, and a +/// second Esc gives back the text. +#[test] +fn escape_with_the_menu_open_closes_only_the_menu() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + rig.key(Key::Escape, Modifiers::NONE); + assert!(rig.rect(&format!("{FIELD}:cut")).is_none(), "the menu closed"); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Rome", vec![]), "the edit goes on"); + assert!(rig.focused()); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Lisbon", vec![Ending::Escape])); +} + +/// Esc after Tab took the focus into the open menu also closes only the menu. +#[test] +fn escape_from_inside_the_menu_closes_only_the_menu() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + rig.key(Key::Tab, Modifiers::NONE); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Rome", vec![]), "the edit goes on"); + assert!(rig.focused()); +} + +/// Clicking elsewhere ends the edit and keeps the text. +#[test] +fn clicking_away_ends_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary); + assert_eq!(rig.text, "Rome"); + assert_eq!(rig.endings, vec![Ending::Away]); +} + +/// Opening the menu and closing it without a choice (a click outside) leaves the field. +#[test] +fn dismissing_the_menu_leaves_the_field() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Primary); + rig.click(FIELD, PointerButton::Secondary); + rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary); + assert!(!rig.focused()); + assert_eq!(rig.endings, vec![Ending::Away]); +} + +/// A field that selects on focus: clicking into it selects everything, so typing replaces it. +#[test] +fn a_field_that_selects_on_focus_is_retyped_whole() { + let mut rig = Rig::new("Lisbon"); + rig.select_on_focus = true; + rig.click(FIELD, PointerButton::Primary); + rig.type_text("Kyoto"); + assert_eq!(rig.text, "Kyoto"); +} + +/// A field focused by the pass before (a dialog asks for focus after drawing it) selects its +/// text before egui handles the first keystroke of the pass the focus takes effect, so that +/// keystroke replaces the text instead of being inserted into it. +#[test] +fn text_typed_on_the_first_focused_pass_replaces_the_whole_text() { + let mut rig = Rig::new("LightCraft Library"); + rig.select_on_focus = true; + // the dialog draws the field, then asks for its focus: effective on the next pass + rig.frame_with(vec![], true); + assert!(rig.view.ctx.memory(|m| m.has_focus(id(FIELD))), "focused for the next pass"); + // the pass the focus takes effect already carries the text + rig.frame(vec![Event::Text("Fresh".into())]); + assert_eq!(rig.text, "Fresh", "the first keystroke replaced the selected text"); + // the text from before the edit is still what Esc gives back + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "LightCraft Library"); + assert_eq!(rig.endings, vec![Ending::Escape]); +} + +/// Esc after using the menu still gives back the text from before the whole edit. +#[test] +fn escape_after_the_menu_gives_back_the_original_text() { + let mut rig = Rig::new("Lisbon"); + rig.view.clipboard = "Porto".into(); + rig.select_all_by_keys(); + rig.menu("paste"); + assert_eq!(rig.text, "Porto"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Lisbon"); +} + +/// A field that goes away in the middle of an edit (its dialog closed) starts afresh when it +/// comes back: Esc then gives back the text it came back with. +#[test] +fn an_edit_cut_short_is_forgotten() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.shown = false; + rig.settle(); + rig.text = "Porto".into(); + rig.shown = true; + rig.settle(); + rig.select_all_by_keys(); + rig.type_text("Kyoto"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Porto"); +} + +/// Switching to another app and back in the middle of an edit carries on with it: the field +/// is still being edited meanwhile, and Esc still gives back the text from before. +#[test] +fn switching_apps_carries_on_with_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.window_focused = false; + rig.settle(); + assert!(rig.editing, "still being edited while the app is in the background"); + assert_eq!(rig.endings, vec![]); + rig.window_focused = true; + rig.settle(); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Lisbon", vec![Ending::Escape])); +} + +/// A field that goes away while its menu is open starts afresh too when it comes back. +#[test] +fn a_field_gone_with_its_menu_open_starts_afresh() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + // a click on the greyed-out Cut takes the focus into the menu + rig.click(&format!("{FIELD}:cut"), PointerButton::Primary); + rig.shown = false; + rig.settle(); + rig.text = "Porto".into(); + rig.shown = true; + rig.settle(); + assert!(!rig.editing, "no edit under way"); + assert_eq!(rig.endings, vec![], "and none ended: it was cut short"); + rig.select_all_by_keys(); + rig.type_text("Kyoto"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Porto"); +} + +/// A field that selects on focus still lets a drag into it select part of the text, as a +/// browser's address bar does: only a plain click selects everything. +#[test] +fn a_drag_into_a_field_that_selects_on_focus_selects_what_it_covers() { + let mut rig = Rig::new("Lisbon Portugal Europe Atlantic Ocean"); + rig.select_on_focus = true; + let r = rig.rect(FIELD).expect("the field"); + let (from, to) = (r.center(), egui::pos2(r.right() + 40.0, r.center().y)); + let press = |at, pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + rig.frame(vec![Event::PointerMoved(from), press(from, true)]); + for i in 1..=5 { + rig.frame(vec![Event::PointerMoved(from + (to - from) * (i as f32 / 5.0))]); + } + rig.frame(vec![press(to, false)]); + rig.settle(); + rig.type_text("X"); + assert!(rig.text.starts_with("Lisbon") && rig.text.ends_with('X') && rig.text != "X", "{:?}", rig.text); +} From 2ca957b2968cdc88e1482c281e62426d27bdeb87 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:01 +0000 Subject: [PATCH 059/191] refactor(lightcraft-ui-egui): move render.rs thumbnail_tests to crates/ui-egui/src/render/thumbnail_tests.rs --- crates/ui-egui/src/render.rs | 773 +------------------ crates/ui-egui/src/render/thumbnail_tests.rs | 770 ++++++++++++++++++ 2 files changed, 771 insertions(+), 772 deletions(-) create mode 100644 crates/ui-egui/src/render/thumbnail_tests.rs diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 973c0d8b8..31117b22e 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -1073,778 +1073,7 @@ fn render_failure_message(slot: Slot, photo: PhotoId, source: Option<&lightcraft } #[cfg(test)] -mod thumbnail_tests { - use super::*; - use lightcraft_catalog::{Catalog, Op, Photo, Source}; - - #[test] - fn render_failures_identify_the_file_for_thumbnail_and_view_slots() { - let source = Source::File { path: "/photos/trip/IMG_0042.nef".into() }; - for slot in [Slot::Thumb(PhotoId(42)), Slot::Main, Slot::Before, Slot::Compare(1), Slot::Region] { - let message = render_failure_message(slot, PhotoId(42), Some(&source), "unsupported compression"); - assert!(message.contains("/photos/trip/IMG_0042.nef"), "{message}"); - assert!(message.contains("PhotoId(42)"), "{message}"); - assert!(message.ends_with(": unsupported compression"), "{message}"); - } - } - - #[test] - fn render_failure_paths_are_escaped_and_missing_sources_still_identified() { - let source = Source::File { path: "line\nbreak.nef".into() }; - let message = render_failure_message(Slot::Main, PhotoId(42), Some(&source), "decode failed"); - assert!(!message.contains('\n')); - assert!(message.contains("line\\nbreak.nef")); - assert!(render_failure_message(Slot::Main, PhotoId(42), None, "decode failed").contains("PhotoId(42), source unavailable")); - let demo = Source::Demo { scene: 7 }; - assert!(render_failure_message(Slot::Main, PhotoId(42), Some(&demo), "decode failed").contains("demo scene 7")); - } - - struct Blocked; - impl RenderOffload for Blocked { - fn try_start(&mut self, _: Slot, job: RenderJob) -> Option { - Some(job) - } - fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { - vec![] - } - } - fn photo() -> Arc { - Arc::new(Photo::new(PhotoId(1), Source::File { path: "audit.jpg".into() }, "audit.jpg", "JPEG", 6000, 4000, "2026-01-01")) - } - #[test] - fn snapshot_tracks_cow_edits_undo_reload_and_replacement() { - let mut catalog = Catalog::default(); - catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); - let old = catalog.photo(PhotoId(1)).unwrap().clone(); - let mut r = Renderer::default(); - r.remember_thumb(&old, 384, 77, false); - r.pending.insert(Slot::Thumb(PhotoId(1)), (77, 10)); - assert!(r.thumb_current(&old, 384, 10)); - assert!(!r.thumb_current(&old, 512, 10)); - let mut settings = lightcraft_develop::DevelopSettings::default(); - settings.light.exposure = 1.0; - let undo = catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(settings), label: "edit".into(), edited: None }).unwrap(); - assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); - catalog.apply(undo).unwrap(); - assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); - catalog - .apply(Op::SetContent { - id: PhotoId(1), - width: 6000, - height: 4000, - file_size: 99, - content_hash: Some("changed".into()), - preview_only: None, - preview_checked: String::new(), - }) - .unwrap(); - assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); - assert!(!r.thumb_current(&photo(), 384, 10)); - r.forget_all(); - assert!(r.thumb_inputs.is_empty()); - } - #[test] - fn queue_promotions_running_work_and_cancellation() { - let p = photo(); - let slot = Slot::Thumb(p.id); - let mut session = Session::new(); - session.catalog.apply(Op::AddPhoto { photo: Box::new((*p).clone()) }).unwrap(); - let job = session.thumb_job(p.id, 384).unwrap(); - let key = job.key; - let mut r = Renderer::default(); - r.set_offload(Box::new(Blocked)); - r.remember_thumb(&p, 384, key, false); - r.request(slot, job, 5); - assert!(r.thumb_current(&p, 384, 5)); - assert!(!r.thumb_current(&p, 384, 10)); - r.request(slot, session.thumb_job(p.id, 384).unwrap(), 10); - assert_eq!(r.pending.get(&slot), Some(&(key, 10))); - r.queue.clear(); // running work: cannot reprioritize, just as Renderer::request - assert!(r.thumb_current(&p, 384, 5)); - r.pending.clear(); - assert!(!r.thumb_current(&p, 384, 10)); - r.evict_thumbs(&Default::default(), 600); - assert!(r.thumb_inputs.is_empty()); - } - fn queued_quick_thumbnail(id: PhotoId) -> (Arc, QuickJob) { - let mut session = Session::new(); - session.media.preview_loader = - Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); - let mut raw = (*photo()).clone(); - raw.id = id; - raw.kind = lightcraft_catalog::MediaKind::Raw; - raw.develop = Arc::new(raw.camera_defaults()); - session.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - let p = session.catalog.photo(id).unwrap().clone(); - let job = session.thumb_job(id, 384).unwrap(); - (p, session.quick_thumb_job(&job).unwrap()) - } - - #[test] - fn queued_standin_promotes_from_prefetch_to_visible_without_restarting() { - let (p, quick) = queued_quick_thumbnail(PhotoId(1)); - let (_, other) = queued_quick_thumbnail(PhotoId(2)); - let slot = Slot::ThumbQuick(p.id); - // Deterministic queue: no native worker starts. - let mut r = Renderer { pool: JobPool::new(0), ..Default::default() }; - r.remember_thumb(&p, 384, quick.key, true); - r.request_quick(slot, quick.clone(), 6); // grid prefetch: 5 + 1 - r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); // filmstrip: 8 + 1 - let request_id = r.requests[&slot].id; - assert!(r.thumb_current(&p, 384, 5)); - assert!(!r.thumb_current(&p, 384, 10)); - r.request_quick(slot, quick.clone(), 11); // visible grid: 10 + 1 - assert!(r.thumb_current(&p, 384, 10)); - r.request_quick(slot, quick.clone(), 11); - assert_eq!(r.requests[&slot].id, request_id); - assert_eq!(r.pool.queued(), 2); - r.pool.run_inline(1); - let done = r.pool.try_recv().unwrap(); - assert_eq!(done.slot, slot); - assert_eq!(done.result.result.request_id, request_id); - assert_eq!(done.result.result.quick, Some(QuickSource::Embedded)); - // A finished result awaiting collection also must not start another extraction. - r.request_quick(slot, quick, 6); - assert!(r.thumb_current(&p, 384, 5)); - assert_eq!(r.pool.queued(), 1); - } - - #[test] - fn queued_standin_demotes_when_it_leaves_the_visible_grid() { - let (p, quick) = queued_quick_thumbnail(PhotoId(1)); - let (_, other) = queued_quick_thumbnail(PhotoId(2)); - let slot = Slot::ThumbQuick(p.id); - let mut r = Renderer { pool: JobPool::new(0), ..Default::default() }; - r.remember_thumb(&p, 384, quick.key, true); - r.request_quick(slot, quick.clone(), 11); - r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); - assert!(!r.thumb_current(&p, 384, 5)); - r.request_quick(slot, quick, 6); - assert!(r.thumb_current(&p, 384, 5)); - r.pool.run_inline(1); - assert_eq!(r.pool.try_recv().unwrap().slot, Slot::ThumbQuick(PhotoId(2))); - } - - #[test] - fn failures_retry_after_revision_and_quick_only_is_not_final() { - let p = photo(); - let slot = Slot::Thumb(p.id); - let mut r = Renderer::default(); - r.remember_thumb(&p, 384, 77, false); - r.pending.insert(Slot::ThumbQuick(p.id), (77, 11)); - assert!(!r.thumb_current(&p, 384, 10)); - r.failed.insert(slot, (77, "no decoder".into(), 0, p.id)); - assert!(r.thumb_current(&p, 384, 10)); - assert_eq!(r.failure_of(slot, p.id), Some("no decoder")); - assert_eq!(r.failure_of(slot, PhotoId(p.id.0 + 1)), None, "another photo's error"); - r.catalog_rev = 1; - assert!(!r.thumb_current(&p, 384, 10)); - } - - #[test] - fn a_fast_draft_is_drawn_in_the_frame_that_asked_for_it() { - let mut s = Session::new(); - s.media.file_loader = Some(Arc::new(|_, _| Ok((lightcraft_raster::Rgb32f::new(8, 8), Default::default())))); - s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); - let mut r = Renderer::with_worker_threads(1); - let ctx = egui::Context::default(); - let budget = std::time::Duration::from_secs(10); - // the last loupe render took longer than the budget (a CPU render): not waited for - r.last_main_ms = 1e6; - r.request(Slot::Main, s.render_job(PhotoId(1), 64, 64, false, true).unwrap(), 100); - r.wait_for_draft(&ctx, &mut s, budget); - assert!(r.is_pending(Slot::Main) && !r.textures.contains_key(&Slot::Main)); - // a fast one (GPU drafts): the texture is the new render when the wait returns - r.last_main_ms = 1.0; - r.request(Slot::Main, s.render_job(PhotoId(1), 32, 32, false, true).unwrap(), 100); - let want = r.wanted(Slot::Main); - r.wait_for_draft(&ctx, &mut s, budget); - assert!(!r.is_pending(Slot::Main)); - assert_eq!(r.textures.get(&Slot::Main).map(|t| t.key), want); - } - - #[derive(Default)] - struct Work { - jobs: Vec<(Slot, RenderJob)>, - finished: Vec<(Slot, RenderResult, f64)>, - } - struct Manual(std::rc::Rc>); - impl RenderOffload for Manual { - fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { - self.0.borrow_mut().jobs.push((slot, job)); - None - } - fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { - std::mem::take(&mut self.0.borrow_mut().finished) - } - } - fn fixture() -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { - fixture_with(Session::new(), (*photo()).clone()) - } - fn fixture_with(mut s: Session, photo: Photo) -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { - s.media.file_loader = Some(Arc::new(|_, _| { - let mut image = lightcraft_raster::Rgb32f::new(8, 8); - image.data.fill([0.3, 0.2, 0.1]); - Ok((image, Default::default())) - })); - // (`with_fs` sets the file system's thumbnail loader, which wins over `file_loader`: thumbnails use the stub too) - s.media.reduced_loader = None; - s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - let mut app = crate::LightcraftApp::new(s, crate::Services::default()); - app.renderer.keep_pixels = true; - let work = std::rc::Rc::new(std::cell::RefCell::new(Work::default())); - app.renderer.set_offload(Box::new(Manual(work.clone()))); - let ctx = egui::Context::default(); - app.renderer.poll(&ctx, &mut app.session); - (app, work, ctx) - } - fn take_job(work: &std::rc::Rc>) -> (Slot, RenderJob) { - work.borrow_mut().jobs.pop().unwrap() - } - fn finish(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, ctx: &egui::Context, slot: Slot, result: RenderResult) { - work.borrow_mut().finished.push((slot, result, 0.0)); - app.renderer.poll(ctx, &mut app.session); - } - fn request(app: &mut crate::LightcraftApp) { - crate::panels::grid::request_thumb(app, PhotoId(1), 256, 10); - } - - fn relink_destination() -> std::path::PathBuf { - let path = std::env::temp_dir().join(format!("lc-render-relinked-{}-{}.nef", std::process::id(), lightcraft_preview::next_tick())); - std::fs::write(&path, b"replacement source").unwrap(); - path - } - - #[test] - fn completed_failure_keeps_requested_source_after_relink() { - let replacement = relink_destination(); - for slot in [Slot::Thumb(PhotoId(1)), Slot::Main, Slot::Before, Slot::Compare(1), Slot::Region] { - let (mut app, work, ctx) = fixture(); - app.session.media.file_loader = Some(Arc::new(|_, _| Err("simulated decode failure".into()))); - let job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); - app.renderer.request(slot, job, 100); - let (submitted, job) = take_job(&work); - assert_eq!(submitted, slot); - let result = job.run(); - assert!(result.rendered.is_err(), "the old request has already failed"); - app.session.execute("photo.relink", &serde_json::json!({"id": 1, "path": replacement})).unwrap(); - assert!( - matches!(&app.session.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path } if path.ends_with(replacement.file_name().unwrap().to_str().unwrap())) - ); - finish(&mut app, &work, &ctx, slot, result); - assert_eq!(app.renderer.failure(slot), Some("simulated decode failure")); - assert!(!app.renderer.is_pending(slot)); - let message = app.renderer.failure_messages.last().expect("the collector emitted a diagnostic"); - assert!(message.contains("audit.jpg"), "{message}"); - assert!(!message.contains("lc-render-relinked-"), "the failed request did not read the replacement: {message}"); - assert!(message.ends_with(": simulated decode failure"), "{message}"); - } - std::fs::remove_file(replacement).unwrap(); - } - - #[test] - fn superseded_failure_does_not_take_the_new_requests_source() { - let replacement = relink_destination(); - let (mut app, work, ctx) = fixture(); - app.session.media.file_loader = Some(Arc::new(|_, _| Err("simulated decode failure".into()))); - let slot = Slot::Main; - let old_job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); - app.renderer.request(slot, old_job, 100); - let (_, old_job) = take_job(&work); - let old_result = old_job.run(); - app.session.execute("photo.relink", &serde_json::json!({"id": 1, "path": replacement})).unwrap(); - let new_job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); - app.renderer.request(slot, new_job, 100); - let (_, new_job) = take_job(&work); - finish(&mut app, &work, &ctx, slot, old_result); - assert!(app.renderer.failure_messages.is_empty(), "obsolete failures stay suppressed"); - assert!(app.renderer.is_pending(slot)); - finish(&mut app, &work, &ctx, slot, new_job.run()); - let message = app.renderer.failure_messages.last().expect("new failure is reported"); - assert!(message.contains("lc-render-relinked-"), "{message}"); - assert!(!message.contains("audit.jpg"), "{message}"); - assert!(app.renderer.requests.is_empty(), "completed request identity is released"); - std::fs::remove_file(replacement).unwrap(); - } - - #[test] - fn grid_queued_standin_changes_priority_without_restarting() { - let mut s = Session::new(); - s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); - let mut raw = (*photo()).clone(); - raw.kind = lightcraft_catalog::MediaKind::Raw; - raw.develop = Arc::new(raw.camera_defaults()); - let (mut app, work, ctx) = fixture_with(s, raw); - app.renderer.pool = JobPool::new(0); // deterministic: no quick job starts until run_inline - let id = PhotoId(1); - let slot = Slot::ThumbQuick(id); - - crate::panels::grid::request_thumb(&mut app, id, 384, 5); - let request_id = app.renderer.requests[&slot].id; - assert_eq!(app.renderer.pending[&slot].1, 6); - assert_eq!(app.renderer.pool.queued(), 1); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - assert_eq!(app.renderer.pending[&slot].1, 11, "the grid must promote its queued stand-in"); - assert_eq!(app.renderer.requests[&slot].id, request_id); - assert_eq!(app.renderer.pool.queued(), 1); - - crate::panels::grid::request_thumb(&mut app, id, 384, 5); - assert_eq!(app.renderer.pending[&slot].1, 6, "the grid must demote its queued stand-in"); - assert_eq!(app.renderer.requests[&slot].id, request_id); - assert_eq!(app.renderer.pool.queued(), 1); - let built = app.renderer.thumb_jobs_built; - app.renderer.pool.run_inline(1); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - assert_eq!(app.renderer.pending[&slot].1, 6, "completed work must not be restarted"); - assert_eq!(app.renderer.requests[&slot].id, request_id); - assert_eq!(app.renderer.pool.queued(), 0); - app.renderer.poll(&ctx, &mut app.session); - assert_eq!(app.renderer.textures[&slot].quick, Some(QuickSource::Embedded)); - - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 5); - assert_eq!(app.renderer.thumb_jobs_built, built); - assert_eq!(app.renderer.pool.queued(), 0); - assert!(!app.renderer.is_pending(Slot::Thumb(id))); - assert!(!app.renderer.textures.contains_key(&Slot::Thumb(id))); - assert!(work.borrow().jobs.is_empty(), "the final embedded thumbnail needs no real render"); - } - - #[test] - fn unchanged_ready_and_running_frames_build_no_jobs() { - let (mut app, work, ctx) = fixture(); - request(&mut app); - assert_eq!(app.renderer.thumb_jobs_built, 1); - for _ in 0..180 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1); - let (slot, job) = take_job(&work); - finish(&mut app, &work, &ctx, slot, job.run()); - for _ in 0..180 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1); - assert!(work.borrow().jobs.is_empty()); - } - - #[test] - fn a_slider_drag_leaves_the_edited_photos_thumbnail_until_it_ends() { - let (mut app, work, ctx) = fixture(); - request(&mut app); - let (slot, job) = take_job(&work); - finish(&mut app, &work, &ctx, slot, job.run()); - app.session.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); - app.session.execute("develop.beginInteraction", &serde_json::json!({"label": "Exposure"})).unwrap(); - for v in [0.1, 0.2, 0.3] { - app.session.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": v})).unwrap(); - app.renderer.poll(&ctx, &mut app.session); - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1, "the drag's frames render no thumbnail of the photo"); - assert!(work.borrow().jobs.is_empty()); - app.session.execute("develop.endInteraction", &serde_json::json!({})).unwrap(); - request(&mut app); - assert_eq!(app.renderer.thumb_jobs_built, 2, "the thumbnail follows once the drag ends"); - } - - #[test] - fn unchanged_queued_frames_build_no_jobs() { - let (mut app, _, _) = fixture(); - app.renderer.set_offload(Box::new(Blocked)); - request(&mut app); - for _ in 0..180 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1); - assert_eq!(app.renderer.queue.len(), 1); - } - - #[test] - fn resizing_the_disk_cache_keeps_textures_and_skips_rebuilds() { - let lib = std::env::temp_dir().join(format!("lc-ui-thumb-resize-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&lib); - let mut s = Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - let (mut app, work, ctx) = fixture_with(s, (*photo()).clone()); - request(&mut app); - let (slot, job) = take_job(&work); - finish(&mut app, &work, &ctx, slot, job.run()); - assert!(app.renderer.thumb(PhotoId(1)).is_some()); - let cache = app.session.media.rendered.clone(); - app.session.execute("library.preferences", &serde_json::json!({"cacheMb": 300})).unwrap(); - app.renderer.poll(&ctx, &mut app.session); - // thumbnails are keyed by content: a new budget leaves them (and the cache) valid - assert!(app.renderer.thumb(PhotoId(1)).is_some()); - assert!(Arc::ptr_eq(&cache, &app.session.media.rendered)); - for _ in 0..30 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1); - assert!(work.borrow().jobs.is_empty()); - // an explicit clear still drops them - app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); - app.renderer.poll(&ctx, &mut app.session); - assert!(app.renderer.thumb(PhotoId(1)).is_none()); - drop(app); - let _ = std::fs::remove_dir_all(&lib); - } - - /// A render that panics (a decoder bug) is a failure like any other: the slot stops waiting, - /// the error is kept (not retried every frame), and the pool's workers live on. Before, the - /// worker died without an answer and the slot stayed pending forever. - #[test] - fn a_panicking_render_is_a_failure_not_a_dead_worker() { - let (mut app, _, ctx) = fixture_with(Session::new(), (*photo()).clone()); - app.session.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); - app.renderer.offload = None; // the real job pool - let slot = Slot::Thumb(PhotoId(1)); - let ask = |app: &mut crate::LightcraftApp| { - if let Some(job) = app.session.thumb_job(PhotoId(1), 256) { - app.renderer.request(slot, job, 30); - } - app.renderer.poll(&ctx, &mut app.session); - }; - ask(&mut app); - let t0 = std::time::Instant::now(); - while app.renderer.is_pending(slot) { - assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the render never answered"); - std::thread::sleep(std::time::Duration::from_millis(5)); - app.renderer.poll(&ctx, &mut app.session); - } - assert_eq!(app.renderer.failure(slot), Some("the render failed unexpectedly: decoder bug")); - let workers = app.renderer.live_workers(); - assert!(workers > 0, "the worker that panicked is still there"); - let done = app.renderer.completed; - for _ in 0..30 { - ask(&mut app); - } - assert!(!app.renderer.is_pending(slot) && app.renderer.completed == done, "no retry loop"); - assert_eq!(app.renderer.live_workers(), workers); - } - - #[test] - fn raw_quick_path_frames_build_no_jobs() { - // an unedited raw: the grid shows its embedded preview (held here until released) and stops - // there — the camera JPEG at 4 416 × 2 944 already covers a 512 px thumbnail, so decoding the - // whole raw behind it only costs the file read (45 s a photo on a 1.2 MB/s share) - let gate = Arc::new(std::sync::Mutex::new(())); - let held = gate.lock().unwrap(); - let mut s = Session::new(); - let g = gate.clone(); - s.media.preview_loader = Some(Arc::new(move |_: &str, _| { - drop(g.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); - Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }) - })); - let mut raw = (*photo()).clone(); - raw.kind = lightcraft_catalog::MediaKind::Raw; - raw.develop = Arc::new(raw.camera_defaults()); - let (mut app, _, ctx) = fixture_with(s, raw); - app.renderer.set_offload(Box::new(Blocked)); - let quick = Slot::ThumbQuick(PhotoId(1)); - // the stand-in is running - for _ in 0..180 { - request(&mut app); - } - assert!(app.renderer.is_pending(quick)); - assert_eq!(app.renderer.thumb_jobs_built, 1); - drop(held); - let t0 = std::time::Instant::now(); - while !app.renderer.textures.contains_key(&quick) { - assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the embedded preview never arrived"); - std::thread::sleep(std::time::Duration::from_millis(5)); - app.renderer.poll(&ctx, &mut app.session); - } - // the embedded preview is the thumbnail: no real render is queued behind it, and the grid - // asking every frame neither rebuilds the job nor re-requests the preview - for _ in 0..180 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1, "an unedited raw's embedded preview is the thumbnail"); - assert!(app.renderer.queue.is_empty(), "nothing is decoded behind it"); - assert!(!app.renderer.is_pending(Slot::Thumb(PhotoId(1)))); - - // an edit takes the embedded preview out of the picture: the real thumbnail is rendered then - let mut edited = (*photo()).clone(); - edited.kind = lightcraft_catalog::MediaKind::Raw; - edited.develop = Arc::new({ - let mut d = edited.camera_defaults(); - d.light.exposure = 0.5; - d - }); - let (mut app, _, _ctx) = fixture_with(Session::new(), edited); - app.renderer.set_offload(Box::new(Blocked)); - for _ in 0..180 { - request(&mut app); - } - assert_eq!(app.renderer.thumb_jobs_built, 1); - assert!(app.renderer.is_pending(Slot::Thumb(PhotoId(1))) || !app.renderer.queue.is_empty(), "an edited raw renders its thumbnail"); - } - - #[test] - fn closing_import_review_cancels_queued_and_running_identities() { - let (mut app, work, ctx) = fixture(); - let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); - app.renderer.request(Slot::Import(0), job.clone(), 10); - let (slot, running) = take_job(&work); - app.renderer.set_offload(Box::new(Blocked)); - app.renderer.request(Slot::Import(1), job, 10); - app.renderer.forget_imports(); - assert!(!app.renderer.is_pending(Slot::Import(0))); - assert!(!app.renderer.is_pending(Slot::Import(1))); - assert!(app.renderer.queue.is_empty()); - work.borrow_mut().finished.push((slot, running.run(), 0.0)); - app.renderer.set_offload(Box::new(Manual(work.clone()))); - app.renderer.poll(&ctx, &mut app.session); - assert!(!app.renderer.textures.contains_key(&slot)); - assert_eq!(app.session.media.source_usage().0, 0); - } - - #[test] - fn loupe_shows_superseded_drafts_newer_than_its_pixels_during_a_drag() { - let (mut app, work, ctx) = fixture(); - let mut jobs = Vec::new(); - for exposure in [0.0, 1.0, 2.0] { - let mut edit = lightcraft_develop::DevelopSettings::default(); - edit.light.exposure = exposure; - app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); - let job = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().draft(); - app.renderer.request(Slot::Main, job, 100); - jobs.push(take_job(&work).1); - } - let keys: Vec = jobs.iter().map(|j| j.key).collect(); - let mut jobs = jobs.into_iter(); - let (first, second, last) = (jobs.next().unwrap(), jobs.next().unwrap(), jobs.next().unwrap()); - // the first draft lands while the newest is still rendering: the loupe moves on - finish(&mut app, &work, &ctx, Slot::Main, first.run()); - assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[0])); - assert!(app.renderer.is_pending(Slot::Main)); - finish(&mut app, &work, &ctx, Slot::Main, last.run()); - assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2])); - // a draft older than the pixels shown never replaces them - finish(&mut app, &work, &ctx, Slot::Main, second.run()); - assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2])); - assert!(!app.renderer.is_pending(Slot::Main)); - } - - #[test] - fn a_drag_gets_its_live_hdr_histogram_beside_the_drafts() { - let (mut app, work, ctx) = fixture(); - let mut jobs = Vec::new(); - for exposure in [1.0, 2.0] { - let mut edit = lightcraft_develop::DevelopSettings::default(); - edit.hdr.enabled = true; - edit.light.exposure = exposure; - app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); - let job = app.session.render_job(PhotoId(1), 32, 32, false, true).unwrap(); - app.renderer.request(Slot::HdrHistogram, job, 95); - jobs.push(take_job(&work).1); - } - let keys: Vec = jobs.iter().map(|j| j.key).collect(); - let mut jobs = jobs.into_iter(); - let (first, last) = (jobs.next().unwrap(), jobs.next().unwrap()); - // superseded while the drag goes on, it still lands: the histogram keeps moving - finish(&mut app, &work, &ctx, Slot::HdrHistogram, first.run()); - let live = app.renderer.live_hdr_histogram.clone().expect("a live HDR histogram"); - assert_eq!((live.0, live.1), (PhotoId(1), keys[0])); - assert!(live.2.hdr.is_some(), "HDR bins"); - finish(&mut app, &work, &ctx, Slot::HdrHistogram, last.run()); - assert_eq!(app.renderer.live_hdr_histogram.as_ref().map(|l| l.1), Some(keys[1])); - // only the histogram is kept: no texture - assert!(!app.renderer.textures.contains_key(&Slot::HdrHistogram)); - } - - #[test] - fn library_reset_rejects_old_job_even_when_photo_id_and_key_are_reused() { - let (mut app, work, ctx) = fixture(); - request(&mut app); - let (slot, old) = take_job(&work); - let old_result = old.run(); - app.renderer.forget_all(); - let photo = app.session.catalog.photo(PhotoId(1)).unwrap().as_ref().clone(); - app.session = lightcraft_engine::Session::new(); - app.session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - app.session.media.file_loader = Some(Arc::new(|_, _| { - let mut image = lightcraft_raster::Rgb32f::new(8, 8); - image.data.fill([0.1, 0.2, 0.9]); - Ok((image, Default::default())) - })); - app.renderer.poll(&ctx, &mut app.session); - request(&mut app); - let (_, new) = take_job(&work); - assert_eq!(old_result.key, new.key); - assert_ne!(old_result.request_id, new.request_id); - let expected = new.clone().run().rendered.unwrap().image.as_bytes(); - finish(&mut app, &work, &ctx, slot, new.run()); - finish(&mut app, &work, &ctx, slot, old_result); - assert_eq!( - app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), - expected - ); - } - - #[test] - fn late_results_and_errors_cannot_replace_newest_pixels_or_sources() { - for order in [0, 1, 2] { - let (mut app, work, ctx) = fixture(); - request(&mut app); - let (slot, old) = take_job(&work); - let mut edit = lightcraft_develop::DevelopSettings::default(); - edit.light.exposure = 1.0; - app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); - request(&mut app); - let (_, new) = take_job(&work); - let expected = new.clone().run().rendered.unwrap().image.as_bytes(); - if order != 0 { - finish(&mut app, &work, &ctx, slot, new.run()); - // An obsolete source and error must not poison the current successful request. - let mut stale = old.run(); - if order == 2 { - stale.rendered = Err("old failure".into()); - } - finish(&mut app, &work, &ctx, slot, stale); - } else { - finish(&mut app, &work, &ctx, slot, old.run()); - assert!(app.renderer.thumb(PhotoId(1)).is_none()); - assert_eq!(app.session.media.source_usage().0, 0); - finish(&mut app, &work, &ctx, slot, new.run()); - } - assert_eq!( - app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), - expected - ); - assert!(app.renderer.failure(slot).is_none()); - } - } - - #[test] - fn undo_to_existing_texture_supersedes_pending_edit() { - let (mut app, work, ctx) = fixture(); - request(&mut app); - let (slot, first) = take_job(&work); - let key = first.key; - finish(&mut app, &work, &ctx, slot, first.run()); - let original = app.renderer.thumb(PhotoId(1)).unwrap().pixels.clone().unwrap(); - let mut edit = lightcraft_develop::DevelopSettings::default(); - edit.light.exposure = 1.0; - let undo = - app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); - request(&mut app); - let (_, edited) = take_job(&work); - app.session.catalog.apply(undo).unwrap(); - request(&mut app); - let (_, restored) = take_job(&work); - assert_eq!(restored.key, key); - finish(&mut app, &work, &ctx, slot, restored.run()); - finish(&mut app, &work, &ctx, slot, edited.run()); - assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels, original.pixels); - } - - #[test] - fn explicit_clear_repairs_same_key_bad_pixels_and_rejects_old_same_key_job() { - let (mut app, work, ctx) = fixture(); - let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); - let expected = job.clone().run().rendered.unwrap().image; - let (cache, key) = job.cache.clone().unwrap(); - let mut bad = expected.clone(); - bad.data.fill([255, 0, 255, 255]); - cache.put(key, Arc::new(bad.clone())); - request(&mut app); - let (slot, corrupt) = take_job(&work); - finish(&mut app, &work, &ctx, slot, corrupt.run()); - assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels[0].to_array(), [255, 0, 255, 255]); - // An old job can have the SAME render key and stale decoded pixels. - let mut old = job.clone(); - old.source = lightcraft_engine::media::SourceRef::Loaded(Box::new(lightcraft_engine::media::DecodedSource::new( - Arc::new(lightcraft_raster::Rgb32f::new(8, 8)), - None, - ))); - app.renderer.textures.clear(); - app.renderer.request(slot, old, 10); - let (_, old) = take_job(&work); - app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); - app.renderer.poll(&ctx, &mut app.session); - assert!(app.renderer.thumb(PhotoId(1)).is_none()); - request(&mut app); - let (_, repaired) = take_job(&work); - assert_eq!(repaired.key, job.key); - finish(&mut app, &work, &ctx, slot, repaired.run()); - finish(&mut app, &work, &ctx, slot, old.run()); - assert_eq!(cache.get(key).unwrap().as_bytes(), expected.as_bytes()); - assert_eq!( - app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), - expected.as_bytes() - ); - } - - #[test] - fn undo_to_embedded_thumb_rejects_late_full_render() { - let mut s = Session::new(); - s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); - let mut raw = (*photo()).clone(); - raw.kind = lightcraft_catalog::MediaKind::Raw; - raw.develop = Arc::new(raw.camera_defaults()); - let (mut app, work, ctx) = fixture_with(s, raw); - app.renderer.pool = JobPool::new(0); - let id = PhotoId(1); - let quick_slot = Slot::ThumbQuick(id); - let full_slot = Slot::Thumb(id); - let settings_a = app.session.catalog.photo(id).unwrap().develop.clone(); - - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - assert_eq!(app.renderer.pool.run_inline(1), 1); - app.renderer.poll(&ctx, &mut app.session); - let (key_a, pixels_a) = { - let shown = app.renderer.thumb(id).unwrap(); - assert_eq!(shown.quick, Some(QuickSource::Embedded)); - (shown.key, shown.pixels.as_ref().unwrap().pixels.clone()) - }; - assert!(app.renderer.textures.contains_key(&quick_slot)); - assert!(!app.renderer.textures.contains_key(&full_slot)); - assert!(work.borrow().jobs.is_empty()); - - let mut settings_b = (*settings_a).clone(); - settings_b.light.exposure += 0.5; - app.session.commit("Exposure", Op::SetDevelop { id, settings: Arc::new(settings_b), label: "Exposure".into(), edited: None }).unwrap(); - assert!(!app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap())); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - // Manual owns dispatched jobs: hold B until the real Undo command restores A. - let (slot, held_b) = take_job(&work); - assert_eq!(slot, full_slot); - let key_b = held_b.key; - assert_ne!(key_b, key_a); - assert!(app.renderer.is_pending(full_slot)); - - app.run("edit.undo", serde_json::json!({})).unwrap(); - assert_eq!(app.session.catalog.photo(id).unwrap().develop.as_ref(), settings_a.as_ref()); - assert!(app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap())); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - { - let restored = app.renderer.thumb(id).unwrap(); - assert_eq!(restored.key, key_a); - assert_eq!(restored.pixels.as_ref().unwrap().pixels, pixels_a); - assert_eq!(restored.quick, Some(QuickSource::Embedded)); - } - assert!(work.borrow().jobs.is_empty(), "restoring embedded-final A needs no full render"); - assert_eq!(app.renderer.pool.queued(), 0); - - let result_b = held_b.run(); - assert_eq!(result_b.key, key_b); - let rendered_b = result_b.rendered.as_ref().expect("held edited thumbnail must render before the stale-result oracle"); - assert_ne!(color_image(&rendered_b.image).pixels, pixels_a, "the held result must be distinguishable from A"); - finish(&mut app, &work, &ctx, slot, result_b); - - let shown = app.renderer.thumb(id).unwrap(); - assert_eq!(shown.key, key_a, "late edited Thumb(B) must not replace restored embedded ThumbQuick(A)"); - assert_eq!(shown.pixels.as_ref().unwrap().pixels, pixels_a); - assert_eq!(shown.quick, Some(QuickSource::Embedded)); - let quick = app.renderer.textures.get(&quick_slot).expect("the correct embedded thumbnail must remain installed"); - assert_eq!(quick.key, key_a); - assert_eq!(quick.quick, Some(QuickSource::Embedded)); - assert_eq!(quick.pixels.as_ref().unwrap().pixels, pixels_a); - assert!(!app.renderer.is_pending(full_slot)); - } -} +mod thumbnail_tests; #[cfg(test)] mod stage_budget_tests { diff --git a/crates/ui-egui/src/render/thumbnail_tests.rs b/crates/ui-egui/src/render/thumbnail_tests.rs new file mode 100644 index 000000000..ddad90970 --- /dev/null +++ b/crates/ui-egui/src/render/thumbnail_tests.rs @@ -0,0 +1,770 @@ +use super::*; +use lightcraft_catalog::{Catalog, Op, Photo, Source}; + +#[test] +fn render_failures_identify_the_file_for_thumbnail_and_view_slots() { + let source = Source::File { path: "/photos/trip/IMG_0042.nef".into() }; + for slot in [Slot::Thumb(PhotoId(42)), Slot::Main, Slot::Before, Slot::Compare(1), Slot::Region] { + let message = render_failure_message(slot, PhotoId(42), Some(&source), "unsupported compression"); + assert!(message.contains("/photos/trip/IMG_0042.nef"), "{message}"); + assert!(message.contains("PhotoId(42)"), "{message}"); + assert!(message.ends_with(": unsupported compression"), "{message}"); + } +} + +#[test] +fn render_failure_paths_are_escaped_and_missing_sources_still_identified() { + let source = Source::File { path: "line\nbreak.nef".into() }; + let message = render_failure_message(Slot::Main, PhotoId(42), Some(&source), "decode failed"); + assert!(!message.contains('\n')); + assert!(message.contains("line\\nbreak.nef")); + assert!(render_failure_message(Slot::Main, PhotoId(42), None, "decode failed").contains("PhotoId(42), source unavailable")); + let demo = Source::Demo { scene: 7 }; + assert!(render_failure_message(Slot::Main, PhotoId(42), Some(&demo), "decode failed").contains("demo scene 7")); +} + +struct Blocked; +impl RenderOffload for Blocked { + fn try_start(&mut self, _: Slot, job: RenderJob) -> Option { + Some(job) + } + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { + vec![] + } +} +fn photo() -> Arc { + Arc::new(Photo::new(PhotoId(1), Source::File { path: "audit.jpg".into() }, "audit.jpg", "JPEG", 6000, 4000, "2026-01-01")) +} +#[test] +fn snapshot_tracks_cow_edits_undo_reload_and_replacement() { + let mut catalog = Catalog::default(); + catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); + let old = catalog.photo(PhotoId(1)).unwrap().clone(); + let mut r = Renderer::default(); + r.remember_thumb(&old, 384, 77, false); + r.pending.insert(Slot::Thumb(PhotoId(1)), (77, 10)); + assert!(r.thumb_current(&old, 384, 10)); + assert!(!r.thumb_current(&old, 512, 10)); + let mut settings = lightcraft_develop::DevelopSettings::default(); + settings.light.exposure = 1.0; + let undo = catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(settings), label: "edit".into(), edited: None }).unwrap(); + assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); + catalog.apply(undo).unwrap(); + assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); + catalog + .apply(Op::SetContent { + id: PhotoId(1), + width: 6000, + height: 4000, + file_size: 99, + content_hash: Some("changed".into()), + preview_only: None, + preview_checked: String::new(), + }) + .unwrap(); + assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); + assert!(!r.thumb_current(&photo(), 384, 10)); + r.forget_all(); + assert!(r.thumb_inputs.is_empty()); +} +#[test] +fn queue_promotions_running_work_and_cancellation() { + let p = photo(); + let slot = Slot::Thumb(p.id); + let mut session = Session::new(); + session.catalog.apply(Op::AddPhoto { photo: Box::new((*p).clone()) }).unwrap(); + let job = session.thumb_job(p.id, 384).unwrap(); + let key = job.key; + let mut r = Renderer::default(); + r.set_offload(Box::new(Blocked)); + r.remember_thumb(&p, 384, key, false); + r.request(slot, job, 5); + assert!(r.thumb_current(&p, 384, 5)); + assert!(!r.thumb_current(&p, 384, 10)); + r.request(slot, session.thumb_job(p.id, 384).unwrap(), 10); + assert_eq!(r.pending.get(&slot), Some(&(key, 10))); + r.queue.clear(); // running work: cannot reprioritize, just as Renderer::request + assert!(r.thumb_current(&p, 384, 5)); + r.pending.clear(); + assert!(!r.thumb_current(&p, 384, 10)); + r.evict_thumbs(&Default::default(), 600); + assert!(r.thumb_inputs.is_empty()); +} +fn queued_quick_thumbnail(id: PhotoId) -> (Arc, QuickJob) { + let mut session = Session::new(); + session.media.preview_loader = + Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.id = id; + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + session.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + let p = session.catalog.photo(id).unwrap().clone(); + let job = session.thumb_job(id, 384).unwrap(); + (p, session.quick_thumb_job(&job).unwrap()) +} + +#[test] +fn queued_standin_promotes_from_prefetch_to_visible_without_restarting() { + let (p, quick) = queued_quick_thumbnail(PhotoId(1)); + let (_, other) = queued_quick_thumbnail(PhotoId(2)); + let slot = Slot::ThumbQuick(p.id); + // Deterministic queue: no native worker starts. + let mut r = Renderer { pool: JobPool::new(0), ..Default::default() }; + r.remember_thumb(&p, 384, quick.key, true); + r.request_quick(slot, quick.clone(), 6); // grid prefetch: 5 + 1 + r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); // filmstrip: 8 + 1 + let request_id = r.requests[&slot].id; + assert!(r.thumb_current(&p, 384, 5)); + assert!(!r.thumb_current(&p, 384, 10)); + r.request_quick(slot, quick.clone(), 11); // visible grid: 10 + 1 + assert!(r.thumb_current(&p, 384, 10)); + r.request_quick(slot, quick.clone(), 11); + assert_eq!(r.requests[&slot].id, request_id); + assert_eq!(r.pool.queued(), 2); + r.pool.run_inline(1); + let done = r.pool.try_recv().unwrap(); + assert_eq!(done.slot, slot); + assert_eq!(done.result.result.request_id, request_id); + assert_eq!(done.result.result.quick, Some(QuickSource::Embedded)); + // A finished result awaiting collection also must not start another extraction. + r.request_quick(slot, quick, 6); + assert!(r.thumb_current(&p, 384, 5)); + assert_eq!(r.pool.queued(), 1); +} + +#[test] +fn queued_standin_demotes_when_it_leaves_the_visible_grid() { + let (p, quick) = queued_quick_thumbnail(PhotoId(1)); + let (_, other) = queued_quick_thumbnail(PhotoId(2)); + let slot = Slot::ThumbQuick(p.id); + let mut r = Renderer { pool: JobPool::new(0), ..Default::default() }; + r.remember_thumb(&p, 384, quick.key, true); + r.request_quick(slot, quick.clone(), 11); + r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); + assert!(!r.thumb_current(&p, 384, 5)); + r.request_quick(slot, quick, 6); + assert!(r.thumb_current(&p, 384, 5)); + r.pool.run_inline(1); + assert_eq!(r.pool.try_recv().unwrap().slot, Slot::ThumbQuick(PhotoId(2))); +} + +#[test] +fn failures_retry_after_revision_and_quick_only_is_not_final() { + let p = photo(); + let slot = Slot::Thumb(p.id); + let mut r = Renderer::default(); + r.remember_thumb(&p, 384, 77, false); + r.pending.insert(Slot::ThumbQuick(p.id), (77, 11)); + assert!(!r.thumb_current(&p, 384, 10)); + r.failed.insert(slot, (77, "no decoder".into(), 0, p.id)); + assert!(r.thumb_current(&p, 384, 10)); + assert_eq!(r.failure_of(slot, p.id), Some("no decoder")); + assert_eq!(r.failure_of(slot, PhotoId(p.id.0 + 1)), None, "another photo's error"); + r.catalog_rev = 1; + assert!(!r.thumb_current(&p, 384, 10)); +} + +#[test] +fn a_fast_draft_is_drawn_in_the_frame_that_asked_for_it() { + let mut s = Session::new(); + s.media.file_loader = Some(Arc::new(|_, _| Ok((lightcraft_raster::Rgb32f::new(8, 8), Default::default())))); + s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); + let mut r = Renderer::with_worker_threads(1); + let ctx = egui::Context::default(); + let budget = std::time::Duration::from_secs(10); + // the last loupe render took longer than the budget (a CPU render): not waited for + r.last_main_ms = 1e6; + r.request(Slot::Main, s.render_job(PhotoId(1), 64, 64, false, true).unwrap(), 100); + r.wait_for_draft(&ctx, &mut s, budget); + assert!(r.is_pending(Slot::Main) && !r.textures.contains_key(&Slot::Main)); + // a fast one (GPU drafts): the texture is the new render when the wait returns + r.last_main_ms = 1.0; + r.request(Slot::Main, s.render_job(PhotoId(1), 32, 32, false, true).unwrap(), 100); + let want = r.wanted(Slot::Main); + r.wait_for_draft(&ctx, &mut s, budget); + assert!(!r.is_pending(Slot::Main)); + assert_eq!(r.textures.get(&Slot::Main).map(|t| t.key), want); +} + +#[derive(Default)] +struct Work { + jobs: Vec<(Slot, RenderJob)>, + finished: Vec<(Slot, RenderResult, f64)>, +} +struct Manual(std::rc::Rc>); +impl RenderOffload for Manual { + fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { + self.0.borrow_mut().jobs.push((slot, job)); + None + } + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { + std::mem::take(&mut self.0.borrow_mut().finished) + } +} +fn fixture() -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { + fixture_with(Session::new(), (*photo()).clone()) +} +fn fixture_with(mut s: Session, photo: Photo) -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { + s.media.file_loader = Some(Arc::new(|_, _| { + let mut image = lightcraft_raster::Rgb32f::new(8, 8); + image.data.fill([0.3, 0.2, 0.1]); + Ok((image, Default::default())) + })); + // (`with_fs` sets the file system's thumbnail loader, which wins over `file_loader`: thumbnails use the stub too) + s.media.reduced_loader = None; + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + let mut app = crate::LightcraftApp::new(s, crate::Services::default()); + app.renderer.keep_pixels = true; + let work = std::rc::Rc::new(std::cell::RefCell::new(Work::default())); + app.renderer.set_offload(Box::new(Manual(work.clone()))); + let ctx = egui::Context::default(); + app.renderer.poll(&ctx, &mut app.session); + (app, work, ctx) +} +fn take_job(work: &std::rc::Rc>) -> (Slot, RenderJob) { + work.borrow_mut().jobs.pop().unwrap() +} +fn finish(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, ctx: &egui::Context, slot: Slot, result: RenderResult) { + work.borrow_mut().finished.push((slot, result, 0.0)); + app.renderer.poll(ctx, &mut app.session); +} +fn request(app: &mut crate::LightcraftApp) { + crate::panels::grid::request_thumb(app, PhotoId(1), 256, 10); +} + +fn relink_destination() -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("lc-render-relinked-{}-{}.nef", std::process::id(), lightcraft_preview::next_tick())); + std::fs::write(&path, b"replacement source").unwrap(); + path +} + +#[test] +fn completed_failure_keeps_requested_source_after_relink() { + let replacement = relink_destination(); + for slot in [Slot::Thumb(PhotoId(1)), Slot::Main, Slot::Before, Slot::Compare(1), Slot::Region] { + let (mut app, work, ctx) = fixture(); + app.session.media.file_loader = Some(Arc::new(|_, _| Err("simulated decode failure".into()))); + let job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); + app.renderer.request(slot, job, 100); + let (submitted, job) = take_job(&work); + assert_eq!(submitted, slot); + let result = job.run(); + assert!(result.rendered.is_err(), "the old request has already failed"); + app.session.execute("photo.relink", &serde_json::json!({"id": 1, "path": replacement})).unwrap(); + assert!( + matches!(&app.session.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path } if path.ends_with(replacement.file_name().unwrap().to_str().unwrap())) + ); + finish(&mut app, &work, &ctx, slot, result); + assert_eq!(app.renderer.failure(slot), Some("simulated decode failure")); + assert!(!app.renderer.is_pending(slot)); + let message = app.renderer.failure_messages.last().expect("the collector emitted a diagnostic"); + assert!(message.contains("audit.jpg"), "{message}"); + assert!(!message.contains("lc-render-relinked-"), "the failed request did not read the replacement: {message}"); + assert!(message.ends_with(": simulated decode failure"), "{message}"); + } + std::fs::remove_file(replacement).unwrap(); +} + +#[test] +fn superseded_failure_does_not_take_the_new_requests_source() { + let replacement = relink_destination(); + let (mut app, work, ctx) = fixture(); + app.session.media.file_loader = Some(Arc::new(|_, _| Err("simulated decode failure".into()))); + let slot = Slot::Main; + let old_job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); + app.renderer.request(slot, old_job, 100); + let (_, old_job) = take_job(&work); + let old_result = old_job.run(); + app.session.execute("photo.relink", &serde_json::json!({"id": 1, "path": replacement})).unwrap(); + let new_job = app.session.render_job(PhotoId(1), 64, 64, false, true).unwrap(); + app.renderer.request(slot, new_job, 100); + let (_, new_job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, old_result); + assert!(app.renderer.failure_messages.is_empty(), "obsolete failures stay suppressed"); + assert!(app.renderer.is_pending(slot)); + finish(&mut app, &work, &ctx, slot, new_job.run()); + let message = app.renderer.failure_messages.last().expect("new failure is reported"); + assert!(message.contains("lc-render-relinked-"), "{message}"); + assert!(!message.contains("audit.jpg"), "{message}"); + assert!(app.renderer.requests.is_empty(), "completed request identity is released"); + std::fs::remove_file(replacement).unwrap(); +} + +#[test] +fn grid_queued_standin_changes_priority_without_restarting() { + let mut s = Session::new(); + s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + let (mut app, work, ctx) = fixture_with(s, raw); + app.renderer.pool = JobPool::new(0); // deterministic: no quick job starts until run_inline + let id = PhotoId(1); + let slot = Slot::ThumbQuick(id); + + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + let request_id = app.renderer.requests[&slot].id; + assert_eq!(app.renderer.pending[&slot].1, 6); + assert_eq!(app.renderer.pool.queued(), 1); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + assert_eq!(app.renderer.pending[&slot].1, 11, "the grid must promote its queued stand-in"); + assert_eq!(app.renderer.requests[&slot].id, request_id); + assert_eq!(app.renderer.pool.queued(), 1); + + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + assert_eq!(app.renderer.pending[&slot].1, 6, "the grid must demote its queued stand-in"); + assert_eq!(app.renderer.requests[&slot].id, request_id); + assert_eq!(app.renderer.pool.queued(), 1); + let built = app.renderer.thumb_jobs_built; + app.renderer.pool.run_inline(1); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + assert_eq!(app.renderer.pending[&slot].1, 6, "completed work must not be restarted"); + assert_eq!(app.renderer.requests[&slot].id, request_id); + assert_eq!(app.renderer.pool.queued(), 0); + app.renderer.poll(&ctx, &mut app.session); + assert_eq!(app.renderer.textures[&slot].quick, Some(QuickSource::Embedded)); + + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + crate::panels::grid::request_thumb(&mut app, id, 384, 5); + assert_eq!(app.renderer.thumb_jobs_built, built); + assert_eq!(app.renderer.pool.queued(), 0); + assert!(!app.renderer.is_pending(Slot::Thumb(id))); + assert!(!app.renderer.textures.contains_key(&Slot::Thumb(id))); + assert!(work.borrow().jobs.is_empty(), "the final embedded thumbnail needs no real render"); +} + +#[test] +fn unchanged_ready_and_running_frames_build_no_jobs() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + assert_eq!(app.renderer.thumb_jobs_built, 1); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + assert!(work.borrow().jobs.is_empty()); +} + +#[test] +fn a_slider_drag_leaves_the_edited_photos_thumbnail_until_it_ends() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + app.session.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); + app.session.execute("develop.beginInteraction", &serde_json::json!({"label": "Exposure"})).unwrap(); + for v in [0.1, 0.2, 0.3] { + app.session.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": v})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1, "the drag's frames render no thumbnail of the photo"); + assert!(work.borrow().jobs.is_empty()); + app.session.execute("develop.endInteraction", &serde_json::json!({})).unwrap(); + request(&mut app); + assert_eq!(app.renderer.thumb_jobs_built, 2, "the thumbnail follows once the drag ends"); +} + +#[test] +fn unchanged_queued_frames_build_no_jobs() { + let (mut app, _, _) = fixture(); + app.renderer.set_offload(Box::new(Blocked)); + request(&mut app); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + assert_eq!(app.renderer.queue.len(), 1); +} + +#[test] +fn resizing_the_disk_cache_keeps_textures_and_skips_rebuilds() { + let lib = std::env::temp_dir().join(format!("lc-ui-thumb-resize-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&lib); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let (mut app, work, ctx) = fixture_with(s, (*photo()).clone()); + request(&mut app); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + assert!(app.renderer.thumb(PhotoId(1)).is_some()); + let cache = app.session.media.rendered.clone(); + app.session.execute("library.preferences", &serde_json::json!({"cacheMb": 300})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + // thumbnails are keyed by content: a new budget leaves them (and the cache) valid + assert!(app.renderer.thumb(PhotoId(1)).is_some()); + assert!(Arc::ptr_eq(&cache, &app.session.media.rendered)); + for _ in 0..30 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + assert!(work.borrow().jobs.is_empty()); + // an explicit clear still drops them + app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + assert!(app.renderer.thumb(PhotoId(1)).is_none()); + drop(app); + let _ = std::fs::remove_dir_all(&lib); +} + +/// A render that panics (a decoder bug) is a failure like any other: the slot stops waiting, +/// the error is kept (not retried every frame), and the pool's workers live on. Before, the +/// worker died without an answer and the slot stayed pending forever. +#[test] +fn a_panicking_render_is_a_failure_not_a_dead_worker() { + let (mut app, _, ctx) = fixture_with(Session::new(), (*photo()).clone()); + app.session.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); + app.renderer.offload = None; // the real job pool + let slot = Slot::Thumb(PhotoId(1)); + let ask = |app: &mut crate::LightcraftApp| { + if let Some(job) = app.session.thumb_job(PhotoId(1), 256) { + app.renderer.request(slot, job, 30); + } + app.renderer.poll(&ctx, &mut app.session); + }; + ask(&mut app); + let t0 = std::time::Instant::now(); + while app.renderer.is_pending(slot) { + assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the render never answered"); + std::thread::sleep(std::time::Duration::from_millis(5)); + app.renderer.poll(&ctx, &mut app.session); + } + assert_eq!(app.renderer.failure(slot), Some("the render failed unexpectedly: decoder bug")); + let workers = app.renderer.live_workers(); + assert!(workers > 0, "the worker that panicked is still there"); + let done = app.renderer.completed; + for _ in 0..30 { + ask(&mut app); + } + assert!(!app.renderer.is_pending(slot) && app.renderer.completed == done, "no retry loop"); + assert_eq!(app.renderer.live_workers(), workers); +} + +#[test] +fn raw_quick_path_frames_build_no_jobs() { + // an unedited raw: the grid shows its embedded preview (held here until released) and stops + // there — the camera JPEG at 4 416 × 2 944 already covers a 512 px thumbnail, so decoding the + // whole raw behind it only costs the file read (45 s a photo on a 1.2 MB/s share) + let gate = Arc::new(std::sync::Mutex::new(())); + let held = gate.lock().unwrap(); + let mut s = Session::new(); + let g = gate.clone(); + s.media.preview_loader = Some(Arc::new(move |_: &str, _| { + drop(g.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); + Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }) + })); + let mut raw = (*photo()).clone(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + let (mut app, _, ctx) = fixture_with(s, raw); + app.renderer.set_offload(Box::new(Blocked)); + let quick = Slot::ThumbQuick(PhotoId(1)); + // the stand-in is running + for _ in 0..180 { + request(&mut app); + } + assert!(app.renderer.is_pending(quick)); + assert_eq!(app.renderer.thumb_jobs_built, 1); + drop(held); + let t0 = std::time::Instant::now(); + while !app.renderer.textures.contains_key(&quick) { + assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the embedded preview never arrived"); + std::thread::sleep(std::time::Duration::from_millis(5)); + app.renderer.poll(&ctx, &mut app.session); + } + // the embedded preview is the thumbnail: no real render is queued behind it, and the grid + // asking every frame neither rebuilds the job nor re-requests the preview + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1, "an unedited raw's embedded preview is the thumbnail"); + assert!(app.renderer.queue.is_empty(), "nothing is decoded behind it"); + assert!(!app.renderer.is_pending(Slot::Thumb(PhotoId(1)))); + + // an edit takes the embedded preview out of the picture: the real thumbnail is rendered then + let mut edited = (*photo()).clone(); + edited.kind = lightcraft_catalog::MediaKind::Raw; + edited.develop = Arc::new({ + let mut d = edited.camera_defaults(); + d.light.exposure = 0.5; + d + }); + let (mut app, _, _ctx) = fixture_with(Session::new(), edited); + app.renderer.set_offload(Box::new(Blocked)); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + assert!(app.renderer.is_pending(Slot::Thumb(PhotoId(1))) || !app.renderer.queue.is_empty(), "an edited raw renders its thumbnail"); +} + +#[test] +fn closing_import_review_cancels_queued_and_running_identities() { + let (mut app, work, ctx) = fixture(); + let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); + app.renderer.request(Slot::Import(0), job.clone(), 10); + let (slot, running) = take_job(&work); + app.renderer.set_offload(Box::new(Blocked)); + app.renderer.request(Slot::Import(1), job, 10); + app.renderer.forget_imports(); + assert!(!app.renderer.is_pending(Slot::Import(0))); + assert!(!app.renderer.is_pending(Slot::Import(1))); + assert!(app.renderer.queue.is_empty()); + work.borrow_mut().finished.push((slot, running.run(), 0.0)); + app.renderer.set_offload(Box::new(Manual(work.clone()))); + app.renderer.poll(&ctx, &mut app.session); + assert!(!app.renderer.textures.contains_key(&slot)); + assert_eq!(app.session.media.source_usage().0, 0); +} + +#[test] +fn loupe_shows_superseded_drafts_newer_than_its_pixels_during_a_drag() { + let (mut app, work, ctx) = fixture(); + let mut jobs = Vec::new(); + for exposure in [0.0, 1.0, 2.0] { + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.light.exposure = exposure; + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); + let job = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().draft(); + app.renderer.request(Slot::Main, job, 100); + jobs.push(take_job(&work).1); + } + let keys: Vec = jobs.iter().map(|j| j.key).collect(); + let mut jobs = jobs.into_iter(); + let (first, second, last) = (jobs.next().unwrap(), jobs.next().unwrap(), jobs.next().unwrap()); + // the first draft lands while the newest is still rendering: the loupe moves on + finish(&mut app, &work, &ctx, Slot::Main, first.run()); + assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[0])); + assert!(app.renderer.is_pending(Slot::Main)); + finish(&mut app, &work, &ctx, Slot::Main, last.run()); + assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2])); + // a draft older than the pixels shown never replaces them + finish(&mut app, &work, &ctx, Slot::Main, second.run()); + assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2])); + assert!(!app.renderer.is_pending(Slot::Main)); +} + +#[test] +fn a_drag_gets_its_live_hdr_histogram_beside_the_drafts() { + let (mut app, work, ctx) = fixture(); + let mut jobs = Vec::new(); + for exposure in [1.0, 2.0] { + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.hdr.enabled = true; + edit.light.exposure = exposure; + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); + let job = app.session.render_job(PhotoId(1), 32, 32, false, true).unwrap(); + app.renderer.request(Slot::HdrHistogram, job, 95); + jobs.push(take_job(&work).1); + } + let keys: Vec = jobs.iter().map(|j| j.key).collect(); + let mut jobs = jobs.into_iter(); + let (first, last) = (jobs.next().unwrap(), jobs.next().unwrap()); + // superseded while the drag goes on, it still lands: the histogram keeps moving + finish(&mut app, &work, &ctx, Slot::HdrHistogram, first.run()); + let live = app.renderer.live_hdr_histogram.clone().expect("a live HDR histogram"); + assert_eq!((live.0, live.1), (PhotoId(1), keys[0])); + assert!(live.2.hdr.is_some(), "HDR bins"); + finish(&mut app, &work, &ctx, Slot::HdrHistogram, last.run()); + assert_eq!(app.renderer.live_hdr_histogram.as_ref().map(|l| l.1), Some(keys[1])); + // only the histogram is kept: no texture + assert!(!app.renderer.textures.contains_key(&Slot::HdrHistogram)); +} + +#[test] +fn library_reset_rejects_old_job_even_when_photo_id_and_key_are_reused() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, old) = take_job(&work); + let old_result = old.run(); + app.renderer.forget_all(); + let photo = app.session.catalog.photo(PhotoId(1)).unwrap().as_ref().clone(); + app.session = lightcraft_engine::Session::new(); + app.session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + app.session.media.file_loader = Some(Arc::new(|_, _| { + let mut image = lightcraft_raster::Rgb32f::new(8, 8); + image.data.fill([0.1, 0.2, 0.9]); + Ok((image, Default::default())) + })); + app.renderer.poll(&ctx, &mut app.session); + request(&mut app); + let (_, new) = take_job(&work); + assert_eq!(old_result.key, new.key); + assert_ne!(old_result.request_id, new.request_id); + let expected = new.clone().run().rendered.unwrap().image.as_bytes(); + finish(&mut app, &work, &ctx, slot, new.run()); + finish(&mut app, &work, &ctx, slot, old_result); + assert_eq!( + app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), + expected + ); +} + +#[test] +fn late_results_and_errors_cannot_replace_newest_pixels_or_sources() { + for order in [0, 1, 2] { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, old) = take_job(&work); + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.light.exposure = 1.0; + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + request(&mut app); + let (_, new) = take_job(&work); + let expected = new.clone().run().rendered.unwrap().image.as_bytes(); + if order != 0 { + finish(&mut app, &work, &ctx, slot, new.run()); + // An obsolete source and error must not poison the current successful request. + let mut stale = old.run(); + if order == 2 { + stale.rendered = Err("old failure".into()); + } + finish(&mut app, &work, &ctx, slot, stale); + } else { + finish(&mut app, &work, &ctx, slot, old.run()); + assert!(app.renderer.thumb(PhotoId(1)).is_none()); + assert_eq!(app.session.media.source_usage().0, 0); + finish(&mut app, &work, &ctx, slot, new.run()); + } + assert_eq!( + app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), + expected + ); + assert!(app.renderer.failure(slot).is_none()); + } +} + +#[test] +fn undo_to_existing_texture_supersedes_pending_edit() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, first) = take_job(&work); + let key = first.key; + finish(&mut app, &work, &ctx, slot, first.run()); + let original = app.renderer.thumb(PhotoId(1)).unwrap().pixels.clone().unwrap(); + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.light.exposure = 1.0; + let undo = + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + request(&mut app); + let (_, edited) = take_job(&work); + app.session.catalog.apply(undo).unwrap(); + request(&mut app); + let (_, restored) = take_job(&work); + assert_eq!(restored.key, key); + finish(&mut app, &work, &ctx, slot, restored.run()); + finish(&mut app, &work, &ctx, slot, edited.run()); + assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels, original.pixels); +} + +#[test] +fn explicit_clear_repairs_same_key_bad_pixels_and_rejects_old_same_key_job() { + let (mut app, work, ctx) = fixture(); + let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); + let expected = job.clone().run().rendered.unwrap().image; + let (cache, key) = job.cache.clone().unwrap(); + let mut bad = expected.clone(); + bad.data.fill([255, 0, 255, 255]); + cache.put(key, Arc::new(bad.clone())); + request(&mut app); + let (slot, corrupt) = take_job(&work); + finish(&mut app, &work, &ctx, slot, corrupt.run()); + assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels[0].to_array(), [255, 0, 255, 255]); + // An old job can have the SAME render key and stale decoded pixels. + let mut old = job.clone(); + old.source = lightcraft_engine::media::SourceRef::Loaded(Box::new(lightcraft_engine::media::DecodedSource::new( + Arc::new(lightcraft_raster::Rgb32f::new(8, 8)), + None, + ))); + app.renderer.textures.clear(); + app.renderer.request(slot, old, 10); + let (_, old) = take_job(&work); + app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + assert!(app.renderer.thumb(PhotoId(1)).is_none()); + request(&mut app); + let (_, repaired) = take_job(&work); + assert_eq!(repaired.key, job.key); + finish(&mut app, &work, &ctx, slot, repaired.run()); + finish(&mut app, &work, &ctx, slot, old.run()); + assert_eq!(cache.get(key).unwrap().as_bytes(), expected.as_bytes()); + assert_eq!( + app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), + expected.as_bytes() + ); +} + +#[test] +fn undo_to_embedded_thumb_rejects_late_full_render() { + let mut s = Session::new(); + s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + let mut raw = (*photo()).clone(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + raw.develop = Arc::new(raw.camera_defaults()); + let (mut app, work, ctx) = fixture_with(s, raw); + app.renderer.pool = JobPool::new(0); + let id = PhotoId(1); + let quick_slot = Slot::ThumbQuick(id); + let full_slot = Slot::Thumb(id); + let settings_a = app.session.catalog.photo(id).unwrap().develop.clone(); + + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + assert_eq!(app.renderer.pool.run_inline(1), 1); + app.renderer.poll(&ctx, &mut app.session); + let (key_a, pixels_a) = { + let shown = app.renderer.thumb(id).unwrap(); + assert_eq!(shown.quick, Some(QuickSource::Embedded)); + (shown.key, shown.pixels.as_ref().unwrap().pixels.clone()) + }; + assert!(app.renderer.textures.contains_key(&quick_slot)); + assert!(!app.renderer.textures.contains_key(&full_slot)); + assert!(work.borrow().jobs.is_empty()); + + let mut settings_b = (*settings_a).clone(); + settings_b.light.exposure += 0.5; + app.session.commit("Exposure", Op::SetDevelop { id, settings: Arc::new(settings_b), label: "Exposure".into(), edited: None }).unwrap(); + assert!(!app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap())); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + // Manual owns dispatched jobs: hold B until the real Undo command restores A. + let (slot, held_b) = take_job(&work); + assert_eq!(slot, full_slot); + let key_b = held_b.key; + assert_ne!(key_b, key_a); + assert!(app.renderer.is_pending(full_slot)); + + app.run("edit.undo", serde_json::json!({})).unwrap(); + assert_eq!(app.session.catalog.photo(id).unwrap().develop.as_ref(), settings_a.as_ref()); + assert!(app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap())); + crate::panels::grid::request_thumb(&mut app, id, 384, 10); + { + let restored = app.renderer.thumb(id).unwrap(); + assert_eq!(restored.key, key_a); + assert_eq!(restored.pixels.as_ref().unwrap().pixels, pixels_a); + assert_eq!(restored.quick, Some(QuickSource::Embedded)); + } + assert!(work.borrow().jobs.is_empty(), "restoring embedded-final A needs no full render"); + assert_eq!(app.renderer.pool.queued(), 0); + + let result_b = held_b.run(); + assert_eq!(result_b.key, key_b); + let rendered_b = result_b.rendered.as_ref().expect("held edited thumbnail must render before the stale-result oracle"); + assert_ne!(color_image(&rendered_b.image).pixels, pixels_a, "the held result must be distinguishable from A"); + finish(&mut app, &work, &ctx, slot, result_b); + + let shown = app.renderer.thumb(id).unwrap(); + assert_eq!(shown.key, key_a, "late edited Thumb(B) must not replace restored embedded ThumbQuick(A)"); + assert_eq!(shown.pixels.as_ref().unwrap().pixels, pixels_a); + assert_eq!(shown.quick, Some(QuickSource::Embedded)); + let quick = app.renderer.textures.get(&quick_slot).expect("the correct embedded thumbnail must remain installed"); + assert_eq!(quick.key, key_a); + assert_eq!(quick.quick, Some(QuickSource::Embedded)); + assert_eq!(quick.pixels.as_ref().unwrap().pixels, pixels_a); + assert!(!app.renderer.is_pending(full_slot)); +} From 9e3d0794d9c971e46570faf73af988c4ca7b4d41 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:04 +0000 Subject: [PATCH 060/191] refactor(lightcraft-ui-egui): move headless.rs tests to crates/ui-egui/src/headless/tests.rs --- crates/ui-egui/src/headless.rs | 4949 +------------------------- crates/ui-egui/src/headless/tests.rs | 4946 +++++++++++++++++++++++++ 2 files changed, 4947 insertions(+), 4948 deletions(-) create mode 100644 crates/ui-egui/src/headless/tests.rs diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index ebb14e03a..ab49563cb 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -541,4951 +541,4 @@ impl crate::render::RenderOffload for RenderGate { } #[cfg(test)] -mod tests { - #[test] - fn detail_offers_ai_denoise_for_raw_photos_and_leads_to_its_settings() { - use crate::state::Dialog; - use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source}; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-denoise-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models"))); - // a raw photo (its file need not exist: nothing here reads it) - let id = PhotoId(h.app.session.catalog.photos().map(|p| p.id.0).max().unwrap_or(0) + 1); - let mut raw = Photo::new( - id, - Source::File { path: dir.join("IMG_1.dng").to_string_lossy().into_owned() }, - "IMG_1.dng", - "DNG", - 6000, - 4000, - "2026-10-01T00:00:00", - ); - raw.kind = MediaKind::Raw; - h.app.session.commit("setup", Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - let widgets = |h: &mut Headless| h.request("ui.widgets", json!({}), t).to_string(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); - h.request("ui.set", json!({"view": "detail"}), t); - h.app.ui.open_sections = vec!["detail".to_string()]; - h.settle(SETTLE); - h.step(); - h.step(); - let w = widgets(&mut h); - assert!(w.contains("\"denoise:setup\""), "a raw photo with no model is offered the setup: {w}"); - // One click lands in Settings, with local installation available even without a download offer. - let r = h.request("ui.clickWidget", json!({"id": "denoise:setup"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); - h.settle(SETTLE); - let listed = h.app.session.execute("denoise.models.list", &json!({})).unwrap(); - assert_eq!(listed["models"].as_array().unwrap().len(), lightcraft_denoise::known::all().len(), "{listed}"); - assert!(widgets(&mut h).contains("\"denoise:installFile\"")); - h.app.ui.dialog = None; - // a photo that is not raw: no offer - h.request( - "engine.execute", - json!({"command": "library.select", "params": {"ids": [h.app.session.catalog.photos().find(|p| p.id != id).unwrap().id.0]}}), - t, - ); - h.settle(SETTLE); - h.step(); - h.step(); - assert!(!widgets(&mut h).contains("\"denoise:setup\""), "nothing is offered for a photo denoise does not apply to"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Install from file… with a model the engine rejects says why in the AI Denoise section (#1381), instead of - /// leaving the button looking dead. - #[test] - fn a_rejected_model_file_says_why_in_settings() { - use crate::state::Dialog; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-denoise-rejected-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models"))); - // without the runtime the button is disabled (the workspace build turns it on through the desktop app) - if h.app.session.execute("denoise.models.list", &json!({})).unwrap()["runtime"] != true { - let _ = std::fs::remove_dir_all(&dir); - return; - } - // another app's model folder: the .onnx with that app's model.json, and no denoise-model.json - let onnx = dir.join("rawdenoise.onnx"); - std::fs::write(&onnx, b"not a model").unwrap(); - std::fs::write(dir.join("model.json"), br#"{"name": "rawdenoise", "scale": 2}"#).unwrap(); - let picked = onnx.to_string_lossy().into_owned(); - h.app.services.pick_denoise_model = Some(Box::new(move || vec![picked.clone()])); - h.app.ui.dialog = Some(Dialog::Settings { tab: "denoise".into() }); - h.settle(SETTLE); - let r = h.request("ui.clickWidget", json!({"id": "denoise:installFile"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); - let painted = h.painted_text(); - assert!( - painted - .iter() - .any(|s| s.starts_with("No denoise-model.json beside this .onnx file. The model.json there") && s.ends_with("(see docs/denoise.md)")), - "{painted:?}" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - use super::*; - use std::sync::Arc; - - /// The pretend host has a clipboard, as a desktop does: text the UI copies lands in it, and a - /// cut, copy or paste request (from a context menu) comes back as that event on the next frame. - #[test] - fn the_headless_host_has_a_clipboard() { - let mut view = HeadlessView::new(); - let raw = || HeadlessView::raw_input(egui::vec2(200.0, 100.0), 1.0, 0.0, vec![]); - view.run(raw(), |ui| ui.ctx().copy_text("Lisbon".into())); - assert_eq!(view.clipboard, "Lisbon"); - for (command, event) in [ - (ViewportCommand::RequestPaste, egui::Event::Paste("Lisbon".into())), - (ViewportCommand::RequestCopy, egui::Event::Copy), - (ViewportCommand::RequestCut, egui::Event::Cut), - ] { - view.run(raw(), |ui| ui.ctx().send_viewport_cmd(command.clone())); - let mut seen = vec![]; - view.run(raw(), |ui| seen = ui.input(|i| i.events.clone())); - assert!(seen.contains(&event), "{command:?}: {seen:?}"); - } - // an empty clipboard pastes nothing, as a desktop host does - view.clipboard.clear(); - view.run(raw(), |ui| ui.ctx().send_viewport_cmd(ViewportCommand::RequestPaste)); - let mut seen = vec![]; - view.run(raw(), |ui| seen = ui.input(|i| i.events.clone())); - assert!(!seen.iter().any(|e| matches!(e, egui::Event::Paste(_))), "{seen:?}"); - } - - #[test] - fn screenshot_dimensions_are_checked_before_layout() { - assert_eq!(viewport_pixels([1600.0, 1000.0], 2.0).unwrap(), [3200, 2000]); - assert_eq!(viewport_pixels([345.0, 200.0], 0.9).unwrap(), [311, 180]); - // The largest accepted surface is exactly the 128 MiB screenshot budget (32 Mi pixels). - assert_eq!(viewport_pixels([16384.0, 2048.0], 1.0).unwrap(), [16384, 2048]); - assert_eq!(viewport_pixels([5792.0, 5792.0], 1.0).unwrap(), [5792, 5792]); - for (size, scale) in [ - ([f32::NAN, 10.0], 1.0), - ([10.0, f32::INFINITY], 1.0), - ([10.0, 10.0], f32::INFINITY), - ([10.0, 10.0], f32::NAN), - ([10.0, 10.0], 0.0), - ([0.0, 10.0], 1.0), - ([10.0, 10.0], 0.01), - ([16384.0, 2049.0], 1.0), - ([8000.0, 8000.0], 1.0), - ([5793.0, 5793.0], 1.0), - ([9000.0, 9000.0], 1.0), - ([9000.0, 100.0], 2.0), - ] { - assert!(viewport_pixels(size, scale).is_err(), "{size:?} at {scale}"); - } - let raw = HeadlessView::raw_input(egui::vec2(f32::INFINITY, 10.0), 1.0, 0.0, vec![]); - assert_eq!(raw.screen_rect.unwrap().size(), egui::vec2(1600.0, 1000.0)); - } - - #[test] - fn ui_zoom_preserves_host_pixels_and_scales_layout() { - for (size, scale, expected) in - [(egui::vec2(400.0, 240.0), 1.0, [400, 240]), (egui::vec2(345.0, 200.0), 0.9, [311, 180]), (egui::vec2(400.0, 240.0), 2.0, [800, 480])] - { - let mut view = HeadlessView::new(); - for frame in 0..9 { - // the interface scale changes between frames (Cmd+Plus / Cmd+Minus are the - // photo's zoom, issue #566: egui's own keyboard zoom is off) - match frame { - 1 => view.ctx.set_zoom_factor(1.1), - 5 => view.ctx.set_zoom_factor(1.0), - _ => {} - } - let raw = HeadlessView::raw_input(size, scale, frame as f64 / 60.0, vec![]); - view.run(raw, |ui| { - ui.label("UI zoom changes layout, keeping the host viewport size"); - }); - assert_eq!(view.size_px(), expected, "frame {frame} at scale {scale}"); - assert_eq!(view.paint(&HashMap::new()).size, expected); - if frame == 4 { - assert!(view.ctx.zoom_factor() > 1.0); - assert!(view.ctx.content_rect().width() < size.x); - } - if frame == 8 { - assert!(view.ctx.zoom_factor() < 1.01); - } - } - } - } - - #[test] - fn shadow_screenshot_keeps_the_zoomed_hosts_size_and_scale() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - app.ui.settings.gpu = false; - let mut h = Headless::new(app, [480.0, 320.0], 2.0); - let t = Duration::from_secs(5); - h.view.ctx.set_zoom_factor(1.1); - assert_eq!(h.request("ui.inspect", json!({}), t)["ok"], true); - assert!(h.view.ctx.zoom_factor() > 1.0); - let main = h.view.ctx.clone(); - let screenshot = h.app.headless_screenshot(&main, true).unwrap(); - assert_eq!(screenshot.size, [960, 640]); - let shadow = h.app.shadow.as_ref().unwrap(); - assert!((shadow.ctx.pixels_per_point() - main.pixels_per_point()).abs() < 0.001); - assert!((shadow.ctx.content_rect().width() - main.content_rect().width()).abs() < 0.1); - } - - #[test] - fn control_resize_uses_effective_points_after_ui_zoom() { - for scale in [0.9, 1.0, 2.0] { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - app.ui.settings.gpu = false; - let mut h = Headless::new(app, [480.0, 320.0], scale); - let t = Duration::from_secs(5); - assert_eq!(h.request("ui.zoomFactor", json!({"factor": 1.1}), t)["ok"], true); - h.step(); - assert!(h.view.ctx.zoom_factor() > 1.0); - let native_size = resized_viewport([320.0, 240.0], scale, h.view.ctx.zoom_factor()).unwrap(); - let expected = native_viewport_pixels([native_size.x, native_size.y], scale).unwrap(); - assert_eq!(h.request("ui.resize", json!({"width": 320, "height": 240}), t)["ok"], true); - assert_eq!(h.view.size_px(), expected); - assert!((h.view.ctx.content_rect().width() - 320.0).abs() < 0.1); - let main = h.view.ctx.clone(); - assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, expected); - } - } - - #[test] - fn control_resize_keeps_a_minimum_width_after_zooming_out() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - app.ui.settings.gpu = false; - let mut h = Headless::new(app, [480.0, 320.0], 1.0); - h.view.ctx.set_zoom_factor(0.5); - let t = Duration::from_secs(5); - assert_eq!(h.request("ui.resize", json!({"width": 1, "height": 120}), t)["ok"], true); - assert_eq!(h.size, egui::vec2(0.5, 60.0)); - assert_eq!(h.view.size_px(), [1, 60]); - assert!((h.view.ctx.content_rect().width() - 1.0).abs() < 0.01); - let main = h.view.ctx.clone(); - assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [1, 60]); - } - - #[test] - fn control_resize_rounds_in_native_points_after_a_fractional_zoom_out() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - app.ui.settings.gpu = false; - let mut h = Headless::new(app, [480.0, 320.0], 0.9); - let t = Duration::from_secs(5); - h.view.ctx.set_zoom_factor(0.9); - h.step(); - assert!((h.view.ctx.zoom_factor() - 0.9).abs() < 0.001); - assert_eq!(h.request("ui.resize", json!({"width": 150, "height": 150}), t)["ok"], true); - assert_eq!(h.size, egui::vec2(135.0, 135.0)); - assert_eq!(h.view.size_px(), [122, 122]); - assert!((h.view.ctx.content_rect().width() - 150.0).abs() < 0.01); - let main = h.view.ctx.clone(); - assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [122, 122]); - } - - #[test] - fn invalid_control_resize_preserves_the_headless_viewport() { - let app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - let mut h = Headless::new(app, [480.0, 320.0], 2.0); - h.app.ui.settings.gpu = false; - h.step(); - let size = h.size; - let rejected = h.request("ui.resize", json!({"width": 9000, "height": 8000}), Duration::from_secs(5)); - assert_eq!(rejected["ok"], false, "{rejected}"); - assert_eq!(h.size, size); - for bad in [json!({"width": "320", "height": 240}), json!({"width": 320, "height": null}), json!({"width": true})] { - let rejected = h.request("ui.resize", bad, Duration::from_secs(5)); - assert_eq!(rejected["ok"], false, "{rejected}"); - assert_eq!(h.size, size); - } - let accepted = h.request("ui.resize", json!({"width": 320, "height": 240}), Duration::from_secs(5)); - assert_eq!(accepted["ok"], true, "{accepted}"); - assert_eq!(h.size, egui::vec2(320.0, 240.0)); - assert_eq!(h.view.size_px(), [640, 480]); - } - - /// Generous: renders are slow when the machine is loaded (parallel builds), and a timed-out - /// settle would show a half-rendered UI. - const SETTLE: Duration = Duration::from_secs(120); - - fn demo(size: [f32; 2]) -> Headless { - let services = crate::Services { png: None, ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - Headless::new(app, size, 1.0) - } - - /// Agents cut, copy and paste in a text field as the system does for ⌘X / ⌘C / ⌘V - /// (`ui.clipboard`), and `ui.inspect` → `copied` says what the UI last copied. - #[test] - fn agents_cut_copy_and_paste_in_text_fields() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.step(); - h.step(); - r - }; - h.settle(SETTLE); - ask(&mut h, "ui.clickWidget", json!({"id": "field:search"})); - ask(&mut h, "ui.text", json!({"text": "wedding"})); - ask(&mut h, "ui.key", json!({"key": "a", "cmd": true})); - ask(&mut h, "ui.clipboard", json!({"action": "copy"})); - assert_eq!(ask(&mut h, "ui.inspect", json!({}))["result"]["copied"], "wedding"); - ask(&mut h, "ui.clipboard", json!({"action": "cut"})); - assert_eq!(h.app.ui.search, ""); - ask(&mut h, "ui.clipboard", json!({"action": "paste", "text": "travel"})); - assert_eq!(h.app.ui.search, "travel"); - let r = h.request("ui.clipboard", json!({"action": "shred"}), t); - assert_eq!(r["ok"], false, "an unknown action is refused: {r}"); - } - - /// `ui.clipboard` acts in a text field: with none focused it is refused, rather than doing - /// what ⌘V does there on one platform (paste edit settings) and nothing on another. - #[test] - fn the_clipboard_method_needs_a_text_field() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - let undo = h.app.session.undo.len(); - for action in ["copy", "paste"] { - let r = h.request("ui.clipboard", json!({"action": action, "text": "travel"}), t); - assert_eq!(r["ok"], false, "{action}: {r}"); - assert!(r["error"].as_str().is_some_and(|e| e.contains("text field")), "{r}"); - } - h.settle(SETTLE); - assert_eq!(h.app.session.undo.len(), undo, "nothing was pasted"); - } - - /// `ui.clickWidget` right-clicks with `button: "right"`, as `ui.click` does (MCP `click` - /// passes it on with a widget): a filmstrip photo's context menu opens. - #[test] - fn click_widget_right_clicks() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.settle(SETTLE); - let cell = h.app.widgets.iter().find(|(w, _)| w.starts_with("film:")).map(|(w, _)| w.clone()).expect("a filmstrip photo"); - assert!(!egui::Popup::is_any_open(&h.view.ctx)); - let r = h.request("ui.clickWidget", json!({"id": cell, "button": "right"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(egui::Popup::is_any_open(&h.view.ctx), "its context menu"); - } - - /// #1877: a right-click while another photo's context menu is open closes that menu and - /// opens this photo's, which stays open (it flashed and closed again). - #[test] - fn right_clicking_elsewhere_moves_the_context_menu() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - let thumbs: Vec = h.app.widgets.iter().filter(|(w, _)| w.starts_with("thumb:")).map(|(w, _)| w.clone()).collect(); - let (Some(a), Some(b)) = (thumbs.first().cloned(), thumbs.get(1).cloned()) else { panic!("two grid photos: {thumbs:?}") }; - let popup = |w: &str| { - let id: u64 = w.trim_start_matches("thumb:").parse().expect("a photo id"); - egui::Id::unique(("cell", id)).with("popup") - }; - assert!(!egui::Popup::is_any_open(&h.view.ctx)); - let r = h.request("ui.clickWidget", json!({"id": a, "button": "right"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "the first photo's menu is open"); - // A real click: the press and the release reach the UI on different frames, with the - // pointer resting on the photo in between (`ui.clickWidget` sends both on one frame). - let rect = h.app.widgets.iter().find(|(w, _)| *w == b).map(|(_, r)| *r).expect("the second photo on screen"); - let at = egui::pos2(rect.left() + rect.width() * 0.9, rect.center().y); - let button = egui::PointerButton::Secondary; - let modifiers = egui::Modifiers::NONE; - h.app.synthetic.push(egui::Event::PointerMoved(at)); - h.step(); - h.step(); - h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); - h.step(); - h.step(); - h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers }); - // one frame at a time: the menu flashed for a frame or two before it closed - for frame in 0..6 { - h.step(); - assert!(!egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "frame {frame}: the first photo's menu closed"); - if frame > 0 { - assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the second photo's menu is open"); - } - } - h.settle(SETTLE); - assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the second photo's menu stays open"); - // The same photo again, elsewhere on it (the menu that is open belongs to this very - // widget): the menu moves to the pointer and stays. - let before = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area"); - let at = egui::pos2(rect.left() + rect.width() * 0.2, rect.center().y); - h.app.synthetic.push(egui::Event::PointerMoved(at)); - h.step(); - h.step(); - h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); - h.step(); - h.step(); - h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers }); - for frame in 0..6 { - h.step(); - if frame > 0 { - assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the menu is open after a right-click on the same photo"); - } - } - h.settle(SETTLE); - assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the menu stays open"); - let after = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area"); - assert!((after.left() - at.x).abs() < 4.0, "the menu moved to the pointer: {after:?} vs {at:?} (was {before:?})"); - } - - /// #1171: Up / Down in the grid go to the cell above / below; in the filmstrip, one photo. - #[test] - fn up_and_down_move_through_the_grid_rows() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "grid"}), t); - h.settle(SETTLE); - let first = h.app.session.visible_cloned()[0]; - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first.0]}}), t); - h.settle(SETTLE); - let rect = |h: &Headless| { - let a = h.app.session.selection.active.unwrap(); - h.app.widgets.iter().find(|(w, _)| *w == format!("thumb:{}", a.0)).map(|(_, r)| *r).expect("grid cell") - }; - let top = rect(&h); - h.request("ui.key", json!({"key": "down"}), t); - h.settle(SETTLE); - let below = rect(&h); - let pos = { - let a = h.app.session.selection.active; - h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) - } - .unwrap(); - assert!(below.top() > top.bottom(), "the next row, at {pos}: {top:?} -> {below:?}"); - assert!(below.left() < top.right() && below.right() > top.left(), "overlapping horizontally"); - h.request("ui.key", json!({"key": "up"}), t); - h.settle(SETTLE); - assert_eq!(h.app.session.selection.active, Some(first), "back up"); - h.request("ui.key", json!({"key": "up"}), t); - h.settle(SETTLE); - assert_eq!(h.app.session.selection.active, Some(first), "the top row stays put"); - // the filmstrip (one row): Down is the next photo - h.request("ui.set", json!({"view": "detail"}), t); - h.settle(SETTLE); - h.request("ui.key", json!({"key": "down"}), t); - h.settle(SETTLE); - assert_eq!( - { - let a = h.app.session.selection.active; - h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) - }, - Some(1) - ); - } - - /// The search field is a shared text field: right-click ▸ Paste puts the clipboard's text in - /// and filters by it. - #[test] - fn the_search_field_pastes_from_its_menu() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - h.view.clipboard = "wedding".into(); - for (id, button) in [("field:search", "right"), ("field:search:paste", "left")] { - let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - h.settle(SETTLE); - } - assert_eq!(h.app.ui.search, "wedding"); - assert_eq!(h.app.session.filter.text, "wedding", "and the library is filtered by it"); - } - - /// Issue #1648: clearing the filters from the loupe's pill empties the search field too, so - /// the field never shows a search that is no longer applied. - #[test] - fn clearing_the_loupe_filters_empties_the_search_field() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - for (method, params) in [("ui.clickWidget", json!({"id": "field:search"})), ("ui.text", json!({"text": "a"}))] { - let r = h.request(method, params, t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - } - assert_eq!(h.app.session.filter.text, "a", "filtered while typing"); - assert!(!h.app.session.visible_cloned().is_empty(), "the search finds photos, so the loupe has one to show"); - h.request("engine.execute", json!({"command": "view.detail"}), t); - h.settle(SETTLE); - let r = h.request("ui.clickWidget", json!({"id": "loupe:filters"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", "")); - } - - /// Esc in the search field gives back the search from before the edit, and the filter with it. - #[test] - fn escape_in_the_search_field_gives_back_the_search() { - let mut h = demo([1200.0, 760.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - for (method, params) in [("ui.clickWidget", json!({"id": "field:search"})), ("ui.text", json!({"text": "travel"}))] { - let r = h.request(method, params, t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - } - assert_eq!(h.app.session.filter.text, "travel", "filtered while typing"); - let r = h.request("ui.key", json!({"key": "Escape"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", "")); - } - - #[test] - fn demo_grid_snapshot_has_ui_pixels() { - let t0 = Instant::now(); - let mut h = demo([1200.0, 760.0]); - // settle() can see a quiet spell before the first thumbnails land on a loaded machine - h.step_until(SETTLE, |h| h.app.renderer.thumb_textures() > 0); - let img = h.snapshot(SETTLE); - eprintln!("headless snapshot: {:?} in {:?} ({} frames)", img.size, t0.elapsed(), h.frames()); - assert_eq!(img.size, [1200, 760]); - // not blank: many distinct colours - let mut colours: Vec = img.pixels.iter().map(|c| u32::from_le_bytes(c.to_array())).collect(); - colours.sort_unstable(); - colours.dedup(); - assert!(colours.len() > 200, "only {} colours", colours.len()); - // text in the top bar: bright pixels on the dark chrome - let top_h = crate::theme::Tokens::default().top_bar_h as usize; - let bright = img.pixels[..top_h * 1200].iter().filter(|c| c.r() > 150 && c.g() > 150 && c.b() > 150).count(); - assert!(bright > 30, "no text in the top bar ({bright} bright px)"); - // thumbnails arrived (photo textures were drawn) - assert!(h.app.renderer.thumb_textures() > 0); - } - - #[test] - fn snapshots_are_deterministic_and_control_requests_work() { - let shot = || { - let mut h = demo([900.0, 600.0]); - let r = h.request("ui.set", json!({"view": "detail"}), Duration::from_secs(10)); - assert_eq!(r["ok"], true, "{r}"); - // Compare only fully settled frames: a timed-out settle would compare half-rendered - // pictures and fail as a misleading pixel diff (seen on loaded CI machines). - assert!(h.settle(Duration::from_secs(300)), "the demo detail view did not settle within 300 s"); - h.paint() - }; - let (a, b) = (shot(), shot()); - assert_eq!(a.size, b.size); - // The photo may come from the GPU in one run and the CPU in the other (the GPU warms up in - // the background), which differ by ≤ 1–2 LSB; anything more is a real difference. - let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.to_array().iter().zip(y.to_array()).any(|(p, q)| p.abs_diff(q) > 2)).count(); - assert_eq!(diff, 0, "{diff} pixels differ between two runs"); - } - - /// Keyboard culling: Compare (rating keys hit the candidate, arrows move it, auto-advance), - /// Survey (keys hit the active photo only), and auto-advance in Detail. - #[test] - fn compare_survey_and_auto_advance_by_keyboard() { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().rating; - let flag = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().flag; - let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]]}}), t); - let r = h.request("engine.execute", json!({"command": "view.compare"}), t); - assert_eq!(r["result"], json!({"select": vis[0], "candidate": vis[1]}), "{r}"); - assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); - // rating applies to the candidate (active) only - let before = rating(&h, vis[0]); - h.request("ui.key", json!({"key": "2"}), t); - assert_eq!((rating(&h, vis[0]), rating(&h, vis[1])), (before, 2)); - // arrows move the candidate; the select stays - h.request("ui.key", json!({"key": "right"}), t); - assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); - h.request("ui.key", json!({"key": "left"}), t); - assert_eq!(h.app.ui.compare, Some((vis[0], vis[1]))); - // Shift+X: reject and advance to the next candidate - h.request("ui.key", json!({"key": "x", "shift": true}), t); - assert_eq!(flag(&h, vis[1]), lightcraft_catalog::Flag::Reject); - assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); - h.request("engine.execute", json!({"command": "compare.swap"}), t); - assert_eq!(h.app.ui.compare, Some((vis[2], vis[0]))); - h.request("engine.execute", json!({"command": "compare.makeSelect"}), t); - assert_eq!(h.app.ui.compare.map(|c| c.0), Some(vis[0])); - // Survey: the selection tiled; keys hit the active photo, auto-advance walks the survey - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[3], vis[4], vis[5]], "active": vis[3]}}), t); - let r = h.request("engine.execute", json!({"command": "view.survey"}), t); - assert_eq!(r["result"]["photos"], 3); - h.request("engine.execute", json!({"command": "view.autoAdvance"}), t); - assert!(h.app.ui.auto_advance); - h.request("ui.key", json!({"key": "5"}), t); - h.request("ui.key", json!({"key": "p"}), t); - assert_eq!((rating(&h, vis[3]), flag(&h, vis[4])), (5, lightcraft_catalog::Flag::Pick)); - assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[5])); - assert_eq!(h.app.session.selection.ids.len(), 3, "the survey keeps its selection"); - let img = h.snapshot(SETTLE); - assert_eq!(img.size, [1000, 700]); - // Detail: auto-advance moves to the next photo - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[6]]}}), t); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("ui.key", json!({"key": "u"}), t); - assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[7])); - // Escape leaves the culling views for Detail - h.request("ui.set", json!({"view": "survey"}), t); - h.request("ui.key", json!({"key": "escape"}), t); - assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail); - // let in-flight renders finish: worker threads must not outlive the test process' TLS - h.settle(SETTLE); - } - - /// Naming a face in the loupe: point at an unnamed face, click its "Add name" label, type, Enter. Needs the - /// recognition runtime only for the background indexer, so the naming itself is tested whatever the build. - #[test] - fn naming_a_face_in_the_loupe() { - use crate::state::NameEdit; - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let id = h.app.session.active().unwrap(); - let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); - meta.regions = vec![lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.55, y1: 0.6 }, - kind: lightcraft_meta::RegionKind::Face, - name: None, - description: None, - }]; - h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - // pointing at the box shows the invitation; clicking it opens the name box - h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); - h.step(); - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - assert!(matches!(&h.app.ui.name_edit, Some(NameEdit { index: 0, .. })), "{:?}", h.app.ui.name_edit); - // Escape closes it without naming anything - let r = h.request("ui.key", json!({"key": "escape"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert!(h.app.ui.name_edit.is_none(), "{:?}", h.app.ui.name_edit); - assert_eq!(h.app.session.catalog.photo(id).unwrap().meta.regions[0].name, None); - // open it again, type a name, Enter: the face is named, in one undo step - h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); - h.step(); - h.step(); - h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); - h.step(); - h.step(); - let undo = h.app.session.undo.len(); - h.request("ui.text", json!({"text": "Ann"}), t); - h.step(); - h.request("ui.key", json!({"key": "enter"}), t); - h.step(); - h.step(); - assert_eq!(h.app.session.catalog.photo(id).unwrap().meta.regions[0].name.as_deref(), Some("Ann")); - assert_eq!(h.app.session.undo.len(), undo + 1); - assert!(h.app.ui.name_edit.is_none()); - } - - /// Adding a face model: the dialog shows the file's terms, the model is installed only once they are - /// accepted, and a file LightCraft cannot use only says why. - #[test] - fn adding_a_face_model_shows_its_terms_and_installs_only_once_accepted() { - use crate::state::Dialog; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-facemodel-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.face_models_dir = Some(dir.join("models")); - let model = dir.join("Mine.onnx"); - std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap(); - let open = |h: &mut Headless, path: &std::path::Path| { - h.request("engine.execute", json!({"command": "dialog.faceModel", "params": {"path": path.to_string_lossy()}}), t) - }; - - // the dialog opens for the chosen file, with nothing accepted and nothing installed - assert_eq!(open(&mut h, &model)["ok"], true); - h.settle(SETTLE); - h.step(); - let Some(dlg) = h.app.ui.dialog.clone() else { panic!("no dialog") }; - let Dialog::FaceModel { info, accepted, .. } = &dlg else { panic!("{dlg:?}") }; - assert!(!accepted); - assert_eq!(info["kind"], "draft"); - assert!(!dir.join("models").exists()); - // OK does nothing yet - assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); - assert!(!dir.join("models").exists()); - // ticking the box and confirming installs it - let r = h.request("ui.clickWidget", json!({"id": "check:faceModel.accept"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - assert!(matches!(&h.app.ui.dialog, Some(Dialog::FaceModel { accepted: true, .. }))); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let listed = h.app.run("faces.models.list", json!({})).unwrap(); - assert!(listed["models"].as_array().unwrap().iter().any(|m| m["installed"] == true && m["known"] == false), "{listed}"); - // installed, in use and recognition on: back in Settings ▸ Faces, where the scan can be watched - assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); - assert_eq!(listed["enabled"], true); - h.app.ui.dialog = None; - - // a file that is not a usable model says why and offers no install - let junk = dir.join("junk.onnx"); - std::fs::write(&junk, b"not a model").unwrap(); - assert_eq!(open(&mut h, &junk)["ok"], true); - h.step(); - let Some(Dialog::FaceModel { info, .. }) = h.app.ui.dialog.clone() else { panic!("no dialog") }; - assert_eq!(info["kind"], "unsupported"); - // a path that does not exist is an error, not a dialog - h.app.ui.dialog = None; - assert_eq!(open(&mut h, &dir.join("nope.onnx"))["ok"], false); - assert!(h.app.ui.dialog.is_none()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Photo > Detect Faces without the detector offers the download: the terms first, nothing fetched before they are - /// accepted, and the model's row in Settings has its Download button. - #[test] - fn detect_faces_without_the_detector_offers_the_download_and_fetches_nothing_before_acceptance() { - use crate::state::Dialog; - let mut h = demo([1400.0, 900.0]); - let dir = std::env::temp_dir().join(format!("lc-ui-detector-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.face_models_dir = Some(dir.join("models")); - - let r = h.app.run("faces.detect", json!({})); - assert!(r.is_err(), "no detector yet"); - let Some(dlg) = h.app.ui.dialog.clone() else { panic!("the download was not offered") }; - let Dialog::FaceModel { info, accepted, .. } = &dlg else { panic!("{dlg:?}") }; - assert!(!accepted); - assert_eq!((info["download"].as_str(), info["host"].as_str()), (Some("yunet-2023mar"), Some("github.com"))); - assert_eq!(info["model"]["licence"]["name"], "MIT"); - // OK without accepting the terms starts nothing - assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); - assert!(h.app.session.face_downloads.snapshot().is_empty()); - assert!(!dir.join("models").join(".downloads").exists()); - assert!(h.app.caches.faces_dl_watch.is_empty()); - - // Settings > Faces shows the detector with a Download button - h.app.ui.dialog = Some(Dialog::Settings { tab: "faces".into() }); - h.step(); - // (the first request switches the widget list on, the next frame fills it) - h.request("ui.widgets", json!({}), Duration::from_secs(10)); - h.step(); - let w = h.request("ui.widgets", json!({"filter": "faces:"}), Duration::from_secs(10)); - assert!(w.to_string().contains("faces:download:yunet-2023mar"), "{w}"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Pressing Download in Settings shows the model's terms first and fetches nothing until they are accepted; a build - /// that cannot run recognition models offers no download. The licence dialog replaces Settings, which it was opened from. - #[test] - fn download_shows_the_terms_first_and_fetches_nothing_until_accepted() { - use crate::state::Dialog; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-facedl-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.face_models_dir = Some(dir.join("models")); - h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "faces"}}), t); - h.settle(SETTLE); - h.step(); - let runtime = h.app.session.execute("faces.models.list", &json!({})).unwrap()["runtime"] == true; - let r = h.request("ui.clickWidget", json!({"id": "faces:download:sface-2021dec"}), t); - if !runtime { - assert_eq!(r["ok"], false, "a build that cannot run the model offers no download: {r}"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - let Some(dlg) = h.app.ui.dialog.clone() else { panic!("no dialog") }; - let Dialog::FaceModel { path, info, accepted } = &dlg else { panic!("the terms did not replace Settings: {dlg:?}") }; - assert_eq!((info["download"].as_str(), *accepted, path.as_str()), (Some("sface-2021dec"), false, "")); - assert_eq!(info["model"]["licence"]["commercial"], "unknown"); - // OK does nothing until the terms are accepted, and nothing has been fetched - assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); - assert_eq!(h.app.session.execute("faces.models.downloads", &json!({})).unwrap()["downloads"], json!([])); - // cancelling leaves it that way - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - assert!(h.app.ui.dialog.is_none()); - assert_eq!(h.app.session.execute("faces.models.downloads", &json!({})).unwrap()["downloads"], json!([])); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Until face recognition is set up, the People view and the loupe's name box say so and offer the next step: with no - /// model, a button that opens Settings ▸ Faces; with a model installed but recognition off, one that switches it on. - #[test] - fn people_and_the_name_box_offer_to_set_face_recognition_up() { - use crate::state::{Dialog, ViewMode}; - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-facesetup-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - h.app.session.face_models_dir = Some(dir.join("models")); - let runtime = h.app.session.execute("faces.models.list", &json!({})).unwrap()["runtime"] == true; - let id = h.app.session.active().unwrap(); - let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); - meta.regions = vec![lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.55, y1: 0.6 }, - kind: lightcraft_meta::RegionKind::Face, - name: None, - description: None, - }]; - h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); - let offers = |h: &mut Headless| h.request("ui.widgets", json!({}), t).to_string().contains("\"faces:setup\""); - - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.step(); - assert_eq!(h.app.ui.view, ViewMode::People); - if !runtime { - assert!(!offers(&mut h), "a build that cannot run recognition offers nothing"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - // no model: the offer is there, and one click lands in Settings ▸ Faces - assert!(offers(&mut h)); - let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); - h.app.ui.dialog = None; - - // the loupe's name box offers it too, and its button leaves for Settings with the box closed - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); - h.step(); - h.step(); - h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); - h.step(); - h.step(); - assert!(h.app.ui.name_edit.is_some() && offers(&mut h), "the name box offers the setup"); - let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert!(h.app.ui.name_edit.is_none()); - assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); - h.app.ui.dialog = None; - - // a model installed but recognition off: "Turn on" does it on the spot - let model = dir.join("Mine.onnx"); - std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap(); - let installed = h.app.run("faces.models.install", json!({"path": model.to_string_lossy(), "acknowledged": true, "activate": false})).unwrap(); - h.app.run("faces.models.select", json!({"id": installed["installed"]["id"]})).unwrap(); - h.app.caches.faces_epoch += 1; - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.step(); - assert!(offers(&mut h)); - assert_eq!(h.app.session.execute("faces.models.list", &json!({})).unwrap()["enabled"], false); - let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!(h.app.session.execute("faces.models.list", &json!({})).unwrap()["enabled"], true); - assert_eq!(h.app.ui.dialog, None, "nothing to go to Settings for"); - h.step(); - assert!(!offers(&mut h), "once it is on the offer is gone"); - h.settle(SETTLE); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A click on a person's card opens their page, which shows only cropped faces (one per face, not per photo); a click - /// on one opens its photo; Back, the People button and Escape return to everyone. - #[test] - fn a_persons_page_shows_their_faces_and_back_returns_to_everyone() { - use crate::state::ViewMode; - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(3).collect(); - let face = |x: f64, name: &str| lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.25, y1: 0.6 }, - kind: lightcraft_meta::RegionKind::Face, - name: Some(name.to_string()), - description: None, - }; - // Jane Doe twice in the first photo and once in the second; John Roe in the third - for (id, regions) in [ - (ids[0], vec![face(0.1, "Jane Doe"), face(0.5, "jane doe")]), - (ids[1], vec![face(0.3, "Jane Doe")]), - (ids[2], vec![face(0.3, "John Roe")]), - ] { - let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); - meta.regions = regions; - h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); - } - let open_people = |h: &mut Headless| { - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.step(); - }; - open_people(&mut h); - assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); - // the card opens the page: a face tile for each of the three faces, not a tile per photo - let r = h.request("ui.clickWidget", json!({"id": "person:Jane Doe"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); - let widgets = h.request("ui.widgets", json!({}), t).to_string(); - for (id, index) in [(ids[0], 0), (ids[0], 1), (ids[1], 0)] { - assert!(widgets.contains(&format!("\"person-face:{}:{index}\"", id.0)), "a tile for each face: {widgets}"); - } - assert!(!widgets.contains(&format!("\"person-face:{}:0\"", ids[2].0)), "someone else's face is not on the page"); - // a face opens its photo in the detail view - h.request("ui.clickWidget", json!({"id": format!("person-face:{}:0", ids[1].0)}), t); - h.step(); - h.step(); - assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1]))); - // Escape from that photo goes back to the page, not to the grid - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); - // the People button from a photo shows everyone, with the person last left one click away next to the title - h.request("ui.clickWidget", json!({"id": format!("person-face:{}:0", ids[1].0)}), t); - h.step(); - h.step(); - assert_eq!(h.app.ui.view, ViewMode::Detail); - open_people(&mut h); - assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "people:last"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); - // a photo reached any other way goes back to the grid on Escape - h.request("engine.execute", json!({"command": "view.detail"}), t); - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "person:back"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!(h.app.ui.person_page, None); - h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None), "Escape goes back to everyone"); - h.request("engine.execute", json!({"command": "view.person", "params": {"name": "John Roe"}}), t); - open_people(&mut h); - assert_eq!(h.app.ui.person_page, None, "the People button shows everyone"); - // a missing or empty name is refused - assert_eq!(h.request("engine.execute", json!({"command": "view.person", "params": {}}), t)["ok"], false); - assert_eq!(h.request("engine.execute", json!({"command": "view.person", "params": {"name": " "}}), t)["ok"], false); - } - - /// A person's More grid confirms a score-bounded batch through either ↑ or the focused tile's A key. Both - /// routes use one undo step and leave hidden, lower-score and already named faces alone. - #[test] - fn person_more_confirms_all_above_with_one_undo_step() { - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(7).collect(); - assert_eq!(ids.len(), 7); - let region = |name: Option<&str>| lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.25, y0: 0.2, x1: 0.55, y1: 0.6 }, - kind: lightcraft_meta::RegionKind::Face, - name: name.map(str::to_string), - description: None, - }; - for (id, name) in ids.iter().zip([Some("Jane Doe"), None, None, None, None, None, Some("John Roe")]) { - let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); - meta.regions = vec![region(name)]; - h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); - } - h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); - h.settle(SETTLE); - let page = crate::panels::person::PersonPage { - name: "Jane Doe".into(), - total: 1, - confirmed: vec![crate::panels::person::Face { photo: ids[0].0, index: 0, rect: region(None).rect, score: 0.0 }], - more: ids[1..6] - .iter() - .zip([0.95, 0.80, 0.80, 0.80, 0.60]) - .map(|(id, score)| crate::panels::person::Face { photo: id.0, index: 0, rect: region(None).rect, score }) - .collect(), - ready: true, - pending: 0, - }; - let set_page = |h: &mut Headless| { - h.app.caches.person_page = - Some(("Jane Doe".into(), h.app.session.catalog.revision, h.app.caches.faces_indexed, 0.0, Arc::new(page.clone()))); - h.step(); - }; - h.app.ui.dismissed_faces.insert((ids[4].0, 0)); - set_page(&mut h); - let bulk_id = format!("more-face-above:{}:0", ids[2].0); - assert!(h.request("ui.widgets", json!({"filter": "more-face-above:"}), t).to_string().contains(&bulk_id)); - let undo = h.app.session.undo.len(); - let r = h.request("ui.clickWidget", json!({"id": bulk_id}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - let names = |h: &Headless| ids.iter().map(|id| h.app.session.catalog.photo(*id).unwrap().meta.regions[0].name.clone()).collect::>(); - assert_eq!( - names(&h), - vec![ - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - None, - None, - Some("John Roe".into()) - ] - ); - assert_eq!(h.app.session.undo.len(), undo + 1); - h.request("engine.execute", json!({"command": "edit.undo"}), t); - h.step(); - assert_eq!(names(&h), vec![Some("Jane Doe".into()), None, None, None, None, None, Some("John Roe".into())]); - // With no target A cannot name a face, even on this page. - h.request("ui.move", json!({"x": 1, "y": 1}), t); - h.view.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique(("more-face-above", ids[2].0, 0_usize)))); - set_page(&mut h); - h.request("ui.key", json!({"key": "a"}), t); - h.step(); - assert_eq!(names(&h)[1], None); - // Text editing owns A even if a face was the last pointer target. - let search = h.request("ui.clickWidget", json!({"id": "field:search"}), t); - assert_eq!(search["ok"], true, "{search}"); - h.step(); - h.request("ui.key", json!({"key": "a"}), t); - h.step(); - assert_eq!(names(&h)[1], None); - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - // Hovering a tile is enough for the A key, without clicking or moving keyboard focus there. - let tile_id = format!("more-face:{}:0", ids[2].0); - let rect = h.app.widgets.iter().find(|(id, _)| id == &tile_id).unwrap().1; - h.request("ui.move", json!({"x": rect.center().x, "y": rect.center().y}), t); - h.step(); - h.request("ui.key", json!({"key": "a"}), t); - h.step(); - assert_eq!(names(&h)[1..4], [Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into())]); - assert_eq!(h.app.session.undo.len(), undo + 1); - h.request("engine.execute", json!({"command": "edit.undo"}), t); - h.step(); - h.request("ui.move", json!({"x": 1, "y": 1}), t); - set_page(&mut h); - // Keyboard focus on the candidate makes the same batch action available without hovering it. - h.view.ctx.memory_mut(|m| m.request_focus(egui::Id::unique(("more-face", ids[2].0, 0_usize)))); - h.request("ui.key", json!({"key": "a"}), t); - h.step(); - assert_eq!( - names(&h), - vec![ - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - None, - None, - Some("John Roe".into()) - ] - ); - assert_eq!(h.app.session.undo.len(), undo + 1); - h.request("engine.execute", json!({"command": "view.photoGrid"}), t); - h.request("ui.key", json!({"key": "a"}), t); - h.step(); - assert_eq!(h.app.session.undo.len(), undo + 1, "A outside the person page cannot name more faces"); - } - - /// The People view lists the unnamed faces below the named people: select some (a click each, or all), type a name, - /// press Enter, and they are all named at once, in one undo step; the new person appears among the named. - #[test] - fn unnamed_faces_are_selected_and_named_together() { - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(3).collect(); - let face = |x: f64, name: Option<&str>| lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.2, y1: 0.55 }, - kind: lightcraft_meta::RegionKind::Face, - name: name.map(str::to_string), - description: None, - }; - for (id, regions) in [ - (ids[0], vec![face(0.1, Some("Jane Doe")), face(0.5, None)]), - (ids[1], vec![face(0.3, None), face(0.6, None)]), - (ids[2], vec![face(0.3, None)]), - ] { - let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); - meta.regions = regions; - h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); - } - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.step(); - h.step(); - let tiles = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "unnamed-face:"}), t)["result"].as_array().map_or(0, Vec::len); - // the named person is a card, and the four unnamed faces are tiles below - let r = h.request("ui.clickWidget", json!({"id": "person:Jane Doe"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.request("engine.execute", json!({"command": "view.people"}), t); - h.step(); - h.step(); - assert_eq!(tiles(&mut h), 4); - // select two (nothing is named by selecting), and the naming bar appears - for (id, i) in [(ids[1], 0), (ids[2], 0)] { - let r = h.request("ui.clickWidget", json!({"id": format!("unnamed-face:{}:{i}", id.0)}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - } - assert_eq!(h.app.ui.unnamed_selected.len(), 2); - let bar = h.request("ui.widgets", json!({"filter": "unnamed:"}), t).to_string(); - assert!(bar.contains("unnamed:name") && bar.contains("unnamed:clear"), "{bar}"); - assert!(h.request("ui.widgets", json!({"filter": "field:unnamedName"}), t).to_string().contains("field:unnamedName")); - // type a name and press Enter: both faces are named, in one undo step, and the selection is gone - let undo = h.app.session.undo.len(); - h.request("ui.text", json!({"text": "Ann Example"}), t); - h.step(); - h.request("ui.key", json!({"key": "enter"}), t); - h.step(); - h.step(); - let named = |h: &Headless, id: lightcraft_catalog::PhotoId, i: usize| h.app.session.catalog.photo(id).unwrap().meta.regions[i].name.clone(); - assert_eq!((named(&h, ids[1], 0), named(&h, ids[2], 0)), (Some("Ann Example".to_string()), Some("Ann Example".to_string()))); - assert_eq!(named(&h, ids[1], 1), None, "a face that was not selected stays unnamed"); - assert_eq!(h.app.session.undo.len(), undo + 1, "one step for both"); - assert!(h.app.ui.unnamed_selected.is_empty() && h.app.ui.unnamed_name.is_empty()); - h.settle(SETTLE); - h.step(); - assert_eq!(tiles(&mut h), 2, "the named faces left the unnamed list"); - assert_eq!(h.app.session.catalog.people().len(), 2, "and the new person is among the named"); - // select all, then clear: nothing is named - h.request("ui.clickWidget", json!({"id": "unnamed:selectAll"}), t); - h.step(); - h.step(); - assert_eq!(h.app.ui.unnamed_selected.len(), 2); - h.request("ui.clickWidget", json!({"id": "unnamed:clear"}), t); - h.step(); - assert!(h.app.ui.unnamed_selected.is_empty()); - // undo gives the two faces back - h.request("engine.execute", json!({"command": "edit.undo"}), t); - h.step(); - assert_eq!(named(&h, ids[1], 0), None); - } - - /// A double click on an unnamed face opens its photo in the loupe, as in the grid (#1664); a single click still only - /// selects it, and Escape from that photo goes back to the People view. - #[test] - fn double_clicking_an_unnamed_face_opens_its_photo() { - use crate::state::ViewMode; - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); - for id in &ids { - let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); - meta.regions = vec![lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.5, y1: 0.55 }, - kind: lightcraft_meta::RegionKind::Face, - name: None, - description: None, - }]; - h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); - } - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0].0]}}), t); - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.step(); - h.step(); - let tile = format!("unnamed-face:{}:0", ids[1].0); - // one click selects the face and stays in People - let r = h.request("ui.clickWidget", json!({"id": tile}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::People, Some(ids[0]))); - assert_eq!(h.app.ui.unnamed_selected.len(), 1); - h.request("ui.clickWidget", json!({"id": "unnamed:clear"}), t); - // half a second of frames, so egui does not count the next two clicks as the third and fourth of one burst - for _ in 0..30 { - h.step(); - } - // two open the photo the face is in - let r = h.request("ui.clickWidget", json!({"id": tile, "count": 2}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1]))); - assert_eq!(h.app.session.catalog.photo(ids[1]).unwrap().meta.regions[0].name, None, "nothing is named"); - // Escape goes back to the faces, not to the grid - h.request("ui.key", json!({"key": "escape"}), t); - h.step(); - assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); - } - - /// The name field under the unnamed faces is a shared text field: right-click ▸ Paste puts the - /// clipboard's name in, and Esc leaves the field and clears the selection, as Clear does. - #[test] - fn the_unnamed_faces_name_field_pastes_and_escape_clears() { - use lightcraft_catalog::Op; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let id = h.app.session.catalog.photos().map(|p| p.id).next().unwrap(); - let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); - meta.regions = vec![lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.5, y1: 0.55 }, - kind: lightcraft_meta::RegionKind::Face, - name: None, - description: None, - }]; - h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); - h.request("engine.execute", json!({"command": "view.people"}), t); - h.settle(SETTLE); - h.view.clipboard = "Wedding Guest".into(); - for (widget, button) in [ - (format!("unnamed-face:{}:0", id.0), "left"), - ("field:unnamedName".to_string(), "left"), - ("field:unnamedName".to_string(), "right"), - ("field:unnamedName:paste".to_string(), "left"), - ] { - let r = h.request("ui.clickWidget", json!({"id": widget, "button": button}), t); - assert_eq!(r["ok"], true, "{widget}: {r}"); - h.step(); - h.step(); - h.step(); - } - assert_eq!(h.app.ui.unnamed_name, "Wedding Guest"); - assert_eq!(h.app.ui.unnamed_selected.len(), 1, "still naming"); - h.request("ui.key", json!({"key": "Escape"}), t); - h.step(); - h.step(); - assert!(h.app.ui.unnamed_selected.is_empty() && h.app.ui.unnamed_name.is_empty(), "Esc cleared the selection and the name"); - } - - /// A screenful of faces larger than the picture cache's usual budget (96) is all kept: with a fixed budget the same few - /// tiles were evicted and re-requested every frame and stayed blank. - #[test] - fn a_screenful_of_small_faces_is_not_evicted_and_left_blank() { - use lightcraft_catalog::Op; - let mut h = demo([1500.0, 1000.0]); - let t = Duration::from_secs(20); - let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).collect(); - // seven unnamed faces in every photo of the demo library - for id in &ids { - let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); - meta.regions = (0..7) - .map(|k| lightcraft_meta::Region { - rect: lightcraft_geom::Rect { x0: 0.05 + 0.12 * k as f64, y0: 0.2, x1: 0.15 + 0.12 * k as f64, y1: 0.45 }, - kind: lightcraft_meta::RegionKind::Face, - name: None, - description: None, - }) - .collect(); - h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); - } - // the smallest faces, so a lot of them fit on the screen - h.request("ui.set", json!({"thumbSize": 90.0, "view": "people"}), t); - h.settle(SETTLE); - h.step(); - let drawn = h.request("ui.widgets", json!({"filter": "unnamed-face:"}), t)["result"].as_array().map_or(0, Vec::len); - assert!(drawn > 96, "the test needs more faces on screen than the usual budget, got {drawn}"); - // the pictures are made and kept, well beyond the old budget (a few demo photos share a scene, so two faces can share a - // picture: the count is a little under the number of tiles) - let started = Instant::now(); - while h.app.renderer.variant_textures() < drawn - 20 && started.elapsed() < Duration::from_secs(60) { - h.step(); - std::thread::sleep(Duration::from_millis(20)); - } - let kept = h.app.renderer.variant_textures(); - assert!(kept > 96 + 30, "{kept} pictures kept for {drawn} faces on screen"); - // and they stay: many more frames, and none is evicted to be asked for again - for _ in 0..30 { - h.step(); - } - assert!(h.app.renderer.variant_textures() >= kept, "{} pictures kept after more frames, {kept} before", h.app.renderer.variant_textures()); - } - - /// `ui.inspect` says how hard the face scan is allowed to work and whether the window counts as in front, so a slow - /// scan can be told from a stuck one. - #[test] - fn inspect_reports_the_face_scan_pace() { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - h.step(); - let r = h.request("ui.inspect", json!({}), t); - let scan = &r["result"]["faceScan"]; - assert!(["pause", "light", "normal", "full"].contains(&scan["pace"].as_str().unwrap_or("")), "{scan}"); - assert!(scan["focused"].is_boolean() && scan["pending"].is_u64() && scan["indexed"].is_u64(), "{scan}"); - } - - /// Profile browser: live variant thumbnails, hover previews in the loupe without touching the - /// photo or its history, click applies, the star toggles the favourite. - #[test] - fn profile_browser_previews_on_hover_and_applies_on_click() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - let r = h.request("engine.execute", json!({"command": "panel.profiles"}), t); - assert_eq!(r["result"]["open"], true, "{r}"); - h.settle(SETTLE); - let id = h.app.session.active().unwrap(); - let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); - let before = photo(&h); - h.step_until(SETTLE, |h| h.app.renderer.variant_textures() >= 6); - assert!(h.app.renderer.variant_textures() >= 6, "variant thumbnails rendered: {}", h.app.renderer.variant_textures()); - // hover: the loupe shows the look, nothing is committed - let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - // the hover render can land after a quiet spell on a loaded machine (FreeBSD CI): wait for it - h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); - assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Profile: Vivid")); - assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); - let hover = h.app.renderer.textures.get(&crate::render::Slot::Hover).expect("hover render"); - assert_eq!(hover.photo, id); - let after = photo(&h); - assert_eq!((after.develop.clone(), after.history.len()), (before.develop.clone(), before.history.len()), "hover leaves the photo alone"); - assert_eq!(h.app.session.undo.len(), 0); - // moving away ends the preview - h.request("ui.move", json!({"x": 5, "y": 500}), t); - h.step(); - assert!(h.app.hover_preview.is_none()); - // click applies (one history step); the star toggles the favourite - h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); - assert_eq!(photo(&h).develop.profile.id, "lc.vivid"); - assert_eq!(photo(&h).history.len(), before.history.len() + 1); - h.request("ui.clickWidget", json!({"id": "profileStar:lc.portrait"}), t); - assert_eq!(h.app.session.profile_favorites, ["lc.portrait"]); - assert_eq!(photo(&h).develop.profile.id, "lc.vivid", "the star doesn't apply"); - h.settle(SETTLE); - } - - /// Hovering the applied profile shows the applied look (no preview), not the last other profile hovered. - #[test] - fn hovering_the_applied_profile_drops_the_last_hover() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.profiles"}), t); - h.settle(SETTLE); - h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); - h.request("ui.hoverWidget", json!({"id": "profileCell:lc.portrait"}), t); - h.step(); - assert!(h.app.hover_preview.is_some(), "another profile previews"); - h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); - h.step(); - assert!(h.app.hover_preview.is_none(), "the applied profile is shown, not the last hovered one"); - } - - /// The preset applied to a photo is still marked (and its Amount slider back) after visiting - /// another photo, which shows none (issue #2392); an edit to the photo drops the mark. - #[test] - fn applied_preset_is_marked_again_on_returning_to_the_photo() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.presets"}), t); - h.settle(SETTLE); - let a = h.app.session.active().unwrap(); - let b = h.app.session.visible_cloned().into_iter().find(|id| *id != a).unwrap(); - let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); - let marked = |h: &Headless| { - let r = h.app.widgets.iter().find(|(id, _)| id == "presetApplied").map(|(_, r)| *r)?; - h.app.widgets.iter().find(|(id, wr)| id.starts_with("preset:") && *wr == r).map(|(id, _)| id.clone()) - }; - let r = h.request("ui.clickWidget", json!({"id": "preset:lc.warm-glow"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(marked(&h).as_deref(), Some("preset:lc.warm-glow")); - assert!(has(&h, "slider:presetAmount")); - for (photo, want) in [(b, None), (a, Some("preset:lc.warm-glow"))] { - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [photo.0]}}), t); - h.settle(SETTLE); - h.step(); - assert_eq!(marked(&h).as_deref(), want, "on photo {photo:?}"); - assert_eq!(has(&h, "slider:presetAmount"), want.is_some(), "the Amount slider follows the mark"); - } - h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); - h.settle(SETTLE); - h.step(); - assert_eq!(marked(&h), None, "an edit after the preset drops the mark"); - } - - /// A preset applied while the active photo lies outside the selection changes only the - /// selection, so the active photo isn't marked as having it. - #[test] - fn applied_preset_is_not_marked_on_an_active_photo_outside_the_selection() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.presets"}), t); - h.settle(SETTLE); - let a = h.app.session.active().unwrap(); - let b = h.app.session.visible_cloned().into_iter().find(|id| *id != a).unwrap(); - h.app.session.selection.ids = vec![b]; - h.app.session.selection.active = Some(a); - let before = h.app.session.develop_of(a).unwrap(); - let r = h.request("ui.clickWidget", json!({"id": "preset:lc.warm-glow"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - h.step(); - assert_eq!(h.app.session.active(), Some(a)); - assert!(*h.app.session.develop_of(a).unwrap() == *before, "the active photo wasn't changed"); - assert!(!h.app.widgets.iter().any(|(id, _)| id == "presetApplied"), "nor marked"); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [b.0]}}), t); - h.settle(SETTLE); - h.step(); - assert!(h.app.widgets.iter().any(|(id, _)| id == "presetApplied"), "the selected photo is"); - } - - /// Presets column: resting on a preset previews it in the loupe without a history entry; - /// thumbnails are variant renders; Create Preset includes only the checked groups. - #[test] - fn preset_hover_previews_and_create_preset_groups() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.presets"}), t); - h.settle(SETTLE); - let id = h.app.session.active().unwrap(); - let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); - let before = photo(&h); - h.request("ui.hoverWidget", json!({"id": "preset:lc.bw-high-contrast"}), t); - h.settle(SETTLE); - h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); - assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); - assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); - let after = photo(&h); - assert_eq!(after.develop, before.develop); - assert_eq!(after.history.len(), before.history.len(), "no history entry while hovering"); - assert!(h.app.session.undo.is_empty()); - // hovering down across group headers within the presets list retains the preview (no flicker) - h.request("ui.hoverWidget", json!({"id": "presetGroup:Creative"}), t); - h.step(); - assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); - // hovering another preset switches smoothly to it - h.request("ui.hoverWidget", json!({"id": "preset:lc.warm-glow"}), t); - h.settle(SETTLE); - h.step(); - assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: Warm Glow")); - // thumbnails: variant textures for the visible presets - h.app.ui.preset_thumbs = true; - h.request("ui.move", json!({"x": 5, "y": 500}), t); - h.settle(SETTLE); - h.step(); - assert!(h.app.hover_preview.is_none()); - assert!(h.app.renderer.variant_textures() >= 5, "{}", h.app.renderer.variant_textures()); - // Create Preset with a group checklist: untick Light, keep Effects - h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); - h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "effects.clarity", "value": 25}}), t); - h.app.ui.dialog = Some(crate::state::Dialog::create_preset()); - h.step(); - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "presetInclude:light"}), t); - assert_eq!(r["ok"], true, "{r}"); - match &h.app.ui.dialog { - Some(crate::state::Dialog::CreatePreset { groups, .. }) => { - assert!(!groups.iter().any(|g| g == "light") && groups.iter().any(|g| g == "effects"), "{groups:?}"); - assert!(!groups.iter().any(|g| g == "crop" || g == "masks"), "crop and masks off by default"); - } - d => panic!("dialog: {d:?}"), - } - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let p = h.app.session.presets.iter().find(|p| !p.builtin).expect("created").clone(); - assert!(p.settings.get("light").is_none() && p.settings["effects"]["clarity"] == 25.0, "{}", p.settings); - h.settle(SETTLE); - } - - /// A collection of thousands of presets (a Lightroom library imported whole) lists only the - /// rows in view: a few dozen widgets and thumbnails a frame, not one per preset (every frame - /// built all of them and froze the app for seconds); scrolling brings later rows in. - #[test] - fn presets_panel_with_thousands_of_presets_builds_only_visible_rows() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - let many = (0..5000) - .map(|i| lightcraft_develop::Preset { - id: format!("user.big.{i}"), - name: format!("Look {i}"), - group: format!("Big {:02}", i / 500), - settings: json!({"light": {"exposure": (i % 20) as f64 / 10.0}}), - favorite: false, - builtin: false, - }) - .collect(); - assert_eq!(h.app.session.add_presets(many).len(), 5000); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.presets"}), t); - h.app.ui.preset_thumbs = true; - h.settle(SETTLE); - h.step(); - let rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("preset:")).map(|(w, _)| w.clone()).collect::>(); - let first = rows(&h); - assert!((5..60).contains(&first.len()), "{} rows built", first.len()); - assert!(h.app.renderer.variant_textures() < 60, "{} thumbnails", h.app.renderer.variant_textures()); - assert!(h.app.widgets.iter().any(|(w, _)| w == "presetGroup:Big 09"), "every group header is listed"); - let started = std::time::Instant::now(); - h.step(); - eprintln!("presets panel frame with 5000 presets: {:?}", started.elapsed()); - // scroll far down over the list: later presets come into view - let r = h.request("ui.hoverWidget", json!({"id": first[first.len() / 2]}), t); - assert_eq!(r["ok"], true, "{r}"); - for _ in 0..20 { - h.request("ui.scroll", json!({"dy": -2000}), t); - h.step(); - } - h.step(); - let later = rows(&h); - assert!((5..60).contains(&later.len()), "{} rows built", later.len()); - let deep = later.iter().filter_map(|w| w.strip_prefix("preset:user.big.")).filter_map(|n| n.parse::().ok()).max(); - assert!(deep.is_some_and(|n| n > 500), "scrolled rows: {later:?}"); - } - - /// With `label` and `labels` both set (a saved single-label filter, or an agent), the filter - /// bar lights only the labels that pass both, and a click starts from those, not their union. - #[test] - fn filter_bar_labels_from_label_and_labels() { - use lightcraft_catalog::ColorLabel; - let mut h = demo([1400.0, 800.0]); - let t = Duration::from_secs(10); - h.request("engine.execute", json!({"command": "view.filterBar"}), t); - h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["red", "yellow"]})).unwrap(); - // a frame so the bar is laid out for the new filter before the click - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); - assert_eq!(r["ok"], true, "{r}"); - let f = h.app.session.filter.clone(); - assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Red, ColorLabel::Green])); - // nothing passes both: nothing is lit, so a click shows just that label - h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["yellow"]})).unwrap(); - h.step(); - h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); - let f = h.app.session.filter.clone(); - assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Green])); - h.settle(SETTLE); - } - - /// The filter bar drives `library.filter` and saves the view as a smart album. - #[test] - fn filter_bar_filters_and_saves_a_smart_album() { - let mut h = demo([1400.0, 800.0]); - let t = Duration::from_secs(10); - let all = h.app.session.visible_cloned().len(); - h.request("engine.execute", json!({"command": "view.filterBar"}), t); - assert!(h.app.ui.filter_bar); - for w in ["filter:star3", "filter:pick", "filter:label-red", "filter:label-red"] { - let r = h.request("ui.clickWidget", json!({"id": w}), t); - assert_eq!(r["ok"], true, "{w}: {r}"); - } - let f = h.app.session.filter.clone(); - assert_eq!((f.rating, f.flag, f.label), (3, Some(lightcraft_catalog::Flag::Pick), None), "a second click clears the label"); - let n = h.app.session.visible_cloned().len(); - assert!(n > 0 && n < all); - h.request("ui.clickWidget", json!({"id": "button:filterSave"}), t); - assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::NewSmartAlbum { .. }))); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let smart = h.app.session.catalog.albums().find(|a| a.is_smart()).expect("smart album").id; - assert_eq!(h.app.session.catalog.album_count(smart), n); - h.request("ui.clickWidget", json!({"id": "button:filterClear"}), t); - assert_eq!(h.app.session.filter, Default::default()); - assert_eq!(h.app.session.visible_cloned().len(), all); - h.settle(SETTLE); - } - - /// The photo grid shows date headers (registered as `group:` widgets); clicking one - /// selects that day's photos; month headers when zoomed out; none when grouping is off. - #[test] - fn grid_groups_by_capture_date() { - let mut h = demo([1300.0, 800.0]); - let t = Duration::from_secs(10); - h.settle(SETTLE); - let groups = h.app.session.execute("library.groups", &json!({})).unwrap(); - let first = groups[0].clone(); - let key = first["key"].as_str().unwrap().to_string(); - let r = h.request("ui.clickWidget", json!({"id": format!("group:{key}")}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.session.selection.ids.len() as u64, first["count"].as_u64().unwrap()); - // zoomed out: month headers - h.app.ui.thumb_size = 120.0; - h.settle(SETTLE); - let vis = h.app.session.visible_cloned(); - let month = h.app.session.catalog.date_runs(&vis, h.app.session.sort.key, lightcraft_catalog::GroupBy::Month); - let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t); - assert_eq!(r["ok"], true, "{r}"); - h.request("engine.execute", json!({"command": "library.sort", "params": {"group": "none"}}), t); - let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t); - assert_eq!(r["ok"], false, "no headers: {r}"); - h.settle(SETTLE); - } - - /// Keywords: the left-panel tree filters (children included), opens levels, the rename dialog - /// renames library-wide, and the Keywords panel adds a suggestion. - #[test] - fn keyword_list_filters_renames_and_suggests() { - // tall: the demo library's own keywords come first in the list - let mut h = demo([1300.0, 1800.0]); - // Host folders arrive asynchronously above Keywords. Keep this keyword fixture's - // geometry independent of their existence and the background stat timing. - if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { - h.app.ui.hidden_locations.extend( - ["Pictures", "Desktop", "Downloads", ""] - .map(|sub| if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }), - ); - } - let t = Duration::from_secs(10); - let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); - let ex = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), Duration::from_secs(10)); - ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]})); - ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]})); - h.request("ui.set", json!({"leftPanel": true}), t); - // clicks land on last frame's layout: let the keyword list settle first (on a loaded machine a - // row could still move, and the click then hit its neighbour, e.g. "sunrise") - h.settle(SETTLE); - let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel")); - assert_eq!(h.app.session.visible_cloned().len(), 3); - // open the level, filter by the child - h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t); - h.settle(SETTLE); - let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel|italy"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.session.visible_cloned().len(), 2); - h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "travel".into(), to: "trips".into() }); - h.settle(SETTLE); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.session.filter.keyword.as_deref(), Some("trips|italy")); - assert_eq!(h.app.session.visible_cloned().len(), 2); - // Keywords panel: suggestions co-occurring with the photo's keywords - h.request("engine.execute", json!({"command": "library.clearFilter"}), t); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[1]]}}), t); - ex(&mut h, "photo.setMeta", json!({"ids": [vis[0]], "addKeywords": ["gelato"]})); - h.request("ui.set", json!({"right": "keywords"}), t); - assert!(h.settle(SETTLE), "keyword suggestions did not settle"); - let r = h.request("ui.clickWidget", json!({"id": "kwSuggest:gelato"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(vis[1])).unwrap().meta.keywords.contains(&"gelato".to_string())); - h.settle(SETTLE); - } - - /// Rename Keyword's name field is a shared text field. It opens with the name selected, so - /// typing replaces it, and Return renames; right-click ▸ Paste works too; Esc cancels the - /// dialog and renames nothing. - #[test] - fn the_rename_keyword_field_selects_pastes_and_cancels() { - let mut h = demo([1200.0, 800.0]); - let t = Duration::from_secs(10); - let first = h.app.session.visible_cloned()[0].0; - h.request("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [first], "addKeywords": ["travel"]}}), t); - let keywords = |h: &Headless| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(first)).unwrap().meta.keywords.clone(); - let open = |h: &mut Headless, name: &str| { - h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: name.into(), to: name.into() }); - h.settle(SETTLE); - }; - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.settle(SETTLE); - }; - // typing replaces the selected name - open(&mut h, "travel"); - ask(&mut h, "ui.text", json!({"text": "trips"})); - ask(&mut h, "ui.key", json!({"key": "Enter"})); - assert!(keywords(&h).contains(&"trips".to_string()) && !keywords(&h).contains(&"travel".to_string()), "{:?}", keywords(&h)); - assert_eq!(h.app.ui.dialog, None); - // the menu's Paste - open(&mut h, "trips"); - h.view.clipboard = "weddings".into(); - ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordName", "button": "right"})); - ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordName:paste"})); - ask(&mut h, "ui.key", json!({"key": "Enter"})); - assert!(keywords(&h).contains(&"weddings".to_string()), "{:?}", keywords(&h)); - // Esc cancels - open(&mut h, "weddings"); - ask(&mut h, "ui.text", json!({"text": "parties"})); - ask(&mut h, "ui.key", json!({"key": "Escape"})); - assert_eq!(h.app.ui.dialog, None, "Esc closed the dialog"); - assert!(keywords(&h).contains(&"weddings".to_string()), "renamed nothing: {:?}", keywords(&h)); - } - - /// Return confirms the one-field dialogs: New Album, Rename Album, New Smart Album, Merge - /// Keywords and a text prompt (here: renaming an album). Their name opens selected, so typing - /// replaces it. Return did nothing: the field took the focus back on every frame, so egui never - /// reported the focus Return gives up. - #[test] - fn return_confirms_the_one_field_dialogs() { - use crate::state::Dialog; - let mut h = demo([1200.0, 800.0]); - let t = Duration::from_secs(10); - let first = h.app.session.visible_cloned()[0].0; - h.request("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [first], "addKeywords": ["holiday"]}}), t); - let album = |h: &Headless, name: &str| h.app.session.catalog.albums().find(|a| a.name == name).map(|a| a.id.0); - let garden = album(&h, "Garden").expect("the demo's Garden album"); - let confirm = |h: &mut Headless, dialog: Dialog, typed: &str| { - h.app.ui.dialog = Some(dialog); - h.settle(SETTLE); - for (method, params) in [("ui.text", json!({"text": typed})), ("ui.key", json!({"key": "Enter"}))] { - let r = h.request(method, params, t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - } - assert_eq!(h.app.ui.dialog, None, "Return confirmed the dialog ({typed})"); - }; - confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }, "Weddings"); - assert!(album(&h, "Weddings").is_some(), "New Album"); - confirm(&mut h, Dialog::RenameAlbum { id: garden, name: "Garden".into() }, "Flowers"); - assert_eq!(album(&h, "Flowers"), Some(garden), "Rename Album"); - confirm(&mut h, Dialog::NewSmartAlbum { name: "Smart Album".into(), parent: None }, "Everything"); - assert!(album(&h, "Everything").is_some(), "New Smart Album"); - confirm(&mut h, Dialog::MergeKeywords { from: vec!["holiday".into()], into: String::new() }, "travel"); - let keywords = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(first)).unwrap().meta.keywords.clone(); - assert!(keywords.contains(&"travel".to_string()) && !keywords.contains(&"holiday".to_string()), "Merge Keywords: {keywords:?}"); - let prompt = Dialog::TextPrompt { - title: "Rename Album".into(), - hint: "Name".into(), - value: "Flowers".into(), - command: "album.rename".into(), - params: json!({"id": garden}), - key: "name".into(), - }; - confirm(&mut h, prompt, "Botanical"); - assert_eq!(album(&h, "Botanical"), Some(garden), "the text prompt"); - } - - /// The sidebar's file-system checks run on worker threads and add rows when they land, which - /// moves every row below them: `busy()` counts them, so `settle` waits for them before a test - /// reads widget positions (a click aimed at a stale rect hits the neighbouring row). - #[test] - fn settle_waits_for_file_system_checks() { - use std::sync::atomic::{AtomicBool, Ordering}; - static RELEASE: AtomicBool = AtomicBool::new(false); - fn slow_check(_: &str) -> bool { - let t0 = Instant::now(); - while !RELEASE.load(Ordering::Acquire) && t0.elapsed() < Duration::from_secs(60) { - std::thread::sleep(Duration::from_millis(1)); - } - true - } - let mut h = demo([800.0, 600.0]); - h.settle(SETTLE); - let check = |h: &mut Headless| { - let raw = HeadlessView::raw_input(h.size, 1.0, 0.0, vec![]); - let mut got = None; - h.view.run(raw, |ui| got = crate::panels::left::fs_cached(ui, "test-slow", "x", f64::INFINITY, slow_check)); - got - }; - assert_eq!(check(&mut h), None, "the answer is worked out off the UI thread"); - assert!(h.busy(), "a pending file-system check is pending work"); - RELEASE.store(true, Ordering::Release); - assert!(h.settle(SETTLE)); - assert!(!h.busy()); - assert_eq!(check(&mut h), Some(true)); - } - - /// Photo > Rename Photos…: the dialog previews and renames the selection. - #[test] - fn rename_dialog_renames_the_selection() { - let mut h = demo([1200.0, 800.0]); - let t = Duration::from_secs(10); - let vis: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": vis}}), t); - let tree = h.request("ui.menu.tree", json!({}), t); - assert!(tree.to_string().contains("Rename 2 Photos…"), "live menu label"); - h.request("engine.execute", json!({"command": "dialog.rename", "params": {"template": "Trip-{seq:2}", "start": 5}}), t); - h.settle(SETTLE); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let name = |id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().file_name.clone(); - assert!(name(vis[0]).starts_with("Trip-05."), "{}", name(vis[0])); - assert!(name(vis[1]).starts_with("Trip-06."), "{}", name(vis[1])); - h.settle(SETTLE); - } - - /// The Export dialog hands its batch to a worker thread: the UI keeps drawing frames, shows - /// progress, and reports the result when the files are written. - #[test] - fn export_dialog_keeps_its_width() { - // A dialog row sized as "available width − a guessed button width" made the auto-sized - // window grow a little every frame when the real button was wider (issue #8, Linux). - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.app.services.pick_folder = Some(Box::new(|| None)); - h.app.services.pick_files = Some(Box::new(Vec::new)); - h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); - // the widest variant: watermark graphic and every optional row - if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { - let mut wm = lightcraft_engine::export::Watermark { image: "logo.png".into(), ..Default::default() }; - wm.text.clear(); - opts.watermark = Some(wm); - } - let width = |h: &mut Headless| { - let r = h.request("ui.widgets", json!({}), t); - r["result"] - .as_array() - .and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")) - .map(|w| w["rect"][2].as_f64().unwrap()) - .expect("dialog on screen") - }; - for _ in 0..5 { - h.step(); - } - let w0 = width(&mut h); - for _ in 0..60 { - h.step(); - } - let w1 = width(&mut h); - assert!((w1 - w0).abs() < 0.5, "the export dialog grew from {w0} to {w1}"); - assert!(w1 < 700.0, "{w1}"); - } - - /// With a watermark on, the Export dialog shows the first photo with the watermark on it. - #[test] - fn export_dialog_previews_the_watermark() { - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); - let preview = |h: &mut Headless| { - let r = h.request("ui.widgets", json!({}), t); - r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "image:exportWatermarkPreview")).map(|w| w["rect"].clone()) - }; - h.settle(SETTLE); - assert!(preview(&mut h).is_none(), "no preview without a watermark"); - if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { - opts.watermark = Some(lightcraft_engine::export::Watermark { text: "© LightCraft".into(), ..Default::default() }); - } - let t0 = Instant::now(); - while preview(&mut h).is_none() && t0.elapsed() < Duration::from_secs(30) { - h.step(); - } - let rect = preview(&mut h).expect("the watermark preview is shown"); - assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}"); - } - - /// Issue #1952: a previous export saved with a depth its format doesn't have (an API call made - /// before `app.export` refused it) opens at a depth the format offers, so the dialog's Export, - /// which has no depth to pick for JPEG, doesn't fail; a depth the format has is kept. - #[test] - fn export_dialog_drops_a_depth_the_format_lacks() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let opened = |h: &mut Headless, last: Value| { - h.app.ui.dialog = None; - h.app.session.last_export = Some(last); - h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); - match &h.app.ui.dialog { - Some(crate::state::Dialog::Export { opts, .. }) => opts.clone(), - _ => panic!("the export dialog is open"), - } - }; - let o = opened(&mut h, json!({"format": "jpeg", "bitDepth": 16})); - assert_eq!(o.bit_depth, None); - lightcraft_engine::export::ExportOptions::from_params(&o.to_json()).expect("the dialog's settings export"); - assert_eq!(opened(&mut h, json!({"format": "tiff", "bitDepth": 32})).bit_depth, Some(32)); - } - - /// Review of #1966: a `path` whose extension names no format exports JPEG here, so its - /// `bitDepth` is checked against JPEG's, not let through to an 8-bit file. - #[test] - fn export_checks_the_depth_of_the_format_a_bare_path_gets() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let out = std::env::temp_dir().join(format!("lc-bare-path-depth-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&out); - std::fs::create_dir_all(&out).unwrap(); - h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); - for name in ["output", "output.xyz"] { - let p = json!({"path": out.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); - assert_eq!(r["ok"], false, "{name}: {r}"); - assert!(r["error"].as_str().unwrap().contains("`bitDepth` must be 8 for jpeg"), "{name}: {r}"); - } - assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); - // the format such a path gets is still JPEG - let p = json!({"path": out.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 8}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); - assert_eq!(r["ok"], true, "{r}"); - let written = std::fs::read_dir(&out).unwrap().next().unwrap().unwrap().path(); - assert!(std::fs::read(&written).unwrap().starts_with(&[0xFF, 0xD8]), "{written:?} is a JPEG"); - let _ = std::fs::remove_dir_all(&out); - } - - #[test] - fn export_dialog_runs_in_the_background() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let w = written.clone(); - h.app.services.write_shared = Some(std::sync::Arc::new(move |p: &str, _: &[u8]| { - std::thread::sleep(Duration::from_millis(150)); // a slow disk: the UI must not wait for it - w.lock().unwrap().push(p.to_string()); - Ok(()) - })); - let ids: Vec = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t); - h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); - h.settle(SETTLE); - if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog { - *full_size = false; - *resize = lightcraft_engine::export::Resize::long_edge(64); - *dir = "/lc-test-out".into(); - } - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.export.is_some(), "running in the background"); - let running = h.request("ui.inspect", json!({}), t); - assert_eq!(running["result"]["export"]["running"]["total"], 3, "{}", running["result"]["export"]); - // …and as a row in the activity stack (issue #345) - assert_eq!(running["result"]["activity"][0]["kind"], "export", "{}", running["result"]["activity"]); - let t0 = Instant::now(); - while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) { - h.step(); - } - assert!(h.app.export.is_none(), "finished"); - let w = written.lock().unwrap().clone(); - assert_eq!(w.len(), 3, "{w:?}"); - assert!(w.iter().all(|p| p.starts_with("/lc-test-out/"))); - let after = h.request("ui.inspect", json!({}), t)["result"].clone(); - assert_eq!(after["activity"], json!([]), "the row is gone"); - let last = after["export"]["last"].clone(); - assert_eq!(last["files"].as_array().map(Vec::len), Some(3), "{last}"); - assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64)); - } - - /// Issue #236: the Export dialog's Add to Apple Photos is there on macOS only; there the - /// background export hands the files it wrote to Photos (a fake runner: tests never start - /// osascript or Photos) and the toast says how it went. - #[test] - fn export_dialog_adds_to_apple_photos_on_macos() { - let mut h = demo([1200.0, 1100.0]); - let t = Duration::from_secs(10); - let out = std::env::temp_dir().join(format!("lc-photos-dialog-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&out); - h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); - let calls = Arc::new(std::sync::Mutex::new(Vec::>::new())); - let c = calls.clone(); - h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { - c.lock().unwrap().push(args.to_vec()); - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "A/L0/001\nB/L0/001\n".into(), stderr: String::new() }) - })); - let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t); - h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); - h.settle(SETTLE); - let shown = h.request("ui.widgets", json!({"filter": "exportAddToPhotos"}), t)["result"].as_array().map_or(0, Vec::len); - assert_eq!(shown > 0, cfg!(target_os = "macos"), "the option is offered where Photos is"); - if !cfg!(target_os = "macos") { - return; - } - h.request("ui.clickWidget", json!({"id": "exportAddToPhotos"}), t); - h.settle(SETTLE); - if let Some(crate::state::Dialog::Export { opts, full_size, resize, dir, .. }) = &mut h.app.ui.dialog { - assert!(opts.add_to_photos, "the checkbox turns it on"); - opts.photos_album = "Trip “2026”".into(); - *full_size = false; - *resize = lightcraft_engine::export::Resize::long_edge(48); - *dir = out.to_string_lossy().into_owned(); - } - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let t0 = Instant::now(); - while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) { - h.step(); - } - let last = h.request("ui.inspect", json!({}), t)["result"]["export"]["last"].clone(); - let written: Vec = last["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap().to_string()).collect(); - assert_eq!(written.len(), 2, "{last}"); - assert_eq!(last["applePhotos"]["imported"], 2, "{last}"); - assert_eq!(last["applePhotos"]["album"], "Trip “2026”"); - let args = calls.lock().unwrap().clone(); - assert_eq!(args.len(), 1); - assert_eq!(args[0][args[0].len() - 2..], written[..], "the files written go to Photos"); - let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); - assert!(toast.contains("2 added to Apple Photos album “Trip “2026””"), "{toast}"); - // Export with Previous repeats it - assert_eq!(h.app.session.last_export.as_ref().unwrap()["addToPhotos"], true); - let _ = std::fs::remove_dir_all(&out); - } - - /// Codex review of #643: the progress line while Photos imports was translated on the export's - /// worker thread, whose language is always English. - #[test] - fn the_photos_progress_line_is_in_the_users_language() { - if !cfg!(target_os = "macos") { - return; - } - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let out = std::env::temp_dir().join(format!("lc-photos-language-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&out); - h.app.ui.language = crate::i18n::Locale::De; - crate::i18n::set_language(crate::i18n::Locale::De); - h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); - let (release, held) = std::sync::mpsc::channel::<()>(); - let held = std::sync::Mutex::new(held); - let (entered, photos_asked) = std::sync::mpsc::channel::<()>(); - let entered = std::sync::Mutex::new(entered); - h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { - let _ = entered.lock().unwrap().send(()); - let _ = held.lock().unwrap().recv_timeout(Duration::from_secs(30)); - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) - })); - let ids: Vec = h.app.session.visible_cloned().iter().take(1).map(|p| p.0).collect(); - let params = json!({"ids": ids, "dir": out, "longEdge": 48, "addToPhotos": true, "background": true}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": params}), t); - assert_eq!(r["ok"], true, "{r}"); - // Photos has been asked, and the window hasn't drawn a frame since (Codex review of #643): - // the export's row already has no ✕, and a cancel is refused rather than half-honoured - photos_asked.recv_timeout(Duration::from_secs(60)).expect("the export asks Photos"); - let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); - assert!(!row.cancellable, "{row:?}"); - assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked"); - let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default(); - assert_eq!(current, crate::i18n::Locale::De.tr("Adding to Apple Photos…")); - // the row says so on the next frame (issue #345) - h.step(); - let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); - assert_eq!((row.detail.as_str(), row.cancellable), ("Wird zu Apple Fotos hinzugefügt…", false), "{row:?}"); - release.send(()).unwrap(); - let t0 = Instant::now(); - while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(90) { - h.step(); - } - let last = h.app.last_export_result.clone().expect("the export reports"); - assert_eq!((last["cancelled"].as_bool(), last["applePhotos"]["imported"].as_u64()), (Some(false), Some(1)), "{last}"); - crate::i18n::set_language(crate::i18n::Locale::En); - let _ = std::fs::remove_dir_all(&out); - } - - /// Issue #236 review: control requests run on the UI thread, which must not wait minutes for - /// Photos. `export.addToPhotos` and `app.export {addToPhotos}` without `background` answer at - /// once with the running job; the app announces the outcome when Photos is done. - #[test] - fn a_slow_photos_import_never_blocks_the_window() { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(30); - let out = std::env::temp_dir().join(format!("lc-photos-slow-ui-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&out); - std::fs::create_dir_all(&out).unwrap(); - let file = out.join("a.jpg"); - std::fs::write(&file, b"jpeg").unwrap(); - h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); - let (go, wait) = std::sync::mpsc::channel::<()>(); - let wait = std::sync::Mutex::new(wait); - h.app.session.apple_photos_background = true; // as the desktop app sets it - h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { - // a regression (Photos on the UI thread) fails the timing below instead of hanging - let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); - let n = args.len() - args.iter().position(|a| a == lightcraft_engine::apple_photos::MARKER).unwrap() - 2; - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) - })); - let t0 = Instant::now(); - let r = - h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "album": "Slow"}}), t); - assert!(t0.elapsed() < Duration::from_secs(5), "answered at once: {r}"); - assert_eq!(r["result"]["running"], true, "{r}"); - go.send(()).unwrap(); - while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Apple Photos")) { - assert!(t0.elapsed() < Duration::from_secs(20), "the outcome is announced"); - h.step(); - } - assert_eq!(h.app.ui.toast.as_ref().unwrap().0, "1 added to Apple Photos album “Slow”"); - if cfg!(target_os = "macos") { - let t1 = Instant::now(); - let p = json!({"dir": out.to_string_lossy(), "longEdge": 32, "addToPhotos": true}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); - assert!(t1.elapsed() < Duration::from_secs(10), "the export answers without waiting for Photos: {r}"); - assert_eq!(r["result"]["applePhotos"]["running"], true, "{r}"); - assert_eq!(r["result"]["applePhotos"]["job"], 2, "{r}"); - go.send(()).unwrap(); - while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("1 added to Apple Photos") && !t.0.contains("Slow")) { - assert!(t1.elapsed() < Duration::from_secs(20), "the outcome is announced"); - h.step(); - } - } - let _ = std::fs::remove_dir_all(&out); - } - - /// Review of #236: a headless run (snapshot scripts) ending while an import it started with - /// `wait: false` is still running waits for it, on every exit path (dropping the `Headless`). - #[test] - fn a_headless_run_waits_for_its_photos_import_before_ending() { - let mut h = demo([800.0, 600.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-photos-headless-drop-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("a.jpg"); - std::fs::write(&file, b"jpeg").unwrap(); - let marker = dir.join("imported"); - let m = marker.clone(); - // Photos "imports" when `go` arrives (at most 20 s): released 0.8 s into the drop below - let (go, wait) = std::sync::mpsc::channel::<()>(); - let wait = std::sync::Mutex::new(wait); - h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { - let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); - std::fs::write(&m, b"done").unwrap(); - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) - })); - let r = - h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "wait": false}}), t); - assert_eq!(r["result"]["running"], true, "{r}"); - assert!(!marker.exists(), "still importing"); - let release = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(800)); - let _ = go.send(()); - }); - drop(h); - assert!(marker.exists(), "the import finished before the run ended"); - release.join().unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Review of #236: a snapshot that ends (an error in its script, say) while its background - /// export is still writing waits through the writing and then the import the export starts. - #[test] - fn a_headless_run_waits_for_its_exports_photos_import() { - if !cfg!(target_os = "macos") { - return; // addToPhotos is refused before exporting elsewhere - } - let mut h = demo([800.0, 600.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-photos-headless-export-drop-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let marker = dir.join("imported"); - h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| { - std::thread::sleep(Duration::from_millis(300)); // a slow disk: still writing at the drop - lightcraft_engine::export::write_file(p, bytes) - })); - let m = marker.clone(); - h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { - std::thread::sleep(Duration::from_millis(500)); - std::fs::write(&m, b"done").unwrap(); - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) - })); - let p = json!({"dir": dir.join("out").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "background": true}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.session.apple_photos_imports.running().is_some(), "reserved while writing"); - drop(h); - assert!(marker.exists(), "the export's import finished before the run ended"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Review of #236: a background export adding to Photos holds the import from before it - /// writes. While its files are being written, `export.addToPhotos` is refused with the - /// export's job number; the export then adds its files under that job. - #[test] - fn a_background_export_holds_photos_while_it_writes() { - if !cfg!(target_os = "macos") { - return; // addToPhotos is refused before exporting elsewhere - } - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - let out = std::env::temp_dir().join(format!("lc-photos-hold-ui-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&out); - std::fs::create_dir_all(&out).unwrap(); - let other = out.join("other.jpg"); - std::fs::write(&other, b"jpeg").unwrap(); - // the writer waits for `go`: the export is still writing while we try another import - let (go, wait) = std::sync::mpsc::channel::<()>(); - let wait = std::sync::Mutex::new(wait); - h.app.services.write_shared = Some(Arc::new(move |p: &str, bytes: &[u8]| { - let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); - lightcraft_engine::export::write_file(p, bytes) - })); - let calls = Arc::new(std::sync::Mutex::new(Vec::>::new())); - let c = calls.clone(); - h.app.session.apple_photos_background = true; - h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { - c.lock().unwrap().push(args.to_vec()); - Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) - })); - let p = json!({"dir": out.join("export").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "photosAlbum": "Held", "background": true}); - let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.export.is_some(), "writing in the background"); - let r = h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [other.to_string_lossy()]}}), t); - assert_eq!(r["ok"], false, "{r}"); - assert!(r["error"].as_str().unwrap().contains("An export is about to add its files to Apple Photos (import 1)"), "{r}"); - go.send(()).unwrap(); - let t0 = Instant::now(); - while h.app.export.is_some() { - assert!(t0.elapsed() < Duration::from_secs(20), "the export finishes"); - h.step(); - } - let last = h.app.last_export_result.clone().unwrap(); - assert_eq!((last["applePhotos"]["job"].as_u64(), last["applePhotos"]["imported"].as_u64()), (Some(1), Some(1)), "{last}"); - let args = calls.lock().unwrap().clone(); - assert_eq!(args.len(), 1, "only the export's files went to Photos"); - assert!(args[0].last().unwrap().contains("export"), "{:?}", args[0].last()); - let _ = std::fs::remove_dir_all(&out); - } - - /// Dragging grid photos onto an album row adds them to the album. - #[test] - fn drag_photos_onto_an_album() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - h.request("engine.execute", json!({"command": "view.leftPanel"}), t); - let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Dropped"}}), t); - let album = r["result"]["id"].as_u64().unwrap_or_else(|| panic!("{r}")); - h.settle(SETTLE); - let rect = |h: &mut Headless, id: &str| { - let w = h.request("ui.widgets", json!({"filter": id}), t); - let r = w["result"] - .as_array() - .and_then(|a| a.iter().find(|x| x["id"] == id)) - .map(|x| x["rect"].clone()) - .unwrap_or_else(|| panic!("{id}: {w}")); - let f = |i: usize| r[i].as_f64().unwrap(); - (f(0) + f(2) / 2.0, f(1) + f(3) / 2.0) - }; - let first = h.app.session.visible_cloned()[0].0; - let (x, y) = rect(&mut h, &format!("thumb:{first}")); - let (tx, ty) = rect(&mut h, &format!("source:album:{album}")); - let r = h.request("ui.drag", json!({"x": x, "y": y, "toX": tx, "toY": ty, "steps": 12}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let members = h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.clone(); - assert_eq!(members, vec![lightcraft_catalog::PhotoId(first)]); - assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); - } - - /// Issue #10: a raw shown from its embedded JPEG (an undecodable raw variant) says so — a badge - /// on its grid thumbnail, a pill on the loupe, a notice in Edit and Info — and other photos - /// show none of it. - #[test] - fn preview_only_raw_shows_badge_and_notices() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - let vis = h.app.session.visible_cloned(); - let (po, other) = (vis[0], vis[1]); - let ph = h.app.session.catalog.photo(po).unwrap().clone(); - h.app - .session - .catalog - .apply(lightcraft_catalog::Op::SetContent { - id: po, - width: ph.width, - height: ph.height, - file_size: ph.file_size, - content_hash: ph.content_hash.clone(), - preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()), - preview_checked: lightcraft_catalog::APP_VERSION.into(), - }) - .unwrap(); - h.settle(SETTLE); - let ids = |h: &mut Headless, filter: &str| -> Vec { - let w = h.request("ui.widgets", json!({"filter": filter}), t); - w["result"].as_array().unwrap_or_else(|| panic!("{w}")).iter().filter_map(|x| x["id"].as_str().map(str::to_string)).collect() - }; - let badges = ids(&mut h, "badge:previewOnly:"); - assert_eq!(badges, vec![format!("badge:previewOnly:{}", po.0)], "only the preview-only photo has the badge"); - // the loupe and the Edit panel - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [po.0]}}), t); - h.request("ui.set", json!({"view": "detail"}), t); - h.app.ui.right = crate::state::RightPanel::Edit; - h.settle(SETTLE); - let n = ids(&mut h, "notice:previewOnly:"); - assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}"); - // with a Reload from Disk button (issue #662) - assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:edit".to_string()]); - // Info - h.app.ui.right = crate::state::RightPanel::Info; - h.settle(SETTLE); - assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string())); - assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:info".to_string()]); - // …which re-reads the file and says what came of it (a demo photo has none to re-read) - let r = h.request("ui.clickWidget", json!({"id": "button:reloadPreviewOnly:info"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(h.app.ui.toast.clone().map(|x| x.0).as_deref(), Some("This raw variant still can't be decoded")); - // a regular photo: no notice - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t); - h.app.ui.right = crate::state::RightPanel::Edit; - h.settle(SETTLE); - assert!(ids(&mut h, "notice:previewOnly:").is_empty()); - } - - #[test] - fn crop_default_browsing_is_read_only_and_drag_is_one_edit() { - let mut h = demo([1200.0, 800.0]); - let photos: Vec<_> = h.app.session.visible_cloned().into_iter().filter(|id| !h.app.session.catalog.photo(*id).unwrap().is_edited()).collect(); - h.app.run("library.select", json!({"ids": [photos[0].0]})).unwrap(); - h.app.ui.right = crate::state::RightPanel::Crop; - h.app.run("crop.aspect", json!({"aspect": "1x1"})).unwrap(); - let saved = serde_json::to_value(&h.app.ui).unwrap(); - for &photo in &photos[1..3] { - h.app.run("library.select", json!({"ids": [photo.0]})).unwrap(); - let before = h.app.session.catalog.photo(photo).unwrap().clone(); - let undo = h.app.session.undo.len(); - h.settle(SETTLE); - let after = h.app.session.catalog.photo(photo).unwrap(); - assert_eq!(after.develop, before.develop); - assert_eq!(after.history.len(), before.history.len()); - assert!(!after.is_edited()); - assert_eq!(h.app.session.undo.len(), undo); - assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); - } - let photo = photos[2]; - let before = h.app.session.develop_of(photo).unwrap(); - let undo = h.app.session.undo.len(); - h.app.run("develop.beginInteraction", json!({"label": "Crop"})).unwrap(); - h.app.begin_crop().unwrap(); - h.app.run("crop.drag", json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [0.7, 0.7]})).unwrap(); - h.app.run("develop.endInteraction", json!({})).unwrap(); - assert_eq!(h.app.session.develop_of(photo).unwrap().crop.aspect, Some((100, 100))); - assert_eq!(h.app.session.undo.len(), undo + 1); - h.app.run("edit.undo", json!({})).unwrap(); - assert_eq!(h.app.session.develop_of(photo).unwrap(), before); - h.app.ui = serde_json::from_value(saved).unwrap(); - assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); - h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); - assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); - assert_eq!(h.app.session.develop_of(photo).unwrap(), before); - h.app.ui.crop_default_aspect = Some(json!("original")); - let p = h.app.session.catalog.photo(photo).unwrap(); - assert_eq!(h.app.crop_tool_aspect(), Some((p.width * 100, p.height * 100))); - h.settle(SETTLE); - assert_eq!(h.app.session.develop_of(photo).unwrap(), before); - } - - /// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect. - #[test] - fn crop_keys() { - let mut h = demo([1200.0, 800.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.crop"}), t); - assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Thirds); - let mask_overlay = h.app.ui.mask_overlay; - h.request("ui.key", json!({"key": "O"}), t); - assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Grid); - h.request("ui.key", json!({"key": "O", "shift": true}), t); - assert_eq!(h.app.ui.crop_overlay_orient, 1); - assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping"); - // an untouched photo starts locked to its own shape (#991): A unlocks, A again locks - let locked = |h: &Headless| h.app.crop_tool_aspect().is_some(); - assert!(locked(&h)); - h.request("ui.key", json!({"key": "A"}), t); - assert!(!locked(&h)); - assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); - h.request("ui.key", json!({"key": "A"}), t); - assert!(locked(&h)); - assert!(!h.app.ui.visualize_spots); - } - - /// Bring a right-panel field into the visible panel before clicking it. The Info panel is a - /// scroll area: 0.37's roomier text rows push the place fields below a 1000-point window, and a - /// click at an off-screen rect would leave the previous field focused. - fn click_field_in_view(h: &mut Headless, key: &str, timeout: Duration) { - let id = format!("field:{key}"); - let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:right_panel").map(|(_, r)| *r).expect("the right panel is laid out"); - let rect = h.app.widgets.iter().find(|(w, _)| *w == id).map(|(_, r)| *r).expect("the field is laid out"); - if !panel.contains(rect.center()) { - let dy = (panel.center().y - rect.center().y).clamp(-4000.0, 4000.0); - assert_eq!(h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), timeout)["ok"], true); - assert_eq!(h.request("ui.scroll", json!({"dy": dy}), timeout)["ok"], true); - h.settle(timeout); - } - let rect = h.app.widgets.iter().find(|(w, _)| *w == id).map(|(_, r)| *r).expect("the field is laid out"); - assert!(panel.contains(rect.center()), "the field must be scrolled into view before clicking: {rect:?} in {panel:?}"); - let r = h.request("ui.clickWidget", json!({"id": id}), timeout); - assert_eq!(r["ok"], true, "{r}"); - } - - /// Info panel: typing into a field survives frames and is saved when it loses focus; the new - /// accessibility and place fields reach the photo. - #[test] - fn info_fields_keep_typing_and_save() { - // (tall enough for the place fields to show without scrolling) - let mut h = demo([1300.0, 1200.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - h.settle(SETTLE); - for (key, text) in [("altText", "A lake at dawn"), ("usageTerms", "Editorial use only"), ("city", "Zermatt")] { - click_field_in_view(&mut h, key, t); - h.request("ui.key", json!({"key": "A", "cmd": true}), t); - h.request("ui.text", json!({"text": text}), t); - h.step(); - h.step(); - // still being edited: not saved yet, not lost either - h.request("ui.key", json!({"key": "Tab"}), t); - h.settle(Duration::from_secs(5)); - } - let m = &h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta; - assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt")); - assert_eq!(m.usage_terms, "Editorial use only"); - // the copyright status picker is on the panel too - let w = h.request("ui.widgets", json!({"filter": "copyrightStatus"}), t); - assert!(w["result"].to_string().contains("field:copyrightStatus"), "{w}"); - } - - /// Info panel fields are shared text fields. The caption takes several lines: Return starts a - /// new one and only leaving the field saves it. Esc gives up an edit (the title stays as it - /// was, nothing saved), and right-click ▸ Paste works there too. - #[test] - fn info_fields_take_lines_esc_and_the_menu() { - let mut h = demo([1300.0, 1000.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - h.settle(SETTLE); - let meta = |h: &Headless| h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta.clone(); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.step(); - h.step(); - }; - let (title, undo) = (meta(&h).title, h.app.session.undo.len()); - // the caption: two lines, saved on leaving - ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"})); - ask(&mut h, "ui.key", json!({"key": "A", "cmd": true})); - ask(&mut h, "ui.text", json!({"text": "Harbour at dusk"})); - ask(&mut h, "ui.key", json!({"key": "Enter"})); - ask(&mut h, "ui.text", json!({"text": "Lisbon, 2026"})); - assert_eq!(h.app.session.undo.len(), undo, "Return in the caption saves nothing yet"); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - assert_eq!(meta(&h).caption, "Harbour at dusk\nLisbon, 2026"); - // the title: Esc gives the edit up - let undo = h.app.session.undo.len(); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); - ask(&mut h, "ui.text", json!({"text": " (draft)"})); - ask(&mut h, "ui.key", json!({"key": "Escape"})); - h.settle(SETTLE); - assert_eq!((meta(&h).title, h.app.session.undo.len()), (title, undo), "Esc saved nothing"); - // the menu - h.view.clipboard = "Wedding in Sintra".into(); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); - ask(&mut h, "ui.key", json!({"key": "A", "cmd": true})); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title", "button": "right"})); - ask(&mut h, "ui.clickWidget", json!({"id": "field:title:paste"})); - ask(&mut h, "ui.key", json!({"key": "Enter"})); - assert_eq!(meta(&h).title, "Wedding in Sintra"); - } - - /// With several photos selected the Info panel offers Target Photo / Selected Photos. Target - /// Photo (the default) shows the active photo's values. Selected Photos leaves a field whose - /// values differ empty, so what is typed there is what every selected photo gets, and keeps - /// the value of a field they share; leaving a mixed field untouched saves nothing. - #[test] - fn info_shows_metadata_for_the_selected_photos() { - let mut h = demo([1300.0, 1000.0]); - let t = Duration::from_secs(10); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.step(); - h.step(); - r - }; - let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { - ask(h, "engine.execute", json!({"command": command, "params": params}))["result"].clone() - }; - let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); - let (a, b) = (lightcraft_catalog::PhotoId(ids[0]), lightcraft_catalog::PhotoId(ids[1])); - let meta = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.clone(); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn", "copyrightStatus": "copyrighted"})); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "title": "Dusk"})); - exec(&mut h, "photo.setMeta", json!({"ids": ids, "creator": "Ana"})); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); - h.settle(SETTLE); - let buttons = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "infoFor"}), t)["result"].to_string(); - assert!(buttons(&mut h).contains("button:infoFor-selected"), "the choice shows with two photos selected"); - assert!(h.app.ui.settings.info_selected_photos, "Selected Photos by default"); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn!"})); - // Target Photo and back to Selected Photos, from their buttons - ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-target"})); - assert!(!h.app.ui.settings.info_selected_photos); - ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-selected"})); - assert!(h.app.ui.settings.info_selected_photos); - // a mixed field left untouched saves nothing - let undo = h.app.session.undo.len(); - click_field_in_view(&mut h, "title", t); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - h.settle(SETTLE); - assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo)); - // neither does switching the choice while a field is being edited, either way - for on in [false, true] { - click_field_in_view(&mut h, "title", t); - exec(&mut h, "view.infoSelectedPhotos", json!({"on": on})); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - h.settle(SETTLE); - assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo), "{on}"); - } - // the mixed title starts empty; the shared creator keeps its value - for (key, typed, want) in [("title", "Harbor", "Harbor"), ("creator", " Silva", "Ana Silva")] { - click_field_in_view(&mut h, key, t); - ask(&mut h, "ui.key", json!({"key": "End"})); - ask(&mut h, "ui.text", json!({"text": typed})); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - h.settle(SETTLE); - let got = |h: &Headless, id| if key == "title" { meta(h, id).title } else { meta(h, id).creator }; - assert_eq!((got(&h, a), got(&h, b)), (want.to_string(), want.to_string()), "{key}"); - } - // a mixed field typed in and left empty clears it on every selected photo - exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Old"})); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Other"})); - h.settle(SETTLE); - click_field_in_view(&mut h, "caption", t); - ask(&mut h, "ui.text", json!({"text": "x"})); - ask(&mut h, "ui.key", json!({"key": "Backspace"})); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - h.settle(SETTLE); - assert_eq!((meta(&h, a).caption, meta(&h, b).caption), (String::new(), String::new())); - // GPS: one photo's place and Show on Map only while the selected photos share it - let map = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "showOnMap"}), t)["result"].to_string().contains("showOnMap"); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "gps": [41.15, -8.61]})); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "gps": [38.72, -9.14]})); - h.settle(SETTLE); - assert!(!map(&mut h), "places differ"); - exec(&mut h, "photo.setMeta", json!({"ids": ids, "gps": [41.15, -8.61]})); - h.settle(SETTLE); - assert!(map(&mut h), "a shared place"); - // the command: refuses a value that isn't true or false, and turns Target Photo back on - let r = h.request("engine.execute", json!({"command": "view.infoSelectedPhotos", "params": {"on": "yes"}}), t); - assert_ne!(r["ok"], true, "{r}"); - assert!(h.app.ui.settings.info_selected_photos); - assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}))["on"], false); - assert!(!h.app.ui.settings.info_selected_photos); - assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({}))["on"], true, "no params toggles"); - // the choice is saved with the app settings; settings saved before it read as Selected Photos - exec(&mut h, "view.infoSelectedPhotos", json!({"on": false})); - let saved = serde_json::to_value(&h.app.ui.settings).unwrap(); - assert_eq!(saved["infoSelectedPhotos"], false); - let read: crate::state::AppSettings = serde_json::from_value(saved).unwrap(); - assert!(!read.info_selected_photos); - let old: crate::state::AppSettings = serde_json::from_value(json!({"filmNames": false})).unwrap(); - assert!(old.info_selected_photos); - // one photo selected: no choice to make - exec(&mut h, "library.select", json!({"ids": [ids[0]]})); - h.settle(SETTLE); - assert!(!buttons(&mut h).contains("infoFor-"), "{}", buttons(&mut h)); - } - - /// With several photos selected and the settings as they come, an edit to a field where the - /// photos differ starts from an empty field: the photos get what was typed, not the active - /// photo's value with the typing added. - #[test] - fn info_edits_of_a_multi_selection_start_from_mixed_fields_by_default() { - let mut h = demo([1300.0, 1000.0]); - let t = Duration::from_secs(10); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.step(); - h.step(); - }; - let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { - ask(h, "engine.execute", json!({"command": command, "params": params})); - }; - let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); - let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone(); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"})); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"})); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); - h.settle(SETTLE); - click_field_in_view(&mut h, "caption", t); - ask(&mut h, "ui.key", json!({"key": "End"})); - ask(&mut h, "ui.text", json!({"text": "Harbor"})); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - h.settle(SETTLE); - assert_eq!((caption(&h, 0), caption(&h, 1)), ("Harbor".into(), "Harbor".into())); - } - - /// Target Photo edits the photo it shows: with several photos selected, a field's edit is - /// saved to the active photo alone. - #[test] - fn info_target_photo_edits_only_the_photo_it_shows() { - let mut h = demo([1300.0, 1000.0]); - let t = Duration::from_secs(10); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.step(); - h.step(); - }; - let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { - ask(h, "engine.execute", json!({"command": command, "params": params})); - }; - let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); - let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone(); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"})); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"})); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); - exec(&mut h, "view.infoSelectedPhotos", json!({"on": false})); - h.settle(SETTLE); - click_field_in_view(&mut h, "caption", t); - ask(&mut h, "ui.key", json!({"key": "End"})); - ask(&mut h, "ui.text", json!({"text": "!"})); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - h.settle(SETTLE); - assert_eq!((caption(&h, 0), caption(&h, 1)), ("Dawn!".into(), "Dusk".into())); - } - - /// The fields the Info panel marks as mixed: each text field, the copyright status and GPS - /// where a selected photo differs from the photo the panel shows, and none for one photo. - #[test] - fn info_mixed_fields_are_the_ones_that_differ() { - let mut s = lightcraft_engine::Session::with_demo(); - let ids: Vec = s.visible_cloned().iter().take(3).map(|p| p.0).collect(); - let all = |key: &str, v: serde_json::Value| json!({"ids": ids, key: v}); - // (every field the panel compares starts the same on the three photos) - let fields = [ - "title", - "caption", - "altText", - "extendedDescription", - "copyright", - "usageTerms", - "copyrightUrl", - "creator", - "location", - "state", - "country", - ]; - for key in fields { - s.execute("photo.setMeta", &all(key, json!(""))).unwrap(); - } - for (key, v) in - [("title", json!("Same")), ("city", json!("Porto")), ("copyrightStatus", json!("copyrighted")), ("gps", json!([41.15, -8.61]))] - { - s.execute("photo.setMeta", &all(key, v)).unwrap(); - } - let photos: Vec = ids.iter().map(|&i| lightcraft_catalog::PhotoId(i)).collect(); - let shown = s.catalog.photo(photos[0]).unwrap().meta.clone(); - assert!(crate::panels::right::mixed_meta(&s.catalog, &photos, &shown).is_empty()); - assert!(crate::panels::right::mixed_meta(&s.catalog, &photos[..1], &shown).is_empty()); - s.execute("photo.setMeta", &json!({"ids": [ids[2]], "city": "Lisbon", "caption": "x", "gps": [38.72, -9.14]})).unwrap(); - s.execute("photo.setMeta", &json!({"ids": [ids[1]], "copyrightStatus": "publicDomain"})).unwrap(); - let mut mixed = crate::panels::right::mixed_meta(&s.catalog, &photos, &shown); - mixed.sort_unstable(); - assert_eq!(mixed, ["caption", "city", "copyrightStatus", "gps"]); - } - - /// Selected Photos with the active photo outside the selection: the fields show what the - /// selected photos share, not the active photo's values. - #[test] - fn info_selected_photos_ignore_an_active_photo_outside_the_selection() { - let mut h = demo([1300.0, 1000.0]); - let t = Duration::from_secs(10); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.step(); - h.step(); - }; - let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { - ask(h, "engine.execute", json!({"command": command, "params": params})); - }; - let ids: Vec = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect(); - let title = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.title.clone(); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[0], ids[1]], "title": "Same"})); - exec(&mut h, "photo.setMeta", json!({"ids": [ids[2]], "title": "Other"})); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[2]})); - exec(&mut h, "view.infoSelectedPhotos", json!({"on": true})); - h.settle(SETTLE); - click_field_in_view(&mut h, "title", t); - ask(&mut h, "ui.key", json!({"key": "End"})); - ask(&mut h, "ui.text", json!({"text": "!"})); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - h.settle(SETTLE); - assert_eq!((title(&h, 0), title(&h, 1), title(&h, 2)), ("Same!".into(), "Same!".into(), "Other".into())); - } - - /// An Info field saves to the photos selected when its edit started, also when the selection - /// changes while the field is being edited. - #[test] - fn info_field_saves_to_the_photos_selected_when_the_edit_started() { - // (tall enough for the GPS field to show without scrolling) - let mut h = demo([1300.0, 1800.0]); - let t = Duration::from_secs(10); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params.clone(), t); - assert_eq!(r["ok"], true, "{method} {params}: {r}"); - h.step(); - h.step(); - }; - let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { - ask(h, "engine.execute", json!({"command": command, "params": params})); - }; - let ids: Vec = h.app.session.visible_cloned().iter().take(4).map(|p| p.0).collect(); - let meta = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.clone(); - exec(&mut h, "photo.setMeta", json!({"ids": ids, "title": "Same", "gps": ""})); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); - h.settle(SETTLE); - click_field_in_view(&mut h, "title", t); - ask(&mut h, "ui.key", json!({"key": "End"})); - ask(&mut h, "ui.text", json!({"text": "!"})); - exec(&mut h, "library.select", json!({"ids": [ids[2]]})); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - h.settle(SETTLE); - assert_eq!((meta(&h, 0).title, meta(&h, 1).title, meta(&h, 2).title), ("Same!".into(), "Same!".into(), "Same".into())); - // GPS too (the new selection is two photos as well, so the panel keeps its layout) - exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); - h.settle(SETTLE); - click_field_in_view(&mut h, "gps", t); - ask(&mut h, "ui.text", json!({"text": "41.15, -8.61"})); - exec(&mut h, "library.select", json!({"ids": [ids[2], ids[3]], "active": ids[2]})); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - h.settle(SETTLE); - let gps: Vec = (0..4).map(|i| meta(&h, i).gps.is_some()).collect(); - assert_eq!(gps, [true, true, false, false]); - // Esc gives a GPS edit up - exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); - h.settle(SETTLE); - let before = meta(&h, 0).gps; - click_field_in_view(&mut h, "gps", t); - ask(&mut h, "ui.key", json!({"key": "End"})); - ask(&mut h, "ui.text", json!({"text": "5"})); - ask(&mut h, "ui.key", json!({"key": "Escape"})); - ask(&mut h, "ui.key", json!({"key": "Tab"})); - h.settle(SETTLE); - assert_eq!((meta(&h, 0).gps, meta(&h, 1).gps), (before, before)); - } - - /// Local: a folder's photos show without joining the library; the breadcrumb, Include - /// subfolders and Add to My Photos work from the grid header. - #[test] - fn browse_a_local_folder() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-browse-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("inner")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - for (i, p) in [dir.join("a.png"), dir.join("inner/b.png")].iter().enumerate() { - let mut img = img.clone(); - img.data[0][0] = i as u8; // different bytes per file - std::fs::write(p, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - } - let library_before = h.app.session.catalog.photos().filter(|p| !p.local).count(); - let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); - // the folder is read in the background: the view switches at once, the photos follow - assert_eq!(r["result"]["scanning"], true, "{r}"); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::Folder); - h.settle(SETTLE); - assert!(h.app.scan.is_none() && h.app.import.is_none()); - assert_eq!(h.app.session.visible_cloned().len(), 1); - assert!(h.settle(SETTLE), "a thumbnail that can't load must not keep the renderer busy"); - // this test session has no file hooks: the thumbnail fails once, is remembered, and isn't retried - let id = h.app.session.visible_cloned()[0]; - assert!(h.app.renderer.failure(crate::render::Slot::Thumb(id)).is_some()); - let done = h.app.renderer.completed; - for _ in 0..30 { - h.step(); - } - assert_eq!(h.app.renderer.completed, done, "no retry loop"); - let w = h.request("ui.widgets", json!({"filter": "crumb:"}), t); - assert!(w["result"].as_array().is_some_and(|a| !a.is_empty()), "breadcrumb: {w}"); - assert_eq!(h.request("ui.clickWidget", json!({"id": "check:includeSubfolders"}), t)["ok"], true); - h.settle(SETTLE); - assert_eq!(h.app.session.visible_cloned().len(), 2); - assert_eq!(h.request("ui.clickWidget", json!({"id": "button:addToLibrary"}), t)["ok"], true); - h.settle(SETTLE); - assert_eq!(h.app.session.catalog.photos().filter(|p| !p.local).count(), library_before + 2); - // Truncated ancestors remain clickable and still open the original, unabridged path. - let inner = dir.join("inner"); - h.request("engine.execute", json!({"command": "library.browse", "params": {"path": inner.to_string_lossy()}}), t); - h.settle(SETTLE); - let parent_index = dir.to_string_lossy().split(['/', '\\']).filter(|p| !p.is_empty()).count() - 1; - assert_eq!(h.request("ui.clickWidget", json!({"id": format!("crumb:{parent_index}")}), t)["ok"], true); - h.settle(SETTLE); - // the same folder, whatever the platform's separators (issue #538) - assert_eq!(std::path::Path::new(&h.app.session.browse.as_ref().unwrap().path), dir.as_path()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Local: Remove from Local hides a sidebar location (nothing on disk changes) and the - /// "Show hidden locations" row puts it back. The folder is browsed and kept the way Browse - /// Folder… does it, in the temporary folder — on Windows beneath Home, whose tree must not - /// open down to the hidden folder and push the restore row out of view. - #[test] - fn local_location_can_be_hidden_and_restored() { - let size = [1300.0, 900.0]; - let mut h = demo(size); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-ui-hide-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.to_string_lossy().to_string(); - let rects = |h: &mut Headless| -> Vec<(String, Vec)> { - let w = h.request("ui.widgets", json!({"filter": "source:local:"}), t); - w["result"] - .as_array() - .unwrap() - .iter() - .map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"].as_array().unwrap().iter().filter_map(|v| v.as_f64()).collect())) - .collect() - }; - let ids = |h: &mut Headless| -> Vec { rects(h).into_iter().map(|(id, _)| id).collect() }; - h.request("ui.set", json!({"leftPanel": true}), t); - h.hide_home_above(&dir); - // Browse Folder… browses the picked folder and keeps it in Local within one frame; a - // request runs frames, so keep it first (no frame sees it browsed but not yet kept) - let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let row = format!("source:local:{path}"); - assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the browsed folder is listed: {:?}", ids(&mut h)); - // the "Show hidden locations" row appears only while something is hidden - let hidden_before = !h.app.ui.hidden_locations.is_empty(); - assert_eq!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), hidden_before); - // hide it while it is still being browsed - let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - // give any listing a reveal would wait for time to arrive - for _ in 0..20 { - h.step(); - std::thread::sleep(Duration::from_millis(5)); - } - h.settle(SETTLE); - let after = rects(&mut h); - let after_ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect(); - assert!(!after_ids.contains(&format!("source:local:{path}").as_str()), "{after_ids:?}"); - let restore = after.iter().find(|(id, _)| id == "source:local:restoreHidden").map(|(_, r)| r.clone()); - let restore = restore.unwrap_or_else(|| panic!("no restore row: {after_ids:?}")); - assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}"); - assert!(h.app.session.browse.is_some(), "hiding does not stop browsing"); - assert!(dir.is_dir(), "the folder itself is untouched"); - // a second hidden place: the row lists them, and each comes back on its own (issue #2114: - // the row used to bring every hidden place back at once) - let dir2 = std::env::temp_dir().join(format!("lc-ui-hide-{}-b", std::process::id())); - let _ = std::fs::remove_dir_all(&dir2); - std::fs::create_dir_all(&dir2).unwrap(); - let path2 = dir2.to_string_lossy().to_string(); - for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] { - let r = h.request("engine.execute", json!({"command": command, "params": params}), t); - assert_eq!(r["ok"], true, "{r}"); - } - h.settle(SETTLE); - let menu = |h: &mut Headless| -> Vec { - let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), t); - w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() - }; - assert!(menu(&mut h).is_empty(), "the list opens from the row"); - assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); - h.settle(SETTLE); - let items = menu(&mut h); - assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}"); - assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true); - h.settle(SETTLE); - assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations); - assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden"); - assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); - // two hidden again: Restore All brings every one back - let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations); - assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); - h.settle(SETTLE); - assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); - h.settle(SETTLE); - assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); - assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); - // one hidden: the row brings it back itself, no list - let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path2}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); - h.settle(SETTLE); - assert!(menu(&mut h).is_empty(), "no list for a single hidden place"); - assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); - if !hidden_before { - // (with Home hidden for this test, the folder is back inside Home's tree instead) - assert!(ids(&mut h).contains(&format!("source:local:{path}"))); - } - let _ = std::fs::remove_dir_all(&dir); - let _ = std::fs::remove_dir_all(&dir2); - } - - /// Local: a kept folder stays a root while its subfolders (and other locations) are - /// browsed — the subfolder is highlighted inside its tree, its siblings stay listed — and - /// the kept folders survive a save/load of the UI state (a restart). - #[test] - fn kept_local_root_stays_while_browsing_below_and_elsewhere() { - let mut h = demo([1300.0, 1400.0]); - let t = Duration::from_secs(10); - let base = std::env::temp_dir().join(format!("lc-ui-roots-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - for d in ["Photos/2026/20260101", "Photos/2026/20260114", "Other"] { - std::fs::create_dir_all(base.join(d)).unwrap(); - } - let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); - // joined by component: the sidebar's ids spell paths with the platform's separator - let year = base.join("Photos").join("2026"); - let (photos, day1, day2, other) = (s(base.join("Photos")), s(year.join("20260101")), s(year.join("20260114")), s(base.join("Other"))); - h.hide_home_above(&base); - let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); - let rects = |h: &mut Headless| -> std::collections::HashMap { - let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t); - w["result"].as_array().unwrap().iter().map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"][0].as_f64().unwrap_or(0.0))).collect() - }; - h.request("ui.set", json!({"leftPanel": true}), t); - // Home may also own this temporary directory. Keep the fixture tree - // independent of the user's real folders and their async listings. - if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { - assert_eq!(exec(&mut h, "local.hide", json!({"path": home}))["ok"], true); - } - assert_eq!(exec(&mut h, "local.addRoot", json!({"path": photos}))["ok"], true); - assert_eq!(exec(&mut h, "local.addRoot", json!({"path": format!("{photos}/")}))["result"]["roots"].as_array().map(Vec::len), Some(1)); - exec(&mut h, "library.browse", json!({"path": day1})); - h.settle(SETTLE); - h.step(); - let row = format!("source:local:{day2}"); - assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the sibling listing did not arrive"); - let r = rects(&mut h); - assert!(r.contains_key(&format!("source:local:{photos}")), "the kept root stays: {r:?}"); - assert!(r.contains_key(&format!("source:local:{day2}")), "the sibling stays reachable: {r:?}"); - let (root_x, child_x) = (r[&format!("folderToggle:{photos}")], r[&format!("folderToggle:{day1}")]); - assert!(child_x > root_x, "the browsed folder is inside the root's tree, not a root of its own ({child_x} vs {root_x})"); - assert_eq!(h.request("ui.clickWidget", json!({"id": format!("source:local:{day2}")}), t)["ok"], true); - h.settle(SETTLE); - assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(day2.clone()), "the sibling is browsed"); - // another location: the kept root stays listed - assert_eq!(exec(&mut h, "library.browse", json!({"path": other}))["ok"], true); - h.settle(SETTLE); - h.step(); - let row = format!("source:local:{other}"); - assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the other location did not arrive"); - let r = rects(&mut h); - assert!(r.contains_key(&format!("source:local:{photos}")) && r.contains_key(&format!("source:local:{other}")), "{r:?}"); - // restart: the kept roots come back with the saved UI state - let saved = serde_json::to_string(&h.app.ui).unwrap(); - let back: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(back.local_roots, vec![photos.clone()]); - let _ = std::fs::remove_dir_all(&base); - h.settle(SETTLE); - } - - /// Versions panel: resting on a version previews it in the loupe without changing the photo; - /// a click restores it. - #[test] - fn versions_preview_on_hover_and_restore_on_click() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); - h.request("ui.set", json!({"view": "detail", "right": "versions"}), t); - exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0}})); - exec(&mut h, "version.create", json!({"name": "Bright"})); - exec(&mut h, "develop.set", json!({"values": {"light.exposure": -1.0}})); - h.settle(SETTLE); - let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; - assert_eq!(h.request("ui.hoverWidget", json!({"id": "version:0"}), t)["ok"], true); - h.step(); - assert_eq!(h.app.hover_preview.as_ref().map(|p| p.settings.light.exposure), Some(1.0)); - assert_eq!(exposure(&h), -1.0, "hovering changes nothing"); - assert_eq!(h.request("ui.clickWidget", json!({"id": "version:0"}), t)["ok"], true); - h.settle(Duration::from_secs(5)); - assert_eq!(exposure(&h), 1.0); - } - - /// ↑ / ↓ over a slider nudge it (one undo step each); ⇧Z picks and moves to the next photo. - #[test] - fn slider_nudge_and_pick_advance() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); - h.settle(SETTLE); - let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; - assert_eq!(h.request("ui.hoverWidget", json!({"id": "slider:light.exposure"}), t)["ok"], true); - h.request("ui.key", json!({"key": "Up"}), t); - h.request("ui.key", json!({"key": "Up", "shift": true}), t); - assert!((exposure(&h) - 0.30).abs() < 1e-9, "{}", exposure(&h)); - h.request("ui.key", json!({"key": "Down"}), t); - assert!((exposure(&h) - 0.25).abs() < 1e-9, "{}", exposure(&h)); - let first = h.app.session.active().unwrap(); - h.request("ui.key", json!({"key": "Z", "shift": true}), t); - assert_eq!(h.app.session.catalog.photo(first).unwrap().flag, lightcraft_catalog::Flag::Pick); - assert_ne!(h.app.session.active(), Some(first), "advanced"); - } - - /// Click a slider's value and type one (issue #322): Return sets it as one undo step, Esc - /// keeps the old value, and the keys typed don't reach the shortcuts (1 = one star). - #[test] - fn slider_values_can_be_typed() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); - h.settle(SETTLE); - let active = h.app.session.active().unwrap(); - let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; - let rating = |h: &Headless| h.app.session.catalog.photo(active).unwrap().rating; - let (undo0, rating0) = (h.app.session.undo.len(), rating(&h)); - assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); - h.request("ui.text", json!({"text": "1,5"}), t); - h.request("ui.key", json!({"key": "Enter"}), t); - assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); - assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); - assert_eq!(rating(&h), rating0, "typing 1 set no rating"); - // Esc keeps the value - assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); - h.request("ui.text", json!({"text": "-2"}), t); - h.request("ui.key", json!({"key": "Escape"}), t); - assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); - // something that isn't a number changes nothing - assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); - h.request("ui.text", json!({"text": "bright"}), t); - h.request("ui.key", json!({"key": "Enter"}), t); - assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); - assert_eq!(h.app.session.undo.len(), undo0 + 1); - } - - /// Reset takes precedence over the value being typed when its button is clicked. - #[test] - fn reset_button_wins_over_an_active_numeric_draft() { - let mut h = demo([960.0, 600.0]); - let t = Duration::from_secs(10); - let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { - let r = h.request(method, params, t); - assert_eq!(r["ok"], true, "{method}: {r}"); - r["result"].clone() - }; - ask(&mut h, "engine.execute", json!({"command": "app.gpu", "params": {"enabled": false}})); - ask(&mut h, "ui.set", json!({"settings": {"gpu": false, "previewEdge": 1280}, "presets": false})); - ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": [1], "active": 1}})); - ask( - &mut h, - "ui.set", - json!({"view": "detail", "right": "edit", "leftPanel": false, "rightWidth": 330, "openSections": ["light"], "histogram": false}), - ); - let active = h.app.session.active().unwrap(); - for (text, terminal) in [("0.75", "Enter"), ("-1,5", "Escape")] { - ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); - ask(&mut h, "ui.text", json!({"text": text})); - ask(&mut h, "ui.key", json!({"key": terminal})); - let value = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); - assert_eq!(value["light"]["exposure"].as_f64(), Some(0.75), "{terminal}: {value}"); - } - ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); - ask(&mut h, "ui.text", json!({"text": "2.0"})); - assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(crate::text_field::id("sliderField:light.exposure"))); - assert!(h.app.widgets.iter().any(|(id, _)| id == "sliderField:light.exposure")); - assert!(h.painted_text().iter().any(|text| text == "2.0"), "the replacement draft must be visible before Reset"); - assert_eq!(h.app.session.develop_of(active).unwrap().light.exposure, 0.75, "the draft is not committed yet"); - ask(&mut h, "ui.clickWidget", json!({"id": "button:reset"})); - let reset = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); - assert_eq!( - reset["light"]["exposure"].as_f64(), - Some(0.0), - "Reset must win over the active numeric draft: {reset}; history={:?}; last_inverse={:?}; focus={:?}", - h.app.session.undo.iter().map(|entry| entry.label.as_str()).collect::>(), - h.app.session.undo.last().map(|entry| &entry.op), - h.view.ctx.memory(|m| m.focused()), - ); - } - - /// A slider's typed value is a shared text field: right-click ▸ Paste replaces the value - /// (selected when the field opens) and Return applies it; the menu doesn't close the field. - #[test] - fn a_slider_value_pastes_from_its_menu() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); - h.settle(SETTLE); - h.view.clipboard = "0.75".into(); - for (id, button) in - [("sliderValue:light.exposure", "left"), ("sliderField:light.exposure", "right"), ("sliderField:light.exposure:paste", "left")] - { - let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - h.settle(SETTLE); - } - h.request("ui.key", json!({"key": "Enter"}), t); - h.settle(SETTLE); - let exposure = h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; - assert!((exposure - 0.75).abs() < 1e-9, "{exposure}"); - } - - /// The eye on a section header switches the section off and on again, one undo step each - /// (issue #316). - #[test] - fn section_eye_switches_a_section_off() { - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); - h.settle(SETTLE); - let light_on = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().section_enabled("light"); - for expected in [false, true] { - // the eye shows while the pointer is on the header - assert_eq!(h.request("ui.hoverWidget", json!({"id": "section:light"}), t)["ok"], true); - assert_eq!(h.request("ui.clickWidget", json!({"id": "sectionEye:light"}), t)["ok"], true); - assert_eq!(light_on(&h), expected); - } - assert!(h.app.ui.section_open("light"), "the click didn't fold the section"); - } - - /// Return commits a tool panel back to Edit; elsewhere it does nothing. - #[test] - fn return_commits_the_crop_tool() { - let mut h = demo([1200.0, 800.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.crop"}), t); - assert_eq!(h.app.ui.right, crate::state::RightPanel::Crop); - h.request("ui.key", json!({"key": "Enter"}), t); - assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit); - h.request("ui.key", json!({"key": "Enter"}), t); - assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit, "no-op outside tools"); - } - - /// ⌘Q (File → Quit LightCraft) closes the window. - #[test] - fn cmd_q_quits() { - let mut h = demo([900.0, 600.0]); - assert!(!h.quit_requested()); - h.request("ui.key", json!({"key": "Q", "cmd": true}), Duration::from_secs(10)); - h.step(); - h.step(); - assert!(h.quit_requested()); - } - - /// File → Import from Folder… opens the import review for a folder (searched recursively). - #[test] - fn add_folder_opens_the_import_review() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-addfolder-{}", std::process::id())); - let sub = dir.join("day 2"); - std::fs::create_dir_all(&sub).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let png = lightcraft_engine::export::encode_image( - &img, - &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, - ) - .unwrap(); - std::fs::write(dir.join("a.png"), &png).unwrap(); - std::fs::write(sub.join("b.png"), &png).unwrap(); - let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) }; - assert_eq!(opts.candidates.len(), 2, "both files, the subfolder's too"); - assert!(!opts.copy, "a folder is added in place by default"); - // the review names its source; scanning doesn't save a Local location - assert_eq!(opts.sources, vec![dir.to_string_lossy().to_string()]); - let name = dir.file_name().unwrap().to_string_lossy().to_string(); - assert_eq!(crate::import::source_summary(&opts.sources), format!("Folder “{name}” (and its subfolders)")); - let r = h.request("ui.widgets", json!({}), t); - assert!(r.to_string().contains("label:importSource"), "source shown"); - assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved"); - // The source facts were cached by a worker with the source-language default. They must - // still be rendered in German after switching, with the folder name kept verbatim. - assert_eq!(h.request("engine.execute", json!({"command": "app.language.german"}), t)["ok"], true); - h.settle(SETTLE); - fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { - match shape { - egui::epaint::Shape::Text(shape) => out.push(shape.galley.job.text.clone()), - egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| texts(shape, out)), - _ => {} - } - } - let mut painted = Vec::new(); - for shape in &h.view.shapes { - texts(&shape.shape, &mut painted); - } - assert!(painted.iter().any(|text| text == &format!("Ordner „{name}“ (und seine Unterordner)")), "{painted:?}"); - for label in ["Quelle", "Übertragen", "Stichwörter", "Vorgabe"] { - assert!(painted.iter().any(|text| text == label), "{label}: {painted:?}"); - } - assert_eq!(h.request("engine.execute", json!({"command": "app.language.english"}), t)["ok"], true); - // a camera / card folder: copied into the library by default - h.app.ui.dialog = None; - let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert!(opts.copy); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Switching Settings tabs keeps the window's top edge (and its tab row) where it is and never makes it - /// narrower or taller, though the tabs hold different amounts: the window is one size on every tab. - #[test] - fn settings_tabs_keep_the_window_in_place() { - let mut h = demo([1300.0, 820.0]); - let t = Duration::from_secs(10); - let window = |h: &Headless| h.app.widgets.iter().find(|(w, _)| w == "dialog:window").map(|(_, r)| *r).expect("the dialog"); - let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "general"}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let first = window(&h); - let mut heights = Vec::new(); - for tab in ["display", "import", "performance", "general", "interface", "display"] { - h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); - h.settle(SETTLE); - let now = window(&h); - assert!((now.top() - first.top()).abs() < 1.0, "{tab}: top {} → {}", first.top(), now.top()); - assert!(now.width() >= first.width() - 1.0, "{tab}: width {} → {}", first.width(), now.width()); - heights.push(now.height()); - } - assert!(heights.iter().all(|h| (h - first.height()).abs() < 1.0), "the tabs keep the height of {}: {heights:?}", first.height()); - } - - /// Settings → Import → "Show the import review when a camera or card is connected": a card that - /// was already in doesn't ask, a new one opens Import from Device (copying), and a busy window - /// asks once it's free. - #[test] - fn a_connected_card_opens_the_import_review() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let root = std::env::temp_dir().join(format!("lc-card-{}", std::process::id())); - let dcim = root.join("DCIM/100NIKON"); - std::fs::create_dir_all(&dcim).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dcim.join("DSC_0001.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - let card = lightcraft_engine::devices::Device { - name: "NIKON Z 8".into(), - path: root.join("DCIM").to_string_lossy().to_string(), - root: root.to_string_lossy().to_string(), - }; - h.app.ui.dialog = None; - // first look: the card was already in - crate::import::cards_seen(&mut h.app, vec![card.clone()]); - assert!(h.app.scan.is_none() && h.app.ui.dialog.is_none(), "a card that was already in doesn't ask"); - crate::import::cards_seen(&mut h.app, vec![card.clone()]); - assert!(h.app.scan.is_none()); - // taken out, then put back while another dialog is open: it waits - crate::import::cards_seen(&mut h.app, Vec::new()); - h.app.ui.dialog = Some(crate::state::Dialog::About); - crate::import::cards_seen(&mut h.app, vec![card.clone()]); - assert!(h.app.scan.is_none(), "a busy window isn't interrupted"); - h.app.ui.dialog = None; - crate::import::cards_seen(&mut h.app, vec![card.clone()]); - assert!(h.app.scan.is_some(), "the card is scanned once the window is free"); - assert!(h.step_until(t, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })))); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert!(opts.copy, "a card is copied into the library"); - // with the option off nothing is remembered, so turning it on again starts with a first look - assert!(!h.app.session.import_defaults.show_on_card); - h.step(); - assert!(h.app.ui.known_cards.is_none()); - let _ = std::fs::remove_dir_all(&root); - } - - /// The folder scan runs in the background: the request returns at once and the review opens - /// when the scan finishes (a folder on a network share must not freeze the window). - /// Clicking the folder being read again keeps the running read (and its progress). - #[test] - fn browsing_the_same_folder_again_does_not_restart() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-browse-again-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let started = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (g, n) = (gate.clone(), started.clone()); - h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { - n.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - while !g.load(std::sync::atomic::Ordering::Relaxed) { - std::thread::sleep(Duration::from_millis(5)); - } - Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) - })); - let browse = - |h: &mut Headless| h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); - assert_eq!(browse(&mut h)["result"]["scanning"], true); - std::thread::sleep(Duration::from_millis(200)); - assert_eq!(browse(&mut h)["result"]["scanning"], true); - std::thread::sleep(Duration::from_millis(200)); - assert_eq!(started.load(std::sync::atomic::Ordering::Relaxed), 1, "the second click must not start another read"); - gate.store(true, std::sync::atomic::Ordering::Relaxed); - h.settle(SETTLE); - assert_eq!(h.app.session.visible_cloned().len(), 1); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Browsing a folder from the UI (read on a worker thread) records it as browsed, as `library.browse` does: - /// untouched Local photos are only forgotten from folders not browsed for the set number of days. - #[test] - fn browsing_from_the_ui_records_the_folder() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-browse-stamp-{}", std::process::id())); - let day = dir.join("2026/10/14"); - std::fs::create_dir_all(&day).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(day.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - let root = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); - let day_s = day.to_string_lossy().to_string(); - assert!(h.app.session.catalog.last_browsed(&root).is_none()); - let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": root, "subfolders": true}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(h.app.session.visible_cloned().len(), 1); - assert!(h.app.session.catalog.last_browsed(&root).is_some(), "the browsed folder"); - assert!(h.app.session.catalog.last_browsed(&day_s).is_some(), "the folder its photo is in"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Browsing a folder again from the sidebar forgets the untouched Local records of files deleted from it by - /// another app, as `library.browse` does (issue #1392). - #[test] - fn browsing_again_from_the_ui_forgets_deleted_files() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-browse-gone-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - let png = lightcraft_engine::export::encode_image(&img, &o).unwrap(); - std::fs::write(dir.join("a.png"), &png).unwrap(); - std::fs::write(dir.join("b.png"), &png).unwrap(); - let path = dir.to_string_lossy().to_string(); - let browse = |h: &mut Headless| { - let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - }; - browse(&mut h); - assert_eq!(h.app.session.visible_cloned().len(), 2); - // another app deletes the photo that isn't selected (a selected or open one stays, shown as missing) - let held: Vec = - h.app.session.selection.ids.iter().copied().chain(h.app.session.selection.active).chain(h.app.session.previous_active).collect(); - let name = |id: &lightcraft_catalog::PhotoId| h.app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default(); - let gone = if held.iter().any(|id| name(id) == "b.png") { "a.png" } else { "b.png" }; - std::fs::remove_file(dir.join(gone)).unwrap(); - // a later session: the browse's "Add 2 Photos" step is no longer in the undo history (photos it refers to - // are kept, like selected ones) - h.app.session.undo.clear(); - h.app.session.redo.clear(); - // the folder clicked again in the sidebar - browse(&mut h); - assert_eq!(h.app.session.visible_cloned().len(), 1, "the deleted file's record is forgotten"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn add_folder_scans_in_the_background() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-scanbg-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let png = lightcraft_engine::export::encode_image( - &img, - &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, - ) - .unwrap(); - std::fs::write(dir.join("a.png"), &png).unwrap(); - let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - assert!(h.app.scan.is_some() || h.app.ui.dialog.is_some()); - h.settle(SETTLE); - assert!(h.app.scan.is_none()); - assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Cancel closes the scan at once and opens no review; Add from Device while a scan runs is - /// refused and leaves the running scan's options alone. - #[test] - fn folder_scan_cancel_and_busy() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let dir = std::env::temp_dir().join(format!("lc-scancancel-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); - let png = lightcraft_engine::export::encode_image( - &img, - &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, - ) - .unwrap(); - std::fs::write(dir.join("a.png"), &png).unwrap(); - // a scan that can't finish until the test lets it: the probe waits on a flag - let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let g = gate.clone(); - h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { - while !g.load(std::sync::atomic::Ordering::Relaxed) { - std::thread::sleep(Duration::from_millis(5)); - } - Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) - })); - let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": dir.to_string_lossy()}}), t); - assert_ne!(r["ok"], true, "a second scan is refused: {r}"); - assert!(!h.app.scan.as_ref().unwrap().copy, "the running scan keeps its options"); - let r = h.request("ui.inspect", json!({}), t); - assert!(r["result"]["scan"].is_object(), "{r}"); - // Cancel is the ✕ of the scan's row in the activity stack (shown once it is half a second old) - let id = h.app.session.activity.list().first().map(|t| t.id).expect("a scan row"); - let cross = format!("activity:cancel:{id}"); - assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); - let r = h.request("ui.clickWidget", json!({"id": cross}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - assert!(h.app.scan.is_none(), "✕ closes the scan at once"); - gate.store(true, std::sync::atomic::Ordering::Relaxed); - h.settle(SETTLE); - assert!(h.app.ui.dialog.is_none(), "a cancelled scan opens no review"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// ⇧⌘V opens Paste Selected Settings (prefilled with the copied groups); unchecking a group - /// leaves it alone. ⌘F puts typing into the search field. - #[test] - fn paste_selected_settings_and_find() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); - let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); - exec(&mut h, "library.select", json!({"ids": [ids[0]]})); - exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0, "color.vibrance": 30}})); - exec(&mut h, "develop.copy", json!({"groups": ["light", "color"]})); - exec(&mut h, "library.select", json!({"ids": [ids[1]]})); - h.request("ui.key", json!({"key": "V", "cmd": true, "shift": true}), t); - h.settle(SETTLE); - let Some(crate::state::Dialog::PasteSettings { groups }) = &h.app.ui.dialog else { panic!("dialog not open: {:?}", h.app.ui.dialog) }; - assert_eq!(groups, &["light", "color"]); - let r = h.request("ui.clickWidget", json!({"id": "pasteGroup:color"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(h.request("ui.dialog.confirm", json!({}), t)["ok"], true); - let d = h.app.session.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); - assert_eq!((d.light.exposure, d.color.vibrance), (1.0, 0.0)); - // ⌘F, then typing filters the grid by text - h.request("ui.set", json!({"view": "grid"}), t); - h.request("ui.key", json!({"key": "F", "cmd": true}), t); - h.settle(SETTLE); - h.request("ui.text", json!({"text": "zz-no-such-photo"}), t); - h.settle(SETTLE); - assert_eq!(h.app.ui.search, "zz-no-such-photo"); - assert!(h.app.session.visible_cloned().is_empty()); - } - - /// Info panel → Edit Capture Time…: a time-zone shift moves the selected photos. - #[test] - fn capture_time_dialog_shifts_time_zone() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let id = h.app.session.visible_cloned()[0]; - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); - // The dock header leaves less vertical space; bring the metadata action into view. - let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; - let clipped_action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; - assert!(!panel.contains_rect(clipped_action), "the capture-time action starts below the visible panel: {clipped_action:?}"); - h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), t); - h.request("ui.scroll", json!({"dy": -160.0}), t); - h.settle(SETTLE); - let action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; - assert!(panel.contains_rect(action), "capture-time action must be visible before clicking: {action:?} in {panel:?}"); - let r = h.request("ui.clickWidget", json!({"id": "icon:editCaptureTime"}), t); - assert_eq!(r["ok"], true, "{r}"); - let r = h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t); - assert_eq!(r["ok"], true, "{r}"); - if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog { - *zone = -3.0; - } else { - panic!("dialog not open: {:?}", h.app.ui.dialog); - } - h.settle(SETTLE); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let after = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); - let secs = lightcraft_catalog::dates::iso_seconds; - assert_eq!(secs(&after).unwrap() - secs(&before).unwrap(), -3 * 3600); - h.settle(SETTLE); - } - - /// Colour labels: Info-panel swatches set/clear the label, the names dialog renames them and - /// the Photo menu shows the names. - #[test] - fn label_swatches_and_names() { - let mut h = demo([1200.0, 900.0]); - let t = Duration::from_secs(10); - let id = h.app.session.visible_cloned()[0]; - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); - h.request("ui.set", json!({"view": "detail", "right": "info"}), t); - let r = h.request("ui.clickWidget", json!({"id": "label:green"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.session.catalog.photo(id).unwrap().label, Some(lightcraft_catalog::ColorLabel::Green)); - h.request("engine.execute", json!({"command": "dialog.labelNames"}), t); - if let Some(crate::state::Dialog::LabelNames { names, .. }) = &mut h.app.ui.dialog { - names[2] = "Approved".into(); - } else { - panic!("no dialog"); - } - h.settle(SETTLE); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let tree = h.request("ui.menu.tree", json!({}), t).to_string(); - assert!(tree.contains("Approved (Green)"), "menu shows the name"); - h.request("ui.clickWidget", json!({"id": "label:green"}), t); - assert_eq!(h.app.session.catalog.photo(id).unwrap().label, None, "clicking the current label clears it"); - h.settle(SETTLE); - } - - /// File → Import Photos… opens the import review: candidates with thumbnails, the duplicate is - /// unchecked; unchecking a cell and confirming imports the rest in batches, into a new album - /// with keywords, as one undo step. - #[test] - fn import_review_dialog() { - let dir = std::env::temp_dir().join(format!("lc-ui-import-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - for i in 0..5u8 { - let (w, h) = (40usize, 30usize); - let data: Vec<[u8; 4]> = (0..w * h).map(|k| [(k % w * 6) as u8, i * 40, (k / w * 8) as u8, 255]).collect(); - let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); - } - std::fs::copy(dir.join("img0.png"), dir.join("img0-copy.png")).unwrap(); - let services = crate::Services { png: None, ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - let t = Duration::from_secs(10); - let n0 = h.app.session.catalog.len(); - let undo0 = h.app.session.undo.len(); - let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - h.step_until(SETTLE, |h| h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.candidates.len(), 6); - assert_eq!(opts.candidates.iter().filter(|c| c.duplicate.is_some()).count(), 1); - assert!(h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5, "thumbnails"); - // uncheck the first photo; name a new album and keywords - let r = h.request("ui.clickWidget", json!({"id": "import:0"}), t); - assert_eq!(r["ok"], true, "{r}"); - if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { - assert_eq!(opts.selected_paths().len(), 4, "duplicate and unchecked photo left out"); - opts.new_album = "Card 1".into(); - opts.keywords = "cardone, 2026".into(); - } else { - panic!("no import dialog"); - } - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.import.is_none(), "finished"); - assert_eq!(h.app.session.catalog.len(), n0 + 4); - let album = h.app.session.catalog.albums().find(|a| a.name == "Card 1").expect("album").id; - assert_eq!(h.app.session.catalog.album_count(album), 4); - assert!(h.app.session.catalog.photos().filter(|p| p.meta.keywords.contains(&"cardone".to_string())).count() == 4); - assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); - h.request("engine.execute", json!({"command": "edit.undo"}), t); - assert_eq!(h.app.session.catalog.len(), n0); - h.settle(SETTLE); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The import review offers what to do with files that are in Recently Deleted (issues #298, - /// #706): they are checked, to be restored, by default; Leave them unchecks them; confirming - /// restores the photo (with its edits) or, with Import as new, imports the file afresh. - #[test] - fn import_review_offers_recently_deleted_files() { - for (choice, tag) in [("restore", "r"), ("fresh", "f")] { - let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - for i in 0..2u8 { - let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); - let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; - let png = - lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); - } - let services = crate::Services { png: None, ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - let t = Duration::from_secs(10); - let r = - h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); - let id = r["result"]["imported"][0].as_u64().unwrap(); - h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); - h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); - h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.on_deleted, "restore", "the default brings a deleted photo back"); - assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked from the start: it will be restored"); - opts.set_on_deleted(""); - assert_eq!(opts.selected_paths().len(), 1, "Leave them: only the new file"); - opts.set_on_deleted(choice); - assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked again once a choice is made"); - // a per-cell choice survives switching between the two options, and "Leave them" unchecks - let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).unwrap(); - opts.checked[trashed] = false; - opts.set_on_deleted(if choice == "restore" { "fresh" } else { "restore" }); - assert_eq!(opts.selected_paths().len(), 1, "still unchecked"); - opts.set_on_deleted(""); - assert_eq!(opts.selected_paths().len(), 1); - opts.set_on_deleted(choice); - assert_eq!(opts.selected_paths().len(), 2); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - // frames until the import has finished, so its (timed) toast is read before it expires - let t0 = std::time::Instant::now(); - while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { - h.step(); - } - assert!(h.app.import.is_none(), "finished"); - let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); - let photos = &h.app.session.catalog; - assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2, "{choice}"); - let old = photos.photo(lightcraft_catalog::PhotoId(id)); - if choice == "restore" { - assert!(toast.contains("1 restored"), "{toast}"); - assert!(old.is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits"); - assert_eq!(photos.len(), 2); - } else { - assert!(old.is_none(), "the trashed record is gone"); - assert_eq!(photos.len(), 2); - } - let _ = std::fs::remove_dir_all(&dir); - } - } - - /// With "Leave them", a file in Recently Deleted is left unchecked but a click on its checkbox - /// (or on its "In Recently Deleted" badge) still checks it, and importing it restores the - /// photo (issue #1285). - #[test] - fn import_review_checks_a_recently_deleted_file_by_hand() { - let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-click-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - for i in 0..2u8 { - let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); - let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); - } - let services = crate::Services { png: None, ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - let t = Duration::from_secs(10); - let r = h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); - let id = r["result"]["imported"][0].as_u64().unwrap(); - h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); - h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); - h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; - let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).expect("a file in Recently Deleted"); - opts.set_on_deleted(""); - h.settle(SETTLE); - let checked = |h: &Headless| matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Import { opts }) if opts.checked[trashed]); - assert!(!checked(&h), "Leave them: unchecked"); - let cell = |h: &mut Headless| { - let r = h.request("ui.widgets", json!({"filter": format!("import:{trashed}")}), t); - let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == format!("import:{trashed}"))).cloned().expect("cell on screen"); - [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) - }; - // the checkbox: 16 pt, 6 pt in from the cell's top-left corner - let [x, y, ..] = cell(&mut h); - h.request("ui.click", json!({"x": x + 14.0, "y": y + 14.0}), t); - h.settle(SETTLE); - assert!(checked(&h), "a click on the checkbox checks the file"); - // the badges ("Will be restored" at the top right, "In Recently Deleted" under the - // checkbox) don't swallow the click either - let [x, y, w, _] = cell(&mut h); - h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 12.0}), t); - h.settle(SETTLE); - assert!(!checked(&h), "a click on the badge unchecks it again"); - h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 34.0}), t); - h.settle(SETTLE); - assert!(checked(&h)); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - let t0 = std::time::Instant::now(); - while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { - h.step(); - } - assert!(h.app.import.is_none(), "finished"); - let photos = &h.app.session.catalog; - assert!(photos.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits"); - assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2); - assert_eq!(photos.len(), 2, "restored, not imported a second time"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The import review opens bigger than the old fixed 6 × 2.5 grid and can be resized by its - /// corner: the photo grid takes the new height, and the window then keeps its size (issue #337). - #[test] - fn import_review_dialog_resizes() { - let mut h = demo([1400.0, 1000.0]); - let t = Duration::from_secs(10); - let candidates = (0..60) - .map(|i| lightcraft_engine::import::ImportCandidate { - path: format!("/lc-test/img{i}.png"), - name: format!("img{i}.png"), - error: Some("not read in this test".into()), - ..Default::default() - }) - .collect(); - h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(candidates)) }); - let rect = |h: &mut Headless| { - let r = h.request("ui.widgets", json!({}), t); - let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")).expect("dialog on screen"); - [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) - }; - for _ in 0..10 { - h.step(); - } - let r0 = rect(&mut h); - assert!(r0[3] > 600.0, "{r0:?}"); - // the Copy options add rows below the grid: the grid makes room, the window doesn't grow - for copy in [true, false] { - if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { - opts.copy = copy; - } - for _ in 0..10 { - h.step(); - } - let r = rect(&mut h); - assert!((r[3] - r0[3]).abs() < 0.5, "copy {copy}: {r0:?} → {r:?}"); - } - let (x, y) = (r0[0] + r0[2] - 3.0, r0[1] + r0[3] - 3.0); - h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 160.0, "toY": y + 120.0, "steps": 12}), t); - for _ in 0..10 { - h.step(); - } - let r1 = rect(&mut h); - assert!(r1[2] > r0[2] + 100.0 && r1[3] > r0[3] + 80.0, "dragging the corner resized it: {r0:?} → {r1:?}"); - for _ in 0..60 { - h.step(); - } - let r2 = rect(&mut h); - assert!((r2[2] - r1[2]).abs() < 0.5 && (r2[3] - r1[3]).abs() < 0.5, "the dialog kept its size: {r1:?} → {r2:?}"); - assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })), "still open"); - } - - /// Adding a file again (dropped on the window, no review) whose photo is in Recently Deleted - /// brings the photo back, selected in All Photos, and says so (issue #706) — it is not - /// skipped as a duplicate; the trash view's menus offer Restore and Delete Permanently, and a - /// re-add of a photo that is in the library selects it in All Photos. - #[test] - fn readding_a_deleted_photo_shows_it_in_recently_deleted() { - let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8 { width: 16, height: 12, data: vec![[200, 120, 40, 255]; 16 * 12] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - let file = dir.join("flower.png"); - std::fs::write(&file, png).unwrap(); - let paths = vec![file.to_string_lossy().to_string()]; - let services = crate::Services { png: None, ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - let t = Duration::from_secs(10); - // frames until the import has finished, so its (timed) toast is read before it expires - let finish_import = |h: &mut Headless| { - let t0 = std::time::Instant::now(); - while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { - h.step(); - } - assert!(h.app.import.is_none(), "import finished"); - }; - crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); - h.settle(SETTLE); - let id = h.app.session.selection.active.expect("imported photo selected"); - let n0 = h.app.session.catalog.len(); - h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); - assert!(h.app.session.catalog.photo(id).unwrap().deleted); - let photo_items = |app: &LightcraftApp| -> Vec { - let bar = crate::menubar::menu_bar(app); - let items = &bar.iter().find(|(title, _)| title == "Photo").expect("Photo menu").1; - items.iter().filter_map(|n| if let crate::menubar::MenuNode::Item { id, .. } = n { Some(id.clone()) } else { None }).collect() - }; - assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos"); - - crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); - finish_import(&mut h); - assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "re-adding the file restores the photo"); - assert_eq!(h.app.session.catalog.len(), n0, "no second record"); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); - assert_eq!(h.app.session.selection.ids, vec![id], "the restored photo is selected"); - let toast = h.app.ui.toast.clone().expect("toast").0; - assert!(toast.contains("Restored 1 photo from Recently Deleted"), "{toast}"); - assert!(!photo_items(&h.app).contains(&"photo.restore".to_string())); - // undo of the re-add puts it back in Recently Deleted; the trash view's menus offer Restore - h.request("engine.execute", json!({"command": "edit.undo"}), t); - assert!(h.app.session.catalog.photo(id).unwrap().deleted, "one undo step"); - h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "recentlyDeleted"}}), t); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); - h.settle(SETTLE); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); - let items = photo_items(&h.app); - assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}"); - assert!(!items.contains(&"photo.delete".to_string()), "{items:?}"); - // a right-click on its filmstrip thumbnail opens the photo menu - h.request("ui.set", json!({"view": "detail"}), t); - h.settle(SETTLE); - let cell = h.app.widgets.iter().find(|(w, _)| *w == format!("film:{}", id.0)).map(|(_, r)| *r).expect("filmstrip cell"); - assert!(!egui::Popup::is_any_open(&h.view.ctx)); - let r = h.request("ui.click", json!({"x": cell.center().x, "y": cell.center().y, "button": "right"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu"); - - // Empty Recently Deleted is offered while the trash is the source - assert!(photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string())); - let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored"); - crate::import::start_paths(&mut h.app, paths).unwrap(); - finish_import(&mut h); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); - assert_eq!(h.app.session.selection.ids, vec![id]); - let toast = h.app.ui.toast.clone().expect("toast").0; - assert!(toast.contains("All Photos"), "{toast}"); - assert!(!photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string()), "Empty is for the trash view only"); - - // deleted permanently, the file imports afresh as a new photo - h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); - h.request("engine.execute", json!({"command": "photo.deletePermanently", "params": {"ids": [id.0]}}), t); - assert!(h.app.session.catalog.photo(id).is_none()); - crate::import::start_paths(&mut h.app, vec![file.to_string_lossy().to_string()]).unwrap(); - h.settle(SETTLE); - let fresh = h.app.session.selection.active.expect("re-imported photo selected"); - assert_ne!(fresh, id); - assert!(!h.app.session.catalog.photo(fresh).unwrap().deleted); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Importing while an album is shown (issue #1446): the new photos aren't in it, so the view - /// switches to All Photos with the first of them active, instead of an active photo the grid - /// and the filmstrip don't show. - #[test] - fn an_import_from_an_album_view_shows_the_first_new_photo() { - let dir = std::env::temp_dir().join(format!("lc-ui-import-show-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - for i in 0..3u8 { - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 40, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join(format!("IMG_{i:02}.png")), png).unwrap(); - } - let mut h = demo([1300.0, 900.0]); - let t = Duration::from_secs(10); - let album = h.app.session.catalog.albums().find(|a| !a.folder && !a.is_smart()).map(|a| a.id.0).expect("a demo album"); - let r = h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "album", "id": album}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let before: Vec = h.app.session.catalog.photos().map(|p| p.id).collect(); - let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.import.is_none(), "finished"); - let active = h.app.session.selection.active.expect("a photo is active"); - assert!(!before.contains(&active), "the active photo is a new one: {active:?}"); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); - assert!(h.app.session.visible_cloned().contains(&active), "the grid shows the active photo"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across - /// the import's batches. - #[test] - fn import_copy_renames_into_destination() { - let base = std::env::temp_dir().join(format!("lc-ui-import-copy-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let (src, dest) = (base.join("card"), base.join("out")); - std::fs::create_dir_all(&src).unwrap(); - // more files than a batch holds (see `batch_size`) - for i in 0..20u8 { - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 12, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap(); - } - let dest_s = dest.to_string_lossy().to_string(); - let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 900.0], 1.0); - let t = Duration::from_secs(10); - let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; - assert_eq!(opts.candidates.len(), 20); - for id in ["button:importCopy", "button:importDest"] { - let r = h.request("ui.clickWidget", json!({"id": id}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - } - if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { - assert!(opts.copy); - assert_eq!(opts.destination, dest.to_string_lossy(), "Choose… sets the folder"); - opts.organize = "flat".into(); - opts.rename = "Trip-{seq:2}".into(); - } else { - panic!("no import dialog"); - } - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.import.is_none(), "finished"); - let mut names: Vec = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); - names.sort(); - let want: Vec = (1..=20).map(|i| format!("Trip-{i:02}.png")).collect(); - assert_eq!(names, want, "numbered across batches of {}", lightcraft_engine::import::batch_size()); - let _ = std::fs::remove_dir_all(&base); - } - - /// Import review, Copy, Folders → Custom template…: the default `{date:%Y}/{date:%Y%m%d}` - /// files copies under `2026/20260114/`, the example line shows the full destination, and a - /// template that would climb out of the destination is refused. - #[test] - fn import_copy_into_a_custom_folder_template() { - let base = std::env::temp_dir().join(format!("lc-ui-import-tpl-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let (src, dest) = (base.join("card"), base.join("out")); - std::fs::create_dir_all(&src).unwrap(); - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(src.join("IMG_01.png"), png).unwrap(); - // undated files are filed by the file's date (2026-01-14T05:58:48) - std::fs::File::options() - .write(true) - .open(src.join("IMG_01.png")) - .unwrap() - .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) - .unwrap(); - let dest_s = dest.to_string_lossy().to_string(); - let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; - let mut session = lightcraft_engine::Session::with_demo().with_fs(); - session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); - let mut app = LightcraftApp::new(session, services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); - let t = Duration::from_secs(10); - let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - for id in ["button:importCopy", "button:importDest"] { - let r = h.request("ui.clickWidget", json!({"id": id}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - } - if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { - opts.organize = "custom".into(); - opts.folder_template = "../{date:%Y}".into(); - } - h.step(); - // refused: the dialog stays open, nothing is imported - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_ne!(r["ok"], true, "{r}"); - assert!(h.app.import.is_none()); - let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("dialog closed") }; - opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into(); - let opts = opts.clone(); - let sep = std::path::MAIN_SEPARATOR; - let example = crate::import::example_destination(&h.app, &opts).expect("example"); - assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}IMG_01.png", dest.to_string_lossy())); - h.step(); - let r = h.request("ui.widgets", json!({}), t); - assert!(r.to_string().contains("label:importExample"), "example shown"); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(dest.join("2026").join("20260114").join("IMG_01.png").is_file()); - let _ = std::fs::remove_dir_all(&base); - } - - /// Import review, Move: an explicit choice with its explanation, the confirm button says - /// Move; the files land in the folder template, renamed, and leave the card. - #[test] - fn import_move_into_a_folder_template() { - let base = std::env::temp_dir().join(format!("lc-ui-import-move-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let (src, dest) = (base.join("card"), base.join("Photos")); - std::fs::create_dir_all(&src).unwrap(); - for i in 0..2u8 { - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40 + i, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(src.join(format!("IMG_0{i}.png")), png).unwrap(); - std::fs::File::options() - .write(true) - .open(src.join(format!("IMG_0{i}.png"))) - .unwrap() - .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) - .unwrap(); - } - let dest_s = dest.to_string_lossy().to_string(); - let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; - let mut session = lightcraft_engine::Session::with_demo().with_fs(); - session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); - let mut app = LightcraftApp::new(session, services); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); - let t = Duration::from_secs(10); - let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - for id in ["button:importMove", "button:importDest"] { - let r = h.request("ui.clickWidget", json!({"id": id}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - } - let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import dialog") }; - assert!(opts.copy && opts.move_files, "Move chosen"); - opts.organize = "custom".into(); - opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into(); - opts.rename = "{date:%Y%m%d}_{seq:3}".into(); - let opts = opts.clone(); - let sep = std::path::MAIN_SEPARATOR; - let example = crate::import::example_destination(&h.app, &opts).expect("example"); - assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}20260114_001.png", dest.to_string_lossy())); - h.step(); - let r = h.request("ui.widgets", json!({}), t); - assert!(r.to_string().contains("label:importModeHelp"), "the Move explanation is shown"); - assert!(!r.to_string().contains("check:importDng"), "no Copy as DNG when moving"); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.import.is_none(), "finished"); - let day = dest.join("2026").join("20260114"); - assert!(day.join("20260114_001.png").is_file() && day.join("20260114_002.png").is_file()); - assert_eq!(std::fs::read_dir(&src).unwrap().count(), 0, "moved off the card"); - let _ = std::fs::remove_dir_all(&base); - } - - /// Import review, Copy: Tags beside the Rename field lists the template tags; clicking one - /// inserts it at the text cursor (not appended, not replacing the template). - #[test] - fn import_rename_tags_insert_at_the_cursor() { - let dir = std::env::temp_dir().join(format!("lc-ui-import-tags-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join("IMG_0007.png"), png).unwrap(); - let lib = dir.join("lib"); - let mut session = lightcraft_engine::Session::with_demo().with_fs(); - session.open_library(&lib, false).unwrap(); - let mut app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); - app.ui.view = crate::state::ViewMode::PhotoGrid; - let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); - let t = Duration::from_secs(10); - let r = - h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t); - assert_eq!(r["result"]["scanning"], true, "{r}"); - h.settle(SETTLE); - let r = h.request("ui.clickWidget", json!({"id": "button:importCopy"}), t); - assert_eq!(r["ok"], true, "{r}"); - if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { - opts.rename = "Trip-_x".into(); - } - // Paint the edited template before locating the Tags button in the resized dialog. - assert!(h.settle(SETTLE), "Copy dialog layout should settle before locating Tags"); - // the cursor sits after "Trip-" - let id = egui::Id::unique("import-rename"); - let mut st = egui::text_edit::TextEditState::default(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(5)))); - st.store(&h.view.ctx, id); - let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTags"}), t); - assert_eq!(r["ok"], true, "{r}"); - let seq3 = lightcraft_engine::rename::TOKENS.iter().position(|x| x.tag == "{seq:3}").unwrap(); - let r = h.request("ui.clickWidget", json!({"id": format!("button:importRenameTag-{seq3}")}), t); - assert_eq!(r["ok"], true, "{r}"); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; - assert_eq!(opts.rename, "Trip-{seq:3}_x", "inserted at the cursor"); - // a second tag goes after the first (the cursor moved past it) - let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTag-0"}), t); - assert_eq!(r["ok"], true, "{r}"); - let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; - assert_eq!(opts.rename, "Trip-{seq:3}{name}_x"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Help ▸ About: the About, Contributors and Models tabs switch and paint (credits are - /// compiled in). - #[test] - fn about_dialog_tabs_show_the_credits() { - // an empty library: the dialog needs no photos, and no decodes compete with other tests - let services = crate::Services { png: None, ..Default::default() }; - let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::new(), services), [1300.0, 820.0], 1.0); - let t = Duration::from_secs(10); - let r = h.request("ui.menu.invoke", json!({"id": "app.about"}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About)); - // a new window sizes itself on its first frame: let it settle before clicking its tabs - h.step(); - h.step(); - for (i, (tab, _)) in crate::panels::dialogs::ABOUT_TABS.iter().enumerate().rev() { - let r = h.request("ui.clickWidget", json!({"id": format!("button:aboutTab-{tab}")}), t); - assert_eq!(r["ok"], true, "{tab}: {r}"); - h.step(); - let shown = h.view.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("about_tab"))); - assert_eq!(shown.map(usize::from), Some(i), "{tab}"); - } - h.request("ui.clickWidget", json!({"id": "button:aboutTab-contributors"}), t); - let img = h.snapshot(SETTLE); - assert_eq!(img.size, [1300, 820]); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About), "switching tabs keeps the dialog open"); - } - - /// Settings ▸ Display / `app.displayProfile`: previews are shown through the monitor profile - /// (the loupe rendered for the display, thumbnails converted from sRGB, the histogram still - /// sRGB); a file that can't be used is an error, or, as the setting, reported with previews - /// left sRGB. - #[test] - fn display_profile_converts_previews() { - use crate::render::Slot; - let dir = std::env::temp_dir().join(format!("lightcraft-display-ui-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let p3 = dir.join("p3.icc"); - std::fs::write(&p3, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap(); - let bad = dir.join("bad.icc"); - std::fs::write(&bad, b"not a profile").unwrap(); - let (p3, bad) = (p3.to_string_lossy().to_string(), bad.to_string_lossy().to_string()); - - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - let thumb_slot = - |h: &Headless| h.app.renderer.textures.keys().copied().filter(|s| matches!(s, Slot::Thumb(_))).min_by_key(|s| format!("{s:?}")); - let ready = |h: &Headless| { - h.app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.pixels.is_some()) - && thumb_slot(h).is_some() - && !h.app.renderer.is_pending(Slot::Main) - }; - assert!(h.step_until(SETTLE, ready)); - h.settle(SETTLE); - let rgba = |c: &egui::ColorImage| lightcraft_raster::Rgba8 { - width: c.size[0], - height: c.size[1], - data: c.pixels.iter().map(|p| p.to_array()).collect(), - }; - let tex = |h: &Headless, s: Slot| rgba(h.app.renderer.textures.get(&s).and_then(|t| t.pixels.clone()).as_deref().unwrap()); - let thumb = thumb_slot(&h).unwrap(); - let (main0, thumb0) = (tex(&h, Slot::Main), tex(&h, thumb)); - let hist0 = h.app.renderer.textures[&Slot::Main].histogram.clone().unwrap(); - - // the command loads it (and keeps it as the setting); textures are made again - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": p3}}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(r["result"]["kind"], "matrix"); - assert_eq!(h.app.ui.settings.display_profile, p3); - let d = h.app.renderer.display().cloned().unwrap(); - assert!(h.step_until(SETTLE, |h| ready(h) && h.app.renderer.textures.contains_key(&thumb))); - h.settle(SETTLE); - let (main1, thumb1) = (tex(&h, Slot::Main), tex(&h, thumb)); - let mut want = thumb0.clone(); - d.profile.from_srgb(&mut want).unwrap(); - let max_diff = |a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8| { - a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] as i32 - q[k] as i32).abs())).max().unwrap_or(0) - }; - assert_eq!((thumb1.width, thumb1.height), (thumb0.width, thumb0.height)); - assert!(max_diff(&thumb1, &want) <= 1, "thumbnails are converted from sRGB: {}", max_diff(&thumb1, &want)); - assert_ne!(thumb1, thumb0); - // the loupe: rendered for the display; colours sRGB holds look as they did - assert_eq!((main1.width, main1.height), (main0.width, main0.height)); - assert_ne!(main1, main0); - let mut main_want = main0.clone(); - d.profile.from_srgb(&mut main_want).unwrap(); - let mean = main1.data.iter().zip(&main_want.data).map(|(p, q)| (0..3).map(|k| (p[k] as f64 - q[k] as f64).abs()).sum::()).sum::() - / (3 * main1.data.len()) as f64; - assert!(mean < 1.5, "{mean}"); - let hist1 = h.app.renderer.textures[&Slot::Main].histogram.clone().unwrap(); - assert_eq!(hist1.total, hist0.total, "the histogram stays sRGB"); - - // a bad file: an error, nothing changes - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": bad}}), t); - assert_eq!(r["ok"], false, "{r}"); - assert_eq!(h.app.renderer.display_id(), Some(d.id())); - assert_eq!(h.app.ui.settings.display_profile, p3); - // …chosen as the setting (Settings ▸ Display), it is reported and previews go back to sRGB - h.app.ui.settings.display_profile = bad.clone(); - h.step(); - assert!(h.app.display_error.as_deref().is_some_and(|e| e.contains("not a valid ICC profile")), "{:?}", h.app.display_error); - assert_eq!(h.app.renderer.display_id(), None); - // the Display tab shows it - let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "display"}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - // none: sRGB again - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(r["result"]["path"].is_null()); - assert!(h.app.ui.settings.display_profile.is_empty() && h.app.display_error.is_none()); - assert!(h.step_until(SETTLE, |h| ready(h) && h.app.renderer.textures.contains_key(&thumb))); - h.settle(SETTLE); - assert_eq!(tex(&h, thumb), thumb0, "back to the sRGB thumbnail"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// With no profile chosen, previews follow the profile of the screen showing the window (the - /// host's `screen_profile`: macOS doesn't colour-manage the window, issue #787), read again so - /// a move to another screen is followed. A chosen profile wins; choosing none goes back to the - /// screen's at once; a screen without a profile is sRGB. - #[test] - fn previews_follow_the_screen_profile_while_none_is_chosen() { - use lightcraft_codecs::{NamedSpace, icc::write_named}; - use std::sync::{Arc, Mutex}; - let id = |space| lightcraft_engine::display::Display::from_bytes("", &write_named(space)).unwrap().id(); - let dir = std::env::temp_dir().join(format!("lightcraft-screen-profile-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let chosen = dir.join("prophoto.icc"); - std::fs::write(&chosen, write_named(NamedSpace::ProPhoto)).unwrap(); - let chosen = chosen.to_string_lossy().to_string(); - - let screen = Arc::new(Mutex::new(Some(write_named(NamedSpace::DisplayP3)))); - let mut h = demo([1000.0, 700.0]); - let read = screen.clone(); - h.app.services.screen_profile = Some(Box::new(move || read.lock().unwrap().clone())); - let t = Duration::from_secs(10); - h.step(); - assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3)), "the screen's profile"); - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {}}), t); - assert_eq!((r["result"]["screen"].clone(), r["result"]["path"].clone()), (json!(true), Value::Null), "{r}"); - assert!(h.app.ui.settings.display_profile.is_empty(), "nothing is saved as the setting"); - - // the window moves to another screen - *screen.lock().unwrap() = Some(write_named(NamedSpace::AdobeRgb)); - assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id() == Some(id(NamedSpace::AdobeRgb)))); - - // a chosen profile wins over the screen's - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": chosen}}), t); - assert_eq!(r["ok"], true, "{r}"); - *screen.lock().unwrap() = Some(write_named(NamedSpace::DisplayP3)); - for _ in 0..90 { - h.step(); - } - assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::ProPhoto))); - - // none chosen again: the screen's profile, at once - let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); - assert_eq!(r["result"]["screen"], true, "{r}"); - assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3))); - - // a screen without a profile: sRGB - *screen.lock().unwrap() = None; - assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id().is_none())); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through - /// the engine; the delete confirmation guards ⌫. - #[test] - fn settings_dialog_by_keyboard_and_clicks() { - let mut h = demo([1300.0, 820.0]); - let t = Duration::from_secs(10); - let r = h.request("ui.key", json!({"key": ",", "cmd": true}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: "general".into() })); - for tab in ["import", "performance", "display", "interface", "general"] { - let r = h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); - assert_eq!(r["ok"], true, "{tab}: {r}"); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() })); - } - // General: confirm before delete (on by default), startup view - assert!(h.app.ui.settings.confirm_delete); - h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); - assert!(!h.app.ui.settings.confirm_delete); - h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); - assert!(h.app.ui.settings.confirm_delete); - h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t); - assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail); - // Interface: filmstrip names off, grid badges always - h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t); - // Appearance cards (one row per theme, with its favorite star) sit above these controls; - // scroll the compact settings viewport past them. - h.request("ui.hoverWidget", json!({"id": "radio:theme-charcoal"}), t); - h.request("ui.scroll", json!({"dy": -700.0}), t); - for _ in 0..4 { - h.step(); - } - h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t); - assert!(!h.app.ui.settings.film_names); - h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t); - assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always); - h.request("ui.scroll", json!({"dy": 700.0}), t); - for _ in 0..4 { - h.step(); - } - // Performance: the thumbnail cache size goes to the library preferences - h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); - h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); - assert_eq!(h.app.session.cache_mb, 512); - h.request("ui.clickWidget", json!({"id": "button:settingsPreview-3"}), t); - assert_eq!(h.app.ui.settings.preview_limit, 3840); - h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t); - assert_eq!(h.app.ui.settings.memory_mb, 1024); - h.step(); - assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget"); - h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t); - h.step(); - assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic"); - // Import: per-camera defaults - h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t); - h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t); - assert!(h.app.session.import_defaults.per_camera); - let img = h.snapshot(SETTLE); - assert_eq!(img.size, [1300, 820]); - // Escape closes; ⌫ now asks first - h.request("ui.key", json!({"key": "escape"}), t); - assert_eq!(h.app.ui.dialog, None); - let active = h.app.session.active().unwrap(); - h.request("ui.key", json!({"key": "delete"}), t); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 })); - assert!(!h.app.session.catalog.photo(active).unwrap().deleted); - let r = h.request("ui.dialog.confirm", json!({}), t); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.session.catalog.photo(active).unwrap().deleted); - // app settings survive a save/load round trip of the UI state (ui.json) - let saved = serde_json::to_string(&h.app.ui).unwrap(); - let back = serde_json::from_str::(&saved).unwrap().sanitized(); - assert_eq!(back.settings, h.app.ui.settings); - assert_eq!(back.view, crate::state::ViewMode::Detail, "startup view applied on load"); - h.settle(SETTLE); - } - - /// F: full-screen preview (arrows step, I cycles the info overlay, Esc exits); ⌘I cycles the - /// overlay in Detail; ⇧⌘F asks the host for window full screen. - #[test] - fn full_screen_preview_and_info_overlay_by_keyboard() { - use crate::state::InfoOverlay; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0]]}}), t); - h.request("ui.key", json!({"key": "f"}), t); - assert!(h.app.ui.fullscreen); - h.request("ui.key", json!({"key": "right"}), t); - assert_eq!(h.app.session.active().map(|p| p.0), Some(vis[1])); - let right = h.app.ui.right; - h.request("ui.key", json!({"key": "i"}), t); - assert_eq!(h.app.ui.info_overlay, InfoOverlay::Basic); - assert_eq!(h.app.ui.right, right, "I doesn't open the Info panel in full screen"); - let img = h.snapshot(SETTLE); - // no chrome: the frame's corners are black - for (x, y) in [(2usize, 2usize), (997, 2), (2, 697), (997, 697)] { - let c = img.pixels[y * 1000 + x]; - assert!(c.r() < 8 && c.g() < 8 && c.b() < 8, "({x},{y}) = {c:?}"); - } - assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay")); - h.request("ui.key", json!({"key": "escape"}), t); - assert!(!h.app.ui.fullscreen); - assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid, "Esc leaves full screen only, back to where it was entered"); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("ui.key", json!({"key": "i", "cmd": true}), t); - assert_eq!(h.app.ui.info_overlay, InfoOverlay::Exposure); - h.request("ui.key", json!({"key": "i", "cmd": true}), t); - assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); - h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); - assert!(h.app.window_is_fullscreen); - h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); - assert!(!h.app.window_is_fullscreen); - h.settle(SETTLE); - } - - /// #911 (toast labels only): cycling the overlay announces each mode in the UI language. - #[test] - fn info_overlay_toasts_are_painted_in_the_users_language() { - use crate::i18n::Locale; - - struct RestoreLanguage(Locale); - impl Drop for RestoreLanguage { - fn drop(&mut self) { - crate::i18n::set_language(self.0); - } - } - let _restore = RestoreLanguage(crate::i18n::language()); - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - assert_eq!(h.request("ui.set", json!({"view": "detail", "infoOverlay": "off"}), t)["ok"], true); - for (command, locale) in [("app.language.german", Locale::De), ("app.language.french", Locale::Fr), ("app.language.english", Locale::En)] { - assert_eq!(h.request("engine.execute", json!({"command": command}), t)["ok"], true); - assert_eq!(h.app.ui.language, locale); - assert_eq!(crate::i18n::language(), locale); - for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { - let expected = locale.tr(key); - assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); - assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); - assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); - h.step(); - let painted = h.painted_text(); - assert!(painted.iter().any(|text| text == expected), "{painted:?}"); - } - } - } - - /// ⌘J opens View ▸ View Options; its menus choose Info 1's and Info 2's lines, Use Defaults puts - /// them back, and `view.viewOptions` sets them for an agent (refusing a line it doesn't know). - #[test] - fn view_options_choose_the_info_overlay_lines() { - use crate::state::{LoupeInfo, LoupeInfoField as F}; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); - assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ViewOptions)); - h.step(); - assert_eq!(h.request("ui.clickWidget", json!({"id": "combo:viewOptionsInfo1Line2"}), t)["ok"], true); - h.step(); - assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsInfo1Line2-lens"}), t)["ok"], true); - h.step(); - assert_eq!(h.app.ui.settings.loupe_info.info1, [F::FileName, F::Lens, F::Dimensions]); - assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsDefaults1"}), t)["ok"], true); - h.step(); - assert_eq!(h.app.ui.settings.loupe_info.info1, LoupeInfo::INFO1); - - let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["camera", "none", "fileName"]}}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName]); - let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["rating", "none", "none"]}}), t); - assert_eq!(r["ok"], false, "{r}"); - assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName], "a refused change changes nothing"); - } - - /// View Options' Show Briefly When Photo Changes: a tick box under each Info (ticking one - /// unticks the other) and `view.viewOptions {briefly}`; with the overlay off, the loupe shows - /// the chosen Info after → changes the photo, and hides it again a few seconds later. - #[test] - fn show_briefly_when_photo_changes_in_view_options() { - use crate::state::{BRIEF_INFO_SECS, InfoOverlay}; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); - h.step(); - assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly2"}), t)["ok"], true); - h.step(); - assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Exposure); - assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly1"}), t)["ok"], true); - h.step(); - assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Basic, "one Info at most"); - h.app.ui.dialog = None; - h.step(); - - let overlay = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay"); - assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); - h.time += BRIEF_INFO_SECS + 1.0; - h.step(); - h.step(); - assert!(!overlay(&h), "hidden once the time has passed"); - let before = h.app.session.active(); - h.request("ui.key", json!({"key": "right"}), t); - assert_ne!(h.app.session.active(), before); - h.step(); - assert!(overlay(&h), "shown after → changes the photo"); - h.time += BRIEF_INFO_SECS + 1.0; - h.step(); - h.step(); - assert!(!overlay(&h), "and hidden again"); - - let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "off"}}), t); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(r["result"]["loupeInfo"]["briefly"], "off", "{r}"); - h.request("ui.key", json!({"key": "right"}), t); - h.step(); - assert!(!overlay(&h), "off: nothing shows when the photo changes"); - let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "info3"}}), t); - assert_eq!(r["ok"], false, "{r}"); - assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Off, "a refused change changes nothing"); - } - - /// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the - /// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. - #[test] - fn click_zoom_ratio_animates() { - use crate::render::Slot; - use crate::state::Zoom; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - assert_eq!(h.app.ui.click_zoom, 100, "1:1 by default"); - let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 3}}), t); - assert_eq!(r["result"]["ratio"], 3, "{r}"); - let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 5}}), t); - assert_ne!(r["ok"], true, "{r}"); - let fit = h.app.image_rect.unwrap(); - h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); - assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0)); - assert!(h.app.ui.zoom_anim, "animation started"); - // every animation frame asks for the same (final-size) render - let mut keys = std::collections::HashSet::new(); - for _ in 0..40 { - h.step(); - keys.extend(h.app.renderer.wanted(Slot::Main)); - if !h.app.ui.zoom_anim { - break; - } - } - assert_eq!(keys.len(), 1, "one render for the whole animation: {keys:?}"); - h.settle(SETTLE); - assert!(!h.app.ui.zoom_anim, "animation finished"); - assert!(h.app.image_rect.unwrap().width() > fit.width()); - h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); - assert_eq!(h.app.ui.zoom, Zoom::Fit); - // Z zooms to the same ratio as a click - h.request("ui.key", json!({"key": "z"}), t); - assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0)); - h.request("ui.key", json!({"key": "z"}), t); - assert_eq!(h.app.ui.zoom, Zoom::Fit); - h.settle(SETTLE); - } - - /// A hover preview gets the loupe's display fit: it is the size the photo is drawn at, as the - /// loupe render is, not a preview-level render the GPU shrinks. - #[test] - fn a_hover_preview_is_fitted_to_the_drawn_size() { - use crate::render::Slot; - let mut h = demo([1400.0, 900.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail"}), t); - h.request("engine.execute", json!({"command": "panel.profiles"}), t); - h.settle(SETTLE); - h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); - let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); - assert_eq!(r["ok"], true, "{r}"); - h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); - let size = |s: Slot| h.app.renderer.textures.get(&s).map(|t| t.size); - let (main, hover) = (size(Slot::Main).expect("loupe render"), size(Slot::Hover).expect("hover render")); - assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "the loupe render is fitted: {main:?}"); - assert!(hover[0].abs_diff(main[0]) <= 1 && hover[1].abs_diff(main[1]) <= 1, "hover {hover:?} vs loupe {main:?}"); - } - - /// Zoomed in under a window render, the hidden whole-frame render stays canvas-sized instead of - /// rising to the preview level. - #[test] - fn a_zoomed_view_keeps_its_whole_frame_render_canvas_sized() { - use crate::render::Slot; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); - h.settle(SETTLE); - assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0)); - h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); - let main = h.app.renderer.textures.get(&Slot::Main).expect("whole-frame render").size; - assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "{main:?}"); - } - - #[test] - fn trackpad_navigation_keeps_the_image_cursor_between_events() { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), t); - h.request("engine.execute", json!({"command": "view.zoom100"}), t); - h.settle(SETTLE); - let anchor = h.app.canvas_rect.unwrap().center(); - h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t); - for (tool, expected) in [ - ("", egui::CursorIcon::Grab), - ("wbPicker", egui::CursorIcon::Crosshair), - ("colorRange", egui::CursorIcon::Crosshair), - ("pointColor", egui::CursorIcon::Crosshair), - ("tat:curve", egui::CursorIcon::ResizeVertical), - ] { - h.app.ui.tool = tool.into(); - for event in [ - None, - Some(egui::Event::Zoom(1.01)), - None, - Some(egui::Event::Zoom(1.01)), - Some(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(10.0, -10.0), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Unknown, - modifiers: egui::Modifiers::NONE, - }), - None, - ] { - let raw = HeadlessView::raw_input(h.size, h.pixels_per_point, h.time, event.into_iter().collect()); - let mut cursor = egui::CursorIcon::Default; - h.view.run(raw, |ui| { - h.app.logic(ui.ctx()); - h.app.ui(ui); - cursor = ui.ctx().output(|output| output.cursor_icon); - }); - h.time += FRAME_DT; - assert_eq!(cursor, expected, "cursor changed between navigation events with tool {tool:?}"); - } - } - } - - #[test] - fn trackpad_pinch_anchors_fractional_zoom_and_scroll_pans() { - use crate::state::Zoom; - let mut h = demo([1000.0, 700.0]); - h.pixels_per_point = 2.0; - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t); - h.request("engine.execute", json!({"command": "view.zoom100"}), t); - h.settle(SETTLE); - let before = h.app.image_rect.unwrap(); - let anchor = h.app.canvas_rect.unwrap().center() + egui::vec2(30.0, -20.0); - h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t); - let point = (anchor - before.min) / before.size(); - h.request("ui.zoom", json!({"factor": 1.001}), t); - let after = h.app.image_rect.unwrap(); - assert!((after.width() / before.width() - 1.001).abs() < 0.00001); - assert!(((anchor - after.min) / after.size() - point).length() < 0.00001, "point under pointer moved"); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(p) if (p - 100.1).abs() < 0.001), "{:?}", h.app.ui.zoom); - assert!(!h.app.ui.zoom_anim, "pinch follows the fingers immediately"); - - h.request("ui.scroll", json!({"dx": 40.0, "dy": -30.0}), t); - for _ in 0..40 { - h.step(); - } - let panned = h.app.image_rect.unwrap(); - assert!(panned.left() > after.left() && panned.top() < after.top(), "two-finger pan must move both axes"); - h.request("ui.scroll", json!({"dx": 100000.0, "dy": -100000.0}), t); - for _ in 0..40 { - h.step(); - } - let edge = h.app.image_rect.unwrap(); - let area = h.app.canvas_rect.unwrap().shrink(24.0); - assert!((edge.left() - area.left()).abs() < 0.01 && (edge.bottom() - area.bottom()).abs() < 0.01, "{edge:?} vs {area:?}"); - - h.request("ui.zoom", json!({"factor": 1e20}), t); - assert_eq!(h.app.ui.zoom, Zoom::Percent(800.0)); - h.request("ui.zoom", json!({"factor": 0.000001}), t); - assert_eq!(h.app.ui.zoom, Zoom::Fit); - assert_eq!(h.app.ui.pan, (0.5, 0.5)); - // Panel / toolbar gestures must not move the image. - h.request("ui.move", json!({"x": 10, "y": 10}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - h.request("ui.scroll", json!({"dx": 100, "dy": 100}), t); - assert_eq!(h.app.ui.zoom, Zoom::Fit); - assert_eq!(h.app.ui.pan, (0.5, 0.5)); - } - - #[test] - fn mouse_wheel_zooms_the_loupe_with_and_without_ctrl() { - use crate::state::Zoom; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t); - h.settle(SETTLE); - for (unit, y, modifiers) in [ - (egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE), - (egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL), - (egui::MouseWheelUnit::Point, 50.0, egui::Modifiers::CTRL), - ] { - h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t); - h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); - h.app.synthetic.push(egui::Event::MouseWheel { - unit, - delta: egui::vec2(0.0, y), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Unknown, - modifiers, - }); - for _ in 0..10 { - h.step(); - } - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "wheel {unit:?} {modifiers:?} must zoom in, got {:?}", h.app.ui.zoom); - } - } - - #[test] - fn trackpad_navigation_works_in_tools_compare_reference_and_before_after() { - use crate::state::Zoom; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "leftPanel": false, "filmstrip": false}), t); - h.settle(SETTLE); - let active = h.app.session.active().unwrap(); - let settings = h.app.session.catalog.photo(active).unwrap().develop.clone(); - for right in ["crop", "masking", "remove", "redEye", "none"] { - h.request("ui.set", json!({"right": right, "zoom": "fit", "pan": [0.5, 0.5]}), t); - h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch in {right}"); - assert_eq!(h.app.session.catalog.photo(active).unwrap().develop, settings, "navigation must not edit the photo"); - } - h.request("ui.set", json!({"fullscreen": true, "zoom": "fit"}), t); - h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "full-screen pinch"); - - h.request("ui.set", json!({"fullscreen": false, "zoom": "fit", "beforeAfter": "sideBySide"}), t); - let canvas = h.app.canvas_rect.unwrap(); - h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch over the Before pane"); - - h.request("ui.set", json!({"beforeAfter": "off", "zoom": "fit"}), t); - h.request("engine.execute", json!({"command": "view.compare"}), t); - // The left Compare pane must navigate too; zoom/pan are shared with the right pane. - let canvas = h.app.canvas_rect.unwrap(); - h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Compare pinch"); - let pan = h.app.ui.pan; - h.request("ui.scroll", json!({"dx": -50, "dy": -50}), t); - assert_ne!(h.app.ui.pan, pan, "Compare pan"); - - h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t); - h.request("engine.execute", json!({"command": "view.reference"}), t); - h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); - h.request("ui.zoom", json!({"factor": 2}), t); - assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Reference pinch"); - } - - #[test] - fn navigation_rejects_invalid_input_and_preserves_old_zoom_state() { - use crate::state::Zoom; - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - assert_eq!(serde_json::from_value::(json!({"percent": 100})).unwrap(), Zoom::Percent(100.0)); - for factor in [json!(0), json!(-1), json!(1e308), json!("big"), Value::Null] { - let r = h.request("ui.zoom", json!({"factor": factor}), t); - assert_eq!(r["ok"], false, "{r}"); - } - for params in [ - json!({"zoom": {"percent": -1}}), - json!({"zoom": {"percent": 1e308}}), - json!({"zoom": "oops"}), - json!({"zoom": {"percent": 123.4}, "pan": [-1, 0.5]}), - json!({"pan": [0.5, 1e308]}), - json!({"pan": [0.5]}), - ] { - assert!(h.app.run("view.navigate", params).is_err()); - assert_eq!(h.app.ui.zoom, Zoom::Fit, "failed request changed zoom"); - assert_eq!(h.app.ui.pan, (0.5, 0.5), "failed request changed pan"); - } - h.app.run("view.navigate", json!({"zoom": {"percent": 123.4}, "pan": [0.4, 0.6]})).unwrap(); - assert_eq!(h.app.ui.zoom, Zoom::Percent(123.4)); - assert_eq!(h.app.ui.pan, (0.4, 0.6)); - h.app.run("view.zoomIn", json!({})).unwrap(); - assert_eq!(h.app.ui.zoom, Zoom::Percent(200.0)); - h.app.run("view.zoomOut", json!({})).unwrap(); - assert_eq!(h.app.ui.zoom, Zoom::Percent(100.0)); - - let area = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(1000.0, 700.0)); - for pan in [(0.0, 0.0), (1.0, 1.0)] { - let image = crate::panels::detail::fit_rect(area, 10.0, Zoom::Percent(100.0), [4000, 400], 1.0, pan); - assert_eq!(image.center().y, area.center().y, "smaller axis must stay centred"); - assert!(image.left() <= area.left() && image.right() >= area.right(), "pan exposed space outside the photo"); - } - } - - /// The Navigator appears when zoomed in; clicking it pans to that point. - #[test] - fn navigator_pans_the_zoomed_loupe() { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "hidden at Fit"); - h.request("engine.execute", json!({"command": "view.zoom100"}), t); - h.settle(SETTLE); - assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "navigator shown when zoomed"); - h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t); - let (u, v) = h.app.ui.pan; - assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan); - assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0), "the click didn't reach the loupe (which would zoom out)"); - h.settle(SETTLE); - let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap(); - let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7); - h.request("ui.dragWidget", json!({"id": "canvas:navigator", "fx": 0.5, "fy": 0.5, "toX": to.x, "toY": to.y}), t); - let (u, v) = h.app.ui.pan; - assert!((u - 0.8).abs() < 0.03 && (v - 0.7).abs() < 0.03, "drag pan {:?}", h.app.ui.pan); - h.request("engine.execute", json!({"command": "view.navigator"}), t); - h.settle(SETTLE); - assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off"); - } - - /// While an edit renders, the loupe shows the last render: it says "Rendering…" once that has - /// lasted a moment (not at once after a spell away from the loupe, when no frames were drawn), - /// and stops when the render lands. - #[test] - fn the_loupe_says_rendering_while_an_edit_renders() { - use crate::render::Slot; - use std::{cell::Cell, rc::Rc}; - let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - assert!(!pill(&h), "a finished render"); - let open = Rc::new(Cell::new(false)); - h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); - // half a second in the grid: the loupe draws no frames - h.request("ui.set", json!({"view": "photoGrid"}), t); - for _ in 0..30 { - h.step(); - } - h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); - h.request("ui.set", json!({"view": "detail"}), t); - for _ in 0..12 { - h.step(); - } - assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held"); - assert!(!pill(&h), "not at once"); - for _ in 0..30 { - h.step(); - } - assert!(pill(&h), "the last render is on screen and the edit's is on its way"); - open.set(true); - assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); - assert!(!h.app.renderer.is_pending(Slot::Main)); - } - - /// View Options' Show message when loading or rendering photos (on by default): turned off in - /// the window, the loupe does not say "Rendering…" while an edit renders; `view.viewOptions - /// {loupeMessages}` turns it back on for an agent (refusing a value that is not true or false). - #[test] - fn view_options_turn_the_rendering_message_off() { - use crate::render::Slot; - use std::{cell::Cell, rc::Rc}; - let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - assert!(h.app.ui.settings.loupe_messages, "on by default"); - assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); - h.step(); - assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.loupeMessages"}), t)["ok"], true); - h.step(); - assert!(!h.app.ui.settings.loupe_messages, "the tick box turns it off"); - h.app.ui.dialog = None; - let open = Rc::new(Cell::new(false)); - h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); - h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); - for _ in 0..45 { - h.step(); - } - assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held"); - assert!(!pill(&h), "no message while it is off"); - - let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": "yes"}}), t); - assert_eq!(r["ok"], false, "{r}"); - assert!(!h.app.ui.settings.loupe_messages, "a refused change changes nothing"); - let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": true}}), t); - assert_eq!(r["result"]["loupeMessages"], true, "{r}"); - assert_eq!(h.app.ui.dialog, None, "a value sets it without opening the window"); - assert!(h.step_until(SETTLE, pill), "the message shows again while the render is held"); - open.set(true); - assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); - } - - /// With nothing to show yet, the loupe says "Rendering…" in its middle only while View Options' - /// Show message when loading or rendering photos is on. - #[test] - fn an_empty_loupe_says_rendering_only_with_the_message_on() { - use std::{cell::Cell, rc::Rc}; - for on in [true, false] { - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.app.ui.settings.loupe_messages = on; - h.app.renderer.set_offload(Box::new(super::RenderGate::new(Rc::new(Cell::new(false))))); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - // drop every texture (quick previews and thumbnails land outside the gate) until a - // frame has nothing to show - let empty = (0..60).any(|_| { - h.app.renderer.textures.clear(); - h.step(); - h.app.loupe_shown.map(|l| l.1) == Some("none") - }); - assert!(empty, "a frame with nothing rendered"); - let said = h.painted_text().iter().any(|text| text == "Rendering…"); - assert_eq!(said, on, "message on: {on}"); - } - } - - /// A render that fails while the loupe shows a stand-in (the embedded JPEG, a thumbnail) left - /// the stand-in up as if it were the photo: the loupe says the photo can't be opened, with the - /// error on hover, and keeps saying so while a library change retries it. - #[test] - fn the_loupe_says_so_when_the_render_behind_a_stand_in_fails() { - use crate::render::Slot; - use std::{cell::Cell, rc::Rc}; - let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); - let mut h = demo([1000.0, 700.0]); - let t = Duration::from_secs(10); - h.request("ui.set", json!({"view": "detail", "right": "none"}), t); - h.settle(SETTLE); - assert!(!has(&h, "loupe:renderFailed")); - let first = h.app.session.active().expect("a photo is open").0; - // every loupe render fails, as an undecodable raw's would; the rest render - let open = Rc::new(Cell::new(true)); - h.app.renderer.set_offload(Box::new(super::RenderGate::failing(open.clone(), |s| s == Slot::Main))); - let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo").0; - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other], "active": other}}), t); - assert!(h.step_until(SETTLE, |h| has(h, "loupe:renderFailed")), "the failure is said"); - let shown = h.app.loupe_shown.map(|l| l.1); - assert!(!matches!(shown, Some("render" | "none")), "over a stand-in: {shown:?}"); - assert!(!has(&h, "loupe:rendering")); - let r = h.request("ui.inspect", json!({}), t); - assert_eq!(r["result"]["loupe"]["failed"], "no decoder for this raw", "{}", r["result"]["loupe"]); - // a library change (here a rating) retries it: the failure stays said, no "Rendering…" - open.set(false); - h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 2}}), t); - for _ in 0..45 { - h.step(); - } - assert!(h.app.renderer.is_pending(Slot::Main), "the retry is held"); - assert!(has(&h, "loupe:renderFailed") && !has(&h, "loupe:rendering"), "no blink while it retries"); - // back on a photo that rendered: nothing to say - open.set(true); - h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first], "active": first}}), t); - h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); - assert!(h.step_until(SETTLE, |h| h.app.loupe_shown.is_some_and(|l| l.1 == "render") && !has(h, "loupe:renderFailed"))); - } - - #[test] - fn screenshot_request_is_answered_without_a_window() { - let mut h = demo([800.0, 500.0]); - let r = h.request("ui.screenshot", json!({}), SETTLE); - assert_eq!(r["ok"], true, "{r}"); - assert_eq!(r["result"]["width"], 800); - assert_eq!(r["result"]["height"], 500); - let r = h.request("ui.resize", json!({"width": 640, "height": 400}), Duration::from_secs(5)); - assert_eq!(r["ok"], true); - let r = h.request("ui.screenshot", json!({"headless": true}), SETTLE); - assert_eq!(r["result"]["width"], 640, "{r}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs new file mode 100644 index 000000000..a67760bc9 --- /dev/null +++ b/crates/ui-egui/src/headless/tests.rs @@ -0,0 +1,4946 @@ +#[test] +fn detail_offers_ai_denoise_for_raw_photos_and_leads_to_its_settings() { + use crate::state::Dialog; + use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source}; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-denoise-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models"))); + // a raw photo (its file need not exist: nothing here reads it) + let id = PhotoId(h.app.session.catalog.photos().map(|p| p.id.0).max().unwrap_or(0) + 1); + let mut raw = Photo::new( + id, + Source::File { path: dir.join("IMG_1.dng").to_string_lossy().into_owned() }, + "IMG_1.dng", + "DNG", + 6000, + 4000, + "2026-10-01T00:00:00", + ); + raw.kind = MediaKind::Raw; + h.app.session.commit("setup", Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + let widgets = |h: &mut Headless| h.request("ui.widgets", json!({}), t).to_string(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.app.ui.open_sections = vec!["detail".to_string()]; + h.settle(SETTLE); + h.step(); + h.step(); + let w = widgets(&mut h); + assert!(w.contains("\"denoise:setup\""), "a raw photo with no model is offered the setup: {w}"); + // One click lands in Settings, with local installation available even without a download offer. + let r = h.request("ui.clickWidget", json!({"id": "denoise:setup"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); + h.settle(SETTLE); + let listed = h.app.session.execute("denoise.models.list", &json!({})).unwrap(); + assert_eq!(listed["models"].as_array().unwrap().len(), lightcraft_denoise::known::all().len(), "{listed}"); + assert!(widgets(&mut h).contains("\"denoise:installFile\"")); + h.app.ui.dialog = None; + // a photo that is not raw: no offer + h.request( + "engine.execute", + json!({"command": "library.select", "params": {"ids": [h.app.session.catalog.photos().find(|p| p.id != id).unwrap().id.0]}}), + t, + ); + h.settle(SETTLE); + h.step(); + h.step(); + assert!(!widgets(&mut h).contains("\"denoise:setup\""), "nothing is offered for a photo denoise does not apply to"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Install from file… with a model the engine rejects says why in the AI Denoise section (#1381), instead of +/// leaving the button looking dead. +#[test] +fn a_rejected_model_file_says_why_in_settings() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-denoise-rejected-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models"))); + // without the runtime the button is disabled (the workspace build turns it on through the desktop app) + if h.app.session.execute("denoise.models.list", &json!({})).unwrap()["runtime"] != true { + let _ = std::fs::remove_dir_all(&dir); + return; + } + // another app's model folder: the .onnx with that app's model.json, and no denoise-model.json + let onnx = dir.join("rawdenoise.onnx"); + std::fs::write(&onnx, b"not a model").unwrap(); + std::fs::write(dir.join("model.json"), br#"{"name": "rawdenoise", "scale": 2}"#).unwrap(); + let picked = onnx.to_string_lossy().into_owned(); + h.app.services.pick_denoise_model = Some(Box::new(move || vec![picked.clone()])); + h.app.ui.dialog = Some(Dialog::Settings { tab: "denoise".into() }); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "denoise:installFile"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); + let painted = h.painted_text(); + assert!( + painted + .iter() + .any(|s| s.starts_with("No denoise-model.json beside this .onnx file. The model.json there") && s.ends_with("(see docs/denoise.md)")), + "{painted:?}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +use super::*; +use std::sync::Arc; + +/// The pretend host has a clipboard, as a desktop does: text the UI copies lands in it, and a +/// cut, copy or paste request (from a context menu) comes back as that event on the next frame. +#[test] +fn the_headless_host_has_a_clipboard() { + let mut view = HeadlessView::new(); + let raw = || HeadlessView::raw_input(egui::vec2(200.0, 100.0), 1.0, 0.0, vec![]); + view.run(raw(), |ui| ui.ctx().copy_text("Lisbon".into())); + assert_eq!(view.clipboard, "Lisbon"); + for (command, event) in [ + (ViewportCommand::RequestPaste, egui::Event::Paste("Lisbon".into())), + (ViewportCommand::RequestCopy, egui::Event::Copy), + (ViewportCommand::RequestCut, egui::Event::Cut), + ] { + view.run(raw(), |ui| ui.ctx().send_viewport_cmd(command.clone())); + let mut seen = vec![]; + view.run(raw(), |ui| seen = ui.input(|i| i.events.clone())); + assert!(seen.contains(&event), "{command:?}: {seen:?}"); + } + // an empty clipboard pastes nothing, as a desktop host does + view.clipboard.clear(); + view.run(raw(), |ui| ui.ctx().send_viewport_cmd(ViewportCommand::RequestPaste)); + let mut seen = vec![]; + view.run(raw(), |ui| seen = ui.input(|i| i.events.clone())); + assert!(!seen.iter().any(|e| matches!(e, egui::Event::Paste(_))), "{seen:?}"); +} + +#[test] +fn screenshot_dimensions_are_checked_before_layout() { + assert_eq!(viewport_pixels([1600.0, 1000.0], 2.0).unwrap(), [3200, 2000]); + assert_eq!(viewport_pixels([345.0, 200.0], 0.9).unwrap(), [311, 180]); + // The largest accepted surface is exactly the 128 MiB screenshot budget (32 Mi pixels). + assert_eq!(viewport_pixels([16384.0, 2048.0], 1.0).unwrap(), [16384, 2048]); + assert_eq!(viewport_pixels([5792.0, 5792.0], 1.0).unwrap(), [5792, 5792]); + for (size, scale) in [ + ([f32::NAN, 10.0], 1.0), + ([10.0, f32::INFINITY], 1.0), + ([10.0, 10.0], f32::INFINITY), + ([10.0, 10.0], f32::NAN), + ([10.0, 10.0], 0.0), + ([0.0, 10.0], 1.0), + ([10.0, 10.0], 0.01), + ([16384.0, 2049.0], 1.0), + ([8000.0, 8000.0], 1.0), + ([5793.0, 5793.0], 1.0), + ([9000.0, 9000.0], 1.0), + ([9000.0, 100.0], 2.0), + ] { + assert!(viewport_pixels(size, scale).is_err(), "{size:?} at {scale}"); + } + let raw = HeadlessView::raw_input(egui::vec2(f32::INFINITY, 10.0), 1.0, 0.0, vec![]); + assert_eq!(raw.screen_rect.unwrap().size(), egui::vec2(1600.0, 1000.0)); +} + +#[test] +fn ui_zoom_preserves_host_pixels_and_scales_layout() { + for (size, scale, expected) in + [(egui::vec2(400.0, 240.0), 1.0, [400, 240]), (egui::vec2(345.0, 200.0), 0.9, [311, 180]), (egui::vec2(400.0, 240.0), 2.0, [800, 480])] + { + let mut view = HeadlessView::new(); + for frame in 0..9 { + // the interface scale changes between frames (Cmd+Plus / Cmd+Minus are the + // photo's zoom, issue #566: egui's own keyboard zoom is off) + match frame { + 1 => view.ctx.set_zoom_factor(1.1), + 5 => view.ctx.set_zoom_factor(1.0), + _ => {} + } + let raw = HeadlessView::raw_input(size, scale, frame as f64 / 60.0, vec![]); + view.run(raw, |ui| { + ui.label("UI zoom changes layout, keeping the host viewport size"); + }); + assert_eq!(view.size_px(), expected, "frame {frame} at scale {scale}"); + assert_eq!(view.paint(&HashMap::new()).size, expected); + if frame == 4 { + assert!(view.ctx.zoom_factor() > 1.0); + assert!(view.ctx.content_rect().width() < size.x); + } + if frame == 8 { + assert!(view.ctx.zoom_factor() < 1.01); + } + } + } +} + +#[test] +fn shadow_screenshot_keeps_the_zoomed_hosts_size_and_scale() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [480.0, 320.0], 2.0); + let t = Duration::from_secs(5); + h.view.ctx.set_zoom_factor(1.1); + assert_eq!(h.request("ui.inspect", json!({}), t)["ok"], true); + assert!(h.view.ctx.zoom_factor() > 1.0); + let main = h.view.ctx.clone(); + let screenshot = h.app.headless_screenshot(&main, true).unwrap(); + assert_eq!(screenshot.size, [960, 640]); + let shadow = h.app.shadow.as_ref().unwrap(); + assert!((shadow.ctx.pixels_per_point() - main.pixels_per_point()).abs() < 0.001); + assert!((shadow.ctx.content_rect().width() - main.content_rect().width()).abs() < 0.1); +} + +#[test] +fn control_resize_uses_effective_points_after_ui_zoom() { + for scale in [0.9, 1.0, 2.0] { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [480.0, 320.0], scale); + let t = Duration::from_secs(5); + assert_eq!(h.request("ui.zoomFactor", json!({"factor": 1.1}), t)["ok"], true); + h.step(); + assert!(h.view.ctx.zoom_factor() > 1.0); + let native_size = resized_viewport([320.0, 240.0], scale, h.view.ctx.zoom_factor()).unwrap(); + let expected = native_viewport_pixels([native_size.x, native_size.y], scale).unwrap(); + assert_eq!(h.request("ui.resize", json!({"width": 320, "height": 240}), t)["ok"], true); + assert_eq!(h.view.size_px(), expected); + assert!((h.view.ctx.content_rect().width() - 320.0).abs() < 0.1); + let main = h.view.ctx.clone(); + assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, expected); + } +} + +#[test] +fn control_resize_keeps_a_minimum_width_after_zooming_out() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [480.0, 320.0], 1.0); + h.view.ctx.set_zoom_factor(0.5); + let t = Duration::from_secs(5); + assert_eq!(h.request("ui.resize", json!({"width": 1, "height": 120}), t)["ok"], true); + assert_eq!(h.size, egui::vec2(0.5, 60.0)); + assert_eq!(h.view.size_px(), [1, 60]); + assert!((h.view.ctx.content_rect().width() - 1.0).abs() < 0.01); + let main = h.view.ctx.clone(); + assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [1, 60]); +} + +#[test] +fn control_resize_rounds_in_native_points_after_a_fractional_zoom_out() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [480.0, 320.0], 0.9); + let t = Duration::from_secs(5); + h.view.ctx.set_zoom_factor(0.9); + h.step(); + assert!((h.view.ctx.zoom_factor() - 0.9).abs() < 0.001); + assert_eq!(h.request("ui.resize", json!({"width": 150, "height": 150}), t)["ok"], true); + assert_eq!(h.size, egui::vec2(135.0, 135.0)); + assert_eq!(h.view.size_px(), [122, 122]); + assert!((h.view.ctx.content_rect().width() - 150.0).abs() < 0.01); + let main = h.view.ctx.clone(); + assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [122, 122]); +} + +#[test] +fn invalid_control_resize_preserves_the_headless_viewport() { + let app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + let mut h = Headless::new(app, [480.0, 320.0], 2.0); + h.app.ui.settings.gpu = false; + h.step(); + let size = h.size; + let rejected = h.request("ui.resize", json!({"width": 9000, "height": 8000}), Duration::from_secs(5)); + assert_eq!(rejected["ok"], false, "{rejected}"); + assert_eq!(h.size, size); + for bad in [json!({"width": "320", "height": 240}), json!({"width": 320, "height": null}), json!({"width": true})] { + let rejected = h.request("ui.resize", bad, Duration::from_secs(5)); + assert_eq!(rejected["ok"], false, "{rejected}"); + assert_eq!(h.size, size); + } + let accepted = h.request("ui.resize", json!({"width": 320, "height": 240}), Duration::from_secs(5)); + assert_eq!(accepted["ok"], true, "{accepted}"); + assert_eq!(h.size, egui::vec2(320.0, 240.0)); + assert_eq!(h.view.size_px(), [640, 480]); +} + +/// Generous: renders are slow when the machine is loaded (parallel builds), and a timed-out +/// settle would show a half-rendered UI. +const SETTLE: Duration = Duration::from_secs(120); + +fn demo(size: [f32; 2]) -> Headless { + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + Headless::new(app, size, 1.0) +} + +/// Agents cut, copy and paste in a text field as the system does for ⌘X / ⌘C / ⌘V +/// (`ui.clipboard`), and `ui.inspect` → `copied` says what the UI last copied. +#[test] +fn agents_cut_copy_and_paste_in_text_fields() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + r + }; + h.settle(SETTLE); + ask(&mut h, "ui.clickWidget", json!({"id": "field:search"})); + ask(&mut h, "ui.text", json!({"text": "wedding"})); + ask(&mut h, "ui.key", json!({"key": "a", "cmd": true})); + ask(&mut h, "ui.clipboard", json!({"action": "copy"})); + assert_eq!(ask(&mut h, "ui.inspect", json!({}))["result"]["copied"], "wedding"); + ask(&mut h, "ui.clipboard", json!({"action": "cut"})); + assert_eq!(h.app.ui.search, ""); + ask(&mut h, "ui.clipboard", json!({"action": "paste", "text": "travel"})); + assert_eq!(h.app.ui.search, "travel"); + let r = h.request("ui.clipboard", json!({"action": "shred"}), t); + assert_eq!(r["ok"], false, "an unknown action is refused: {r}"); +} + +/// `ui.clipboard` acts in a text field: with none focused it is refused, rather than doing +/// what ⌘V does there on one platform (paste edit settings) and nothing on another. +#[test] +fn the_clipboard_method_needs_a_text_field() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + let undo = h.app.session.undo.len(); + for action in ["copy", "paste"] { + let r = h.request("ui.clipboard", json!({"action": action, "text": "travel"}), t); + assert_eq!(r["ok"], false, "{action}: {r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("text field")), "{r}"); + } + h.settle(SETTLE); + assert_eq!(h.app.session.undo.len(), undo, "nothing was pasted"); +} + +/// `ui.clickWidget` right-clicks with `button: "right"`, as `ui.click` does (MCP `click` +/// passes it on with a widget): a filmstrip photo's context menu opens. +#[test] +fn click_widget_right_clicks() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + let cell = h.app.widgets.iter().find(|(w, _)| w.starts_with("film:")).map(|(w, _)| w.clone()).expect("a filmstrip photo"); + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.clickWidget", json!({"id": cell, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_any_open(&h.view.ctx), "its context menu"); +} + +/// #1877: a right-click while another photo's context menu is open closes that menu and +/// opens this photo's, which stays open (it flashed and closed again). +#[test] +fn right_clicking_elsewhere_moves_the_context_menu() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + let thumbs: Vec = h.app.widgets.iter().filter(|(w, _)| w.starts_with("thumb:")).map(|(w, _)| w.clone()).collect(); + let (Some(a), Some(b)) = (thumbs.first().cloned(), thumbs.get(1).cloned()) else { panic!("two grid photos: {thumbs:?}") }; + let popup = |w: &str| { + let id: u64 = w.trim_start_matches("thumb:").parse().expect("a photo id"); + egui::Id::unique(("cell", id)).with("popup") + }; + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.clickWidget", json!({"id": a, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "the first photo's menu is open"); + // A real click: the press and the release reach the UI on different frames, with the + // pointer resting on the photo in between (`ui.clickWidget` sends both on one frame). + let rect = h.app.widgets.iter().find(|(w, _)| *w == b).map(|(_, r)| *r).expect("the second photo on screen"); + let at = egui::pos2(rect.left() + rect.width() * 0.9, rect.center().y); + let button = egui::PointerButton::Secondary; + let modifiers = egui::Modifiers::NONE; + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers }); + // one frame at a time: the menu flashed for a frame or two before it closed + for frame in 0..6 { + h.step(); + assert!(!egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "frame {frame}: the first photo's menu closed"); + if frame > 0 { + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the second photo's menu is open"); + } + } + h.settle(SETTLE); + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the second photo's menu stays open"); + // The same photo again, elsewhere on it (the menu that is open belongs to this very + // widget): the menu moves to the pointer and stays. + let before = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area"); + let at = egui::pos2(rect.left() + rect.width() * 0.2, rect.center().y); + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers }); + h.step(); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers }); + for frame in 0..6 { + h.step(); + if frame > 0 { + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the menu is open after a right-click on the same photo"); + } + } + h.settle(SETTLE); + assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the menu stays open"); + let after = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area"); + assert!((after.left() - at.x).abs() < 4.0, "the menu moved to the pointer: {after:?} vs {at:?} (was {before:?})"); +} + +/// #1171: Up / Down in the grid go to the cell above / below; in the filmstrip, one photo. +#[test] +fn up_and_down_move_through_the_grid_rows() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "grid"}), t); + h.settle(SETTLE); + let first = h.app.session.visible_cloned()[0]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first.0]}}), t); + h.settle(SETTLE); + let rect = |h: &Headless| { + let a = h.app.session.selection.active.unwrap(); + h.app.widgets.iter().find(|(w, _)| *w == format!("thumb:{}", a.0)).map(|(_, r)| *r).expect("grid cell") + }; + let top = rect(&h); + h.request("ui.key", json!({"key": "down"}), t); + h.settle(SETTLE); + let below = rect(&h); + let pos = { + let a = h.app.session.selection.active; + h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) + } + .unwrap(); + assert!(below.top() > top.bottom(), "the next row, at {pos}: {top:?} -> {below:?}"); + assert!(below.left() < top.right() && below.right() > top.left(), "overlapping horizontally"); + h.request("ui.key", json!({"key": "up"}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.selection.active, Some(first), "back up"); + h.request("ui.key", json!({"key": "up"}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.selection.active, Some(first), "the top row stays put"); + // the filmstrip (one row): Down is the next photo + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + h.request("ui.key", json!({"key": "down"}), t); + h.settle(SETTLE); + assert_eq!( + { + let a = h.app.session.selection.active; + h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) + }, + Some(1) + ); +} + +/// The search field is a shared text field: right-click ▸ Paste puts the clipboard's text in +/// and filters by it. +#[test] +fn the_search_field_pastes_from_its_menu() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + h.view.clipboard = "wedding".into(); + for (id, button) in [("field:search", "right"), ("field:search:paste", "left")] { + let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.ui.search, "wedding"); + assert_eq!(h.app.session.filter.text, "wedding", "and the library is filtered by it"); +} + +/// Issue #1648: clearing the filters from the loupe's pill empties the search field too, so +/// the field never shows a search that is no longer applied. +#[test] +fn clearing_the_loupe_filters_empties_the_search_field() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + for (method, params) in [("ui.clickWidget", json!({"id": "field:search"})), ("ui.text", json!({"text": "a"}))] { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.session.filter.text, "a", "filtered while typing"); + assert!(!h.app.session.visible_cloned().is_empty(), "the search finds photos, so the loupe has one to show"); + h.request("engine.execute", json!({"command": "view.detail"}), t); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "loupe:filters"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", "")); +} + +/// Esc in the search field gives back the search from before the edit, and the filter with it. +#[test] +fn escape_in_the_search_field_gives_back_the_search() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + for (method, params) in [("ui.clickWidget", json!({"id": "field:search"})), ("ui.text", json!({"text": "travel"}))] { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.session.filter.text, "travel", "filtered while typing"); + let r = h.request("ui.key", json!({"key": "Escape"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", "")); +} + +#[test] +fn demo_grid_snapshot_has_ui_pixels() { + let t0 = Instant::now(); + let mut h = demo([1200.0, 760.0]); + // settle() can see a quiet spell before the first thumbnails land on a loaded machine + h.step_until(SETTLE, |h| h.app.renderer.thumb_textures() > 0); + let img = h.snapshot(SETTLE); + eprintln!("headless snapshot: {:?} in {:?} ({} frames)", img.size, t0.elapsed(), h.frames()); + assert_eq!(img.size, [1200, 760]); + // not blank: many distinct colours + let mut colours: Vec = img.pixels.iter().map(|c| u32::from_le_bytes(c.to_array())).collect(); + colours.sort_unstable(); + colours.dedup(); + assert!(colours.len() > 200, "only {} colours", colours.len()); + // text in the top bar: bright pixels on the dark chrome + let top_h = crate::theme::Tokens::default().top_bar_h as usize; + let bright = img.pixels[..top_h * 1200].iter().filter(|c| c.r() > 150 && c.g() > 150 && c.b() > 150).count(); + assert!(bright > 30, "no text in the top bar ({bright} bright px)"); + // thumbnails arrived (photo textures were drawn) + assert!(h.app.renderer.thumb_textures() > 0); +} + +#[test] +fn snapshots_are_deterministic_and_control_requests_work() { + let shot = || { + let mut h = demo([900.0, 600.0]); + let r = h.request("ui.set", json!({"view": "detail"}), Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + // Compare only fully settled frames: a timed-out settle would compare half-rendered + // pictures and fail as a misleading pixel diff (seen on loaded CI machines). + assert!(h.settle(Duration::from_secs(300)), "the demo detail view did not settle within 300 s"); + h.paint() + }; + let (a, b) = (shot(), shot()); + assert_eq!(a.size, b.size); + // The photo may come from the GPU in one run and the CPU in the other (the GPU warms up in + // the background), which differ by ≤ 1–2 LSB; anything more is a real difference. + let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.to_array().iter().zip(y.to_array()).any(|(p, q)| p.abs_diff(q) > 2)).count(); + assert_eq!(diff, 0, "{diff} pixels differ between two runs"); +} + +/// Keyboard culling: Compare (rating keys hit the candidate, arrows move it, auto-advance), +/// Survey (keys hit the active photo only), and auto-advance in Detail. +#[test] +fn compare_survey_and_auto_advance_by_keyboard() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().rating; + let flag = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().flag; + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]]}}), t); + let r = h.request("engine.execute", json!({"command": "view.compare"}), t); + assert_eq!(r["result"], json!({"select": vis[0], "candidate": vis[1]}), "{r}"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); + // rating applies to the candidate (active) only + let before = rating(&h, vis[0]); + h.request("ui.key", json!({"key": "2"}), t); + assert_eq!((rating(&h, vis[0]), rating(&h, vis[1])), (before, 2)); + // arrows move the candidate; the select stays + h.request("ui.key", json!({"key": "right"}), t); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); + h.request("ui.key", json!({"key": "left"}), t); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[1]))); + // Shift+X: reject and advance to the next candidate + h.request("ui.key", json!({"key": "x", "shift": true}), t); + assert_eq!(flag(&h, vis[1]), lightcraft_catalog::Flag::Reject); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); + h.request("engine.execute", json!({"command": "compare.swap"}), t); + assert_eq!(h.app.ui.compare, Some((vis[2], vis[0]))); + h.request("engine.execute", json!({"command": "compare.makeSelect"}), t); + assert_eq!(h.app.ui.compare.map(|c| c.0), Some(vis[0])); + // Survey: the selection tiled; keys hit the active photo, auto-advance walks the survey + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[3], vis[4], vis[5]], "active": vis[3]}}), t); + let r = h.request("engine.execute", json!({"command": "view.survey"}), t); + assert_eq!(r["result"]["photos"], 3); + h.request("engine.execute", json!({"command": "view.autoAdvance"}), t); + assert!(h.app.ui.auto_advance); + h.request("ui.key", json!({"key": "5"}), t); + h.request("ui.key", json!({"key": "p"}), t); + assert_eq!((rating(&h, vis[3]), flag(&h, vis[4])), (5, lightcraft_catalog::Flag::Pick)); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[5])); + assert_eq!(h.app.session.selection.ids.len(), 3, "the survey keeps its selection"); + let img = h.snapshot(SETTLE); + assert_eq!(img.size, [1000, 700]); + // Detail: auto-advance moves to the next photo + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[6]]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("ui.key", json!({"key": "u"}), t); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[7])); + // Escape leaves the culling views for Detail + h.request("ui.set", json!({"view": "survey"}), t); + h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail); + // let in-flight renders finish: worker threads must not outlive the test process' TLS + h.settle(SETTLE); +} + +/// Naming a face in the loupe: point at an unnamed face, click its "Add name" label, type, Enter. Needs the +/// recognition runtime only for the background indexer, so the naming itself is tested whatever the build. +#[test] +fn naming_a_face_in_the_loupe() { + use crate::state::NameEdit; + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let id = h.app.session.active().unwrap(); + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.55, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + // pointing at the box shows the invitation; clicking it opens the name box + h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(matches!(&h.app.ui.name_edit, Some(NameEdit { index: 0, .. })), "{:?}", h.app.ui.name_edit); + // Escape closes it without naming anything + let r = h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.ui.name_edit.is_none(), "{:?}", h.app.ui.name_edit); + assert_eq!(h.app.session.catalog.photo(id).unwrap().meta.regions[0].name, None); + // open it again, type a name, Enter: the face is named, in one undo step + h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); + h.step(); + h.step(); + h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); + h.step(); + h.step(); + let undo = h.app.session.undo.len(); + h.request("ui.text", json!({"text": "Ann"}), t); + h.step(); + h.request("ui.key", json!({"key": "enter"}), t); + h.step(); + h.step(); + assert_eq!(h.app.session.catalog.photo(id).unwrap().meta.regions[0].name.as_deref(), Some("Ann")); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.ui.name_edit.is_none()); +} + +/// Adding a face model: the dialog shows the file's terms, the model is installed only once they are +/// accepted, and a file LightCraft cannot use only says why. +#[test] +fn adding_a_face_model_shows_its_terms_and_installs_only_once_accepted() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-facemodel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.face_models_dir = Some(dir.join("models")); + let model = dir.join("Mine.onnx"); + std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap(); + let open = |h: &mut Headless, path: &std::path::Path| { + h.request("engine.execute", json!({"command": "dialog.faceModel", "params": {"path": path.to_string_lossy()}}), t) + }; + + // the dialog opens for the chosen file, with nothing accepted and nothing installed + assert_eq!(open(&mut h, &model)["ok"], true); + h.settle(SETTLE); + h.step(); + let Some(dlg) = h.app.ui.dialog.clone() else { panic!("no dialog") }; + let Dialog::FaceModel { info, accepted, .. } = &dlg else { panic!("{dlg:?}") }; + assert!(!accepted); + assert_eq!(info["kind"], "draft"); + assert!(!dir.join("models").exists()); + // OK does nothing yet + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); + assert!(!dir.join("models").exists()); + // ticking the box and confirming installs it + let r = h.request("ui.clickWidget", json!({"id": "check:faceModel.accept"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::FaceModel { accepted: true, .. }))); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let listed = h.app.run("faces.models.list", json!({})).unwrap(); + assert!(listed["models"].as_array().unwrap().iter().any(|m| m["installed"] == true && m["known"] == false), "{listed}"); + // installed, in use and recognition on: back in Settings ▸ Faces, where the scan can be watched + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); + assert_eq!(listed["enabled"], true); + h.app.ui.dialog = None; + + // a file that is not a usable model says why and offers no install + let junk = dir.join("junk.onnx"); + std::fs::write(&junk, b"not a model").unwrap(); + assert_eq!(open(&mut h, &junk)["ok"], true); + h.step(); + let Some(Dialog::FaceModel { info, .. }) = h.app.ui.dialog.clone() else { panic!("no dialog") }; + assert_eq!(info["kind"], "unsupported"); + // a path that does not exist is an error, not a dialog + h.app.ui.dialog = None; + assert_eq!(open(&mut h, &dir.join("nope.onnx"))["ok"], false); + assert!(h.app.ui.dialog.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Photo > Detect Faces without the detector offers the download: the terms first, nothing fetched before they are +/// accepted, and the model's row in Settings has its Download button. +#[test] +fn detect_faces_without_the_detector_offers_the_download_and_fetches_nothing_before_acceptance() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0]); + let dir = std::env::temp_dir().join(format!("lc-ui-detector-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.face_models_dir = Some(dir.join("models")); + + let r = h.app.run("faces.detect", json!({})); + assert!(r.is_err(), "no detector yet"); + let Some(dlg) = h.app.ui.dialog.clone() else { panic!("the download was not offered") }; + let Dialog::FaceModel { info, accepted, .. } = &dlg else { panic!("{dlg:?}") }; + assert!(!accepted); + assert_eq!((info["download"].as_str(), info["host"].as_str()), (Some("yunet-2023mar"), Some("github.com"))); + assert_eq!(info["model"]["licence"]["name"], "MIT"); + // OK without accepting the terms starts nothing + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); + assert!(h.app.session.face_downloads.snapshot().is_empty()); + assert!(!dir.join("models").join(".downloads").exists()); + assert!(h.app.caches.faces_dl_watch.is_empty()); + + // Settings > Faces shows the detector with a Download button + h.app.ui.dialog = Some(Dialog::Settings { tab: "faces".into() }); + h.step(); + // (the first request switches the widget list on, the next frame fills it) + h.request("ui.widgets", json!({}), Duration::from_secs(10)); + h.step(); + let w = h.request("ui.widgets", json!({"filter": "faces:"}), Duration::from_secs(10)); + assert!(w.to_string().contains("faces:download:yunet-2023mar"), "{w}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Pressing Download in Settings shows the model's terms first and fetches nothing until they are accepted; a build +/// that cannot run recognition models offers no download. The licence dialog replaces Settings, which it was opened from. +#[test] +fn download_shows_the_terms_first_and_fetches_nothing_until_accepted() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-facedl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.face_models_dir = Some(dir.join("models")); + h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "faces"}}), t); + h.settle(SETTLE); + h.step(); + let runtime = h.app.session.execute("faces.models.list", &json!({})).unwrap()["runtime"] == true; + let r = h.request("ui.clickWidget", json!({"id": "faces:download:sface-2021dec"}), t); + if !runtime { + assert_eq!(r["ok"], false, "a build that cannot run the model offers no download: {r}"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let Some(dlg) = h.app.ui.dialog.clone() else { panic!("no dialog") }; + let Dialog::FaceModel { path, info, accepted } = &dlg else { panic!("the terms did not replace Settings: {dlg:?}") }; + assert_eq!((info["download"].as_str(), *accepted, path.as_str()), (Some("sface-2021dec"), false, "")); + assert_eq!(info["model"]["licence"]["commercial"], "unknown"); + // OK does nothing until the terms are accepted, and nothing has been fetched + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err()); + assert_eq!(h.app.session.execute("faces.models.downloads", &json!({})).unwrap()["downloads"], json!([])); + // cancelling leaves it that way + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(h.app.session.execute("faces.models.downloads", &json!({})).unwrap()["downloads"], json!([])); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Until face recognition is set up, the People view and the loupe's name box say so and offer the next step: with no +/// model, a button that opens Settings ▸ Faces; with a model installed but recognition off, one that switches it on. +#[test] +fn people_and_the_name_box_offer_to_set_face_recognition_up() { + use crate::state::{Dialog, ViewMode}; + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-facesetup-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.face_models_dir = Some(dir.join("models")); + let runtime = h.app.session.execute("faces.models.list", &json!({})).unwrap()["runtime"] == true; + let id = h.app.session.active().unwrap(); + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.55, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + let offers = |h: &mut Headless| h.request("ui.widgets", json!({}), t).to_string().contains("\"faces:setup\""); + + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::People); + if !runtime { + assert!(!offers(&mut h), "a build that cannot run recognition offers nothing"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + // no model: the offer is there, and one click lands in Settings ▸ Faces + assert!(offers(&mut h)); + let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); + h.app.ui.dialog = None; + + // the loupe's name box offers it too, and its button leaves for Settings with the box closed + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.42, "y": 0.4}]}), t); + h.step(); + h.step(); + h.request("ui.clickWidget", json!({"id": "regionLabel:0"}), t); + h.step(); + h.step(); + assert!(h.app.ui.name_edit.is_some() && offers(&mut h), "the name box offers the setup"); + let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.ui.name_edit.is_none()); + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "faces".into() })); + h.app.ui.dialog = None; + + // a model installed but recognition off: "Turn on" does it on the spot + let model = dir.join("Mine.onnx"); + std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap(); + let installed = h.app.run("faces.models.install", json!({"path": model.to_string_lossy(), "acknowledged": true, "activate": false})).unwrap(); + h.app.run("faces.models.select", json!({"id": installed["installed"]["id"]})).unwrap(); + h.app.caches.faces_epoch += 1; + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + assert!(offers(&mut h)); + assert_eq!(h.app.session.execute("faces.models.list", &json!({})).unwrap()["enabled"], false); + let r = h.request("ui.clickWidget", json!({"id": "faces:setup"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.session.execute("faces.models.list", &json!({})).unwrap()["enabled"], true); + assert_eq!(h.app.ui.dialog, None, "nothing to go to Settings for"); + h.step(); + assert!(!offers(&mut h), "once it is on the offer is gone"); + h.settle(SETTLE); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A click on a person's card opens their page, which shows only cropped faces (one per face, not per photo); a click +/// on one opens its photo; Back, the People button and Escape return to everyone. +#[test] +fn a_persons_page_shows_their_faces_and_back_returns_to_everyone() { + use crate::state::ViewMode; + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(3).collect(); + let face = |x: f64, name: &str| lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.25, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some(name.to_string()), + description: None, + }; + // Jane Doe twice in the first photo and once in the second; John Roe in the third + for (id, regions) in [ + (ids[0], vec![face(0.1, "Jane Doe"), face(0.5, "jane doe")]), + (ids[1], vec![face(0.3, "Jane Doe")]), + (ids[2], vec![face(0.3, "John Roe")]), + ] { + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = regions; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + } + let open_people = |h: &mut Headless| { + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + }; + open_people(&mut h); + assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); + // the card opens the page: a face tile for each of the three faces, not a tile per photo + let r = h.request("ui.clickWidget", json!({"id": "person:Jane Doe"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); + let widgets = h.request("ui.widgets", json!({}), t).to_string(); + for (id, index) in [(ids[0], 0), (ids[0], 1), (ids[1], 0)] { + assert!(widgets.contains(&format!("\"person-face:{}:{index}\"", id.0)), "a tile for each face: {widgets}"); + } + assert!(!widgets.contains(&format!("\"person-face:{}:0\"", ids[2].0)), "someone else's face is not on the page"); + // a face opens its photo in the detail view + h.request("ui.clickWidget", json!({"id": format!("person-face:{}:0", ids[1].0)}), t); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1]))); + // Escape from that photo goes back to the page, not to the grid + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); + // the People button from a photo shows everyone, with the person last left one click away next to the title + h.request("ui.clickWidget", json!({"id": format!("person-face:{}:0", ids[1].0)}), t); + h.step(); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::Detail); + open_people(&mut h); + assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "people:last"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe"))); + // a photo reached any other way goes back to the grid on Escape + h.request("engine.execute", json!({"command": "view.detail"}), t); + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "person:back"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.person_page, None); + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None), "Escape goes back to everyone"); + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "John Roe"}}), t); + open_people(&mut h); + assert_eq!(h.app.ui.person_page, None, "the People button shows everyone"); + // a missing or empty name is refused + assert_eq!(h.request("engine.execute", json!({"command": "view.person", "params": {}}), t)["ok"], false); + assert_eq!(h.request("engine.execute", json!({"command": "view.person", "params": {"name": " "}}), t)["ok"], false); +} + +/// A person's More grid confirms a score-bounded batch through either ↑ or the focused tile's A key. Both +/// routes use one undo step and leave hidden, lower-score and already named faces alone. +#[test] +fn person_more_confirms_all_above_with_one_undo_step() { + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(7).collect(); + assert_eq!(ids.len(), 7); + let region = |name: Option<&str>| lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.25, y0: 0.2, x1: 0.55, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: name.map(str::to_string), + description: None, + }; + for (id, name) in ids.iter().zip([Some("Jane Doe"), None, None, None, None, None, Some("John Roe")]) { + let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); + meta.regions = vec![region(name)]; + h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); + } + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); + h.settle(SETTLE); + let page = crate::panels::person::PersonPage { + name: "Jane Doe".into(), + total: 1, + confirmed: vec![crate::panels::person::Face { photo: ids[0].0, index: 0, rect: region(None).rect, score: 0.0 }], + more: ids[1..6] + .iter() + .zip([0.95, 0.80, 0.80, 0.80, 0.60]) + .map(|(id, score)| crate::panels::person::Face { photo: id.0, index: 0, rect: region(None).rect, score }) + .collect(), + ready: true, + pending: 0, + }; + let set_page = |h: &mut Headless| { + h.app.caches.person_page = + Some(("Jane Doe".into(), h.app.session.catalog.revision, h.app.caches.faces_indexed, 0.0, Arc::new(page.clone()))); + h.step(); + }; + h.app.ui.dismissed_faces.insert((ids[4].0, 0)); + set_page(&mut h); + let bulk_id = format!("more-face-above:{}:0", ids[2].0); + assert!(h.request("ui.widgets", json!({"filter": "more-face-above:"}), t).to_string().contains(&bulk_id)); + let undo = h.app.session.undo.len(); + let r = h.request("ui.clickWidget", json!({"id": bulk_id}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let names = |h: &Headless| ids.iter().map(|id| h.app.session.catalog.photo(*id).unwrap().meta.regions[0].name.clone()).collect::>(); + assert_eq!( + names(&h), + vec![ + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + None, + None, + Some("John Roe".into()) + ] + ); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "edit.undo"}), t); + h.step(); + assert_eq!(names(&h), vec![Some("Jane Doe".into()), None, None, None, None, None, Some("John Roe".into())]); + // With no target A cannot name a face, even on this page. + h.request("ui.move", json!({"x": 1, "y": 1}), t); + h.view.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique(("more-face-above", ids[2].0, 0_usize)))); + set_page(&mut h); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1], None); + // Text editing owns A even if a face was the last pointer target. + let search = h.request("ui.clickWidget", json!({"id": "field:search"}), t); + assert_eq!(search["ok"], true, "{search}"); + h.step(); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1], None); + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + // Hovering a tile is enough for the A key, without clicking or moving keyboard focus there. + let tile_id = format!("more-face:{}:0", ids[2].0); + let rect = h.app.widgets.iter().find(|(id, _)| id == &tile_id).unwrap().1; + h.request("ui.move", json!({"x": rect.center().x, "y": rect.center().y}), t); + h.step(); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1..4], [Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into())]); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "edit.undo"}), t); + h.step(); + h.request("ui.move", json!({"x": 1, "y": 1}), t); + set_page(&mut h); + // Keyboard focus on the candidate makes the same batch action available without hovering it. + h.view.ctx.memory_mut(|m| m.request_focus(egui::Id::unique(("more-face", ids[2].0, 0_usize)))); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!( + names(&h), + vec![ + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + None, + None, + Some("John Roe".into()) + ] + ); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "view.photoGrid"}), t); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(h.app.session.undo.len(), undo + 1, "A outside the person page cannot name more faces"); +} + +/// The People view lists the unnamed faces below the named people: select some (a click each, or all), type a name, +/// press Enter, and they are all named at once, in one undo step; the new person appears among the named. +#[test] +fn unnamed_faces_are_selected_and_named_together() { + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(3).collect(); + let face = |x: f64, name: Option<&str>| lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.2, y1: 0.55 }, + kind: lightcraft_meta::RegionKind::Face, + name: name.map(str::to_string), + description: None, + }; + for (id, regions) in [ + (ids[0], vec![face(0.1, Some("Jane Doe")), face(0.5, None)]), + (ids[1], vec![face(0.3, None), face(0.6, None)]), + (ids[2], vec![face(0.3, None)]), + ] { + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = regions; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + } + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + h.step(); + let tiles = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "unnamed-face:"}), t)["result"].as_array().map_or(0, Vec::len); + // the named person is a card, and the four unnamed faces are tiles below + let r = h.request("ui.clickWidget", json!({"id": "person:Jane Doe"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.request("engine.execute", json!({"command": "view.people"}), t); + h.step(); + h.step(); + assert_eq!(tiles(&mut h), 4); + // select two (nothing is named by selecting), and the naming bar appears + for (id, i) in [(ids[1], 0), (ids[2], 0)] { + let r = h.request("ui.clickWidget", json!({"id": format!("unnamed-face:{}:{i}", id.0)}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + } + assert_eq!(h.app.ui.unnamed_selected.len(), 2); + let bar = h.request("ui.widgets", json!({"filter": "unnamed:"}), t).to_string(); + assert!(bar.contains("unnamed:name") && bar.contains("unnamed:clear"), "{bar}"); + assert!(h.request("ui.widgets", json!({"filter": "field:unnamedName"}), t).to_string().contains("field:unnamedName")); + // type a name and press Enter: both faces are named, in one undo step, and the selection is gone + let undo = h.app.session.undo.len(); + h.request("ui.text", json!({"text": "Ann Example"}), t); + h.step(); + h.request("ui.key", json!({"key": "enter"}), t); + h.step(); + h.step(); + let named = |h: &Headless, id: lightcraft_catalog::PhotoId, i: usize| h.app.session.catalog.photo(id).unwrap().meta.regions[i].name.clone(); + assert_eq!((named(&h, ids[1], 0), named(&h, ids[2], 0)), (Some("Ann Example".to_string()), Some("Ann Example".to_string()))); + assert_eq!(named(&h, ids[1], 1), None, "a face that was not selected stays unnamed"); + assert_eq!(h.app.session.undo.len(), undo + 1, "one step for both"); + assert!(h.app.ui.unnamed_selected.is_empty() && h.app.ui.unnamed_name.is_empty()); + h.settle(SETTLE); + h.step(); + assert_eq!(tiles(&mut h), 2, "the named faces left the unnamed list"); + assert_eq!(h.app.session.catalog.people().len(), 2, "and the new person is among the named"); + // select all, then clear: nothing is named + h.request("ui.clickWidget", json!({"id": "unnamed:selectAll"}), t); + h.step(); + h.step(); + assert_eq!(h.app.ui.unnamed_selected.len(), 2); + h.request("ui.clickWidget", json!({"id": "unnamed:clear"}), t); + h.step(); + assert!(h.app.ui.unnamed_selected.is_empty()); + // undo gives the two faces back + h.request("engine.execute", json!({"command": "edit.undo"}), t); + h.step(); + assert_eq!(named(&h, ids[1], 0), None); +} + +/// A double click on an unnamed face opens its photo in the loupe, as in the grid (#1664); a single click still only +/// selects it, and Escape from that photo goes back to the People view. +#[test] +fn double_clicking_an_unnamed_face_opens_its_photo() { + use crate::state::ViewMode; + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); + for id in &ids { + let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.5, y1: 0.55 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); + } + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0].0]}}), t); + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + h.step(); + let tile = format!("unnamed-face:{}:0", ids[1].0); + // one click selects the face and stays in People + let r = h.request("ui.clickWidget", json!({"id": tile}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::People, Some(ids[0]))); + assert_eq!(h.app.ui.unnamed_selected.len(), 1); + h.request("ui.clickWidget", json!({"id": "unnamed:clear"}), t); + // half a second of frames, so egui does not count the next two clicks as the third and fourth of one burst + for _ in 0..30 { + h.step(); + } + // two open the photo the face is in + let r = h.request("ui.clickWidget", json!({"id": tile, "count": 2}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1]))); + assert_eq!(h.app.session.catalog.photo(ids[1]).unwrap().meta.regions[0].name, None, "nothing is named"); + // Escape goes back to the faces, not to the grid + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); +} + +/// The name field under the unnamed faces is a shared text field: right-click ▸ Paste puts the +/// clipboard's name in, and Esc leaves the field and clears the selection, as Clear does. +#[test] +fn the_unnamed_faces_name_field_pastes_and_escape_clears() { + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let id = h.app.session.catalog.photos().map(|p| p.id).next().unwrap(); + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.5, y1: 0.55 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.view.clipboard = "Wedding Guest".into(); + for (widget, button) in [ + (format!("unnamed-face:{}:0", id.0), "left"), + ("field:unnamedName".to_string(), "left"), + ("field:unnamedName".to_string(), "right"), + ("field:unnamedName:paste".to_string(), "left"), + ] { + let r = h.request("ui.clickWidget", json!({"id": widget, "button": button}), t); + assert_eq!(r["ok"], true, "{widget}: {r}"); + h.step(); + h.step(); + h.step(); + } + assert_eq!(h.app.ui.unnamed_name, "Wedding Guest"); + assert_eq!(h.app.ui.unnamed_selected.len(), 1, "still naming"); + h.request("ui.key", json!({"key": "Escape"}), t); + h.step(); + h.step(); + assert!(h.app.ui.unnamed_selected.is_empty() && h.app.ui.unnamed_name.is_empty(), "Esc cleared the selection and the name"); +} + +/// A screenful of faces larger than the picture cache's usual budget (96) is all kept: with a fixed budget the same few +/// tiles were evicted and re-requested every frame and stayed blank. +#[test] +fn a_screenful_of_small_faces_is_not_evicted_and_left_blank() { + use lightcraft_catalog::Op; + let mut h = demo([1500.0, 1000.0]); + let t = Duration::from_secs(20); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).collect(); + // seven unnamed faces in every photo of the demo library + for id in &ids { + let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); + meta.regions = (0..7) + .map(|k| lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.05 + 0.12 * k as f64, y0: 0.2, x1: 0.15 + 0.12 * k as f64, y1: 0.45 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }) + .collect(); + h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); + } + // the smallest faces, so a lot of them fit on the screen + h.request("ui.set", json!({"thumbSize": 90.0, "view": "people"}), t); + h.settle(SETTLE); + h.step(); + let drawn = h.request("ui.widgets", json!({"filter": "unnamed-face:"}), t)["result"].as_array().map_or(0, Vec::len); + assert!(drawn > 96, "the test needs more faces on screen than the usual budget, got {drawn}"); + // the pictures are made and kept, well beyond the old budget (a few demo photos share a scene, so two faces can share a + // picture: the count is a little under the number of tiles) + let started = Instant::now(); + while h.app.renderer.variant_textures() < drawn - 20 && started.elapsed() < Duration::from_secs(60) { + h.step(); + std::thread::sleep(Duration::from_millis(20)); + } + let kept = h.app.renderer.variant_textures(); + assert!(kept > 96 + 30, "{kept} pictures kept for {drawn} faces on screen"); + // and they stay: many more frames, and none is evicted to be asked for again + for _ in 0..30 { + h.step(); + } + assert!(h.app.renderer.variant_textures() >= kept, "{} pictures kept after more frames, {kept} before", h.app.renderer.variant_textures()); +} + +/// `ui.inspect` says how hard the face scan is allowed to work and whether the window counts as in front, so a slow +/// scan can be told from a stuck one. +#[test] +fn inspect_reports_the_face_scan_pace() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + h.step(); + let r = h.request("ui.inspect", json!({}), t); + let scan = &r["result"]["faceScan"]; + assert!(["pause", "light", "normal", "full"].contains(&scan["pace"].as_str().unwrap_or("")), "{scan}"); + assert!(scan["focused"].is_boolean() && scan["pending"].is_u64() && scan["indexed"].is_u64(), "{scan}"); +} + +/// Profile browser: live variant thumbnails, hover previews in the loupe without touching the +/// photo or its history, click applies, the star toggles the favourite. +#[test] +fn profile_browser_previews_on_hover_and_applies_on_click() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + let r = h.request("engine.execute", json!({"command": "panel.profiles"}), t); + assert_eq!(r["result"]["open"], true, "{r}"); + h.settle(SETTLE); + let id = h.app.session.active().unwrap(); + let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); + let before = photo(&h); + h.step_until(SETTLE, |h| h.app.renderer.variant_textures() >= 6); + assert!(h.app.renderer.variant_textures() >= 6, "variant thumbnails rendered: {}", h.app.renderer.variant_textures()); + // hover: the loupe shows the look, nothing is committed + let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + // the hover render can land after a quiet spell on a loaded machine (FreeBSD CI): wait for it + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Profile: Vivid")); + assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); + let hover = h.app.renderer.textures.get(&crate::render::Slot::Hover).expect("hover render"); + assert_eq!(hover.photo, id); + let after = photo(&h); + assert_eq!((after.develop.clone(), after.history.len()), (before.develop.clone(), before.history.len()), "hover leaves the photo alone"); + assert_eq!(h.app.session.undo.len(), 0); + // moving away ends the preview + h.request("ui.move", json!({"x": 5, "y": 500}), t); + h.step(); + assert!(h.app.hover_preview.is_none()); + // click applies (one history step); the star toggles the favourite + h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(photo(&h).develop.profile.id, "lc.vivid"); + assert_eq!(photo(&h).history.len(), before.history.len() + 1); + h.request("ui.clickWidget", json!({"id": "profileStar:lc.portrait"}), t); + assert_eq!(h.app.session.profile_favorites, ["lc.portrait"]); + assert_eq!(photo(&h).develop.profile.id, "lc.vivid", "the star doesn't apply"); + h.settle(SETTLE); +} + +/// Hovering the applied profile shows the applied look (no preview), not the last other profile hovered. +#[test] +fn hovering_the_applied_profile_drops_the_last_hover() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); + h.request("ui.hoverWidget", json!({"id": "profileCell:lc.portrait"}), t); + h.step(); + assert!(h.app.hover_preview.is_some(), "another profile previews"); + h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + h.step(); + assert!(h.app.hover_preview.is_none(), "the applied profile is shown, not the last hovered one"); +} + +/// The preset applied to a photo is still marked (and its Amount slider back) after visiting +/// another photo, which shows none (issue #2392); an edit to the photo drops the mark. +#[test] +fn applied_preset_is_marked_again_on_returning_to_the_photo() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.settle(SETTLE); + let a = h.app.session.active().unwrap(); + let b = h.app.session.visible_cloned().into_iter().find(|id| *id != a).unwrap(); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + let marked = |h: &Headless| { + let r = h.app.widgets.iter().find(|(id, _)| id == "presetApplied").map(|(_, r)| *r)?; + h.app.widgets.iter().find(|(id, wr)| id.starts_with("preset:") && *wr == r).map(|(id, _)| id.clone()) + }; + let r = h.request("ui.clickWidget", json!({"id": "preset:lc.warm-glow"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(marked(&h).as_deref(), Some("preset:lc.warm-glow")); + assert!(has(&h, "slider:presetAmount")); + for (photo, want) in [(b, None), (a, Some("preset:lc.warm-glow"))] { + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [photo.0]}}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(marked(&h).as_deref(), want, "on photo {photo:?}"); + assert_eq!(has(&h, "slider:presetAmount"), want.is_some(), "the Amount slider follows the mark"); + } + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(marked(&h), None, "an edit after the preset drops the mark"); +} + +/// A preset applied while the active photo lies outside the selection changes only the +/// selection, so the active photo isn't marked as having it. +#[test] +fn applied_preset_is_not_marked_on_an_active_photo_outside_the_selection() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.settle(SETTLE); + let a = h.app.session.active().unwrap(); + let b = h.app.session.visible_cloned().into_iter().find(|id| *id != a).unwrap(); + h.app.session.selection.ids = vec![b]; + h.app.session.selection.active = Some(a); + let before = h.app.session.develop_of(a).unwrap(); + let r = h.request("ui.clickWidget", json!({"id": "preset:lc.warm-glow"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.session.active(), Some(a)); + assert!(*h.app.session.develop_of(a).unwrap() == *before, "the active photo wasn't changed"); + assert!(!h.app.widgets.iter().any(|(id, _)| id == "presetApplied"), "nor marked"); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [b.0]}}), t); + h.settle(SETTLE); + h.step(); + assert!(h.app.widgets.iter().any(|(id, _)| id == "presetApplied"), "the selected photo is"); +} + +/// Presets column: resting on a preset previews it in the loupe without a history entry; +/// thumbnails are variant renders; Create Preset includes only the checked groups. +#[test] +fn preset_hover_previews_and_create_preset_groups() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.settle(SETTLE); + let id = h.app.session.active().unwrap(); + let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); + let before = photo(&h); + h.request("ui.hoverWidget", json!({"id": "preset:lc.bw-high-contrast"}), t); + h.settle(SETTLE); + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); + assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); + let after = photo(&h); + assert_eq!(after.develop, before.develop); + assert_eq!(after.history.len(), before.history.len(), "no history entry while hovering"); + assert!(h.app.session.undo.is_empty()); + // hovering down across group headers within the presets list retains the preview (no flicker) + h.request("ui.hoverWidget", json!({"id": "presetGroup:Creative"}), t); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); + // hovering another preset switches smoothly to it + h.request("ui.hoverWidget", json!({"id": "preset:lc.warm-glow"}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: Warm Glow")); + // thumbnails: variant textures for the visible presets + h.app.ui.preset_thumbs = true; + h.request("ui.move", json!({"x": 5, "y": 500}), t); + h.settle(SETTLE); + h.step(); + assert!(h.app.hover_preview.is_none()); + assert!(h.app.renderer.variant_textures() >= 5, "{}", h.app.renderer.variant_textures()); + // Create Preset with a group checklist: untick Light, keep Effects + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "effects.clarity", "value": 25}}), t); + h.app.ui.dialog = Some(crate::state::Dialog::create_preset()); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "presetInclude:light"}), t); + assert_eq!(r["ok"], true, "{r}"); + match &h.app.ui.dialog { + Some(crate::state::Dialog::CreatePreset { groups, .. }) => { + assert!(!groups.iter().any(|g| g == "light") && groups.iter().any(|g| g == "effects"), "{groups:?}"); + assert!(!groups.iter().any(|g| g == "crop" || g == "masks"), "crop and masks off by default"); + } + d => panic!("dialog: {d:?}"), + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let p = h.app.session.presets.iter().find(|p| !p.builtin).expect("created").clone(); + assert!(p.settings.get("light").is_none() && p.settings["effects"]["clarity"] == 25.0, "{}", p.settings); + h.settle(SETTLE); +} + +/// A collection of thousands of presets (a Lightroom library imported whole) lists only the +/// rows in view: a few dozen widgets and thumbnails a frame, not one per preset (every frame +/// built all of them and froze the app for seconds); scrolling brings later rows in. +#[test] +fn presets_panel_with_thousands_of_presets_builds_only_visible_rows() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let many = (0..5000) + .map(|i| lightcraft_develop::Preset { + id: format!("user.big.{i}"), + name: format!("Look {i}"), + group: format!("Big {:02}", i / 500), + settings: json!({"light": {"exposure": (i % 20) as f64 / 10.0}}), + favorite: false, + builtin: false, + }) + .collect(); + assert_eq!(h.app.session.add_presets(many).len(), 5000); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.app.ui.preset_thumbs = true; + h.settle(SETTLE); + h.step(); + let rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("preset:")).map(|(w, _)| w.clone()).collect::>(); + let first = rows(&h); + assert!((5..60).contains(&first.len()), "{} rows built", first.len()); + assert!(h.app.renderer.variant_textures() < 60, "{} thumbnails", h.app.renderer.variant_textures()); + assert!(h.app.widgets.iter().any(|(w, _)| w == "presetGroup:Big 09"), "every group header is listed"); + let started = std::time::Instant::now(); + h.step(); + eprintln!("presets panel frame with 5000 presets: {:?}", started.elapsed()); + // scroll far down over the list: later presets come into view + let r = h.request("ui.hoverWidget", json!({"id": first[first.len() / 2]}), t); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..20 { + h.request("ui.scroll", json!({"dy": -2000}), t); + h.step(); + } + h.step(); + let later = rows(&h); + assert!((5..60).contains(&later.len()), "{} rows built", later.len()); + let deep = later.iter().filter_map(|w| w.strip_prefix("preset:user.big.")).filter_map(|n| n.parse::().ok()).max(); + assert!(deep.is_some_and(|n| n > 500), "scrolled rows: {later:?}"); +} + +/// With `label` and `labels` both set (a saved single-label filter, or an agent), the filter +/// bar lights only the labels that pass both, and a click starts from those, not their union. +#[test] +fn filter_bar_labels_from_label_and_labels() { + use lightcraft_catalog::ColorLabel; + let mut h = demo([1400.0, 800.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "view.filterBar"}), t); + h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["red", "yellow"]})).unwrap(); + // a frame so the bar is laid out for the new filter before the click + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); + assert_eq!(r["ok"], true, "{r}"); + let f = h.app.session.filter.clone(); + assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Red, ColorLabel::Green])); + // nothing passes both: nothing is lit, so a click shows just that label + h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["yellow"]})).unwrap(); + h.step(); + h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); + let f = h.app.session.filter.clone(); + assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Green])); + h.settle(SETTLE); +} + +/// The filter bar drives `library.filter` and saves the view as a smart album. +#[test] +fn filter_bar_filters_and_saves_a_smart_album() { + let mut h = demo([1400.0, 800.0]); + let t = Duration::from_secs(10); + let all = h.app.session.visible_cloned().len(); + h.request("engine.execute", json!({"command": "view.filterBar"}), t); + assert!(h.app.ui.filter_bar); + for w in ["filter:star3", "filter:pick", "filter:label-red", "filter:label-red"] { + let r = h.request("ui.clickWidget", json!({"id": w}), t); + assert_eq!(r["ok"], true, "{w}: {r}"); + } + let f = h.app.session.filter.clone(); + assert_eq!((f.rating, f.flag, f.label), (3, Some(lightcraft_catalog::Flag::Pick), None), "a second click clears the label"); + let n = h.app.session.visible_cloned().len(); + assert!(n > 0 && n < all); + h.request("ui.clickWidget", json!({"id": "button:filterSave"}), t); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::NewSmartAlbum { .. }))); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let smart = h.app.session.catalog.albums().find(|a| a.is_smart()).expect("smart album").id; + assert_eq!(h.app.session.catalog.album_count(smart), n); + h.request("ui.clickWidget", json!({"id": "button:filterClear"}), t); + assert_eq!(h.app.session.filter, Default::default()); + assert_eq!(h.app.session.visible_cloned().len(), all); + h.settle(SETTLE); +} + +/// The photo grid shows date headers (registered as `group:` widgets); clicking one +/// selects that day's photos; month headers when zoomed out; none when grouping is off. +#[test] +fn grid_groups_by_capture_date() { + let mut h = demo([1300.0, 800.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + let groups = h.app.session.execute("library.groups", &json!({})).unwrap(); + let first = groups[0].clone(); + let key = first["key"].as_str().unwrap().to_string(); + let r = h.request("ui.clickWidget", json!({"id": format!("group:{key}")}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.selection.ids.len() as u64, first["count"].as_u64().unwrap()); + // zoomed out: month headers + h.app.ui.thumb_size = 120.0; + h.settle(SETTLE); + let vis = h.app.session.visible_cloned(); + let month = h.app.session.catalog.date_runs(&vis, h.app.session.sort.key, lightcraft_catalog::GroupBy::Month); + let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t); + assert_eq!(r["ok"], true, "{r}"); + h.request("engine.execute", json!({"command": "library.sort", "params": {"group": "none"}}), t); + let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t); + assert_eq!(r["ok"], false, "no headers: {r}"); + h.settle(SETTLE); +} + +/// Keywords: the left-panel tree filters (children included), opens levels, the rename dialog +/// renames library-wide, and the Keywords panel adds a suggestion. +#[test] +fn keyword_list_filters_renames_and_suggests() { + // tall: the demo library's own keywords come first in the list + let mut h = demo([1300.0, 1800.0]); + // Host folders arrive asynchronously above Keywords. Keep this keyword fixture's + // geometry independent of their existence and the background stat timing. + if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { + h.app.ui.hidden_locations.extend( + ["Pictures", "Desktop", "Downloads", ""] + .map(|sub| if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }), + ); + } + let t = Duration::from_secs(10); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + let ex = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), Duration::from_secs(10)); + ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]})); + ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]})); + h.request("ui.set", json!({"leftPanel": true}), t); + // clicks land on last frame's layout: let the keyword list settle first (on a loaded machine a + // row could still move, and the click then hit its neighbour, e.g. "sunrise") + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel")); + assert_eq!(h.app.session.visible_cloned().len(), 3); + // open the level, filter by the child + h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel|italy"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.visible_cloned().len(), 2); + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "travel".into(), to: "trips".into() }); + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.filter.keyword.as_deref(), Some("trips|italy")); + assert_eq!(h.app.session.visible_cloned().len(), 2); + // Keywords panel: suggestions co-occurring with the photo's keywords + h.request("engine.execute", json!({"command": "library.clearFilter"}), t); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[1]]}}), t); + ex(&mut h, "photo.setMeta", json!({"ids": [vis[0]], "addKeywords": ["gelato"]})); + h.request("ui.set", json!({"right": "keywords"}), t); + assert!(h.settle(SETTLE), "keyword suggestions did not settle"); + let r = h.request("ui.clickWidget", json!({"id": "kwSuggest:gelato"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(vis[1])).unwrap().meta.keywords.contains(&"gelato".to_string())); + h.settle(SETTLE); +} + +/// Rename Keyword's name field is a shared text field. It opens with the name selected, so +/// typing replaces it, and Return renames; right-click ▸ Paste works too; Esc cancels the +/// dialog and renames nothing. +#[test] +fn the_rename_keyword_field_selects_pastes_and_cancels() { + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + let first = h.app.session.visible_cloned()[0].0; + h.request("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [first], "addKeywords": ["travel"]}}), t); + let keywords = |h: &Headless| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(first)).unwrap().meta.keywords.clone(); + let open = |h: &mut Headless, name: &str| { + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: name.into(), to: name.into() }); + h.settle(SETTLE); + }; + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.settle(SETTLE); + }; + // typing replaces the selected name + open(&mut h, "travel"); + ask(&mut h, "ui.text", json!({"text": "trips"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(keywords(&h).contains(&"trips".to_string()) && !keywords(&h).contains(&"travel".to_string()), "{:?}", keywords(&h)); + assert_eq!(h.app.ui.dialog, None); + // the menu's Paste + open(&mut h, "trips"); + h.view.clipboard = "weddings".into(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordName", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordName:paste"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(keywords(&h).contains(&"weddings".to_string()), "{:?}", keywords(&h)); + // Esc cancels + open(&mut h, "weddings"); + ask(&mut h, "ui.text", json!({"text": "parties"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + assert_eq!(h.app.ui.dialog, None, "Esc closed the dialog"); + assert!(keywords(&h).contains(&"weddings".to_string()), "renamed nothing: {:?}", keywords(&h)); +} + +/// Return confirms the one-field dialogs: New Album, Rename Album, New Smart Album, Merge +/// Keywords and a text prompt (here: renaming an album). Their name opens selected, so typing +/// replaces it. Return did nothing: the field took the focus back on every frame, so egui never +/// reported the focus Return gives up. +#[test] +fn return_confirms_the_one_field_dialogs() { + use crate::state::Dialog; + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + let first = h.app.session.visible_cloned()[0].0; + h.request("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [first], "addKeywords": ["holiday"]}}), t); + let album = |h: &Headless, name: &str| h.app.session.catalog.albums().find(|a| a.name == name).map(|a| a.id.0); + let garden = album(&h, "Garden").expect("the demo's Garden album"); + let confirm = |h: &mut Headless, dialog: Dialog, typed: &str| { + h.app.ui.dialog = Some(dialog); + h.settle(SETTLE); + for (method, params) in [("ui.text", json!({"text": typed})), ("ui.key", json!({"key": "Enter"}))] { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.ui.dialog, None, "Return confirmed the dialog ({typed})"); + }; + confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }, "Weddings"); + assert!(album(&h, "Weddings").is_some(), "New Album"); + confirm(&mut h, Dialog::RenameAlbum { id: garden, name: "Garden".into() }, "Flowers"); + assert_eq!(album(&h, "Flowers"), Some(garden), "Rename Album"); + confirm(&mut h, Dialog::NewSmartAlbum { name: "Smart Album".into(), parent: None }, "Everything"); + assert!(album(&h, "Everything").is_some(), "New Smart Album"); + confirm(&mut h, Dialog::MergeKeywords { from: vec!["holiday".into()], into: String::new() }, "travel"); + let keywords = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(first)).unwrap().meta.keywords.clone(); + assert!(keywords.contains(&"travel".to_string()) && !keywords.contains(&"holiday".to_string()), "Merge Keywords: {keywords:?}"); + let prompt = Dialog::TextPrompt { + title: "Rename Album".into(), + hint: "Name".into(), + value: "Flowers".into(), + command: "album.rename".into(), + params: json!({"id": garden}), + key: "name".into(), + }; + confirm(&mut h, prompt, "Botanical"); + assert_eq!(album(&h, "Botanical"), Some(garden), "the text prompt"); +} + +/// The sidebar's file-system checks run on worker threads and add rows when they land, which +/// moves every row below them: `busy()` counts them, so `settle` waits for them before a test +/// reads widget positions (a click aimed at a stale rect hits the neighbouring row). +#[test] +fn settle_waits_for_file_system_checks() { + use std::sync::atomic::{AtomicBool, Ordering}; + static RELEASE: AtomicBool = AtomicBool::new(false); + fn slow_check(_: &str) -> bool { + let t0 = Instant::now(); + while !RELEASE.load(Ordering::Acquire) && t0.elapsed() < Duration::from_secs(60) { + std::thread::sleep(Duration::from_millis(1)); + } + true + } + let mut h = demo([800.0, 600.0]); + h.settle(SETTLE); + let check = |h: &mut Headless| { + let raw = HeadlessView::raw_input(h.size, 1.0, 0.0, vec![]); + let mut got = None; + h.view.run(raw, |ui| got = crate::panels::left::fs_cached(ui, "test-slow", "x", f64::INFINITY, slow_check)); + got + }; + assert_eq!(check(&mut h), None, "the answer is worked out off the UI thread"); + assert!(h.busy(), "a pending file-system check is pending work"); + RELEASE.store(true, Ordering::Release); + assert!(h.settle(SETTLE)); + assert!(!h.busy()); + assert_eq!(check(&mut h), Some(true)); +} + +/// Photo > Rename Photos…: the dialog previews and renames the selection. +#[test] +fn rename_dialog_renames_the_selection() { + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + let vis: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": vis}}), t); + let tree = h.request("ui.menu.tree", json!({}), t); + assert!(tree.to_string().contains("Rename 2 Photos…"), "live menu label"); + h.request("engine.execute", json!({"command": "dialog.rename", "params": {"template": "Trip-{seq:2}", "start": 5}}), t); + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let name = |id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().file_name.clone(); + assert!(name(vis[0]).starts_with("Trip-05."), "{}", name(vis[0])); + assert!(name(vis[1]).starts_with("Trip-06."), "{}", name(vis[1])); + h.settle(SETTLE); +} + +/// The Export dialog hands its batch to a worker thread: the UI keeps drawing frames, shows +/// progress, and reports the result when the files are written. +#[test] +fn export_dialog_keeps_its_width() { + // A dialog row sized as "available width − a guessed button width" made the auto-sized + // window grow a little every frame when the real button was wider (issue #8, Linux). + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.app.services.pick_folder = Some(Box::new(|| None)); + h.app.services.pick_files = Some(Box::new(Vec::new)); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + // the widest variant: watermark graphic and every optional row + if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { + let mut wm = lightcraft_engine::export::Watermark { image: "logo.png".into(), ..Default::default() }; + wm.text.clear(); + opts.watermark = Some(wm); + } + let width = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({}), t); + r["result"] + .as_array() + .and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")) + .map(|w| w["rect"][2].as_f64().unwrap()) + .expect("dialog on screen") + }; + for _ in 0..5 { + h.step(); + } + let w0 = width(&mut h); + for _ in 0..60 { + h.step(); + } + let w1 = width(&mut h); + assert!((w1 - w0).abs() < 0.5, "the export dialog grew from {w0} to {w1}"); + assert!(w1 < 700.0, "{w1}"); +} + +/// With a watermark on, the Export dialog shows the first photo with the watermark on it. +#[test] +fn export_dialog_previews_the_watermark() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + let preview = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({}), t); + r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "image:exportWatermarkPreview")).map(|w| w["rect"].clone()) + }; + h.settle(SETTLE); + assert!(preview(&mut h).is_none(), "no preview without a watermark"); + if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { + opts.watermark = Some(lightcraft_engine::export::Watermark { text: "© LightCraft".into(), ..Default::default() }); + } + let t0 = Instant::now(); + while preview(&mut h).is_none() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + let rect = preview(&mut h).expect("the watermark preview is shown"); + assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}"); +} + +/// Issue #1952: a previous export saved with a depth its format doesn't have (an API call made +/// before `app.export` refused it) opens at a depth the format offers, so the dialog's Export, +/// which has no depth to pick for JPEG, doesn't fail; a depth the format has is kept. +#[test] +fn export_dialog_drops_a_depth_the_format_lacks() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let opened = |h: &mut Headless, last: Value| { + h.app.ui.dialog = None; + h.app.session.last_export = Some(last); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + match &h.app.ui.dialog { + Some(crate::state::Dialog::Export { opts, .. }) => opts.clone(), + _ => panic!("the export dialog is open"), + } + }; + let o = opened(&mut h, json!({"format": "jpeg", "bitDepth": 16})); + assert_eq!(o.bit_depth, None); + lightcraft_engine::export::ExportOptions::from_params(&o.to_json()).expect("the dialog's settings export"); + assert_eq!(opened(&mut h, json!({"format": "tiff", "bitDepth": 32})).bit_depth, Some(32)); +} + +/// Review of #1966: a `path` whose extension names no format exports JPEG here, so its +/// `bitDepth` is checked against JPEG's, not let through to an 8-bit file. +#[test] +fn export_checks_the_depth_of_the_format_a_bare_path_gets() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-bare-path-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + for name in ["output", "output.xyz"] { + let p = json!({"path": out.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], false, "{name}: {r}"); + assert!(r["error"].as_str().unwrap().contains("`bitDepth` must be 8 for jpeg"), "{name}: {r}"); + } + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); + // the format such a path gets is still JPEG + let p = json!({"path": out.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 8}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], true, "{r}"); + let written = std::fs::read_dir(&out).unwrap().next().unwrap().unwrap().path(); + assert!(std::fs::read(&written).unwrap().starts_with(&[0xFF, 0xD8]), "{written:?} is a JPEG"); + let _ = std::fs::remove_dir_all(&out); +} + +#[test] +fn export_dialog_runs_in_the_background() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let w = written.clone(); + h.app.services.write_shared = Some(std::sync::Arc::new(move |p: &str, _: &[u8]| { + std::thread::sleep(Duration::from_millis(150)); // a slow disk: the UI must not wait for it + w.lock().unwrap().push(p.to_string()); + Ok(()) + })); + let ids: Vec = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + h.settle(SETTLE); + if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog { + *full_size = false; + *resize = lightcraft_engine::export::Resize::long_edge(64); + *dir = "/lc-test-out".into(); + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.export.is_some(), "running in the background"); + let running = h.request("ui.inspect", json!({}), t); + assert_eq!(running["result"]["export"]["running"]["total"], 3, "{}", running["result"]["export"]); + // …and as a row in the activity stack (issue #345) + assert_eq!(running["result"]["activity"][0]["kind"], "export", "{}", running["result"]["activity"]); + let t0 = Instant::now(); + while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) { + h.step(); + } + assert!(h.app.export.is_none(), "finished"); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 3, "{w:?}"); + assert!(w.iter().all(|p| p.starts_with("/lc-test-out/"))); + let after = h.request("ui.inspect", json!({}), t)["result"].clone(); + assert_eq!(after["activity"], json!([]), "the row is gone"); + let last = after["export"]["last"].clone(); + assert_eq!(last["files"].as_array().map(Vec::len), Some(3), "{last}"); + assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64)); +} + +/// Issue #236: the Export dialog's Add to Apple Photos is there on macOS only; there the +/// background export hands the files it wrote to Photos (a fake runner: tests never start +/// osascript or Photos) and the toast says how it went. +#[test] +fn export_dialog_adds_to_apple_photos_on_macos() { + let mut h = demo([1200.0, 1100.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-photos-dialog-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + let calls = Arc::new(std::sync::Mutex::new(Vec::>::new())); + let c = calls.clone(); + h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { + c.lock().unwrap().push(args.to_vec()); + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "A/L0/001\nB/L0/001\n".into(), stderr: String::new() }) + })); + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + h.settle(SETTLE); + let shown = h.request("ui.widgets", json!({"filter": "exportAddToPhotos"}), t)["result"].as_array().map_or(0, Vec::len); + assert_eq!(shown > 0, cfg!(target_os = "macos"), "the option is offered where Photos is"); + if !cfg!(target_os = "macos") { + return; + } + h.request("ui.clickWidget", json!({"id": "exportAddToPhotos"}), t); + h.settle(SETTLE); + if let Some(crate::state::Dialog::Export { opts, full_size, resize, dir, .. }) = &mut h.app.ui.dialog { + assert!(opts.add_to_photos, "the checkbox turns it on"); + opts.photos_album = "Trip “2026”".into(); + *full_size = false; + *resize = lightcraft_engine::export::Resize::long_edge(48); + *dir = out.to_string_lossy().into_owned(); + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let t0 = Instant::now(); + while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) { + h.step(); + } + let last = h.request("ui.inspect", json!({}), t)["result"]["export"]["last"].clone(); + let written: Vec = last["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap().to_string()).collect(); + assert_eq!(written.len(), 2, "{last}"); + assert_eq!(last["applePhotos"]["imported"], 2, "{last}"); + assert_eq!(last["applePhotos"]["album"], "Trip “2026”"); + let args = calls.lock().unwrap().clone(); + assert_eq!(args.len(), 1); + assert_eq!(args[0][args[0].len() - 2..], written[..], "the files written go to Photos"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("2 added to Apple Photos album “Trip “2026””"), "{toast}"); + // Export with Previous repeats it + assert_eq!(h.app.session.last_export.as_ref().unwrap()["addToPhotos"], true); + let _ = std::fs::remove_dir_all(&out); +} + +/// Codex review of #643: the progress line while Photos imports was translated on the export's +/// worker thread, whose language is always English. +#[test] +fn the_photos_progress_line_is_in_the_users_language() { + if !cfg!(target_os = "macos") { + return; + } + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-photos-language-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + h.app.ui.language = crate::i18n::Locale::De; + crate::i18n::set_language(crate::i18n::Locale::De); + h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + let (release, held) = std::sync::mpsc::channel::<()>(); + let held = std::sync::Mutex::new(held); + let (entered, photos_asked) = std::sync::mpsc::channel::<()>(); + let entered = std::sync::Mutex::new(entered); + h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { + let _ = entered.lock().unwrap().send(()); + let _ = held.lock().unwrap().recv_timeout(Duration::from_secs(30)); + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) + })); + let ids: Vec = h.app.session.visible_cloned().iter().take(1).map(|p| p.0).collect(); + let params = json!({"ids": ids, "dir": out, "longEdge": 48, "addToPhotos": true, "background": true}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": params}), t); + assert_eq!(r["ok"], true, "{r}"); + // Photos has been asked, and the window hasn't drawn a frame since (Codex review of #643): + // the export's row already has no ✕, and a cancel is refused rather than half-honoured + photos_asked.recv_timeout(Duration::from_secs(60)).expect("the export asks Photos"); + let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); + assert!(!row.cancellable, "{row:?}"); + assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked"); + let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default(); + assert_eq!(current, crate::i18n::Locale::De.tr("Adding to Apple Photos…")); + // the row says so on the next frame (issue #345) + h.step(); + let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); + assert_eq!((row.detail.as_str(), row.cancellable), ("Wird zu Apple Fotos hinzugefügt…", false), "{row:?}"); + release.send(()).unwrap(); + let t0 = Instant::now(); + while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(90) { + h.step(); + } + let last = h.app.last_export_result.clone().expect("the export reports"); + assert_eq!((last["cancelled"].as_bool(), last["applePhotos"]["imported"].as_u64()), (Some(false), Some(1)), "{last}"); + crate::i18n::set_language(crate::i18n::Locale::En); + let _ = std::fs::remove_dir_all(&out); +} + +/// Issue #236 review: control requests run on the UI thread, which must not wait minutes for +/// Photos. `export.addToPhotos` and `app.export {addToPhotos}` without `background` answer at +/// once with the running job; the app announces the outcome when Photos is done. +#[test] +fn a_slow_photos_import_never_blocks_the_window() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(30); + let out = std::env::temp_dir().join(format!("lc-photos-slow-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + let file = out.join("a.jpg"); + std::fs::write(&file, b"jpeg").unwrap(); + h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = std::sync::Mutex::new(wait); + h.app.session.apple_photos_background = true; // as the desktop app sets it + h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { + // a regression (Photos on the UI thread) fails the timing below instead of hanging + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + let n = args.len() - args.iter().position(|a| a == lightcraft_engine::apple_photos::MARKER).unwrap() - 2; + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) + })); + let t0 = Instant::now(); + let r = + h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "album": "Slow"}}), t); + assert!(t0.elapsed() < Duration::from_secs(5), "answered at once: {r}"); + assert_eq!(r["result"]["running"], true, "{r}"); + go.send(()).unwrap(); + while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Apple Photos")) { + assert!(t0.elapsed() < Duration::from_secs(20), "the outcome is announced"); + h.step(); + } + assert_eq!(h.app.ui.toast.as_ref().unwrap().0, "1 added to Apple Photos album “Slow”"); + if cfg!(target_os = "macos") { + let t1 = Instant::now(); + let p = json!({"dir": out.to_string_lossy(), "longEdge": 32, "addToPhotos": true}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert!(t1.elapsed() < Duration::from_secs(10), "the export answers without waiting for Photos: {r}"); + assert_eq!(r["result"]["applePhotos"]["running"], true, "{r}"); + assert_eq!(r["result"]["applePhotos"]["job"], 2, "{r}"); + go.send(()).unwrap(); + while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("1 added to Apple Photos") && !t.0.contains("Slow")) { + assert!(t1.elapsed() < Duration::from_secs(20), "the outcome is announced"); + h.step(); + } + } + let _ = std::fs::remove_dir_all(&out); +} + +/// Review of #236: a headless run (snapshot scripts) ending while an import it started with +/// `wait: false` is still running waits for it, on every exit path (dropping the `Headless`). +#[test] +fn a_headless_run_waits_for_its_photos_import_before_ending() { + let mut h = demo([800.0, 600.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-photos-headless-drop-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a.jpg"); + std::fs::write(&file, b"jpeg").unwrap(); + let marker = dir.join("imported"); + let m = marker.clone(); + // Photos "imports" when `go` arrives (at most 20 s): released 0.8 s into the drop below + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = std::sync::Mutex::new(wait); + h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + std::fs::write(&m, b"done").unwrap(); + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) + })); + let r = + h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "wait": false}}), t); + assert_eq!(r["result"]["running"], true, "{r}"); + assert!(!marker.exists(), "still importing"); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(800)); + let _ = go.send(()); + }); + drop(h); + assert!(marker.exists(), "the import finished before the run ended"); + release.join().unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Review of #236: a snapshot that ends (an error in its script, say) while its background +/// export is still writing waits through the writing and then the import the export starts. +#[test] +fn a_headless_run_waits_for_its_exports_photos_import() { + if !cfg!(target_os = "macos") { + return; // addToPhotos is refused before exporting elsewhere + } + let mut h = demo([800.0, 600.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-photos-headless-export-drop-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let marker = dir.join("imported"); + h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| { + std::thread::sleep(Duration::from_millis(300)); // a slow disk: still writing at the drop + lightcraft_engine::export::write_file(p, bytes) + })); + let m = marker.clone(); + h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| { + std::thread::sleep(Duration::from_millis(500)); + std::fs::write(&m, b"done").unwrap(); + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) + })); + let p = json!({"dir": dir.join("out").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "background": true}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.apple_photos_imports.running().is_some(), "reserved while writing"); + drop(h); + assert!(marker.exists(), "the export's import finished before the run ended"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Review of #236: a background export adding to Photos holds the import from before it +/// writes. While its files are being written, `export.addToPhotos` is refused with the +/// export's job number; the export then adds its files under that job. +#[test] +fn a_background_export_holds_photos_while_it_writes() { + if !cfg!(target_os = "macos") { + return; // addToPhotos is refused before exporting elsewhere + } + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-photos-hold-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + let other = out.join("other.jpg"); + std::fs::write(&other, b"jpeg").unwrap(); + // the writer waits for `go`: the export is still writing while we try another import + let (go, wait) = std::sync::mpsc::channel::<()>(); + let wait = std::sync::Mutex::new(wait); + h.app.services.write_shared = Some(Arc::new(move |p: &str, bytes: &[u8]| { + let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20)); + lightcraft_engine::export::write_file(p, bytes) + })); + let calls = Arc::new(std::sync::Mutex::new(Vec::>::new())); + let c = calls.clone(); + h.app.session.apple_photos_background = true; + h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| { + c.lock().unwrap().push(args.to_vec()); + Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) + })); + let p = json!({"dir": out.join("export").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "photosAlbum": "Held", "background": true}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.export.is_some(), "writing in the background"); + let r = h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [other.to_string_lossy()]}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().unwrap().contains("An export is about to add its files to Apple Photos (import 1)"), "{r}"); + go.send(()).unwrap(); + let t0 = Instant::now(); + while h.app.export.is_some() { + assert!(t0.elapsed() < Duration::from_secs(20), "the export finishes"); + h.step(); + } + let last = h.app.last_export_result.clone().unwrap(); + assert_eq!((last["applePhotos"]["job"].as_u64(), last["applePhotos"]["imported"].as_u64()), (Some(1), Some(1)), "{last}"); + let args = calls.lock().unwrap().clone(); + assert_eq!(args.len(), 1, "only the export's files went to Photos"); + assert!(args[0].last().unwrap().contains("export"), "{:?}", args[0].last()); + let _ = std::fs::remove_dir_all(&out); +} + +/// Dragging grid photos onto an album row adds them to the album. +#[test] +fn drag_photos_onto_an_album() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "view.leftPanel"}), t); + let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Dropped"}}), t); + let album = r["result"]["id"].as_u64().unwrap_or_else(|| panic!("{r}")); + h.settle(SETTLE); + let rect = |h: &mut Headless, id: &str| { + let w = h.request("ui.widgets", json!({"filter": id}), t); + let r = w["result"] + .as_array() + .and_then(|a| a.iter().find(|x| x["id"] == id)) + .map(|x| x["rect"].clone()) + .unwrap_or_else(|| panic!("{id}: {w}")); + let f = |i: usize| r[i].as_f64().unwrap(); + (f(0) + f(2) / 2.0, f(1) + f(3) / 2.0) + }; + let first = h.app.session.visible_cloned()[0].0; + let (x, y) = rect(&mut h, &format!("thumb:{first}")); + let (tx, ty) = rect(&mut h, &format!("source:album:{album}")); + let r = h.request("ui.drag", json!({"x": x, "y": y, "toX": tx, "toY": ty, "steps": 12}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let members = h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.clone(); + assert_eq!(members, vec![lightcraft_catalog::PhotoId(first)]); + assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); +} + +/// Issue #10: a raw shown from its embedded JPEG (an undecodable raw variant) says so — a badge +/// on its grid thumbnail, a pill on the loupe, a notice in Edit and Info — and other photos +/// show none of it. +#[test] +fn preview_only_raw_shows_badge_and_notices() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let vis = h.app.session.visible_cloned(); + let (po, other) = (vis[0], vis[1]); + let ph = h.app.session.catalog.photo(po).unwrap().clone(); + h.app + .session + .catalog + .apply(lightcraft_catalog::Op::SetContent { + id: po, + width: ph.width, + height: ph.height, + file_size: ph.file_size, + content_hash: ph.content_hash.clone(), + preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()), + preview_checked: lightcraft_catalog::APP_VERSION.into(), + }) + .unwrap(); + h.settle(SETTLE); + let ids = |h: &mut Headless, filter: &str| -> Vec { + let w = h.request("ui.widgets", json!({"filter": filter}), t); + w["result"].as_array().unwrap_or_else(|| panic!("{w}")).iter().filter_map(|x| x["id"].as_str().map(str::to_string)).collect() + }; + let badges = ids(&mut h, "badge:previewOnly:"); + assert_eq!(badges, vec![format!("badge:previewOnly:{}", po.0)], "only the preview-only photo has the badge"); + // the loupe and the Edit panel + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [po.0]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + let n = ids(&mut h, "notice:previewOnly:"); + assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}"); + // with a Reload from Disk button (issue #662) + assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:edit".to_string()]); + // Info + h.app.ui.right = crate::state::RightPanel::Info; + h.settle(SETTLE); + assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string())); + assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:info".to_string()]); + // …which re-reads the file and says what came of it (a demo photo has none to re-read) + let r = h.request("ui.clickWidget", json!({"id": "button:reloadPreviewOnly:info"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.toast.clone().map(|x| x.0).as_deref(), Some("This raw variant still can't be decoded")); + // a regular photo: no notice + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + assert!(ids(&mut h, "notice:previewOnly:").is_empty()); +} + +#[test] +fn crop_default_browsing_is_read_only_and_drag_is_one_edit() { + let mut h = demo([1200.0, 800.0]); + let photos: Vec<_> = h.app.session.visible_cloned().into_iter().filter(|id| !h.app.session.catalog.photo(*id).unwrap().is_edited()).collect(); + h.app.run("library.select", json!({"ids": [photos[0].0]})).unwrap(); + h.app.ui.right = crate::state::RightPanel::Crop; + h.app.run("crop.aspect", json!({"aspect": "1x1"})).unwrap(); + let saved = serde_json::to_value(&h.app.ui).unwrap(); + for &photo in &photos[1..3] { + h.app.run("library.select", json!({"ids": [photo.0]})).unwrap(); + let before = h.app.session.catalog.photo(photo).unwrap().clone(); + let undo = h.app.session.undo.len(); + h.settle(SETTLE); + let after = h.app.session.catalog.photo(photo).unwrap(); + assert_eq!(after.develop, before.develop); + assert_eq!(after.history.len(), before.history.len()); + assert!(!after.is_edited()); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); + } + let photo = photos[2]; + let before = h.app.session.develop_of(photo).unwrap(); + let undo = h.app.session.undo.len(); + h.app.run("develop.beginInteraction", json!({"label": "Crop"})).unwrap(); + h.app.begin_crop().unwrap(); + h.app.run("crop.drag", json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [0.7, 0.7]})).unwrap(); + h.app.run("develop.endInteraction", json!({})).unwrap(); + assert_eq!(h.app.session.develop_of(photo).unwrap().crop.aspect, Some((100, 100))); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); + h.app.ui = serde_json::from_value(saved).unwrap(); + assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); + h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); + h.app.ui.crop_default_aspect = Some(json!("original")); + let p = h.app.session.catalog.photo(photo).unwrap(); + assert_eq!(h.app.crop_tool_aspect(), Some((p.width * 100, p.height * 100))); + h.settle(SETTLE); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); +} + +/// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect. +#[test] +fn crop_keys() { + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.crop"}), t); + assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Thirds); + let mask_overlay = h.app.ui.mask_overlay; + h.request("ui.key", json!({"key": "O"}), t); + assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Grid); + h.request("ui.key", json!({"key": "O", "shift": true}), t); + assert_eq!(h.app.ui.crop_overlay_orient, 1); + assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping"); + // an untouched photo starts locked to its own shape (#991): A unlocks, A again locks + let locked = |h: &Headless| h.app.crop_tool_aspect().is_some(); + assert!(locked(&h)); + h.request("ui.key", json!({"key": "A"}), t); + assert!(!locked(&h)); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + h.request("ui.key", json!({"key": "A"}), t); + assert!(locked(&h)); + assert!(!h.app.ui.visualize_spots); +} + +/// Bring a right-panel field into the visible panel before clicking it. The Info panel is a +/// scroll area: 0.37's roomier text rows push the place fields below a 1000-point window, and a +/// click at an off-screen rect would leave the previous field focused. +fn click_field_in_view(h: &mut Headless, key: &str, timeout: Duration) { + let id = format!("field:{key}"); + let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:right_panel").map(|(_, r)| *r).expect("the right panel is laid out"); + let rect = h.app.widgets.iter().find(|(w, _)| *w == id).map(|(_, r)| *r).expect("the field is laid out"); + if !panel.contains(rect.center()) { + let dy = (panel.center().y - rect.center().y).clamp(-4000.0, 4000.0); + assert_eq!(h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), timeout)["ok"], true); + assert_eq!(h.request("ui.scroll", json!({"dy": dy}), timeout)["ok"], true); + h.settle(timeout); + } + let rect = h.app.widgets.iter().find(|(w, _)| *w == id).map(|(_, r)| *r).expect("the field is laid out"); + assert!(panel.contains(rect.center()), "the field must be scrolled into view before clicking: {rect:?} in {panel:?}"); + let r = h.request("ui.clickWidget", json!({"id": id}), timeout); + assert_eq!(r["ok"], true, "{r}"); +} + +/// Info panel: typing into a field survives frames and is saved when it loses focus; the new +/// accessibility and place fields reach the photo. +#[test] +fn info_fields_keep_typing_and_save() { + // (tall enough for the place fields to show without scrolling) + let mut h = demo([1300.0, 1200.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + h.settle(SETTLE); + for (key, text) in [("altText", "A lake at dawn"), ("usageTerms", "Editorial use only"), ("city", "Zermatt")] { + click_field_in_view(&mut h, key, t); + h.request("ui.key", json!({"key": "A", "cmd": true}), t); + h.request("ui.text", json!({"text": text}), t); + h.step(); + h.step(); + // still being edited: not saved yet, not lost either + h.request("ui.key", json!({"key": "Tab"}), t); + h.settle(Duration::from_secs(5)); + } + let m = &h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta; + assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt")); + assert_eq!(m.usage_terms, "Editorial use only"); + // the copyright status picker is on the panel too + let w = h.request("ui.widgets", json!({"filter": "copyrightStatus"}), t); + assert!(w["result"].to_string().contains("field:copyrightStatus"), "{w}"); +} + +/// Info panel fields are shared text fields. The caption takes several lines: Return starts a +/// new one and only leaving the field saves it. Esc gives up an edit (the title stays as it +/// was, nothing saved), and right-click ▸ Paste works there too. +#[test] +fn info_fields_take_lines_esc_and_the_menu() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + h.settle(SETTLE); + let meta = |h: &Headless| h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta.clone(); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let (title, undo) = (meta(&h).title, h.app.session.undo.len()); + // the caption: two lines, saved on leaving + ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"})); + ask(&mut h, "ui.key", json!({"key": "A", "cmd": true})); + ask(&mut h, "ui.text", json!({"text": "Harbour at dusk"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + ask(&mut h, "ui.text", json!({"text": "Lisbon, 2026"})); + assert_eq!(h.app.session.undo.len(), undo, "Return in the caption saves nothing yet"); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + assert_eq!(meta(&h).caption, "Harbour at dusk\nLisbon, 2026"); + // the title: Esc gives the edit up + let undo = h.app.session.undo.len(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + ask(&mut h, "ui.text", json!({"text": " (draft)"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + h.settle(SETTLE); + assert_eq!((meta(&h).title, h.app.session.undo.len()), (title, undo), "Esc saved nothing"); + // the menu + h.view.clipboard = "Wedding in Sintra".into(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + ask(&mut h, "ui.key", json!({"key": "A", "cmd": true})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title:paste"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert_eq!(meta(&h).title, "Wedding in Sintra"); +} + +/// With several photos selected the Info panel offers Target Photo / Selected Photos. Target +/// Photo (the default) shows the active photo's values. Selected Photos leaves a field whose +/// values differ empty, so what is typed there is what every selected photo gets, and keeps +/// the value of a field they share; leaving a mixed field untouched saves nothing. +#[test] +fn info_shows_metadata_for_the_selected_photos() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + r + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params}))["result"].clone() + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let (a, b) = (lightcraft_catalog::PhotoId(ids[0]), lightcraft_catalog::PhotoId(ids[1])); + let meta = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn", "copyrightStatus": "copyrighted"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "title": "Dusk"})); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "creator": "Ana"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); + h.settle(SETTLE); + let buttons = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "infoFor"}), t)["result"].to_string(); + assert!(buttons(&mut h).contains("button:infoFor-selected"), "the choice shows with two photos selected"); + assert!(h.app.ui.settings.info_selected_photos, "Selected Photos by default"); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn!"})); + // Target Photo and back to Selected Photos, from their buttons + ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-target"})); + assert!(!h.app.ui.settings.info_selected_photos); + ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-selected"})); + assert!(h.app.ui.settings.info_selected_photos); + // a mixed field left untouched saves nothing + let undo = h.app.session.undo.len(); + click_field_in_view(&mut h, "title", t); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo)); + // neither does switching the choice while a field is being edited, either way + for on in [false, true] { + click_field_in_view(&mut h, "title", t); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": on})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo), "{on}"); + } + // the mixed title starts empty; the shared creator keeps its value + for (key, typed, want) in [("title", "Harbor", "Harbor"), ("creator", " Silva", "Ana Silva")] { + click_field_in_view(&mut h, key, t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": typed})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + let got = |h: &Headless, id| if key == "title" { meta(h, id).title } else { meta(h, id).creator }; + assert_eq!((got(&h, a), got(&h, b)), (want.to_string(), want.to_string()), "{key}"); + } + // a mixed field typed in and left empty clears it on every selected photo + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Old"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Other"})); + h.settle(SETTLE); + click_field_in_view(&mut h, "caption", t); + ask(&mut h, "ui.text", json!({"text": "x"})); + ask(&mut h, "ui.key", json!({"key": "Backspace"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).caption, meta(&h, b).caption), (String::new(), String::new())); + // GPS: one photo's place and Show on Map only while the selected photos share it + let map = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "showOnMap"}), t)["result"].to_string().contains("showOnMap"); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "gps": [41.15, -8.61]})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "gps": [38.72, -9.14]})); + h.settle(SETTLE); + assert!(!map(&mut h), "places differ"); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "gps": [41.15, -8.61]})); + h.settle(SETTLE); + assert!(map(&mut h), "a shared place"); + // the command: refuses a value that isn't true or false, and turns Target Photo back on + let r = h.request("engine.execute", json!({"command": "view.infoSelectedPhotos", "params": {"on": "yes"}}), t); + assert_ne!(r["ok"], true, "{r}"); + assert!(h.app.ui.settings.info_selected_photos); + assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}))["on"], false); + assert!(!h.app.ui.settings.info_selected_photos); + assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({}))["on"], true, "no params toggles"); + // the choice is saved with the app settings; settings saved before it read as Selected Photos + exec(&mut h, "view.infoSelectedPhotos", json!({"on": false})); + let saved = serde_json::to_value(&h.app.ui.settings).unwrap(); + assert_eq!(saved["infoSelectedPhotos"], false); + let read: crate::state::AppSettings = serde_json::from_value(saved).unwrap(); + assert!(!read.info_selected_photos); + let old: crate::state::AppSettings = serde_json::from_value(json!({"filmNames": false})).unwrap(); + assert!(old.info_selected_photos); + // one photo selected: no choice to make + exec(&mut h, "library.select", json!({"ids": [ids[0]]})); + h.settle(SETTLE); + assert!(!buttons(&mut h).contains("infoFor-"), "{}", buttons(&mut h)); +} + +/// With several photos selected and the settings as they come, an edit to a field where the +/// photos differ starts from an empty field: the photos get what was typed, not the active +/// photo's value with the typing added. +#[test] +fn info_edits_of_a_multi_selection_start_from_mixed_fields_by_default() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); + h.settle(SETTLE); + click_field_in_view(&mut h, "caption", t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "Harbor"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((caption(&h, 0), caption(&h, 1)), ("Harbor".into(), "Harbor".into())); +} + +/// Target Photo edits the photo it shows: with several photos selected, a field's edit is +/// saved to the active photo alone. +#[test] +fn info_target_photo_edits_only_the_photo_it_shows() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": false})); + h.settle(SETTLE); + click_field_in_view(&mut h, "caption", t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((caption(&h, 0), caption(&h, 1)), ("Dawn!".into(), "Dusk".into())); +} + +/// The fields the Info panel marks as mixed: each text field, the copyright status and GPS +/// where a selected photo differs from the photo the panel shows, and none for one photo. +#[test] +fn info_mixed_fields_are_the_ones_that_differ() { + let mut s = lightcraft_engine::Session::with_demo(); + let ids: Vec = s.visible_cloned().iter().take(3).map(|p| p.0).collect(); + let all = |key: &str, v: serde_json::Value| json!({"ids": ids, key: v}); + // (every field the panel compares starts the same on the three photos) + let fields = [ + "title", + "caption", + "altText", + "extendedDescription", + "copyright", + "usageTerms", + "copyrightUrl", + "creator", + "location", + "state", + "country", + ]; + for key in fields { + s.execute("photo.setMeta", &all(key, json!(""))).unwrap(); + } + for (key, v) in + [("title", json!("Same")), ("city", json!("Porto")), ("copyrightStatus", json!("copyrighted")), ("gps", json!([41.15, -8.61]))] + { + s.execute("photo.setMeta", &all(key, v)).unwrap(); + } + let photos: Vec = ids.iter().map(|&i| lightcraft_catalog::PhotoId(i)).collect(); + let shown = s.catalog.photo(photos[0]).unwrap().meta.clone(); + assert!(crate::panels::right::mixed_meta(&s.catalog, &photos, &shown).is_empty()); + assert!(crate::panels::right::mixed_meta(&s.catalog, &photos[..1], &shown).is_empty()); + s.execute("photo.setMeta", &json!({"ids": [ids[2]], "city": "Lisbon", "caption": "x", "gps": [38.72, -9.14]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [ids[1]], "copyrightStatus": "publicDomain"})).unwrap(); + let mut mixed = crate::panels::right::mixed_meta(&s.catalog, &photos, &shown); + mixed.sort_unstable(); + assert_eq!(mixed, ["caption", "city", "copyrightStatus", "gps"]); +} + +/// Selected Photos with the active photo outside the selection: the fields show what the +/// selected photos share, not the active photo's values. +#[test] +fn info_selected_photos_ignore_an_active_photo_outside_the_selection() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect(); + let title = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.title.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0], ids[1]], "title": "Same"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[2]], "title": "Other"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[2]})); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": true})); + h.settle(SETTLE); + click_field_in_view(&mut h, "title", t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((title(&h, 0), title(&h, 1), title(&h, 2)), ("Same!".into(), "Same!".into(), "Other".into())); +} + +/// An Info field saves to the photos selected when its edit started, also when the selection +/// changes while the field is being edited. +#[test] +fn info_field_saves_to_the_photos_selected_when_the_edit_started() { + // (tall enough for the GPS field to show without scrolling) + let mut h = demo([1300.0, 1800.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(4).map(|p| p.0).collect(); + let meta = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "title": "Same", "gps": ""})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + click_field_in_view(&mut h, "title", t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + exec(&mut h, "library.select", json!({"ids": [ids[2]]})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, 0).title, meta(&h, 1).title, meta(&h, 2).title), ("Same!".into(), "Same!".into(), "Same".into())); + // GPS too (the new selection is two photos as well, so the panel keeps its layout) + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + click_field_in_view(&mut h, "gps", t); + ask(&mut h, "ui.text", json!({"text": "41.15, -8.61"})); + exec(&mut h, "library.select", json!({"ids": [ids[2], ids[3]], "active": ids[2]})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + let gps: Vec = (0..4).map(|i| meta(&h, i).gps.is_some()).collect(); + assert_eq!(gps, [true, true, false, false]); + // Esc gives a GPS edit up + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + let before = meta(&h, 0).gps; + click_field_in_view(&mut h, "gps", t); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "5"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, 0).gps, meta(&h, 1).gps), (before, before)); +} + +/// Local: a folder's photos show without joining the library; the breadcrumb, Include +/// subfolders and Add to My Photos work from the grid header. +#[test] +fn browse_a_local_folder() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-browse-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("inner")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + for (i, p) in [dir.join("a.png"), dir.join("inner/b.png")].iter().enumerate() { + let mut img = img.clone(); + img.data[0][0] = i as u8; // different bytes per file + std::fs::write(p, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + } + let library_before = h.app.session.catalog.photos().filter(|p| !p.local).count(); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); + // the folder is read in the background: the view switches at once, the photos follow + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::Folder); + h.settle(SETTLE); + assert!(h.app.scan.is_none() && h.app.import.is_none()); + assert_eq!(h.app.session.visible_cloned().len(), 1); + assert!(h.settle(SETTLE), "a thumbnail that can't load must not keep the renderer busy"); + // this test session has no file hooks: the thumbnail fails once, is remembered, and isn't retried + let id = h.app.session.visible_cloned()[0]; + assert!(h.app.renderer.failure(crate::render::Slot::Thumb(id)).is_some()); + let done = h.app.renderer.completed; + for _ in 0..30 { + h.step(); + } + assert_eq!(h.app.renderer.completed, done, "no retry loop"); + let w = h.request("ui.widgets", json!({"filter": "crumb:"}), t); + assert!(w["result"].as_array().is_some_and(|a| !a.is_empty()), "breadcrumb: {w}"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:includeSubfolders"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.app.session.visible_cloned().len(), 2); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:addToLibrary"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.app.session.catalog.photos().filter(|p| !p.local).count(), library_before + 2); + // Truncated ancestors remain clickable and still open the original, unabridged path. + let inner = dir.join("inner"); + h.request("engine.execute", json!({"command": "library.browse", "params": {"path": inner.to_string_lossy()}}), t); + h.settle(SETTLE); + let parent_index = dir.to_string_lossy().split(['/', '\\']).filter(|p| !p.is_empty()).count() - 1; + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("crumb:{parent_index}")}), t)["ok"], true); + h.settle(SETTLE); + // the same folder, whatever the platform's separators (issue #538) + assert_eq!(std::path::Path::new(&h.app.session.browse.as_ref().unwrap().path), dir.as_path()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Local: Remove from Local hides a sidebar location (nothing on disk changes) and the +/// "Show hidden locations" row puts it back. The folder is browsed and kept the way Browse +/// Folder… does it, in the temporary folder — on Windows beneath Home, whose tree must not +/// open down to the hidden folder and push the restore row out of view. +#[test] +fn local_location_can_be_hidden_and_restored() { + let size = [1300.0, 900.0]; + let mut h = demo(size); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-hide-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.to_string_lossy().to_string(); + let rects = |h: &mut Headless| -> Vec<(String, Vec)> { + let w = h.request("ui.widgets", json!({"filter": "source:local:"}), t); + w["result"] + .as_array() + .unwrap() + .iter() + .map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"].as_array().unwrap().iter().filter_map(|v| v.as_f64()).collect())) + .collect() + }; + let ids = |h: &mut Headless| -> Vec { rects(h).into_iter().map(|(id, _)| id).collect() }; + h.request("ui.set", json!({"leftPanel": true}), t); + h.hide_home_above(&dir); + // Browse Folder… browses the picked folder and keeps it in Local within one frame; a + // request runs frames, so keep it first (no frame sees it browsed but not yet kept) + let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let row = format!("source:local:{path}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the browsed folder is listed: {:?}", ids(&mut h)); + // the "Show hidden locations" row appears only while something is hidden + let hidden_before = !h.app.ui.hidden_locations.is_empty(); + assert_eq!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), hidden_before); + // hide it while it is still being browsed + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + // give any listing a reveal would wait for time to arrive + for _ in 0..20 { + h.step(); + std::thread::sleep(Duration::from_millis(5)); + } + h.settle(SETTLE); + let after = rects(&mut h); + let after_ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect(); + assert!(!after_ids.contains(&format!("source:local:{path}").as_str()), "{after_ids:?}"); + let restore = after.iter().find(|(id, _)| id == "source:local:restoreHidden").map(|(_, r)| r.clone()); + let restore = restore.unwrap_or_else(|| panic!("no restore row: {after_ids:?}")); + assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}"); + assert!(h.app.session.browse.is_some(), "hiding does not stop browsing"); + assert!(dir.is_dir(), "the folder itself is untouched"); + // a second hidden place: the row lists them, and each comes back on its own (issue #2114: + // the row used to bring every hidden place back at once) + let dir2 = std::env::temp_dir().join(format!("lc-ui-hide-{}-b", std::process::id())); + let _ = std::fs::remove_dir_all(&dir2); + std::fs::create_dir_all(&dir2).unwrap(); + let path2 = dir2.to_string_lossy().to_string(); + for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), t); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let menu = |h: &mut Headless| -> Vec { + let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), t); + w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() + }; + assert!(menu(&mut h).is_empty(), "the list opens from the row"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + let items = menu(&mut h); + assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}"); + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true); + h.settle(SETTLE); + assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations); + assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden"); + assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); + // two hidden again: Restore All brings every one back + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); + assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); + // one hidden: the row brings it back itself, no list + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path2}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert!(menu(&mut h).is_empty(), "no list for a single hidden place"); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); + if !hidden_before { + // (with Home hidden for this test, the folder is back inside Home's tree instead) + assert!(ids(&mut h).contains(&format!("source:local:{path}"))); + } + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); +} + +/// Local: a kept folder stays a root while its subfolders (and other locations) are +/// browsed — the subfolder is highlighted inside its tree, its siblings stay listed — and +/// the kept folders survive a save/load of the UI state (a restart). +#[test] +fn kept_local_root_stays_while_browsing_below_and_elsewhere() { + let mut h = demo([1300.0, 1400.0]); + let t = Duration::from_secs(10); + let base = std::env::temp_dir().join(format!("lc-ui-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["Photos/2026/20260101", "Photos/2026/20260114", "Other"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); + // joined by component: the sidebar's ids spell paths with the platform's separator + let year = base.join("Photos").join("2026"); + let (photos, day1, day2, other) = (s(base.join("Photos")), s(year.join("20260101")), s(year.join("20260114")), s(base.join("Other"))); + h.hide_home_above(&base); + let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); + let rects = |h: &mut Headless| -> std::collections::HashMap { + let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t); + w["result"].as_array().unwrap().iter().map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"][0].as_f64().unwrap_or(0.0))).collect() + }; + h.request("ui.set", json!({"leftPanel": true}), t); + // Home may also own this temporary directory. Keep the fixture tree + // independent of the user's real folders and their async listings. + if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { + assert_eq!(exec(&mut h, "local.hide", json!({"path": home}))["ok"], true); + } + assert_eq!(exec(&mut h, "local.addRoot", json!({"path": photos}))["ok"], true); + assert_eq!(exec(&mut h, "local.addRoot", json!({"path": format!("{photos}/")}))["result"]["roots"].as_array().map(Vec::len), Some(1)); + exec(&mut h, "library.browse", json!({"path": day1})); + h.settle(SETTLE); + h.step(); + let row = format!("source:local:{day2}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the sibling listing did not arrive"); + let r = rects(&mut h); + assert!(r.contains_key(&format!("source:local:{photos}")), "the kept root stays: {r:?}"); + assert!(r.contains_key(&format!("source:local:{day2}")), "the sibling stays reachable: {r:?}"); + let (root_x, child_x) = (r[&format!("folderToggle:{photos}")], r[&format!("folderToggle:{day1}")]); + assert!(child_x > root_x, "the browsed folder is inside the root's tree, not a root of its own ({child_x} vs {root_x})"); + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("source:local:{day2}")}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(day2.clone()), "the sibling is browsed"); + // another location: the kept root stays listed + assert_eq!(exec(&mut h, "library.browse", json!({"path": other}))["ok"], true); + h.settle(SETTLE); + h.step(); + let row = format!("source:local:{other}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the other location did not arrive"); + let r = rects(&mut h); + assert!(r.contains_key(&format!("source:local:{photos}")) && r.contains_key(&format!("source:local:{other}")), "{r:?}"); + // restart: the kept roots come back with the saved UI state + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(back.local_roots, vec![photos.clone()]); + let _ = std::fs::remove_dir_all(&base); + h.settle(SETTLE); +} + +/// Versions panel: resting on a version previews it in the loupe without changing the photo; +/// a click restores it. +#[test] +fn versions_preview_on_hover_and_restore_on_click() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); + h.request("ui.set", json!({"view": "detail", "right": "versions"}), t); + exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0}})); + exec(&mut h, "version.create", json!({"name": "Bright"})); + exec(&mut h, "develop.set", json!({"values": {"light.exposure": -1.0}})); + h.settle(SETTLE); + let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + assert_eq!(h.request("ui.hoverWidget", json!({"id": "version:0"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.settings.light.exposure), Some(1.0)); + assert_eq!(exposure(&h), -1.0, "hovering changes nothing"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "version:0"}), t)["ok"], true); + h.settle(Duration::from_secs(5)); + assert_eq!(exposure(&h), 1.0); +} + +/// ↑ / ↓ over a slider nudge it (one undo step each); ⇧Z picks and moves to the next photo. +#[test] +fn slider_nudge_and_pick_advance() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); + h.settle(SETTLE); + let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + assert_eq!(h.request("ui.hoverWidget", json!({"id": "slider:light.exposure"}), t)["ok"], true); + h.request("ui.key", json!({"key": "Up"}), t); + h.request("ui.key", json!({"key": "Up", "shift": true}), t); + assert!((exposure(&h) - 0.30).abs() < 1e-9, "{}", exposure(&h)); + h.request("ui.key", json!({"key": "Down"}), t); + assert!((exposure(&h) - 0.25).abs() < 1e-9, "{}", exposure(&h)); + let first = h.app.session.active().unwrap(); + h.request("ui.key", json!({"key": "Z", "shift": true}), t); + assert_eq!(h.app.session.catalog.photo(first).unwrap().flag, lightcraft_catalog::Flag::Pick); + assert_ne!(h.app.session.active(), Some(first), "advanced"); +} + +/// Click a slider's value and type one (issue #322): Return sets it as one undo step, Esc +/// keeps the old value, and the keys typed don't reach the shortcuts (1 = one star). +#[test] +fn slider_values_can_be_typed() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); + h.settle(SETTLE); + let active = h.app.session.active().unwrap(); + let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + let rating = |h: &Headless| h.app.session.catalog.photo(active).unwrap().rating; + let (undo0, rating0) = (h.app.session.undo.len(), rating(&h)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); + h.request("ui.text", json!({"text": "1,5"}), t); + h.request("ui.key", json!({"key": "Enter"}), t); + assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); + assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); + assert_eq!(rating(&h), rating0, "typing 1 set no rating"); + // Esc keeps the value + assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); + h.request("ui.text", json!({"text": "-2"}), t); + h.request("ui.key", json!({"key": "Escape"}), t); + assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); + // something that isn't a number changes nothing + assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true); + h.request("ui.text", json!({"text": "bright"}), t); + h.request("ui.key", json!({"key": "Enter"}), t); + assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h)); + assert_eq!(h.app.session.undo.len(), undo0 + 1); +} + +/// Reset takes precedence over the value being typed when its button is clicked. +#[test] +fn reset_button_wins_over_an_active_numeric_draft() { + let mut h = demo([960.0, 600.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{method}: {r}"); + r["result"].clone() + }; + ask(&mut h, "engine.execute", json!({"command": "app.gpu", "params": {"enabled": false}})); + ask(&mut h, "ui.set", json!({"settings": {"gpu": false, "previewEdge": 1280}, "presets": false})); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": [1], "active": 1}})); + ask( + &mut h, + "ui.set", + json!({"view": "detail", "right": "edit", "leftPanel": false, "rightWidth": 330, "openSections": ["light"], "histogram": false}), + ); + let active = h.app.session.active().unwrap(); + for (text, terminal) in [("0.75", "Enter"), ("-1,5", "Escape")] { + ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); + ask(&mut h, "ui.text", json!({"text": text})); + ask(&mut h, "ui.key", json!({"key": terminal})); + let value = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); + assert_eq!(value["light"]["exposure"].as_f64(), Some(0.75), "{terminal}: {value}"); + } + ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); + ask(&mut h, "ui.text", json!({"text": "2.0"})); + assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(crate::text_field::id("sliderField:light.exposure"))); + assert!(h.app.widgets.iter().any(|(id, _)| id == "sliderField:light.exposure")); + assert!(h.painted_text().iter().any(|text| text == "2.0"), "the replacement draft must be visible before Reset"); + assert_eq!(h.app.session.develop_of(active).unwrap().light.exposure, 0.75, "the draft is not committed yet"); + ask(&mut h, "ui.clickWidget", json!({"id": "button:reset"})); + let reset = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); + assert_eq!( + reset["light"]["exposure"].as_f64(), + Some(0.0), + "Reset must win over the active numeric draft: {reset}; history={:?}; last_inverse={:?}; focus={:?}", + h.app.session.undo.iter().map(|entry| entry.label.as_str()).collect::>(), + h.app.session.undo.last().map(|entry| &entry.op), + h.view.ctx.memory(|m| m.focused()), + ); +} + +/// A slider's typed value is a shared text field: right-click ▸ Paste replaces the value +/// (selected when the field opens) and Return applies it; the menu doesn't close the field. +#[test] +fn a_slider_value_pastes_from_its_menu() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); + h.settle(SETTLE); + h.view.clipboard = "0.75".into(); + for (id, button) in + [("sliderValue:light.exposure", "left"), ("sliderField:light.exposure", "right"), ("sliderField:light.exposure:paste", "left")] + { + let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(SETTLE); + } + h.request("ui.key", json!({"key": "Enter"}), t); + h.settle(SETTLE); + let exposure = h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + assert!((exposure - 0.75).abs() < 1e-9, "{exposure}"); +} + +/// The eye on a section header switches the section off and on again, one undo step each +/// (issue #316). +#[test] +fn section_eye_switches_a_section_off() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); + h.settle(SETTLE); + let light_on = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().section_enabled("light"); + for expected in [false, true] { + // the eye shows while the pointer is on the header + assert_eq!(h.request("ui.hoverWidget", json!({"id": "section:light"}), t)["ok"], true); + assert_eq!(h.request("ui.clickWidget", json!({"id": "sectionEye:light"}), t)["ok"], true); + assert_eq!(light_on(&h), expected); + } + assert!(h.app.ui.section_open("light"), "the click didn't fold the section"); +} + +/// Return commits a tool panel back to Edit; elsewhere it does nothing. +#[test] +fn return_commits_the_crop_tool() { + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.crop"}), t); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Crop); + h.request("ui.key", json!({"key": "Enter"}), t); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit); + h.request("ui.key", json!({"key": "Enter"}), t); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit, "no-op outside tools"); +} + +/// ⌘Q (File → Quit LightCraft) closes the window. +#[test] +fn cmd_q_quits() { + let mut h = demo([900.0, 600.0]); + assert!(!h.quit_requested()); + h.request("ui.key", json!({"key": "Q", "cmd": true}), Duration::from_secs(10)); + h.step(); + h.step(); + assert!(h.quit_requested()); +} + +/// File → Import from Folder… opens the import review for a folder (searched recursively). +#[test] +fn add_folder_opens_the_import_review() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-addfolder-{}", std::process::id())); + let sub = dir.join("day 2"); + std::fs::create_dir_all(&sub).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let png = lightcraft_engine::export::encode_image( + &img, + &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, + ) + .unwrap(); + std::fs::write(dir.join("a.png"), &png).unwrap(); + std::fs::write(sub.join("b.png"), &png).unwrap(); + let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) }; + assert_eq!(opts.candidates.len(), 2, "both files, the subfolder's too"); + assert!(!opts.copy, "a folder is added in place by default"); + // the review names its source; scanning doesn't save a Local location + assert_eq!(opts.sources, vec![dir.to_string_lossy().to_string()]); + let name = dir.file_name().unwrap().to_string_lossy().to_string(); + assert_eq!(crate::import::source_summary(&opts.sources), format!("Folder “{name}” (and its subfolders)")); + let r = h.request("ui.widgets", json!({}), t); + assert!(r.to_string().contains("label:importSource"), "source shown"); + assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved"); + // The source facts were cached by a worker with the source-language default. They must + // still be rendered in German after switching, with the folder name kept verbatim. + assert_eq!(h.request("engine.execute", json!({"command": "app.language.german"}), t)["ok"], true); + h.settle(SETTLE); + fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { + match shape { + egui::epaint::Shape::Text(shape) => out.push(shape.galley.job.text.clone()), + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| texts(shape, out)), + _ => {} + } + } + let mut painted = Vec::new(); + for shape in &h.view.shapes { + texts(&shape.shape, &mut painted); + } + assert!(painted.iter().any(|text| text == &format!("Ordner „{name}“ (und seine Unterordner)")), "{painted:?}"); + for label in ["Quelle", "Übertragen", "Stichwörter", "Vorgabe"] { + assert!(painted.iter().any(|text| text == label), "{label}: {painted:?}"); + } + assert_eq!(h.request("engine.execute", json!({"command": "app.language.english"}), t)["ok"], true); + // a camera / card folder: copied into the library by default + h.app.ui.dialog = None; + let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert!(opts.copy); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Switching Settings tabs keeps the window's top edge (and its tab row) where it is and never makes it +/// narrower or taller, though the tabs hold different amounts: the window is one size on every tab. +#[test] +fn settings_tabs_keep_the_window_in_place() { + let mut h = demo([1300.0, 820.0]); + let t = Duration::from_secs(10); + let window = |h: &Headless| h.app.widgets.iter().find(|(w, _)| w == "dialog:window").map(|(_, r)| *r).expect("the dialog"); + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "general"}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let first = window(&h); + let mut heights = Vec::new(); + for tab in ["display", "import", "performance", "general", "interface", "display"] { + h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); + h.settle(SETTLE); + let now = window(&h); + assert!((now.top() - first.top()).abs() < 1.0, "{tab}: top {} → {}", first.top(), now.top()); + assert!(now.width() >= first.width() - 1.0, "{tab}: width {} → {}", first.width(), now.width()); + heights.push(now.height()); + } + assert!(heights.iter().all(|h| (h - first.height()).abs() < 1.0), "the tabs keep the height of {}: {heights:?}", first.height()); +} + +/// Settings → Import → "Show the import review when a camera or card is connected": a card that +/// was already in doesn't ask, a new one opens Import from Device (copying), and a busy window +/// asks once it's free. +#[test] +fn a_connected_card_opens_the_import_review() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let root = std::env::temp_dir().join(format!("lc-card-{}", std::process::id())); + let dcim = root.join("DCIM/100NIKON"); + std::fs::create_dir_all(&dcim).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dcim.join("DSC_0001.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let card = lightcraft_engine::devices::Device { + name: "NIKON Z 8".into(), + path: root.join("DCIM").to_string_lossy().to_string(), + root: root.to_string_lossy().to_string(), + }; + h.app.ui.dialog = None; + // first look: the card was already in + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none() && h.app.ui.dialog.is_none(), "a card that was already in doesn't ask"); + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none()); + // taken out, then put back while another dialog is open: it waits + crate::import::cards_seen(&mut h.app, Vec::new()); + h.app.ui.dialog = Some(crate::state::Dialog::About); + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none(), "a busy window isn't interrupted"); + h.app.ui.dialog = None; + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_some(), "the card is scanned once the window is free"); + assert!(h.step_until(t, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert!(opts.copy, "a card is copied into the library"); + // with the option off nothing is remembered, so turning it on again starts with a first look + assert!(!h.app.session.import_defaults.show_on_card); + h.step(); + assert!(h.app.ui.known_cards.is_none()); + let _ = std::fs::remove_dir_all(&root); +} + +/// The folder scan runs in the background: the request returns at once and the review opens +/// when the scan finishes (a folder on a network share must not freeze the window). +/// Clicking the folder being read again keeps the running read (and its progress). +#[test] +fn browsing_the_same_folder_again_does_not_restart() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-again-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (g, n) = (gate.clone(), started.clone()); + h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { + n.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + while !g.load(std::sync::atomic::Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) + })); + let browse = + |h: &mut Headless| h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); + assert_eq!(browse(&mut h)["result"]["scanning"], true); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(browse(&mut h)["result"]["scanning"], true); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(started.load(std::sync::atomic::Ordering::Relaxed), 1, "the second click must not start another read"); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + h.settle(SETTLE); + assert_eq!(h.app.session.visible_cloned().len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Browsing a folder from the UI (read on a worker thread) records it as browsed, as `library.browse` does: +/// untouched Local photos are only forgotten from folders not browsed for the set number of days. +#[test] +fn browsing_from_the_ui_records_the_folder() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-stamp-{}", std::process::id())); + let day = dir.join("2026/10/14"); + std::fs::create_dir_all(&day).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(day.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let root = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let day_s = day.to_string_lossy().to_string(); + assert!(h.app.session.catalog.last_browsed(&root).is_none()); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": root, "subfolders": true}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.session.visible_cloned().len(), 1); + assert!(h.app.session.catalog.last_browsed(&root).is_some(), "the browsed folder"); + assert!(h.app.session.catalog.last_browsed(&day_s).is_some(), "the folder its photo is in"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Browsing a folder again from the sidebar forgets the untouched Local records of files deleted from it by +/// another app, as `library.browse` does (issue #1392). +#[test] +fn browsing_again_from_the_ui_forgets_deleted_files() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-gone-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + let png = lightcraft_engine::export::encode_image(&img, &o).unwrap(); + std::fs::write(dir.join("a.png"), &png).unwrap(); + std::fs::write(dir.join("b.png"), &png).unwrap(); + let path = dir.to_string_lossy().to_string(); + let browse = |h: &mut Headless| { + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + }; + browse(&mut h); + assert_eq!(h.app.session.visible_cloned().len(), 2); + // another app deletes the photo that isn't selected (a selected or open one stays, shown as missing) + let held: Vec = + h.app.session.selection.ids.iter().copied().chain(h.app.session.selection.active).chain(h.app.session.previous_active).collect(); + let name = |id: &lightcraft_catalog::PhotoId| h.app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default(); + let gone = if held.iter().any(|id| name(id) == "b.png") { "a.png" } else { "b.png" }; + std::fs::remove_file(dir.join(gone)).unwrap(); + // a later session: the browse's "Add 2 Photos" step is no longer in the undo history (photos it refers to + // are kept, like selected ones) + h.app.session.undo.clear(); + h.app.session.redo.clear(); + // the folder clicked again in the sidebar + browse(&mut h); + assert_eq!(h.app.session.visible_cloned().len(), 1, "the deleted file's record is forgotten"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn add_folder_scans_in_the_background() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-scanbg-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let png = lightcraft_engine::export::encode_image( + &img, + &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, + ) + .unwrap(); + std::fs::write(dir.join("a.png"), &png).unwrap(); + let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert!(h.app.scan.is_some() || h.app.ui.dialog.is_some()); + h.settle(SETTLE); + assert!(h.app.scan.is_none()); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Cancel closes the scan at once and opens no review; Add from Device while a scan runs is +/// refused and leaves the running scan's options alone. +#[test] +fn folder_scan_cancel_and_busy() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-scancancel-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let png = lightcraft_engine::export::encode_image( + &img, + &lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }, + ) + .unwrap(); + std::fs::write(dir.join("a.png"), &png).unwrap(); + // a scan that can't finish until the test lets it: the probe waits on a flag + let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let g = gate.clone(); + h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { + while !g.load(std::sync::atomic::Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) + })); + let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": dir.to_string_lossy()}}), t); + assert_ne!(r["ok"], true, "a second scan is refused: {r}"); + assert!(!h.app.scan.as_ref().unwrap().copy, "the running scan keeps its options"); + let r = h.request("ui.inspect", json!({}), t); + assert!(r["result"]["scan"].is_object(), "{r}"); + // Cancel is the ✕ of the scan's row in the activity stack (shown once it is half a second old) + let id = h.app.session.activity.list().first().map(|t| t.id).expect("a scan row"); + let cross = format!("activity:cancel:{id}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + let r = h.request("ui.clickWidget", json!({"id": cross}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.scan.is_none(), "✕ closes the scan at once"); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + h.settle(SETTLE); + assert!(h.app.ui.dialog.is_none(), "a cancelled scan opens no review"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// ⇧⌘V opens Paste Selected Settings (prefilled with the copied groups); unchecking a group +/// leaves it alone. ⌘F puts typing into the search field. +#[test] +fn paste_selected_settings_and_find() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0]]})); + exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0, "color.vibrance": 30}})); + exec(&mut h, "develop.copy", json!({"groups": ["light", "color"]})); + exec(&mut h, "library.select", json!({"ids": [ids[1]]})); + h.request("ui.key", json!({"key": "V", "cmd": true, "shift": true}), t); + h.settle(SETTLE); + let Some(crate::state::Dialog::PasteSettings { groups }) = &h.app.ui.dialog else { panic!("dialog not open: {:?}", h.app.ui.dialog) }; + assert_eq!(groups, &["light", "color"]); + let r = h.request("ui.clickWidget", json!({"id": "pasteGroup:color"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.request("ui.dialog.confirm", json!({}), t)["ok"], true); + let d = h.app.session.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); + assert_eq!((d.light.exposure, d.color.vibrance), (1.0, 0.0)); + // ⌘F, then typing filters the grid by text + h.request("ui.set", json!({"view": "grid"}), t); + h.request("ui.key", json!({"key": "F", "cmd": true}), t); + h.settle(SETTLE); + h.request("ui.text", json!({"text": "zz-no-such-photo"}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.search, "zz-no-such-photo"); + assert!(h.app.session.visible_cloned().is_empty()); +} + +/// Info panel → Edit Capture Time…: a time-zone shift moves the selected photos. +#[test] +fn capture_time_dialog_shifts_time_zone() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let id = h.app.session.visible_cloned()[0]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); + // The dock header leaves less vertical space; bring the metadata action into view. + let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; + let clipped_action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; + assert!(!panel.contains_rect(clipped_action), "the capture-time action starts below the visible panel: {clipped_action:?}"); + h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), t); + h.request("ui.scroll", json!({"dy": -160.0}), t); + h.settle(SETTLE); + let action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; + assert!(panel.contains_rect(action), "capture-time action must be visible before clicking: {action:?} in {panel:?}"); + let r = h.request("ui.clickWidget", json!({"id": "icon:editCaptureTime"}), t); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t); + assert_eq!(r["ok"], true, "{r}"); + if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog { + *zone = -3.0; + } else { + panic!("dialog not open: {:?}", h.app.ui.dialog); + } + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let after = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); + let secs = lightcraft_catalog::dates::iso_seconds; + assert_eq!(secs(&after).unwrap() - secs(&before).unwrap(), -3 * 3600); + h.settle(SETTLE); +} + +/// Colour labels: Info-panel swatches set/clear the label, the names dialog renames them and +/// the Photo menu shows the names. +#[test] +fn label_swatches_and_names() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let id = h.app.session.visible_cloned()[0]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + let r = h.request("ui.clickWidget", json!({"id": "label:green"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.catalog.photo(id).unwrap().label, Some(lightcraft_catalog::ColorLabel::Green)); + h.request("engine.execute", json!({"command": "dialog.labelNames"}), t); + if let Some(crate::state::Dialog::LabelNames { names, .. }) = &mut h.app.ui.dialog { + names[2] = "Approved".into(); + } else { + panic!("no dialog"); + } + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let tree = h.request("ui.menu.tree", json!({}), t).to_string(); + assert!(tree.contains("Approved (Green)"), "menu shows the name"); + h.request("ui.clickWidget", json!({"id": "label:green"}), t); + assert_eq!(h.app.session.catalog.photo(id).unwrap().label, None, "clicking the current label clears it"); + h.settle(SETTLE); +} + +/// File → Import Photos… opens the import review: candidates with thumbnails, the duplicate is +/// unchecked; unchecking a cell and confirming imports the rest in batches, into a new album +/// with keywords, as one undo step. +#[test] +fn import_review_dialog() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..5u8 { + let (w, h) = (40usize, 30usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|k| [(k % w * 6) as u8, i * 40, (k / w * 8) as u8, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); + } + std::fs::copy(dir.join("img0.png"), dir.join("img0-copy.png")).unwrap(); + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + let n0 = h.app.session.catalog.len(); + let undo0 = h.app.session.undo.len(); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + h.step_until(SETTLE, |h| h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.candidates.len(), 6); + assert_eq!(opts.candidates.iter().filter(|c| c.duplicate.is_some()).count(), 1); + assert!(h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5, "thumbnails"); + // uncheck the first photo; name a new album and keywords + let r = h.request("ui.clickWidget", json!({"id": "import:0"}), t); + assert_eq!(r["ok"], true, "{r}"); + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + assert_eq!(opts.selected_paths().len(), 4, "duplicate and unchecked photo left out"); + opts.new_album = "Card 1".into(); + opts.keywords = "cardone, 2026".into(); + } else { + panic!("no import dialog"); + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.import.is_none(), "finished"); + assert_eq!(h.app.session.catalog.len(), n0 + 4); + let album = h.app.session.catalog.albums().find(|a| a.name == "Card 1").expect("album").id; + assert_eq!(h.app.session.catalog.album_count(album), 4); + assert!(h.app.session.catalog.photos().filter(|p| p.meta.keywords.contains(&"cardone".to_string())).count() == 4); + assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); + h.request("engine.execute", json!({"command": "edit.undo"}), t); + assert_eq!(h.app.session.catalog.len(), n0); + h.settle(SETTLE); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The import review offers what to do with files that are in Recently Deleted (issues #298, +/// #706): they are checked, to be restored, by default; Leave them unchecks them; confirming +/// restores the photo (with its edits) or, with Import as new, imports the file afresh. +#[test] +fn import_review_offers_recently_deleted_files() { + for (choice, tag) in [("restore", "r"), ("fresh", "f")] { + let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..2u8 { + let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; + let png = + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); + } + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + let r = + h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); + let id = r["result"]["imported"][0].as_u64().unwrap(); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); + h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.on_deleted, "restore", "the default brings a deleted photo back"); + assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked from the start: it will be restored"); + opts.set_on_deleted(""); + assert_eq!(opts.selected_paths().len(), 1, "Leave them: only the new file"); + opts.set_on_deleted(choice); + assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked again once a choice is made"); + // a per-cell choice survives switching between the two options, and "Leave them" unchecks + let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).unwrap(); + opts.checked[trashed] = false; + opts.set_on_deleted(if choice == "restore" { "fresh" } else { "restore" }); + assert_eq!(opts.selected_paths().len(), 1, "still unchecked"); + opts.set_on_deleted(""); + assert_eq!(opts.selected_paths().len(), 1); + opts.set_on_deleted(choice); + assert_eq!(opts.selected_paths().len(), 2); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + // frames until the import has finished, so its (timed) toast is read before it expires + let t0 = std::time::Instant::now(); + while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.app.import.is_none(), "finished"); + let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); + let photos = &h.app.session.catalog; + assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2, "{choice}"); + let old = photos.photo(lightcraft_catalog::PhotoId(id)); + if choice == "restore" { + assert!(toast.contains("1 restored"), "{toast}"); + assert!(old.is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits"); + assert_eq!(photos.len(), 2); + } else { + assert!(old.is_none(), "the trashed record is gone"); + assert_eq!(photos.len(), 2); + } + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// With "Leave them", a file in Recently Deleted is left unchecked but a click on its checkbox +/// (or on its "In Recently Deleted" badge) still checks it, and importing it restores the +/// photo (issue #1285). +#[test] +fn import_review_checks_a_recently_deleted_file_by_hand() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-click-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..2u8 { + let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); + } + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); + let id = r["result"]["imported"][0].as_u64().unwrap(); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); + h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; + let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).expect("a file in Recently Deleted"); + opts.set_on_deleted(""); + h.settle(SETTLE); + let checked = |h: &Headless| matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Import { opts }) if opts.checked[trashed]); + assert!(!checked(&h), "Leave them: unchecked"); + let cell = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({"filter": format!("import:{trashed}")}), t); + let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == format!("import:{trashed}"))).cloned().expect("cell on screen"); + [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) + }; + // the checkbox: 16 pt, 6 pt in from the cell's top-left corner + let [x, y, ..] = cell(&mut h); + h.request("ui.click", json!({"x": x + 14.0, "y": y + 14.0}), t); + h.settle(SETTLE); + assert!(checked(&h), "a click on the checkbox checks the file"); + // the badges ("Will be restored" at the top right, "In Recently Deleted" under the + // checkbox) don't swallow the click either + let [x, y, w, _] = cell(&mut h); + h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 12.0}), t); + h.settle(SETTLE); + assert!(!checked(&h), "a click on the badge unchecks it again"); + h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 34.0}), t); + h.settle(SETTLE); + assert!(checked(&h)); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let t0 = std::time::Instant::now(); + while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.app.import.is_none(), "finished"); + let photos = &h.app.session.catalog; + assert!(photos.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits"); + assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2); + assert_eq!(photos.len(), 2, "restored, not imported a second time"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The import review opens bigger than the old fixed 6 × 2.5 grid and can be resized by its +/// corner: the photo grid takes the new height, and the window then keeps its size (issue #337). +#[test] +fn import_review_dialog_resizes() { + let mut h = demo([1400.0, 1000.0]); + let t = Duration::from_secs(10); + let candidates = (0..60) + .map(|i| lightcraft_engine::import::ImportCandidate { + path: format!("/lc-test/img{i}.png"), + name: format!("img{i}.png"), + error: Some("not read in this test".into()), + ..Default::default() + }) + .collect(); + h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(candidates)) }); + let rect = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({}), t); + let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")).expect("dialog on screen"); + [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) + }; + for _ in 0..10 { + h.step(); + } + let r0 = rect(&mut h); + assert!(r0[3] > 600.0, "{r0:?}"); + // the Copy options add rows below the grid: the grid makes room, the window doesn't grow + for copy in [true, false] { + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + opts.copy = copy; + } + for _ in 0..10 { + h.step(); + } + let r = rect(&mut h); + assert!((r[3] - r0[3]).abs() < 0.5, "copy {copy}: {r0:?} → {r:?}"); + } + let (x, y) = (r0[0] + r0[2] - 3.0, r0[1] + r0[3] - 3.0); + h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 160.0, "toY": y + 120.0, "steps": 12}), t); + for _ in 0..10 { + h.step(); + } + let r1 = rect(&mut h); + assert!(r1[2] > r0[2] + 100.0 && r1[3] > r0[3] + 80.0, "dragging the corner resized it: {r0:?} → {r1:?}"); + for _ in 0..60 { + h.step(); + } + let r2 = rect(&mut h); + assert!((r2[2] - r1[2]).abs() < 0.5 && (r2[3] - r1[3]).abs() < 0.5, "the dialog kept its size: {r1:?} → {r2:?}"); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })), "still open"); +} + +/// Adding a file again (dropped on the window, no review) whose photo is in Recently Deleted +/// brings the photo back, selected in All Photos, and says so (issue #706) — it is not +/// skipped as a duplicate; the trash view's menus offer Restore and Delete Permanently, and a +/// re-add of a photo that is in the library selects it in All Photos. +#[test] +fn readding_a_deleted_photo_shows_it_in_recently_deleted() { + let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 16, height: 12, data: vec![[200, 120, 40, 255]; 16 * 12] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let file = dir.join("flower.png"); + std::fs::write(&file, png).unwrap(); + let paths = vec![file.to_string_lossy().to_string()]; + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + // frames until the import has finished, so its (timed) toast is read before it expires + let finish_import = |h: &mut Headless| { + let t0 = std::time::Instant::now(); + while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.app.import.is_none(), "import finished"); + }; + crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); + h.settle(SETTLE); + let id = h.app.session.selection.active.expect("imported photo selected"); + let n0 = h.app.session.catalog.len(); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); + assert!(h.app.session.catalog.photo(id).unwrap().deleted); + let photo_items = |app: &LightcraftApp| -> Vec { + let bar = crate::menubar::menu_bar(app); + let items = &bar.iter().find(|(title, _)| title == "Photo").expect("Photo menu").1; + items.iter().filter_map(|n| if let crate::menubar::MenuNode::Item { id, .. } = n { Some(id.clone()) } else { None }).collect() + }; + assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos"); + + crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); + finish_import(&mut h); + assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "re-adding the file restores the photo"); + assert_eq!(h.app.session.catalog.len(), n0, "no second record"); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.selection.ids, vec![id], "the restored photo is selected"); + let toast = h.app.ui.toast.clone().expect("toast").0; + assert!(toast.contains("Restored 1 photo from Recently Deleted"), "{toast}"); + assert!(!photo_items(&h.app).contains(&"photo.restore".to_string())); + // undo of the re-add puts it back in Recently Deleted; the trash view's menus offer Restore + h.request("engine.execute", json!({"command": "edit.undo"}), t); + assert!(h.app.session.catalog.photo(id).unwrap().deleted, "one undo step"); + h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "recentlyDeleted"}}), t); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); + let items = photo_items(&h.app); + assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}"); + assert!(!items.contains(&"photo.delete".to_string()), "{items:?}"); + // a right-click on its filmstrip thumbnail opens the photo menu + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + let cell = h.app.widgets.iter().find(|(w, _)| *w == format!("film:{}", id.0)).map(|(_, r)| *r).expect("filmstrip cell"); + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.click", json!({"x": cell.center().x, "y": cell.center().y, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu"); + + // Empty Recently Deleted is offered while the trash is the source + assert!(photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string())); + let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored"); + crate::import::start_paths(&mut h.app, paths).unwrap(); + finish_import(&mut h); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.selection.ids, vec![id]); + let toast = h.app.ui.toast.clone().expect("toast").0; + assert!(toast.contains("All Photos"), "{toast}"); + assert!(!photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string()), "Empty is for the trash view only"); + + // deleted permanently, the file imports afresh as a new photo + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); + h.request("engine.execute", json!({"command": "photo.deletePermanently", "params": {"ids": [id.0]}}), t); + assert!(h.app.session.catalog.photo(id).is_none()); + crate::import::start_paths(&mut h.app, vec![file.to_string_lossy().to_string()]).unwrap(); + h.settle(SETTLE); + let fresh = h.app.session.selection.active.expect("re-imported photo selected"); + assert_ne!(fresh, id); + assert!(!h.app.session.catalog.photo(fresh).unwrap().deleted); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Importing while an album is shown (issue #1446): the new photos aren't in it, so the view +/// switches to All Photos with the first of them active, instead of an active photo the grid +/// and the filmstrip don't show. +#[test] +fn an_import_from_an_album_view_shows_the_first_new_photo() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-show-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..3u8 { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 40, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("IMG_{i:02}.png")), png).unwrap(); + } + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let album = h.app.session.catalog.albums().find(|a| !a.folder && !a.is_smart()).map(|a| a.id.0).expect("a demo album"); + let r = h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "album", "id": album}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let before: Vec = h.app.session.catalog.photos().map(|p| p.id).collect(); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.import.is_none(), "finished"); + let active = h.app.session.selection.active.expect("a photo is active"); + assert!(!before.contains(&active), "the active photo is a new one: {active:?}"); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert!(h.app.session.visible_cloned().contains(&active), "the grid shows the active photo"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across +/// the import's batches. +#[test] +fn import_copy_renames_into_destination() { + let base = std::env::temp_dir().join(format!("lc-ui-import-copy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (src, dest) = (base.join("card"), base.join("out")); + std::fs::create_dir_all(&src).unwrap(); + // more files than a batch holds (see `batch_size`) + for i in 0..20u8 { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 12, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap(); + } + let dest_s = dest.to_string_lossy().to_string(); + let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.candidates.len(), 20); + for id in ["button:importCopy", "button:importDest"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + } + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + assert!(opts.copy); + assert_eq!(opts.destination, dest.to_string_lossy(), "Choose… sets the folder"); + opts.organize = "flat".into(); + opts.rename = "Trip-{seq:2}".into(); + } else { + panic!("no import dialog"); + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.import.is_none(), "finished"); + let mut names: Vec = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + names.sort(); + let want: Vec = (1..=20).map(|i| format!("Trip-{i:02}.png")).collect(); + assert_eq!(names, want, "numbered across batches of {}", lightcraft_engine::import::batch_size()); + let _ = std::fs::remove_dir_all(&base); +} + +/// Import review, Copy, Folders → Custom template…: the default `{date:%Y}/{date:%Y%m%d}` +/// files copies under `2026/20260114/`, the example line shows the full destination, and a +/// template that would climb out of the destination is refused. +#[test] +fn import_copy_into_a_custom_folder_template() { + let base = std::env::temp_dir().join(format!("lc-ui-import-tpl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (src, dest) = (base.join("card"), base.join("out")); + std::fs::create_dir_all(&src).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(src.join("IMG_01.png"), png).unwrap(); + // undated files are filed by the file's date (2026-01-14T05:58:48) + std::fs::File::options() + .write(true) + .open(src.join("IMG_01.png")) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) + .unwrap(); + let dest_s = dest.to_string_lossy().to_string(); + let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); + let mut app = LightcraftApp::new(session, services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + for id in ["button:importCopy", "button:importDest"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + } + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + opts.organize = "custom".into(); + opts.folder_template = "../{date:%Y}".into(); + } + h.step(); + // refused: the dialog stays open, nothing is imported + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_ne!(r["ok"], true, "{r}"); + assert!(h.app.import.is_none()); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("dialog closed") }; + opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into(); + let opts = opts.clone(); + let sep = std::path::MAIN_SEPARATOR; + let example = crate::import::example_destination(&h.app, &opts).expect("example"); + assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}IMG_01.png", dest.to_string_lossy())); + h.step(); + let r = h.request("ui.widgets", json!({}), t); + assert!(r.to_string().contains("label:importExample"), "example shown"); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(dest.join("2026").join("20260114").join("IMG_01.png").is_file()); + let _ = std::fs::remove_dir_all(&base); +} + +/// Import review, Move: an explicit choice with its explanation, the confirm button says +/// Move; the files land in the folder template, renamed, and leave the card. +#[test] +fn import_move_into_a_folder_template() { + let base = std::env::temp_dir().join(format!("lc-ui-import-move-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (src, dest) = (base.join("card"), base.join("Photos")); + std::fs::create_dir_all(&src).unwrap(); + for i in 0..2u8 { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40 + i, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(src.join(format!("IMG_0{i}.png")), png).unwrap(); + std::fs::File::options() + .write(true) + .open(src.join(format!("IMG_0{i}.png"))) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) + .unwrap(); + } + let dest_s = dest.to_string_lossy().to_string(); + let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); + let mut app = LightcraftApp::new(session, services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + for id in ["button:importMove", "button:importDest"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + } + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import dialog") }; + assert!(opts.copy && opts.move_files, "Move chosen"); + opts.organize = "custom".into(); + opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into(); + opts.rename = "{date:%Y%m%d}_{seq:3}".into(); + let opts = opts.clone(); + let sep = std::path::MAIN_SEPARATOR; + let example = crate::import::example_destination(&h.app, &opts).expect("example"); + assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}20260114_001.png", dest.to_string_lossy())); + h.step(); + let r = h.request("ui.widgets", json!({}), t); + assert!(r.to_string().contains("label:importModeHelp"), "the Move explanation is shown"); + assert!(!r.to_string().contains("check:importDng"), "no Copy as DNG when moving"); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.import.is_none(), "finished"); + let day = dest.join("2026").join("20260114"); + assert!(day.join("20260114_001.png").is_file() && day.join("20260114_002.png").is_file()); + assert_eq!(std::fs::read_dir(&src).unwrap().count(), 0, "moved off the card"); + let _ = std::fs::remove_dir_all(&base); +} + +/// Import review, Copy: Tags beside the Rename field lists the template tags; clicking one +/// inserts it at the text cursor (not appended, not replacing the template). +#[test] +fn import_rename_tags_insert_at_the_cursor() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-tags-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join("IMG_0007.png"), png).unwrap(); + let lib = dir.join("lib"); + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + session.open_library(&lib, false).unwrap(); + let mut app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + let t = Duration::from_secs(10); + let r = + h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "button:importCopy"}), t); + assert_eq!(r["ok"], true, "{r}"); + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + opts.rename = "Trip-_x".into(); + } + // Paint the edited template before locating the Tags button in the resized dialog. + assert!(h.settle(SETTLE), "Copy dialog layout should settle before locating Tags"); + // the cursor sits after "Trip-" + let id = egui::Id::unique("import-rename"); + let mut st = egui::text_edit::TextEditState::default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(5)))); + st.store(&h.view.ctx, id); + let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTags"}), t); + assert_eq!(r["ok"], true, "{r}"); + let seq3 = lightcraft_engine::rename::TOKENS.iter().position(|x| x.tag == "{seq:3}").unwrap(); + let r = h.request("ui.clickWidget", json!({"id": format!("button:importRenameTag-{seq3}")}), t); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; + assert_eq!(opts.rename, "Trip-{seq:3}_x", "inserted at the cursor"); + // a second tag goes after the first (the cursor moved past it) + let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTag-0"}), t); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; + assert_eq!(opts.rename, "Trip-{seq:3}{name}_x"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Help ▸ About: the About, Contributors and Models tabs switch and paint (credits are +/// compiled in). +#[test] +fn about_dialog_tabs_show_the_credits() { + // an empty library: the dialog needs no photos, and no decodes compete with other tests + let services = crate::Services { png: None, ..Default::default() }; + let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::new(), services), [1300.0, 820.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("ui.menu.invoke", json!({"id": "app.about"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About)); + // a new window sizes itself on its first frame: let it settle before clicking its tabs + h.step(); + h.step(); + for (i, (tab, _)) in crate::panels::dialogs::ABOUT_TABS.iter().enumerate().rev() { + let r = h.request("ui.clickWidget", json!({"id": format!("button:aboutTab-{tab}")}), t); + assert_eq!(r["ok"], true, "{tab}: {r}"); + h.step(); + let shown = h.view.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("about_tab"))); + assert_eq!(shown.map(usize::from), Some(i), "{tab}"); + } + h.request("ui.clickWidget", json!({"id": "button:aboutTab-contributors"}), t); + let img = h.snapshot(SETTLE); + assert_eq!(img.size, [1300, 820]); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About), "switching tabs keeps the dialog open"); +} + +/// Settings ▸ Display / `app.displayProfile`: previews are shown through the monitor profile +/// (the loupe rendered for the display, thumbnails converted from sRGB, the histogram still +/// sRGB); a file that can't be used is an error, or, as the setting, reported with previews +/// left sRGB. +#[test] +fn display_profile_converts_previews() { + use crate::render::Slot; + let dir = std::env::temp_dir().join(format!("lightcraft-display-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p3 = dir.join("p3.icc"); + std::fs::write(&p3, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap(); + let bad = dir.join("bad.icc"); + std::fs::write(&bad, b"not a profile").unwrap(); + let (p3, bad) = (p3.to_string_lossy().to_string(), bad.to_string_lossy().to_string()); + + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + let thumb_slot = + |h: &Headless| h.app.renderer.textures.keys().copied().filter(|s| matches!(s, Slot::Thumb(_))).min_by_key(|s| format!("{s:?}")); + let ready = |h: &Headless| { + h.app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.pixels.is_some()) + && thumb_slot(h).is_some() + && !h.app.renderer.is_pending(Slot::Main) + }; + assert!(h.step_until(SETTLE, ready)); + h.settle(SETTLE); + let rgba = |c: &egui::ColorImage| lightcraft_raster::Rgba8 { + width: c.size[0], + height: c.size[1], + data: c.pixels.iter().map(|p| p.to_array()).collect(), + }; + let tex = |h: &Headless, s: Slot| rgba(h.app.renderer.textures.get(&s).and_then(|t| t.pixels.clone()).as_deref().unwrap()); + let thumb = thumb_slot(&h).unwrap(); + let (main0, thumb0) = (tex(&h, Slot::Main), tex(&h, thumb)); + let hist0 = h.app.renderer.textures[&Slot::Main].histogram.clone().unwrap(); + + // the command loads it (and keeps it as the setting); textures are made again + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": p3}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["kind"], "matrix"); + assert_eq!(h.app.ui.settings.display_profile, p3); + let d = h.app.renderer.display().cloned().unwrap(); + assert!(h.step_until(SETTLE, |h| ready(h) && h.app.renderer.textures.contains_key(&thumb))); + h.settle(SETTLE); + let (main1, thumb1) = (tex(&h, Slot::Main), tex(&h, thumb)); + let mut want = thumb0.clone(); + d.profile.from_srgb(&mut want).unwrap(); + let max_diff = |a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8| { + a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] as i32 - q[k] as i32).abs())).max().unwrap_or(0) + }; + assert_eq!((thumb1.width, thumb1.height), (thumb0.width, thumb0.height)); + assert!(max_diff(&thumb1, &want) <= 1, "thumbnails are converted from sRGB: {}", max_diff(&thumb1, &want)); + assert_ne!(thumb1, thumb0); + // the loupe: rendered for the display; colours sRGB holds look as they did + assert_eq!((main1.width, main1.height), (main0.width, main0.height)); + assert_ne!(main1, main0); + let mut main_want = main0.clone(); + d.profile.from_srgb(&mut main_want).unwrap(); + let mean = main1.data.iter().zip(&main_want.data).map(|(p, q)| (0..3).map(|k| (p[k] as f64 - q[k] as f64).abs()).sum::()).sum::() + / (3 * main1.data.len()) as f64; + assert!(mean < 1.5, "{mean}"); + let hist1 = h.app.renderer.textures[&Slot::Main].histogram.clone().unwrap(); + assert_eq!(hist1.total, hist0.total, "the histogram stays sRGB"); + + // a bad file: an error, nothing changes + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": bad}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.renderer.display_id(), Some(d.id())); + assert_eq!(h.app.ui.settings.display_profile, p3); + // …chosen as the setting (Settings ▸ Display), it is reported and previews go back to sRGB + h.app.ui.settings.display_profile = bad.clone(); + h.step(); + assert!(h.app.display_error.as_deref().is_some_and(|e| e.contains("not a valid ICC profile")), "{:?}", h.app.display_error); + assert_eq!(h.app.renderer.display_id(), None); + // the Display tab shows it + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "display"}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + // none: sRGB again + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(r["result"]["path"].is_null()); + assert!(h.app.ui.settings.display_profile.is_empty() && h.app.display_error.is_none()); + assert!(h.step_until(SETTLE, |h| ready(h) && h.app.renderer.textures.contains_key(&thumb))); + h.settle(SETTLE); + assert_eq!(tex(&h, thumb), thumb0, "back to the sRGB thumbnail"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// With no profile chosen, previews follow the profile of the screen showing the window (the +/// host's `screen_profile`: macOS doesn't colour-manage the window, issue #787), read again so +/// a move to another screen is followed. A chosen profile wins; choosing none goes back to the +/// screen's at once; a screen without a profile is sRGB. +#[test] +fn previews_follow_the_screen_profile_while_none_is_chosen() { + use lightcraft_codecs::{NamedSpace, icc::write_named}; + use std::sync::{Arc, Mutex}; + let id = |space| lightcraft_engine::display::Display::from_bytes("", &write_named(space)).unwrap().id(); + let dir = std::env::temp_dir().join(format!("lightcraft-screen-profile-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let chosen = dir.join("prophoto.icc"); + std::fs::write(&chosen, write_named(NamedSpace::ProPhoto)).unwrap(); + let chosen = chosen.to_string_lossy().to_string(); + + let screen = Arc::new(Mutex::new(Some(write_named(NamedSpace::DisplayP3)))); + let mut h = demo([1000.0, 700.0]); + let read = screen.clone(); + h.app.services.screen_profile = Some(Box::new(move || read.lock().unwrap().clone())); + let t = Duration::from_secs(10); + h.step(); + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3)), "the screen's profile"); + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {}}), t); + assert_eq!((r["result"]["screen"].clone(), r["result"]["path"].clone()), (json!(true), Value::Null), "{r}"); + assert!(h.app.ui.settings.display_profile.is_empty(), "nothing is saved as the setting"); + + // the window moves to another screen + *screen.lock().unwrap() = Some(write_named(NamedSpace::AdobeRgb)); + assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id() == Some(id(NamedSpace::AdobeRgb)))); + + // a chosen profile wins over the screen's + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": chosen}}), t); + assert_eq!(r["ok"], true, "{r}"); + *screen.lock().unwrap() = Some(write_named(NamedSpace::DisplayP3)); + for _ in 0..90 { + h.step(); + } + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::ProPhoto))); + + // none chosen again: the screen's profile, at once + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); + assert_eq!(r["result"]["screen"], true, "{r}"); + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3))); + + // a screen without a profile: sRGB + *screen.lock().unwrap() = None; + assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id().is_none())); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through +/// the engine; the delete confirmation guards ⌫. +#[test] +fn settings_dialog_by_keyboard_and_clicks() { + let mut h = demo([1300.0, 820.0]); + let t = Duration::from_secs(10); + let r = h.request("ui.key", json!({"key": ",", "cmd": true}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: "general".into() })); + for tab in ["import", "performance", "display", "interface", "general"] { + let r = h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); + assert_eq!(r["ok"], true, "{tab}: {r}"); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() })); + } + // General: confirm before delete (on by default), startup view + assert!(h.app.ui.settings.confirm_delete); + h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); + assert!(!h.app.ui.settings.confirm_delete); + h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); + assert!(h.app.ui.settings.confirm_delete); + h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t); + assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail); + // Interface: filmstrip names off, grid badges always + h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t); + // Appearance cards (one row per theme, with its favorite star) sit above these controls; + // scroll the compact settings viewport past them. + h.request("ui.hoverWidget", json!({"id": "radio:theme-charcoal"}), t); + h.request("ui.scroll", json!({"dy": -700.0}), t); + for _ in 0..4 { + h.step(); + } + h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t); + assert!(!h.app.ui.settings.film_names); + h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t); + assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always); + h.request("ui.scroll", json!({"dy": 700.0}), t); + for _ in 0..4 { + h.step(); + } + // Performance: the thumbnail cache size goes to the library preferences + h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); + h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); + assert_eq!(h.app.session.cache_mb, 512); + h.request("ui.clickWidget", json!({"id": "button:settingsPreview-3"}), t); + assert_eq!(h.app.ui.settings.preview_limit, 3840); + h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t); + assert_eq!(h.app.ui.settings.memory_mb, 1024); + h.step(); + assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget"); + h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t); + h.step(); + assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic"); + // Import: per-camera defaults + h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t); + h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t); + assert!(h.app.session.import_defaults.per_camera); + let img = h.snapshot(SETTLE); + assert_eq!(img.size, [1300, 820]); + // Escape closes; ⌫ now asks first + h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(h.app.ui.dialog, None); + let active = h.app.session.active().unwrap(); + h.request("ui.key", json!({"key": "delete"}), t); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 })); + assert!(!h.app.session.catalog.photo(active).unwrap().deleted); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.catalog.photo(active).unwrap().deleted); + // app settings survive a save/load round trip of the UI state (ui.json) + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back = serde_json::from_str::(&saved).unwrap().sanitized(); + assert_eq!(back.settings, h.app.ui.settings); + assert_eq!(back.view, crate::state::ViewMode::Detail, "startup view applied on load"); + h.settle(SETTLE); +} + +/// F: full-screen preview (arrows step, I cycles the info overlay, Esc exits); ⌘I cycles the +/// overlay in Detail; ⇧⌘F asks the host for window full screen. +#[test] +fn full_screen_preview_and_info_overlay_by_keyboard() { + use crate::state::InfoOverlay; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0]]}}), t); + h.request("ui.key", json!({"key": "f"}), t); + assert!(h.app.ui.fullscreen); + h.request("ui.key", json!({"key": "right"}), t); + assert_eq!(h.app.session.active().map(|p| p.0), Some(vis[1])); + let right = h.app.ui.right; + h.request("ui.key", json!({"key": "i"}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Basic); + assert_eq!(h.app.ui.right, right, "I doesn't open the Info panel in full screen"); + let img = h.snapshot(SETTLE); + // no chrome: the frame's corners are black + for (x, y) in [(2usize, 2usize), (997, 2), (2, 697), (997, 697)] { + let c = img.pixels[y * 1000 + x]; + assert!(c.r() < 8 && c.g() < 8 && c.b() < 8, "({x},{y}) = {c:?}"); + } + assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay")); + h.request("ui.key", json!({"key": "escape"}), t); + assert!(!h.app.ui.fullscreen); + assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid, "Esc leaves full screen only, back to where it was entered"); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("ui.key", json!({"key": "i", "cmd": true}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Exposure); + h.request("ui.key", json!({"key": "i", "cmd": true}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); + assert!(h.app.window_is_fullscreen); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); + assert!(!h.app.window_is_fullscreen); + h.settle(SETTLE); +} + +/// #911 (toast labels only): cycling the overlay announces each mode in the UI language. +#[test] +fn info_overlay_toasts_are_painted_in_the_users_language() { + use crate::i18n::Locale; + + struct RestoreLanguage(Locale); + impl Drop for RestoreLanguage { + fn drop(&mut self) { + crate::i18n::set_language(self.0); + } + } + let _restore = RestoreLanguage(crate::i18n::language()); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + assert_eq!(h.request("ui.set", json!({"view": "detail", "infoOverlay": "off"}), t)["ok"], true); + for (command, locale) in [("app.language.german", Locale::De), ("app.language.french", Locale::Fr), ("app.language.english", Locale::En)] { + assert_eq!(h.request("engine.execute", json!({"command": command}), t)["ok"], true); + assert_eq!(h.app.ui.language, locale); + assert_eq!(crate::i18n::language(), locale); + for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { + let expected = locale.tr(key); + assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); + assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); + assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); + h.step(); + let painted = h.painted_text(); + assert!(painted.iter().any(|text| text == expected), "{painted:?}"); + } + } +} + +/// ⌘J opens View ▸ View Options; its menus choose Info 1's and Info 2's lines, Use Defaults puts +/// them back, and `view.viewOptions` sets them for an agent (refusing a line it doesn't know). +#[test] +fn view_options_choose_the_info_overlay_lines() { + use crate::state::{LoupeInfo, LoupeInfoField as F}; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ViewOptions)); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "combo:viewOptionsInfo1Line2"}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsInfo1Line2-lens"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.info1, [F::FileName, F::Lens, F::Dimensions]); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsDefaults1"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.info1, LoupeInfo::INFO1); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["camera", "none", "fileName"]}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName]); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["rating", "none", "none"]}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName], "a refused change changes nothing"); +} + +/// View Options' Show Briefly When Photo Changes: a tick box under each Info (ticking one +/// unticks the other) and `view.viewOptions {briefly}`; with the overlay off, the loupe shows +/// the chosen Info after → changes the photo, and hides it again a few seconds later. +#[test] +fn show_briefly_when_photo_changes_in_view_options() { + use crate::state::{BRIEF_INFO_SECS, InfoOverlay}; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly2"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Exposure); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly1"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Basic, "one Info at most"); + h.app.ui.dialog = None; + h.step(); + + let overlay = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay"); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); + h.time += BRIEF_INFO_SECS + 1.0; + h.step(); + h.step(); + assert!(!overlay(&h), "hidden once the time has passed"); + let before = h.app.session.active(); + h.request("ui.key", json!({"key": "right"}), t); + assert_ne!(h.app.session.active(), before); + h.step(); + assert!(overlay(&h), "shown after → changes the photo"); + h.time += BRIEF_INFO_SECS + 1.0; + h.step(); + h.step(); + assert!(!overlay(&h), "and hidden again"); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "off"}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["loupeInfo"]["briefly"], "off", "{r}"); + h.request("ui.key", json!({"key": "right"}), t); + h.step(); + assert!(!overlay(&h), "off: nothing shows when the photo changes"); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "info3"}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Off, "a refused change changes nothing"); +} + +/// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the +/// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. +#[test] +fn click_zoom_ratio_animates() { + use crate::render::Slot; + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.click_zoom, 100, "1:1 by default"); + let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 3}}), t); + assert_eq!(r["result"]["ratio"], 3, "{r}"); + let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 5}}), t); + assert_ne!(r["ok"], true, "{r}"); + let fit = h.app.image_rect.unwrap(); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0)); + assert!(h.app.ui.zoom_anim, "animation started"); + // every animation frame asks for the same (final-size) render + let mut keys = std::collections::HashSet::new(); + for _ in 0..40 { + h.step(); + keys.extend(h.app.renderer.wanted(Slot::Main)); + if !h.app.ui.zoom_anim { + break; + } + } + assert_eq!(keys.len(), 1, "one render for the whole animation: {keys:?}"); + h.settle(SETTLE); + assert!(!h.app.ui.zoom_anim, "animation finished"); + assert!(h.app.image_rect.unwrap().width() > fit.width()); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + // Z zooms to the same ratio as a click + h.request("ui.key", json!({"key": "z"}), t); + assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0)); + h.request("ui.key", json!({"key": "z"}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + h.settle(SETTLE); +} + +/// A hover preview gets the loupe's display fit: it is the size the photo is drawn at, as the +/// loupe render is, not a preview-level render the GPU shrinks. +#[test] +fn a_hover_preview_is_fitted_to_the_drawn_size() { + use crate::render::Slot; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); + let size = |s: Slot| h.app.renderer.textures.get(&s).map(|t| t.size); + let (main, hover) = (size(Slot::Main).expect("loupe render"), size(Slot::Hover).expect("hover render")); + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "the loupe render is fitted: {main:?}"); + assert!(hover[0].abs_diff(main[0]) <= 1 && hover[1].abs_diff(main[1]) <= 1, "hover {hover:?} vs loupe {main:?}"); +} + +/// Zoomed in under a window render, the hidden whole-frame render stays canvas-sized instead of +/// rising to the preview level. +#[test] +fn a_zoomed_view_keeps_its_whole_frame_render_canvas_sized() { + use crate::render::Slot; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0)); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let main = h.app.renderer.textures.get(&Slot::Main).expect("whole-frame render").size; + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "{main:?}"); +} + +#[test] +fn trackpad_navigation_keeps_the_image_cursor_between_events() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), t); + h.request("engine.execute", json!({"command": "view.zoom100"}), t); + h.settle(SETTLE); + let anchor = h.app.canvas_rect.unwrap().center(); + h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t); + for (tool, expected) in [ + ("", egui::CursorIcon::Grab), + ("wbPicker", egui::CursorIcon::Crosshair), + ("colorRange", egui::CursorIcon::Crosshair), + ("pointColor", egui::CursorIcon::Crosshair), + ("tat:curve", egui::CursorIcon::ResizeVertical), + ] { + h.app.ui.tool = tool.into(); + for event in [ + None, + Some(egui::Event::Zoom(1.01)), + None, + Some(egui::Event::Zoom(1.01)), + Some(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(10.0, -10.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + modifiers: egui::Modifiers::NONE, + }), + None, + ] { + let raw = HeadlessView::raw_input(h.size, h.pixels_per_point, h.time, event.into_iter().collect()); + let mut cursor = egui::CursorIcon::Default; + h.view.run(raw, |ui| { + h.app.logic(ui.ctx()); + h.app.ui(ui); + cursor = ui.ctx().output(|output| output.cursor_icon); + }); + h.time += FRAME_DT; + assert_eq!(cursor, expected, "cursor changed between navigation events with tool {tool:?}"); + } + } +} + +#[test] +fn trackpad_pinch_anchors_fractional_zoom_and_scroll_pans() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + h.pixels_per_point = 2.0; + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t); + h.request("engine.execute", json!({"command": "view.zoom100"}), t); + h.settle(SETTLE); + let before = h.app.image_rect.unwrap(); + let anchor = h.app.canvas_rect.unwrap().center() + egui::vec2(30.0, -20.0); + h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t); + let point = (anchor - before.min) / before.size(); + h.request("ui.zoom", json!({"factor": 1.001}), t); + let after = h.app.image_rect.unwrap(); + assert!((after.width() / before.width() - 1.001).abs() < 0.00001); + assert!(((anchor - after.min) / after.size() - point).length() < 0.00001, "point under pointer moved"); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(p) if (p - 100.1).abs() < 0.001), "{:?}", h.app.ui.zoom); + assert!(!h.app.ui.zoom_anim, "pinch follows the fingers immediately"); + + h.request("ui.scroll", json!({"dx": 40.0, "dy": -30.0}), t); + for _ in 0..40 { + h.step(); + } + let panned = h.app.image_rect.unwrap(); + assert!(panned.left() > after.left() && panned.top() < after.top(), "two-finger pan must move both axes"); + h.request("ui.scroll", json!({"dx": 100000.0, "dy": -100000.0}), t); + for _ in 0..40 { + h.step(); + } + let edge = h.app.image_rect.unwrap(); + let area = h.app.canvas_rect.unwrap().shrink(24.0); + assert!((edge.left() - area.left()).abs() < 0.01 && (edge.bottom() - area.bottom()).abs() < 0.01, "{edge:?} vs {area:?}"); + + h.request("ui.zoom", json!({"factor": 1e20}), t); + assert_eq!(h.app.ui.zoom, Zoom::Percent(800.0)); + h.request("ui.zoom", json!({"factor": 0.000001}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + assert_eq!(h.app.ui.pan, (0.5, 0.5)); + // Panel / toolbar gestures must not move the image. + h.request("ui.move", json!({"x": 10, "y": 10}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + h.request("ui.scroll", json!({"dx": 100, "dy": 100}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + assert_eq!(h.app.ui.pan, (0.5, 0.5)); +} + +#[test] +fn mouse_wheel_zooms_the_loupe_with_and_without_ctrl() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t); + h.settle(SETTLE); + for (unit, y, modifiers) in [ + (egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE), + (egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL), + (egui::MouseWheelUnit::Point, 50.0, egui::Modifiers::CTRL), + ] { + h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t); + h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); + h.app.synthetic.push(egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, y), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + modifiers, + }); + for _ in 0..10 { + h.step(); + } + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "wheel {unit:?} {modifiers:?} must zoom in, got {:?}", h.app.ui.zoom); + } +} + +#[test] +fn trackpad_navigation_works_in_tools_compare_reference_and_before_after() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "leftPanel": false, "filmstrip": false}), t); + h.settle(SETTLE); + let active = h.app.session.active().unwrap(); + let settings = h.app.session.catalog.photo(active).unwrap().develop.clone(); + for right in ["crop", "masking", "remove", "redEye", "none"] { + h.request("ui.set", json!({"right": right, "zoom": "fit", "pan": [0.5, 0.5]}), t); + h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch in {right}"); + assert_eq!(h.app.session.catalog.photo(active).unwrap().develop, settings, "navigation must not edit the photo"); + } + h.request("ui.set", json!({"fullscreen": true, "zoom": "fit"}), t); + h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "full-screen pinch"); + + h.request("ui.set", json!({"fullscreen": false, "zoom": "fit", "beforeAfter": "sideBySide"}), t); + let canvas = h.app.canvas_rect.unwrap(); + h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch over the Before pane"); + + h.request("ui.set", json!({"beforeAfter": "off", "zoom": "fit"}), t); + h.request("engine.execute", json!({"command": "view.compare"}), t); + // The left Compare pane must navigate too; zoom/pan are shared with the right pane. + let canvas = h.app.canvas_rect.unwrap(); + h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Compare pinch"); + let pan = h.app.ui.pan; + h.request("ui.scroll", json!({"dx": -50, "dy": -50}), t); + assert_ne!(h.app.ui.pan, pan, "Compare pan"); + + h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t); + h.request("engine.execute", json!({"command": "view.reference"}), t); + h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Reference pinch"); +} + +#[test] +fn navigation_rejects_invalid_input_and_preserves_old_zoom_state() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + assert_eq!(serde_json::from_value::(json!({"percent": 100})).unwrap(), Zoom::Percent(100.0)); + for factor in [json!(0), json!(-1), json!(1e308), json!("big"), Value::Null] { + let r = h.request("ui.zoom", json!({"factor": factor}), t); + assert_eq!(r["ok"], false, "{r}"); + } + for params in [ + json!({"zoom": {"percent": -1}}), + json!({"zoom": {"percent": 1e308}}), + json!({"zoom": "oops"}), + json!({"zoom": {"percent": 123.4}, "pan": [-1, 0.5]}), + json!({"pan": [0.5, 1e308]}), + json!({"pan": [0.5]}), + ] { + assert!(h.app.run("view.navigate", params).is_err()); + assert_eq!(h.app.ui.zoom, Zoom::Fit, "failed request changed zoom"); + assert_eq!(h.app.ui.pan, (0.5, 0.5), "failed request changed pan"); + } + h.app.run("view.navigate", json!({"zoom": {"percent": 123.4}, "pan": [0.4, 0.6]})).unwrap(); + assert_eq!(h.app.ui.zoom, Zoom::Percent(123.4)); + assert_eq!(h.app.ui.pan, (0.4, 0.6)); + h.app.run("view.zoomIn", json!({})).unwrap(); + assert_eq!(h.app.ui.zoom, Zoom::Percent(200.0)); + h.app.run("view.zoomOut", json!({})).unwrap(); + assert_eq!(h.app.ui.zoom, Zoom::Percent(100.0)); + + let area = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(1000.0, 700.0)); + for pan in [(0.0, 0.0), (1.0, 1.0)] { + let image = crate::panels::detail::fit_rect(area, 10.0, Zoom::Percent(100.0), [4000, 400], 1.0, pan); + assert_eq!(image.center().y, area.center().y, "smaller axis must stay centred"); + assert!(image.left() <= area.left() && image.right() >= area.right(), "pan exposed space outside the photo"); + } +} + +/// The Navigator appears when zoomed in; clicking it pans to that point. +#[test] +fn navigator_pans_the_zoomed_loupe() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "hidden at Fit"); + h.request("engine.execute", json!({"command": "view.zoom100"}), t); + h.settle(SETTLE); + assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "navigator shown when zoomed"); + h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t); + let (u, v) = h.app.ui.pan; + assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0), "the click didn't reach the loupe (which would zoom out)"); + h.settle(SETTLE); + let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap(); + let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7); + h.request("ui.dragWidget", json!({"id": "canvas:navigator", "fx": 0.5, "fy": 0.5, "toX": to.x, "toY": to.y}), t); + let (u, v) = h.app.ui.pan; + assert!((u - 0.8).abs() < 0.03 && (v - 0.7).abs() < 0.03, "drag pan {:?}", h.app.ui.pan); + h.request("engine.execute", json!({"command": "view.navigator"}), t); + h.settle(SETTLE); + assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off"); +} + +/// While an edit renders, the loupe shows the last render: it says "Rendering…" once that has +/// lasted a moment (not at once after a spell away from the loupe, when no frames were drawn), +/// and stops when the render lands. +#[test] +fn the_loupe_says_rendering_while_an_edit_renders() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!pill(&h), "a finished render"); + let open = Rc::new(Cell::new(false)); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); + // half a second in the grid: the loupe draws no frames + h.request("ui.set", json!({"view": "photoGrid"}), t); + for _ in 0..30 { + h.step(); + } + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + for _ in 0..12 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held"); + assert!(!pill(&h), "not at once"); + for _ in 0..30 { + h.step(); + } + assert!(pill(&h), "the last render is on screen and the edit's is on its way"); + open.set(true); + assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); + assert!(!h.app.renderer.is_pending(Slot::Main)); +} + +/// View Options' Show message when loading or rendering photos (on by default): turned off in +/// the window, the loupe does not say "Rendering…" while an edit renders; `view.viewOptions +/// {loupeMessages}` turns it back on for an agent (refusing a value that is not true or false). +#[test] +fn view_options_turn_the_rendering_message_off() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(h.app.ui.settings.loupe_messages, "on by default"); + assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.loupeMessages"}), t)["ok"], true); + h.step(); + assert!(!h.app.ui.settings.loupe_messages, "the tick box turns it off"); + h.app.ui.dialog = None; + let open = Rc::new(Cell::new(false)); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + for _ in 0..45 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held"); + assert!(!pill(&h), "no message while it is off"); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": "yes"}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert!(!h.app.ui.settings.loupe_messages, "a refused change changes nothing"); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": true}}), t); + assert_eq!(r["result"]["loupeMessages"], true, "{r}"); + assert_eq!(h.app.ui.dialog, None, "a value sets it without opening the window"); + assert!(h.step_until(SETTLE, pill), "the message shows again while the render is held"); + open.set(true); + assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); +} + +/// With nothing to show yet, the loupe says "Rendering…" in its middle only while View Options' +/// Show message when loading or rendering photos is on. +#[test] +fn an_empty_loupe_says_rendering_only_with_the_message_on() { + use std::{cell::Cell, rc::Rc}; + for on in [true, false] { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.app.ui.settings.loupe_messages = on; + h.app.renderer.set_offload(Box::new(super::RenderGate::new(Rc::new(Cell::new(false))))); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + // drop every texture (quick previews and thumbnails land outside the gate) until a + // frame has nothing to show + let empty = (0..60).any(|_| { + h.app.renderer.textures.clear(); + h.step(); + h.app.loupe_shown.map(|l| l.1) == Some("none") + }); + assert!(empty, "a frame with nothing rendered"); + let said = h.painted_text().iter().any(|text| text == "Rendering…"); + assert_eq!(said, on, "message on: {on}"); + } +} + +/// A render that fails while the loupe shows a stand-in (the embedded JPEG, a thumbnail) left +/// the stand-in up as if it were the photo: the loupe says the photo can't be opened, with the +/// error on hover, and keeps saying so while a library change retries it. +#[test] +fn the_loupe_says_so_when_the_render_behind_a_stand_in_fails() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!has(&h, "loupe:renderFailed")); + let first = h.app.session.active().expect("a photo is open").0; + // every loupe render fails, as an undecodable raw's would; the rest render + let open = Rc::new(Cell::new(true)); + h.app.renderer.set_offload(Box::new(super::RenderGate::failing(open.clone(), |s| s == Slot::Main))); + let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo").0; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other], "active": other}}), t); + assert!(h.step_until(SETTLE, |h| has(h, "loupe:renderFailed")), "the failure is said"); + let shown = h.app.loupe_shown.map(|l| l.1); + assert!(!matches!(shown, Some("render" | "none")), "over a stand-in: {shown:?}"); + assert!(!has(&h, "loupe:rendering")); + let r = h.request("ui.inspect", json!({}), t); + assert_eq!(r["result"]["loupe"]["failed"], "no decoder for this raw", "{}", r["result"]["loupe"]); + // a library change (here a rating) retries it: the failure stays said, no "Rendering…" + open.set(false); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 2}}), t); + for _ in 0..45 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the retry is held"); + assert!(has(&h, "loupe:renderFailed") && !has(&h, "loupe:rendering"), "no blink while it retries"); + // back on a photo that rendered: nothing to say + open.set(true); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first], "active": first}}), t); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); + assert!(h.step_until(SETTLE, |h| h.app.loupe_shown.is_some_and(|l| l.1 == "render") && !has(h, "loupe:renderFailed"))); +} + +#[test] +fn screenshot_request_is_answered_without_a_window() { + let mut h = demo([800.0, 500.0]); + let r = h.request("ui.screenshot", json!({}), SETTLE); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["width"], 800); + assert_eq!(r["result"]["height"], 500); + let r = h.request("ui.resize", json!({"width": 640, "height": 400}), Duration::from_secs(5)); + assert_eq!(r["ok"], true); + let r = h.request("ui.screenshot", json!({"headless": true}), SETTLE); + assert_eq!(r["result"]["width"], 640, "{r}"); +} From bea225af9db8902068b7174c4967251212e99a79 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:04 +0000 Subject: [PATCH 061/191] refactor(lightcraft-ui-egui): move shortcuts.rs tests to crates/ui-egui/src/shortcuts/tests.rs --- crates/ui-egui/src/shortcuts.rs | 718 +------------------------- crates/ui-egui/src/shortcuts/tests.rs | 714 +++++++++++++++++++++++++ 2 files changed, 715 insertions(+), 717 deletions(-) create mode 100644 crates/ui-egui/src/shortcuts/tests.rs diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 0903eedb3..c36bcec98 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -717,723 +717,7 @@ pub(crate) fn library_grid(app: &LightcraftApp) -> bool { } #[cfg(test)] -mod tests { - - fn library() -> crate::headless::Headless { - use lightcraft_catalog::{Op, Photo, PhotoId, Source}; - let mut session = lightcraft_engine::Session::new(); - for id in 1..=4 { - let photo = Photo::new( - PhotoId(id), - Source::File { path: format!("/lightcraft-shortcuts/{id}.jpg") }, - &format!("{id}.jpg"), - "JPEG", - 100, - 100, - "2026-10-08", - ); - session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - } - session.execute("library.sort", &json!({"key": "fileName", "ascending": true})).unwrap(); - session.execute("library.select", &json!({"ids": [1]})).unwrap(); - let app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); - let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0); - h.app.ui.view = crate::state::ViewMode::PhotoGrid; - for _ in 0..3 { - h.step(); - } - h - } - - fn key(h: &mut crate::headless::Headless, key: &str, shift: bool) { - let r = h.request("ui.key", json!({"key": key, "shift": shift}), std::time::Duration::from_secs(20)); - assert_eq!(r["ok"], true, "{r}"); - } - - /// Press the default Select Only Active Photo chord (the command key on every platform). - fn select_only_active_chord(h: &mut crate::headless::Headless) { - let r = h.request("ui.key", json!({"key": "D", "cmd": true, "shift": true}), std::time::Duration::from_secs(20)); - assert_eq!(r["ok"], true, "{r}"); - } - - #[test] - fn select_only_active_keeps_the_active_photo_and_the_edit_undo_step() { - use crate::state::ViewMode; - use lightcraft_catalog::PhotoId; - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail] { - let mut h = library(); - h.app.ui.view = view; - h.app.session.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2})).unwrap(); - h.app.session.execute("photo.rate", &json!({"rating": 4})).unwrap(); - select_only_active_chord(&mut h); - assert_eq!(h.app.session.selection.ids, vec![PhotoId(2)]); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - // Narrowing the selection does not replace the last photo-edit undo step. - h.app.session.execute("edit.undo", &json!({})).unwrap(); - for id in 1..=3 { - assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, 0); - } - } - } - - #[test] - fn select_only_active_yields_to_a_focused_text_field() { - use lightcraft_catalog::PhotoId; - let mut h = library(); - h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); - let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20)); - assert_eq!(r["ok"], true, "{r}"); - select_only_active_chord(&mut h); - assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2)]); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - } - - #[test] - fn select_only_active_follows_the_keymap() { - use lightcraft_catalog::PhotoId; - let mut h = library(); - let both = vec![PhotoId(1), PhotoId(2)]; - h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); - assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "library.selectOnlyActive"), Some("Cmd+Shift+D")); - // unbound: the default chord does nothing - super::assign(&mut h.app.ui.settings.keymap, "library.selectOnlyActive", None).unwrap(); - select_only_active_chord(&mut h); - assert_eq!(h.app.session.selection.ids, both); - // rebound: the new key narrows the selection and the old chord still does not - super::assign(&mut h.app.ui.settings.keymap, "library.selectOnlyActive", Some("F9")).unwrap(); - select_only_active_chord(&mut h); - assert_eq!(h.app.session.selection.ids, both); - key(&mut h, "F9", false); - assert_eq!(h.app.session.selection.ids, vec![PhotoId(2)]); - } - - #[test] - fn select_only_active_without_active_photo_does_not_change_selection() { - let mut h = library(); - // Deselect All keeps the active photo selected, so clear the whole selection state instead. - h.app.session.selection = Default::default(); - assert!(!crate::menus::ui_enabled(&h.app, "library.selectOnlyActive")); - assert!(h.app.run("library.selectOnlyActive", json!({})).is_err()); - select_only_active_chord(&mut h); - assert!(h.app.session.selection.ids.is_empty()); - } - - #[test] - fn library_rating_labels_flags_and_undo() { - use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; - for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::SquareGrid] { - let mut h = library(); - h.app.ui.view = view; - h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); - for rating in [1, 2, 3, 4, 5, 0] { - key(&mut h, &rating.to_string(), false); - for id in [1, 2] { - assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, rating); - } - } - for (k, label) in [("6", ColorLabel::Red), ("7", ColorLabel::Yellow), ("8", ColorLabel::Green), ("9", ColorLabel::Blue)] { - key(&mut h, k, false); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, Some(label)); - assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(label)); - } - for (k, flag) in [("P", Flag::Pick), ("X", Flag::Reject), ("U", Flag::None)] { - key(&mut h, k, false); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, flag); - assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, flag); - } - h.app.session.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject); - assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, Flag::Reject); - h.app.session.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); - assert_eq!(h.app.session.active(), Some(PhotoId(1))); - } - } - - /// A saved keymap entry that gives a key to one command (kept from before another command - /// declared that key) wins: the press runs that command alone, and the other command's declared - /// key comes back once the entry is gone. - #[test] - fn a_saved_key_wins_over_another_commands_declared_key() { - use lightcraft_catalog::{Flag, PhotoId}; - let mut h = library(); - h.app.ui.settings.keymap.insert("photo.increaseRating".into(), "P".into()); - assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), None); - assert_eq!(super::conflicts(&h.app.ui.settings.keymap, "view.survey", "P"), vec!["photo.increaseRating"]); - key(&mut h, "P", false); - let photo = h.app.session.catalog.photo(PhotoId(1)).unwrap(); - assert_eq!(photo.rating, 1, "the saved key raised the rating"); - assert_eq!(photo.flag, Flag::None, "Flag as Pick's declared key yielded"); - // with the entry gone, P picks again - h.app.ui.settings.keymap.remove("photo.increaseRating"); - assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), Some("P")); - key(&mut h, "P", false); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Pick); - // a contextual partner's saved key does not silence the other side - h.app.ui.settings.keymap.insert("crop.rotateAspect".into(), "X".into()); - assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.reject"), Some("X")); - // nor does an entry for a command this build doesn't know - h.app.ui.settings.keymap.insert("no.suchCommand".into(), "U".into()); - assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.unflag"), Some("U")); - } - - /// Keymap entries for commands this build doesn't know take no key: an alias, a rating key and - /// a declared shortcut on the same keys keep working. - #[test] - fn junk_keymap_entries_leave_aliases_ratings_and_declared_keys() { - use lightcraft_catalog::{Flag, PhotoId}; - let mut h = library(); - for (id, sc) in [("no.suchCommand", "Shift+X"), ("no.otherCommand", "5"), ("no.thirdCommand", "P")] { - h.app.ui.settings.keymap.insert(id.into(), sc.into()); - } - key(&mut h, "5", false); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 5, "5 still rates"); - key(&mut h, "P", false); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Pick, "P still picks"); - key(&mut h, "X", true); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject, "Shift+X still rejects"); - } - - #[test] - fn classic_f6_and_f7_toggle_the_filmstrip_and_left_panel() { - let mut h = library(); - let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); - key(&mut h, "F6", false); - assert_eq!(h.app.ui.filmstrip, !filmstrip); - assert_eq!(h.app.ui.left_panel, left); - key(&mut h, "F7", false); - assert_eq!(h.app.ui.left_panel, !left); - key(&mut h, "F6", false); - key(&mut h, "F7", false); - assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); - } - - /// Classic's F8 hides the right panel and shows the same one again; the other panels stay. - #[test] - fn classic_f8_hides_the_right_panel_and_brings_the_same_one_back() { - use crate::state::RightPanel; - let mut h = library(); - h.app.ui.view = crate::state::ViewMode::Detail; - h.app.ui.right = RightPanel::Keywords; - h.settle(std::time::Duration::from_secs(10)); - let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); - key(&mut h, "F8", false); - assert_eq!(h.app.ui.right, RightPanel::None); - assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); - key(&mut h, "F8", false); - assert_eq!(h.app.ui.right, RightPanel::Keywords, "the panel F8 hid comes back"); - // a tool ends when F8 hides it; with nothing hidden before, F8 opens Edit - h.app.ui.right = RightPanel::None; - h.app.ui.right_hidden = RightPanel::None; - key(&mut h, "F8", false); - assert_eq!(h.app.ui.right, RightPanel::Edit); - } - - /// F8 on a minimized Tools window opens it again in the mode it had. - #[test] - fn classic_f8_shows_a_minimized_tools_window_and_keeps_its_mode() { - use crate::{docking::Panel, state::RightPanel}; - let mut h = library(); - h.app.ui.view = crate::state::ViewMode::Detail; - h.app.ui.right = RightPanel::Edit; - h.settle(std::time::Duration::from_secs(10)); - h.app.run("ui.dock", json!({"operation": "float", "panel": "tools", "rect": [400, 100, 320, 500]})).unwrap(); - h.app.run("ui.dock", json!({"action": {"SetMinimized": {"panel": "tools", "minimized": true}}})).unwrap(); - h.app.ui.right = RightPanel::Crop; - h.step(); - assert!(!crate::docking::exposed(&h.app, Panel::Tools)); - key(&mut h, "F8", false); - assert_eq!(h.app.ui.right, RightPanel::Crop); - assert!(crate::docking::exposed(&h.app, Panel::Tools), "the window opens"); - } - - #[test] - fn shift_culling_keys_advance_exactly_once() { - use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; - for auto in [false, true] { - for (k, rating, label, flag) in [ - ("0", 0, None, Flag::Pick), - ("5", 5, None, Flag::Pick), - ("6", 3, Some(ColorLabel::Red), Flag::Pick), - ("7", 3, Some(ColorLabel::Yellow), Flag::Pick), - ("8", 3, Some(ColorLabel::Green), Flag::Pick), - ("9", 3, Some(ColorLabel::Blue), Flag::Pick), - ("P", 3, None, Flag::Pick), - ("X", 3, None, Flag::Reject), - ("U", 3, None, Flag::None), - ] { - let mut h = library(); - h.app.ui.auto_advance = auto; - h.app.session.execute("photo.rate", &json!({"rating": 3})).unwrap(); - h.app.session.execute("photo.pick", &json!({})).unwrap(); - key(&mut h, k, true); - let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); - assert_eq!(p.rating, rating, "Shift+{k}"); - assert_eq!(p.label, label, "Shift+{k}"); - assert_eq!(p.flag, flag, "Shift+{k}"); - assert_eq!(h.app.session.active(), Some(PhotoId(2)), "Shift+{k}, auto={auto}"); - } - } - let mut h = library(); - h.app.session.execute("library.select", &json!({"ids": [4]})).unwrap(); - key(&mut h, "6", true); - assert_eq!(h.app.session.active(), Some(PhotoId(4)), "last photo stays selected"); - } - - #[test] - fn shift_pick_preserves_presets_outside_the_library_grids() { - let mut h = library(); - h.app.ui.view = crate::state::ViewMode::SquareGrid; - key(&mut h, "P", true); - assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); - assert!(!h.app.ui.presets, "grid Shift+P must not open Presets"); - assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid); - h.app.ui.view = crate::state::ViewMode::Detail; - key(&mut h, "P", true); - assert!(h.app.ui.presets); - assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); - } - - #[test] - fn shift_label_targets_only_candidate_in_compare() { - use lightcraft_catalog::{ColorLabel, PhotoId}; - let mut h = library(); - h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); - crate::panels::compare::enter_compare(&mut h.app).unwrap(); - key(&mut h, "6", true); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, None); - assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(ColorLabel::Red)); - assert_eq!(h.app.session.active(), Some(PhotoId(3))); - } - - #[test] - fn plain_culling_keys_auto_advance_once() { - use lightcraft_catalog::PhotoId; - for k in ["0", "5", "6", "9", "P", "X", "U"] { - let mut h = library(); - h.app.ui.auto_advance = true; - key(&mut h, k, false); - assert_eq!(h.app.session.active(), Some(PhotoId(2)), "{k}"); - } - } - - #[test] - fn culling_keys_go_on_from_a_photo_that_leaves_the_filter() { - // #2312: the filter shows one-star photos; taking photo 2's star off makes photo 3 active, once, with or - // without Shift and Auto Advance, and → goes on from there instead of from the first photo - use lightcraft_catalog::PhotoId; - let one_star = || { - let mut h = library(); - h.app.session.execute("library.selectAll", &json!({})).unwrap(); - h.app.session.execute("photo.rate", &json!({"rating": 1})).unwrap(); - h.app.session.execute("library.filter", &json!({"rating": 1, "ratingOp": "exactly"})).unwrap(); - h.app.session.execute("library.select", &json!({"ids": [2]})).unwrap(); - h - }; - for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::Detail] { - for (shift, auto) in [(false, false), (true, false), (false, true), (true, true)] { - let mut h = one_star(); - h.app.ui.view = view; - h.app.ui.auto_advance = auto; - key(&mut h, "0", shift); - assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().rating, 0); - assert_eq!(h.app.session.active(), Some(PhotoId(3)), "{view:?} Shift={shift} Auto Advance={auto}"); - key(&mut h, "right", false); - assert_eq!(h.app.session.active(), Some(PhotoId(4)), "{view:?} Shift={shift} Auto Advance={auto}"); - } - } - // the Photo menu with Auto Advance too - let mut h = one_star(); - h.app.ui.auto_advance = true; - crate::menubar::run_item(&mut h.app, "photo.rate", json!({"rating": 0})).unwrap(); - assert_eq!(h.app.session.active(), Some(PhotoId(3))); - } - - #[test] - fn color_label_actions_show_feedback_only_after_success() { - use lightcraft_catalog::PhotoId; - let mut h = library(); - for shift in [false, true] { - for (k, name) in [("6", "Red"), ("7", "Yellow"), ("8", "Green"), ("9", "Blue")] { - h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); - h.app.ui.toast = None; - key(&mut h, k, shift); - assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some(format!("{name} Label").as_str())); - assert_eq!(h.app.ui.toast.as_ref().and_then(|t| t.2), h.app.session.catalog.photo(PhotoId(1)).unwrap().label); - assert_eq!(h.app.session.active(), Some(PhotoId(if shift { 2 } else { 1 }))); - } - } - // Menus and the Info-panel swatches dispatch the same command, including purple/clear. - h.app.run("photo.label", json!({"label": "purple"})).unwrap(); - assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Purple Label")); - h.app.run("photo.label", json!({"label": "none"})).unwrap(); - assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Color label cleared")); - assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.2.is_none()), "clear uses neutral feedback"); - h.app.session.execute("label.setNames", &json!({"names": {"red": "Needs review"}})).unwrap(); - key(&mut h, "6", false); - assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Needs review Label")); - // Native menu callbacks run before logic() catches up to the current frame's clock. - let now = h.view.ctx.input(|i| i.time); - h.app.last_time = now - 10.0; - h.app.run("photo.label", json!({"label": "blue"})).unwrap(); - assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.1 > now), "menu feedback must survive an idle gap"); - h.app.ui.toast = None; - assert!(h.app.run("photo.label", json!({"label": "invalid"})).is_err()); - assert!(h.app.ui.toast.is_none(), "failed actions must not announce success"); - } - - #[test] - fn shifted_number_punctuation_rates_and_advances() { - use lightcraft_catalog::PhotoId; - let mut h = library(); - // A real winit event on a layout with Shift+1 = !, rather than ui.key's logical Num1. - for pressed in [true, false] { - h.app.synthetic.push(egui::Event::Key { - key: Key::Exclamationmark, - physical_key: Some(Key::Num1), - pressed, - repeat: false, - modifiers: Modifiers::SHIFT, - }); - } - h.step(); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 1); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - } - - #[test] - fn library_keys_yield_to_search_and_crop_context() { - use lightcraft_catalog::{Flag, PhotoId}; - let mut h = library(); - let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20)); - assert_eq!(r["ok"], true, "{r}"); - for k in ["5", "6", "P", "X", "U"] { - key(&mut h, k, false); - key(&mut h, k, true); - } - let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); - assert_eq!(p.rating, 0); - assert_eq!(p.label, None); - assert_eq!(p.flag, Flag::None); - assert_eq!(h.app.session.active(), Some(PhotoId(1))); - - let mut h = library(); - h.app.ui.view = crate::state::ViewMode::Detail; - h.app.ui.right = crate::state::RightPanel::Crop; - key(&mut h, "X", false); - assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); - } - - #[test] - fn delete_means_the_backspace_key() { - assert_eq!(parse("Delete"), Some((Modifiers::NONE, Key::Backspace))); - assert_eq!(parse("Cmd+Delete"), Some((Modifiers::COMMAND, Key::Backspace))); - } - use super::*; - - #[test] - fn parses_shortcuts() { - let (m, k) = parse("Cmd+Shift+Z").unwrap(); - assert!(m.command && m.shift && !m.alt); - assert_eq!(k, Key::Z); - assert_eq!(parse("Right").unwrap().1, Key::ArrowRight); - assert_eq!(parse("\\").unwrap().1, Key::Backslash); - // every declared shortcut parses - for (id, _, sc, _) in crate::menus::ui_commands() { - if let Some(sc) = sc { - assert!(parse(sc).is_some(), "{id}: {sc}"); - } - } - for c in lightcraft_engine::command_specs() { - if let Some(sc) = c.shortcut { - assert!(parse(sc).is_some(), "{}: {sc}", c.id); - } - } - } - - #[test] - fn aliases_parse_and_target_existing_commands() { - let ui: Vec<&str> = crate::menus::ui_commands().map(|c| c.0).collect(); - for (sc, id, params) in ALIASES { - assert!(parse(sc).is_some(), "{id}: {sc}"); - assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}"); - assert!(serde_json::from_str::(params).is_ok(), "alias {sc}: bad params"); - } - } - - #[test] - fn key_presses_format_to_shortcuts_that_parse_back() { - for k in Key::ALL { - for m in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::ALT | Modifiers::SHIFT] { - if let Some(sc) = format(m, *k) { - let want = if *k == Key::Delete { Key::Backspace } else { *k }; - assert_eq!(parse(&sc).map(|p| p.1), Some(want), "{sc}"); - } - } - } - assert_eq!(format(Modifiers::COMMAND | Modifiers::SHIFT, Key::K).as_deref(), Some("Cmd+Shift+K")); - assert_eq!(format(Modifiers::NONE, Key::Slash).as_deref(), Some("/")); - assert_eq!(format(Modifiers::NONE, Key::Backspace).as_deref(), Some("Delete")); - } - - #[test] - fn assigning_a_key_moves_it_and_reset_restores_it() { - let mut keymap = Keymap::new(); - assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); - // D belongs to Detail: Survey takes it, Detail loses it - let lost = assign(&mut keymap, "view.survey", Some("D")).unwrap(); - assert_eq!(lost, vec!["view.detail"]); - assert_eq!(shortcut_of(&keymap, "view.survey"), Some("D")); - assert_eq!(shortcut_of(&keymap, "view.detail"), None); - // restoring Detail takes D back - let lost = reset(&mut keymap, "view.detail").unwrap(); - assert_eq!(lost, vec!["view.survey"]); - assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); - reset(&mut keymap, "view.survey").unwrap(); - assert!(keymap.is_empty(), "declared shortcuts aren't stored: {keymap:?}"); - // a command without a declared shortcut can get one, and lose it again - assign(&mut keymap, "view.photoGrid", Some("cmd+shift+1")).unwrap_err(); - assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); - assert_eq!(shortcut_of(&keymap, "view.photoGrid"), Some("Cmd+Shift+1")); - assign(&mut keymap, "view.photoGrid", None).unwrap(); - assert!(keymap.is_empty()); - } - - #[test] - fn bad_assignments_are_errors() { - let mut keymap = Keymap::new(); - assert!(assign(&mut keymap, "no.suchCommand", Some("K")).is_err()); - assert!(assign(&mut keymap, "view.survey", Some("Cmd+Nonsense")).is_err()); - assert!(assign(&mut keymap, "view.survey", Some("Cmd+Q")).is_err()); - assert!(assign(&mut keymap, "view.survey", Some("Cmd+,")).is_err()); - assert!(keymap.is_empty()); - } - - /// Junk in a hand-edited `ui.json` keymap is ignored (the declared shortcut stays), never a panic. - #[test] - fn junk_keymap_entries_mean_no_shortcut() { - let mut keymap = Keymap::new(); - keymap.insert("view.survey".into(), "Hyper+☃".into()); - keymap.insert("no.suchCommand".into(), "K".into()); - keymap.insert("view.detail".into(), "Cmd+SuperLeft".into()); - assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); - assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); - assert!(conflicts(&keymap, "view.compare", "N").contains(&"view.survey")); - assert_eq!(parse("Hyper+K"), None); - } - - /// Contextual partners keep sharing their key when one is reassigned. - #[test] - fn contextual_partners_are_not_conflicts() { - let keymap = Keymap::new(); - assert!(conflicts(&keymap, "photo.reject", "X").is_empty()); - } - - /// No key fires two different actions (a UI command may shadow the engine command it wraps). - #[test] - fn no_conflicting_bindings() { - let mut ui: Vec<((Modifiers, Key), String)> = Vec::new(); - for (id, _, sc, _) in crate::menus::ui_commands() { - if let Some(k) = sc.and_then(parse) { - ui.push((k, id.to_string())); - } - } - for (sc, id, _) in ALIASES { - ui.push((parse(sc).unwrap(), format!("alias {id}"))); - } - let mut engine: Vec<((Modifiers, Key), &str)> = Vec::new(); - for c in lightcraft_engine::command_specs() { - if let Some(k) = c.shortcut.and_then(parse) { - engine.push((k, c.id)); - } - } - for (sc, id, _) in ALIASES { - let k = parse(sc).unwrap(); - assert!(!engine.iter().any(|(k2, _)| *k2 == k), "alias {sc} ({id}) shadows an engine shortcut"); - } - for (i, (k, a)) in ui.iter().enumerate() { - for (k2, b) in &ui[i + 1..] { - let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); - assert!(k != k2 || contextual, "{a} and {b} share a key"); - } - } - for (i, (k, a)) in engine.iter().enumerate() { - for (k2, b) in &engine[i + 1..] { - let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); - assert!(k != k2 || contextual, "{a} and {b} share a key"); - } - } - } - mod issue1771 { - use super::*; - use crate::state::{Dialog, ViewMode}; - use lightcraft_catalog::PhotoId; - - fn grid(view: ViewMode) -> crate::headless::Headless { - let mut h = library(); - h.app.ui.view = view; - h.app.session.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2})).unwrap(); - for _ in 0..3 { - h.step(); - } - h - } - - #[test] - fn issue1771_photo_grid_enter_opens_active_preserving_multiselection() { - opens(ViewMode::PhotoGrid); - } - - #[test] - fn issue1771_square_grid_enter_opens_active_preserving_multiselection() { - opens(ViewMode::SquareGrid); - } - - fn opens(view: ViewMode) { - let mut h = grid(view); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, ViewMode::Detail, "Enter from {view:?}"); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, ViewMode::Detail, "second Enter remains in Detail"); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - } - - #[test] - fn issue1771_no_selection_and_modifier_chords_do_not_open() { - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { - let mut h = grid(view); - for modifiers in [json!({"shift":true}), json!({"cmd":true}), json!({"alt":true}), json!({"ctrl":true})] { - let mut args = modifiers; - args["key"] = json!("Enter"); - assert_eq!(h.request("ui.key", args, std::time::Duration::from_secs(20))["ok"], true); - assert_eq!(h.app.ui.view, view); - } - h.app.session.selection = Default::default(); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view); - assert!(h.app.session.selection.ids.is_empty()); - assert_eq!(h.app.session.active(), None); - } - } - - #[test] - fn issue1771_search_dialog_and_shortcut_recording_keep_enter() { - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { - let mut h = grid(view); - assert_eq!(h.request("ui.clickWidget", json!({"id":"field:search"}), std::time::Duration::from_secs(20))["ok"], true); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view, "focused search"); - h.app.run("app.shortcuts", json!({})).unwrap(); - h.step(); - h.step(); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view, "shortcut dialog"); - assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); - h.app.recording_shortcut = Some("view.survey".into()); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view, "recording owns Enter"); - assert_eq!(super::super::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Enter")); - } - } - - #[test] - fn issue1771_custom_binding_and_native_menu_keep_enter() { - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { - let mut h = grid(view); - h.app.run("app.setShortcut", json!({"id":"view.survey", "shortcut":"Enter"})).unwrap(); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, ViewMode::Survey, "custom binding wins"); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - assert_eq!(h.app.session.selection.ids.len(), 3); - let mut h = grid(view); - h.app.native_shortcuts.insert("Enter".into()); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view, "native menu owns Enter"); - } - } - - #[test] - fn issue1771_popup_keeps_enter() { - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { - let mut h = grid(view); - let ctx = egui::Context::default(); - let mut output = ctx.run_ui( - egui::RawInput { - events: vec![egui::Event::Key { - key: egui::Key::Enter, - physical_key: None, - pressed: true, - repeat: false, - modifiers: egui::Modifiers::NONE, - }], - ..Default::default() - }, - |ui| { - let ctx = ui.ctx(); - egui::Popup::open_id(ctx, egui::Id::unique("issue1771-menu")); - assert!(egui::Popup::is_any_open(ctx)); - super::super::handle(&mut h.app, ctx); - }, - ); - output.textures_delta.clear(); - assert_eq!(h.app.ui.view, view); - } - } - - #[test] - fn issue1771_detail_enter_keeps_active_and_multiselection() { - let mut h = grid(ViewMode::Detail); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, ViewMode::Detail); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); - } - - #[test] - fn issue1771_open_file_menu_keeps_enter() { - for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { - let mut h = grid(view); - assert_eq!(h.request("ui.clickWidget", json!({"id":"menu:File"}), std::time::Duration::from_secs(20))["ok"], true); - h.step(); - assert!(egui::Popup::is_any_open(&h.view.ctx), "File menu open before Enter"); - key(&mut h, "Enter", false); - assert_eq!(h.app.ui.view, view); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - assert_eq!(h.app.session.selection.ids.len(), 3); - } - } - - #[test] - fn issue1771_repeat_event_opens_once_and_detail_keeps_selection() { - let mut h = grid(ViewMode::PhotoGrid); - h.events.push(egui::Event::Key { - key: egui::Key::Enter, - physical_key: None, - pressed: true, - repeat: true, - modifiers: egui::Modifiers::NONE, - }); - h.step(); - h.step(); - assert_eq!(h.app.ui.view, ViewMode::Detail); - assert_eq!(h.app.session.active(), Some(PhotoId(2))); - assert_eq!(h.app.session.selection.ids.len(), 3); - h.events.push(egui::Event::Key { - key: egui::Key::Enter, - physical_key: None, - pressed: true, - repeat: true, - modifiers: egui::Modifiers::NONE, - }); - h.step(); - h.step(); - assert_eq!(h.app.ui.view, ViewMode::Detail); - assert_eq!(h.app.session.selection.ids.len(), 3); - } - } -} +mod tests; #[cfg(test)] #[path = "shortcuts/tests_relative_ratings.rs"] diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs new file mode 100644 index 000000000..1254e8ab3 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -0,0 +1,714 @@ +fn library() -> crate::headless::Headless { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut session = lightcraft_engine::Session::new(); + for id in 1..=4 { + let photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-shortcuts/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 100, + 100, + "2026-10-08", + ); + session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + } + session.execute("library.sort", &json!({"key": "fileName", "ascending": true})).unwrap(); + session.execute("library.select", &json!({"ids": [1]})).unwrap(); + let app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); + let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.view = crate::state::ViewMode::PhotoGrid; + for _ in 0..3 { + h.step(); + } + h +} + +fn key(h: &mut crate::headless::Headless, key: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": key, "shift": shift}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); +} + +/// Press the default Select Only Active Photo chord (the command key on every platform). +fn select_only_active_chord(h: &mut crate::headless::Headless) { + let r = h.request("ui.key", json!({"key": "D", "cmd": true, "shift": true}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); +} + +#[test] +fn select_only_active_keeps_the_active_photo_and_the_edit_undo_step() { + use crate::state::ViewMode; + use lightcraft_catalog::PhotoId; + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail] { + let mut h = library(); + h.app.ui.view = view; + h.app.session.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2})).unwrap(); + h.app.session.execute("photo.rate", &json!({"rating": 4})).unwrap(); + select_only_active_chord(&mut h); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(2)]); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + // Narrowing the selection does not replace the last photo-edit undo step. + h.app.session.execute("edit.undo", &json!({})).unwrap(); + for id in 1..=3 { + assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, 0); + } + } +} + +#[test] +fn select_only_active_yields_to_a_focused_text_field() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); + select_only_active_chord(&mut h); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2)]); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); +} + +#[test] +fn select_only_active_follows_the_keymap() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + let both = vec![PhotoId(1), PhotoId(2)]; + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "library.selectOnlyActive"), Some("Cmd+Shift+D")); + // unbound: the default chord does nothing + super::assign(&mut h.app.ui.settings.keymap, "library.selectOnlyActive", None).unwrap(); + select_only_active_chord(&mut h); + assert_eq!(h.app.session.selection.ids, both); + // rebound: the new key narrows the selection and the old chord still does not + super::assign(&mut h.app.ui.settings.keymap, "library.selectOnlyActive", Some("F9")).unwrap(); + select_only_active_chord(&mut h); + assert_eq!(h.app.session.selection.ids, both); + key(&mut h, "F9", false); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(2)]); +} + +#[test] +fn select_only_active_without_active_photo_does_not_change_selection() { + let mut h = library(); + // Deselect All keeps the active photo selected, so clear the whole selection state instead. + h.app.session.selection = Default::default(); + assert!(!crate::menus::ui_enabled(&h.app, "library.selectOnlyActive")); + assert!(h.app.run("library.selectOnlyActive", json!({})).is_err()); + select_only_active_chord(&mut h); + assert!(h.app.session.selection.ids.is_empty()); +} + +#[test] +fn library_rating_labels_flags_and_undo() { + use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; + for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::SquareGrid] { + let mut h = library(); + h.app.ui.view = view; + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); + for rating in [1, 2, 3, 4, 5, 0] { + key(&mut h, &rating.to_string(), false); + for id in [1, 2] { + assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, rating); + } + } + for (k, label) in [("6", ColorLabel::Red), ("7", ColorLabel::Yellow), ("8", ColorLabel::Green), ("9", ColorLabel::Blue)] { + key(&mut h, k, false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, Some(label)); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(label)); + } + for (k, flag) in [("P", Flag::Pick), ("X", Flag::Reject), ("U", Flag::None)] { + key(&mut h, k, false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, flag); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, flag); + } + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, Flag::Reject); + h.app.session.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); + assert_eq!(h.app.session.active(), Some(PhotoId(1))); + } +} + +/// A saved keymap entry that gives a key to one command (kept from before another command +/// declared that key) wins: the press runs that command alone, and the other command's declared +/// key comes back once the entry is gone. +#[test] +fn a_saved_key_wins_over_another_commands_declared_key() { + use lightcraft_catalog::{Flag, PhotoId}; + let mut h = library(); + h.app.ui.settings.keymap.insert("photo.increaseRating".into(), "P".into()); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), None); + assert_eq!(super::conflicts(&h.app.ui.settings.keymap, "view.survey", "P"), vec!["photo.increaseRating"]); + key(&mut h, "P", false); + let photo = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(photo.rating, 1, "the saved key raised the rating"); + assert_eq!(photo.flag, Flag::None, "Flag as Pick's declared key yielded"); + // with the entry gone, P picks again + h.app.ui.settings.keymap.remove("photo.increaseRating"); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), Some("P")); + key(&mut h, "P", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Pick); + // a contextual partner's saved key does not silence the other side + h.app.ui.settings.keymap.insert("crop.rotateAspect".into(), "X".into()); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.reject"), Some("X")); + // nor does an entry for a command this build doesn't know + h.app.ui.settings.keymap.insert("no.suchCommand".into(), "U".into()); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.unflag"), Some("U")); +} + +/// Keymap entries for commands this build doesn't know take no key: an alias, a rating key and +/// a declared shortcut on the same keys keep working. +#[test] +fn junk_keymap_entries_leave_aliases_ratings_and_declared_keys() { + use lightcraft_catalog::{Flag, PhotoId}; + let mut h = library(); + for (id, sc) in [("no.suchCommand", "Shift+X"), ("no.otherCommand", "5"), ("no.thirdCommand", "P")] { + h.app.ui.settings.keymap.insert(id.into(), sc.into()); + } + key(&mut h, "5", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 5, "5 still rates"); + key(&mut h, "P", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Pick, "P still picks"); + key(&mut h, "X", true); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject, "Shift+X still rejects"); +} + +#[test] +fn classic_f6_and_f7_toggle_the_filmstrip_and_left_panel() { + let mut h = library(); + let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); + key(&mut h, "F6", false); + assert_eq!(h.app.ui.filmstrip, !filmstrip); + assert_eq!(h.app.ui.left_panel, left); + key(&mut h, "F7", false); + assert_eq!(h.app.ui.left_panel, !left); + key(&mut h, "F6", false); + key(&mut h, "F7", false); + assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); +} + +/// Classic's F8 hides the right panel and shows the same one again; the other panels stay. +#[test] +fn classic_f8_hides_the_right_panel_and_brings_the_same_one_back() { + use crate::state::RightPanel; + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = RightPanel::Keywords; + h.settle(std::time::Duration::from_secs(10)); + let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::None); + assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Keywords, "the panel F8 hid comes back"); + // a tool ends when F8 hides it; with nothing hidden before, F8 opens Edit + h.app.ui.right = RightPanel::None; + h.app.ui.right_hidden = RightPanel::None; + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Edit); +} + +/// F8 on a minimized Tools window opens it again in the mode it had. +#[test] +fn classic_f8_shows_a_minimized_tools_window_and_keeps_its_mode() { + use crate::{docking::Panel, state::RightPanel}; + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = RightPanel::Edit; + h.settle(std::time::Duration::from_secs(10)); + h.app.run("ui.dock", json!({"operation": "float", "panel": "tools", "rect": [400, 100, 320, 500]})).unwrap(); + h.app.run("ui.dock", json!({"action": {"SetMinimized": {"panel": "tools", "minimized": true}}})).unwrap(); + h.app.ui.right = RightPanel::Crop; + h.step(); + assert!(!crate::docking::exposed(&h.app, Panel::Tools)); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Crop); + assert!(crate::docking::exposed(&h.app, Panel::Tools), "the window opens"); +} + +#[test] +fn shift_culling_keys_advance_exactly_once() { + use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; + for auto in [false, true] { + for (k, rating, label, flag) in [ + ("0", 0, None, Flag::Pick), + ("5", 5, None, Flag::Pick), + ("6", 3, Some(ColorLabel::Red), Flag::Pick), + ("7", 3, Some(ColorLabel::Yellow), Flag::Pick), + ("8", 3, Some(ColorLabel::Green), Flag::Pick), + ("9", 3, Some(ColorLabel::Blue), Flag::Pick), + ("P", 3, None, Flag::Pick), + ("X", 3, None, Flag::Reject), + ("U", 3, None, Flag::None), + ] { + let mut h = library(); + h.app.ui.auto_advance = auto; + h.app.session.execute("photo.rate", &json!({"rating": 3})).unwrap(); + h.app.session.execute("photo.pick", &json!({})).unwrap(); + key(&mut h, k, true); + let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(p.rating, rating, "Shift+{k}"); + assert_eq!(p.label, label, "Shift+{k}"); + assert_eq!(p.flag, flag, "Shift+{k}"); + assert_eq!(h.app.session.active(), Some(PhotoId(2)), "Shift+{k}, auto={auto}"); + } + } + let mut h = library(); + h.app.session.execute("library.select", &json!({"ids": [4]})).unwrap(); + key(&mut h, "6", true); + assert_eq!(h.app.session.active(), Some(PhotoId(4)), "last photo stays selected"); +} + +#[test] +fn shift_pick_preserves_presets_outside_the_library_grids() { + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::SquareGrid; + key(&mut h, "P", true); + assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); + assert!(!h.app.ui.presets, "grid Shift+P must not open Presets"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid); + h.app.ui.view = crate::state::ViewMode::Detail; + key(&mut h, "P", true); + assert!(h.app.ui.presets); + assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); +} + +#[test] +fn shift_label_targets_only_candidate_in_compare() { + use lightcraft_catalog::{ColorLabel, PhotoId}; + let mut h = library(); + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); + crate::panels::compare::enter_compare(&mut h.app).unwrap(); + key(&mut h, "6", true); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, None); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(ColorLabel::Red)); + assert_eq!(h.app.session.active(), Some(PhotoId(3))); +} + +#[test] +fn plain_culling_keys_auto_advance_once() { + use lightcraft_catalog::PhotoId; + for k in ["0", "5", "6", "9", "P", "X", "U"] { + let mut h = library(); + h.app.ui.auto_advance = true; + key(&mut h, k, false); + assert_eq!(h.app.session.active(), Some(PhotoId(2)), "{k}"); + } +} + +#[test] +fn culling_keys_go_on_from_a_photo_that_leaves_the_filter() { + // #2312: the filter shows one-star photos; taking photo 2's star off makes photo 3 active, once, with or + // without Shift and Auto Advance, and → goes on from there instead of from the first photo + use lightcraft_catalog::PhotoId; + let one_star = || { + let mut h = library(); + h.app.session.execute("library.selectAll", &json!({})).unwrap(); + h.app.session.execute("photo.rate", &json!({"rating": 1})).unwrap(); + h.app.session.execute("library.filter", &json!({"rating": 1, "ratingOp": "exactly"})).unwrap(); + h.app.session.execute("library.select", &json!({"ids": [2]})).unwrap(); + h + }; + for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::Detail] { + for (shift, auto) in [(false, false), (true, false), (false, true), (true, true)] { + let mut h = one_star(); + h.app.ui.view = view; + h.app.ui.auto_advance = auto; + key(&mut h, "0", shift); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().rating, 0); + assert_eq!(h.app.session.active(), Some(PhotoId(3)), "{view:?} Shift={shift} Auto Advance={auto}"); + key(&mut h, "right", false); + assert_eq!(h.app.session.active(), Some(PhotoId(4)), "{view:?} Shift={shift} Auto Advance={auto}"); + } + } + // the Photo menu with Auto Advance too + let mut h = one_star(); + h.app.ui.auto_advance = true; + crate::menubar::run_item(&mut h.app, "photo.rate", json!({"rating": 0})).unwrap(); + assert_eq!(h.app.session.active(), Some(PhotoId(3))); +} + +#[test] +fn color_label_actions_show_feedback_only_after_success() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + for shift in [false, true] { + for (k, name) in [("6", "Red"), ("7", "Yellow"), ("8", "Green"), ("9", "Blue")] { + h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); + h.app.ui.toast = None; + key(&mut h, k, shift); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some(format!("{name} Label").as_str())); + assert_eq!(h.app.ui.toast.as_ref().and_then(|t| t.2), h.app.session.catalog.photo(PhotoId(1)).unwrap().label); + assert_eq!(h.app.session.active(), Some(PhotoId(if shift { 2 } else { 1 }))); + } + } + // Menus and the Info-panel swatches dispatch the same command, including purple/clear. + h.app.run("photo.label", json!({"label": "purple"})).unwrap(); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Purple Label")); + h.app.run("photo.label", json!({"label": "none"})).unwrap(); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Color label cleared")); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.2.is_none()), "clear uses neutral feedback"); + h.app.session.execute("label.setNames", &json!({"names": {"red": "Needs review"}})).unwrap(); + key(&mut h, "6", false); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Needs review Label")); + // Native menu callbacks run before logic() catches up to the current frame's clock. + let now = h.view.ctx.input(|i| i.time); + h.app.last_time = now - 10.0; + h.app.run("photo.label", json!({"label": "blue"})).unwrap(); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.1 > now), "menu feedback must survive an idle gap"); + h.app.ui.toast = None; + assert!(h.app.run("photo.label", json!({"label": "invalid"})).is_err()); + assert!(h.app.ui.toast.is_none(), "failed actions must not announce success"); +} + +#[test] +fn shifted_number_punctuation_rates_and_advances() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + // A real winit event on a layout with Shift+1 = !, rather than ui.key's logical Num1. + for pressed in [true, false] { + h.app.synthetic.push(egui::Event::Key { + key: Key::Exclamationmark, + physical_key: Some(Key::Num1), + pressed, + repeat: false, + modifiers: Modifiers::SHIFT, + }); + } + h.step(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 1); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); +} + +#[test] +fn library_keys_yield_to_search_and_crop_context() { + use lightcraft_catalog::{Flag, PhotoId}; + let mut h = library(); + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); + for k in ["5", "6", "P", "X", "U"] { + key(&mut h, k, false); + key(&mut h, k, true); + } + let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(p.rating, 0); + assert_eq!(p.label, None); + assert_eq!(p.flag, Flag::None); + assert_eq!(h.app.session.active(), Some(PhotoId(1))); + + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = crate::state::RightPanel::Crop; + key(&mut h, "X", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); +} + +#[test] +fn delete_means_the_backspace_key() { + assert_eq!(parse("Delete"), Some((Modifiers::NONE, Key::Backspace))); + assert_eq!(parse("Cmd+Delete"), Some((Modifiers::COMMAND, Key::Backspace))); +} +use super::*; + +#[test] +fn parses_shortcuts() { + let (m, k) = parse("Cmd+Shift+Z").unwrap(); + assert!(m.command && m.shift && !m.alt); + assert_eq!(k, Key::Z); + assert_eq!(parse("Right").unwrap().1, Key::ArrowRight); + assert_eq!(parse("\\").unwrap().1, Key::Backslash); + // every declared shortcut parses + for (id, _, sc, _) in crate::menus::ui_commands() { + if let Some(sc) = sc { + assert!(parse(sc).is_some(), "{id}: {sc}"); + } + } + for c in lightcraft_engine::command_specs() { + if let Some(sc) = c.shortcut { + assert!(parse(sc).is_some(), "{}: {sc}", c.id); + } + } +} + +#[test] +fn aliases_parse_and_target_existing_commands() { + let ui: Vec<&str> = crate::menus::ui_commands().map(|c| c.0).collect(); + for (sc, id, params) in ALIASES { + assert!(parse(sc).is_some(), "{id}: {sc}"); + assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}"); + assert!(serde_json::from_str::(params).is_ok(), "alias {sc}: bad params"); + } +} + +#[test] +fn key_presses_format_to_shortcuts_that_parse_back() { + for k in Key::ALL { + for m in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::ALT | Modifiers::SHIFT] { + if let Some(sc) = format(m, *k) { + let want = if *k == Key::Delete { Key::Backspace } else { *k }; + assert_eq!(parse(&sc).map(|p| p.1), Some(want), "{sc}"); + } + } + } + assert_eq!(format(Modifiers::COMMAND | Modifiers::SHIFT, Key::K).as_deref(), Some("Cmd+Shift+K")); + assert_eq!(format(Modifiers::NONE, Key::Slash).as_deref(), Some("/")); + assert_eq!(format(Modifiers::NONE, Key::Backspace).as_deref(), Some("Delete")); +} + +#[test] +fn assigning_a_key_moves_it_and_reset_restores_it() { + let mut keymap = Keymap::new(); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); + // D belongs to Detail: Survey takes it, Detail loses it + let lost = assign(&mut keymap, "view.survey", Some("D")).unwrap(); + assert_eq!(lost, vec!["view.detail"]); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("D")); + assert_eq!(shortcut_of(&keymap, "view.detail"), None); + // restoring Detail takes D back + let lost = reset(&mut keymap, "view.detail").unwrap(); + assert_eq!(lost, vec!["view.survey"]); + assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); + reset(&mut keymap, "view.survey").unwrap(); + assert!(keymap.is_empty(), "declared shortcuts aren't stored: {keymap:?}"); + // a command without a declared shortcut can get one, and lose it again + assign(&mut keymap, "view.photoGrid", Some("cmd+shift+1")).unwrap_err(); + assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + assert_eq!(shortcut_of(&keymap, "view.photoGrid"), Some("Cmd+Shift+1")); + assign(&mut keymap, "view.photoGrid", None).unwrap(); + assert!(keymap.is_empty()); +} + +#[test] +fn bad_assignments_are_errors() { + let mut keymap = Keymap::new(); + assert!(assign(&mut keymap, "no.suchCommand", Some("K")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+Nonsense")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+Q")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+,")).is_err()); + assert!(keymap.is_empty()); +} + +/// Junk in a hand-edited `ui.json` keymap is ignored (the declared shortcut stays), never a panic. +#[test] +fn junk_keymap_entries_mean_no_shortcut() { + let mut keymap = Keymap::new(); + keymap.insert("view.survey".into(), "Hyper+☃".into()); + keymap.insert("no.suchCommand".into(), "K".into()); + keymap.insert("view.detail".into(), "Cmd+SuperLeft".into()); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); + assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); + assert!(conflicts(&keymap, "view.compare", "N").contains(&"view.survey")); + assert_eq!(parse("Hyper+K"), None); +} + +/// Contextual partners keep sharing their key when one is reassigned. +#[test] +fn contextual_partners_are_not_conflicts() { + let keymap = Keymap::new(); + assert!(conflicts(&keymap, "photo.reject", "X").is_empty()); +} + +/// No key fires two different actions (a UI command may shadow the engine command it wraps). +#[test] +fn no_conflicting_bindings() { + let mut ui: Vec<((Modifiers, Key), String)> = Vec::new(); + for (id, _, sc, _) in crate::menus::ui_commands() { + if let Some(k) = sc.and_then(parse) { + ui.push((k, id.to_string())); + } + } + for (sc, id, _) in ALIASES { + ui.push((parse(sc).unwrap(), format!("alias {id}"))); + } + let mut engine: Vec<((Modifiers, Key), &str)> = Vec::new(); + for c in lightcraft_engine::command_specs() { + if let Some(k) = c.shortcut.and_then(parse) { + engine.push((k, c.id)); + } + } + for (sc, id, _) in ALIASES { + let k = parse(sc).unwrap(); + assert!(!engine.iter().any(|(k2, _)| *k2 == k), "alias {sc} ({id}) shadows an engine shortcut"); + } + for (i, (k, a)) in ui.iter().enumerate() { + for (k2, b) in &ui[i + 1..] { + let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); + assert!(k != k2 || contextual, "{a} and {b} share a key"); + } + } + for (i, (k, a)) in engine.iter().enumerate() { + for (k2, b) in &engine[i + 1..] { + let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); + assert!(k != k2 || contextual, "{a} and {b} share a key"); + } + } +} +mod issue1771 { + use super::*; + use crate::state::{Dialog, ViewMode}; + use lightcraft_catalog::PhotoId; + + fn grid(view: ViewMode) -> crate::headless::Headless { + let mut h = library(); + h.app.ui.view = view; + h.app.session.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2})).unwrap(); + for _ in 0..3 { + h.step(); + } + h + } + + #[test] + fn issue1771_photo_grid_enter_opens_active_preserving_multiselection() { + opens(ViewMode::PhotoGrid); + } + + #[test] + fn issue1771_square_grid_enter_opens_active_preserving_multiselection() { + opens(ViewMode::SquareGrid); + } + + fn opens(view: ViewMode) { + let mut h = grid(view); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail, "Enter from {view:?}"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail, "second Enter remains in Detail"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + } + + #[test] + fn issue1771_no_selection_and_modifier_chords_do_not_open() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + for modifiers in [json!({"shift":true}), json!({"cmd":true}), json!({"alt":true}), json!({"ctrl":true})] { + let mut args = modifiers; + args["key"] = json!("Enter"); + assert_eq!(h.request("ui.key", args, std::time::Duration::from_secs(20))["ok"], true); + assert_eq!(h.app.ui.view, view); + } + h.app.session.selection = Default::default(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view); + assert!(h.app.session.selection.ids.is_empty()); + assert_eq!(h.app.session.active(), None); + } + } + + #[test] + fn issue1771_search_dialog_and_shortcut_recording_keep_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + assert_eq!(h.request("ui.clickWidget", json!({"id":"field:search"}), std::time::Duration::from_secs(20))["ok"], true); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "focused search"); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "shortcut dialog"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + h.app.recording_shortcut = Some("view.survey".into()); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "recording owns Enter"); + assert_eq!(super::super::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Enter")); + } + } + + #[test] + fn issue1771_custom_binding_and_native_menu_keep_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + h.app.run("app.setShortcut", json!({"id":"view.survey", "shortcut":"Enter"})).unwrap(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Survey, "custom binding wins"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + let mut h = grid(view); + h.app.native_shortcuts.insert("Enter".into()); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "native menu owns Enter"); + } + } + + #[test] + fn issue1771_popup_keeps_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| { + let ctx = ui.ctx(); + egui::Popup::open_id(ctx, egui::Id::unique("issue1771-menu")); + assert!(egui::Popup::is_any_open(ctx)); + super::super::handle(&mut h.app, ctx); + }, + ); + output.textures_delta.clear(); + assert_eq!(h.app.ui.view, view); + } + } + + #[test] + fn issue1771_detail_enter_keeps_active_and_multiselection() { + let mut h = grid(ViewMode::Detail); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); + } + + #[test] + fn issue1771_open_file_menu_keeps_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + assert_eq!(h.request("ui.clickWidget", json!({"id":"menu:File"}), std::time::Duration::from_secs(20))["ok"], true); + h.step(); + assert!(egui::Popup::is_any_open(&h.view.ctx), "File menu open before Enter"); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + } + } + + #[test] + fn issue1771_repeat_event_opens_once_and_detail_keeps_selection() { + let mut h = grid(ViewMode::PhotoGrid); + h.events.push(egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + h.events.push(egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.selection.ids.len(), 3); + } +} From fa6b0f565e2e6cb6eb0032a65da3e44a34b7fbd5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:05 +0000 Subject: [PATCH 062/191] refactor(lightcraft-ui-egui): move i18n.rs tests to crates/ui-egui/src/i18n/tests.rs --- crates/ui-egui/src/i18n.rs | 1207 +----------------------------- crates/ui-egui/src/i18n/tests.rs | 1204 +++++++++++++++++++++++++++++ 2 files changed, 1205 insertions(+), 1206 deletions(-) create mode 100644 crates/ui-egui/src/i18n/tests.rs diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9ba1cd5af..8be0b3e8e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -564,1212 +564,7 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_cat } #[cfg(test)] -mod tests { - use super::*; - - /// Each language through its menu command and the control channel's locale tags, saved by its - /// code: built-in labels follow it, user values and ids don't, the window paints its text and - /// the bundled fonts have its letters. - #[test] - fn languages_switch_persist_and_keep_user_values() { - use crate::control::{ControlRequest, Outcome}; - let languages: [(Locale, &str, &[&str], &str); 8] = [ - (Locale::Cs, "czech", &["cs", "cs-CZ", "cs_CZ.UTF-8", "cs-Latn-CZ"], "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“"), - (Locale::Id, "indonesian", &["id", "id-ID", "id_ID.UTF-8", "id-Latn-ID"], ""), - (Locale::Sv, "swedish", &["sv", "sv-SE", "sv_SE.UTF-8", "sv-FI"], "åäöÅÄÖé”’"), - (Locale::Uk, "ukrainian", &["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"], ""), - (Locale::Ka, "georgian", &["ka", "ka-GE", "ka_GE.UTF-8"], ""), - (Locale::It, "italian", &["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"], ""), - (Locale::Fi, "finnish", &["fi", "fi-FI", "fi_FI.UTF-8", "fi-Latn-FI"], ""), - (Locale::Vi, "vietnamese", &["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"], ""), - ]; - for (locale, command, tags, letters) in languages { - let command = format!("app.language.{command}"); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run(&command, serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, locale); - assert_eq!(language(), locale); - assert_eq!(crate::menubar::checked(&app, &command), Some(true)); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), locale.tr("Warm Glow")); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(&format!(r#""language":"{}""#, locale.code())), "{saved}"); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, locale); - let ctx = egui::Context::default(); - for tag in tags { - assert_eq!(Locale::parse_tag(tag), Some(locale), "{tag}"); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), locale); - } - let text = painted_text(&ctx, &mut app, locale); - // (not the menu titles: a language whose titles don't fit shows one Menu button) - assert!(text.contains(locale.tr("Exposure")), "{locale:?}: {text}"); - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in letters.chars() { - assert!(fonts.has_glyph(&font, ch), "{locale:?}: missing glyph {ch}"); - } - } - }); - } - set_language(Locale::En); - } - - #[test] - fn sentence_case_keeps_georgian_lowercase() { - assert_eq!(upper_first("its input is not an image"), "Its input is not an image"); - assert_eq!(upper_first("ფაილი ვერ იკითხება"), "ფაილი ვერ იკითხება"); - assert_eq!(upper_first("ᲥᲐᲠᲗᲣᲚᲘ"), "ᲥᲐᲠᲗᲣᲚᲘ"); - assert_eq!(upper_first(""), ""); - assert!(capitalizes('a') && capitalizes('ж') && !capitalizes('ქ') && !capitalizes('Ⴀ') && !capitalizes('ⴀ')); - } - - #[test] - fn every_language_has_a_name_a_script_and_its_own_code() { - assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); - for language in Locale::ALL { - assert!(!language.name().is_empty(), "{language:?} has a name"); - assert!(!language.script().is_empty(), "{language:?} has a script"); - assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code"); - } - } - - /// Every catalog file is a JSON object of strings, each key once: a malformed file would load as - /// no catalog, and a repeated key silently drops one of its translations. - #[test] - fn catalogs_load() { - let files = catalog_files(); - assert_eq!(files.len(), 2 * (Locale::ALL.len() - 1), "a plain and a formats catalog per translation"); - for path in files { - let repeated = repeated_keys(&path); - assert_eq!(repeated, Ok(Vec::new()), "{}", path.display()); - } - } - - /// `locales/*.json`, sorted. - fn catalog_files() -> Vec { - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - let mut files: Vec = std::fs::read_dir(dir) - .unwrap() - .map(|entry| entry.unwrap().path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "json")) - .collect(); - files.sort(); - files - } - - /// The keys a catalog file names more than once, or why it isn't an object of strings. - fn repeated_keys(path: &std::path::Path) -> Result, String> { - struct Repeated; - impl<'de> serde::de::Visitor<'de> for Repeated { - type Value = Vec; - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("an object of strings") - } - fn visit_map>(self, mut map: A) -> Result, A::Error> { - let (mut seen, mut repeated) = (std::collections::BTreeSet::new(), Vec::new()); - while let Some((key, _)) = map.next_entry::()? { - if !seen.insert(key.clone()) { - repeated.push(key); - } - } - Ok(repeated) - } - } - let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?; - serde::Deserializer::deserialize_map(&mut serde_json::Deserializer::from_str(&text), Repeated).map_err(|error| error.to_string()) - } - - /// A translation carries the placeholders of the message it translates. - #[test] - fn catalogs_agree_on_placeholders() { - let fields = |text: &str| { - let mut out: Vec = Vec::new(); - let mut rest = text; - while let Some(start) = rest.find('{') { - let Some(end) = rest[start..].find('}') else { break }; - out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string()); - rest = &rest[start + end + 1..]; - } - out.sort(); - out - }; - for language in Locale::ALL { - for (key, value) in language.catalog() { - assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}"); - } - } - } - - /// Locale tags are matched leniently, so a system locale reaches a shipped language. - /// The message box for a desktop start that failed (issue #260) names the error and the log - /// file, in the UI language. - #[test] - fn startup_failure_message_names_the_error_and_the_log() { - let (title, text) = startup_failed_message("no adapter", Some("/home/a/.config/lightcraft/logs/lightcraft.log")); - assert_eq!(title, "LightCraft could not start"); - assert!(text.contains("no adapter") && text.ends_with("lightcraft.log"), "{text}"); - assert!(!startup_failed_message("no adapter", None).1.contains("log file")); - } - - #[test] - fn locale_tags_normalize() { - assert_eq!(Locale::parse_tag("en"), Some(Locale::En)); - assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En)); - assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("zh-hant"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja)); - assert_eq!(Locale::parse_tag("pt-br"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("fr"), Some(Locale::Fr)); - assert_eq!(Locale::parse_tag("fr-FR"), Some(Locale::Fr)); - assert_eq!(Locale::parse_tag("fr_FR.UTF-8"), Some(Locale::Fr)); - for tag in ["de", "de-DE", "de_AT.UTF-8", "de-CH"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::De), "{tag}"); - } - for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}"); - } - for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}"); - } - for tag in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Vi), "{tag}"); - } - assert_eq!(Locale::parse_tag("xx"), None); - } - - /// The web host's "Auto" (issue #2085): the browser's `navigator.languages` in order, first - /// shipped language wins, unsupported tags are skipped, an empty or foreign list stays English - /// ([`default_language`] fallback). - #[test] - fn locale_from_tags_takes_the_first_shipped_language() { - assert_eq!(locale_from_tags(&[]), None); - assert_eq!(locale_from_tags(&["xx-XX".into()]), None); - assert_eq!(locale_from_tags(&["it-IT".into()]), Some(Locale::It)); - assert_eq!(locale_from_tags(&["xx".into(), "de-DE".into(), "fr".into()]), Some(Locale::De)); - assert_eq!(locale_from_tags(&["zh-TW".into(), "en".into()]), Some(Locale::ZhHant)); - assert_eq!(locale_from_tags(&["ja_JP.UTF-8".into(), "en".into()]), Some(Locale::Ja)); - assert_eq!(locale_from_tags(&["pt".into(), "pt-BR".into()]), Some(Locale::PtBr)); - } - - #[test] - fn switching_language_translates_and_restores() { - for language in Locale::ALL { - set_language(*language); - for source in ["Exposure", "Settings"] { - assert_eq!(tr(source), language.catalog().get(source).map_or(source, String::as_str), "{language:?}"); - } - // Untranslated and data-like text passes through untouched. - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - assert_eq!(tr("A string no catalog has"), "A string no catalog has"); - } - set_language(Locale::En); - assert_eq!(tr("Exposure"), "Exposure"); - } - - /// Format strings carry their values in every language, and a language without a translation - /// falls back to the English format rather than dropping the values. - #[test] - fn formats_render_in_every_language() { - // Every language's format string is checked by `format!` against the call site's values. - set_language(Locale::En); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); - // A message no catalog has formats its English source. - assert_eq!(tr_format!("{} of {} (no catalog has this)", 1, 2), "1 of 2 (no catalog has this)"); - // Format specs survive translation: precision, sign, and values a language reorders; user - // text (an album name with braces and CJK) and counts are never dropped. - for language in Locale::ALL { - set_language(*language); - let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); - assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); - let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); - assert!(merging.contains("42%"), "{language:?}: {merging}"); - let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); - assert!(delta.contains("+5"), "{language:?}: {delta}"); - let reading = tr_format!("Reading photos… {} of {}", 3, 10); - let (three, ten) = (reading.find('3'), reading.find("10")); - assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); - let name = "Color 1234.56 {title} 日本語"; - let added = tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22); - assert!(added.contains(name) && added.contains("22"), "{language:?}: {added}"); - for n in [0, 1, 2, 5, 22] { - let imported = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); - assert!(imported.contains(&n.to_string()), "{language:?}: {imported}"); - } - // The trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - let denoising = tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4); - assert!(denoising.contains("IMG.CR3") && denoising.contains('5'), "{language:?}: {denoising}"); - } - set_language(Locale::En); - assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); - } - - /// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation - /// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK - /// catalogs are skipped (the same degradation the Japanese UI has always had). - #[test] - fn catalog_characters_are_paintable() { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - for language in Locale::ALL { - let messages = language.catalog(); - if messages.is_empty() { - continue; - } - // The faces this language's script needs; a language the craft-fonts input does not - // cover (a translation added ahead of its font) is reported, not failed. - let mut faces: Vec<&str> = - lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); - faces.dedup(); - if faces.is_empty() { - lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); - continue; - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family.clone()); - let mut missing: Vec = messages - .values() - .flat_map(|message| message.chars()) - .filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch)) - .collect(); - missing.sort_unstable(); - missing.dedup(); - assert!( - missing.is_empty(), - "{} in {family:?} (faces: {faces:?}) lack glyphs: {}", - language.code(), - missing.iter().collect::() - ); - } - }); - } - } - - /// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a - /// translation must not carry a symbol no installed font has (egui's defaults cover the rest). - #[test] - fn interface_symbols_are_paintable() { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - // ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07). - ctx.fonts_mut(|fonts| { - for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] { - let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); - assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font"); - } - }); - } - - /// Symbols a translation must not carry, because the craft-fonts faces of at least one shipped - /// script have no glyph for them and egui's bundled fonts do not either: the menu triangles, - /// and the heavy multiplication X (U+2715) that Noto Sans Georgian lacks — `×` (U+00D7) reads - /// the same and every face has it. - const UNPAINTABLE: [char; 3] = ['▸', '▾', '✕']; - - #[test] - fn translated_catalogs_do_not_use_missing_menu_triangles() { - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.extension().is_none_or(|ext| ext != "json") { - continue; - } - let messages: BTreeMap = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - for (source, text) in messages { - for bad in UNPAINTABLE { - assert!(!text.contains(bad), "{}: {source:?} uses the unpaintable {bad:?}", path.display()); - } - } - } - } - - /// The language menu covers every language, and a language's own command selects it. - #[test] - fn language_commands_cover_every_language() { - let commands = [ - ("app.language.traditionalChinese", Locale::ZhHant), - ("app.language.english", Locale::En), - ("app.language.simplifiedChinese", Locale::ZhHans), - ("app.language.japanese", Locale::Ja), - ("app.language.portuguese", Locale::PtBr), - ("app.language.french", Locale::Fr), - ("app.language.spanish", Locale::Es), - ("app.language.german", Locale::De), - ("app.language.russian", Locale::Ru), - ("app.language.ukrainian", Locale::Uk), - ("app.language.polish", Locale::Pl), - ("app.language.czech", Locale::Cs), - ("app.language.italian", Locale::It), - ("app.language.georgian", Locale::Ka), - ("app.language.swedish", Locale::Sv), - ("app.language.finnish", Locale::Fi), - ("app.language.indonesian", Locale::Id), - ("app.language.vietnamese", Locale::Vi), - ]; - // One command per language, and every command reachable from the menu table. - assert_eq!(commands.len(), Locale::ALL.len()); - for (id, language) in commands { - assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}"); - assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu"); - } - assert_eq!(crate::menus::language_from_command("app.language.klingon"), None); - assert_eq!(crate::menus::language_from_command("view.detail"), None); - } - - #[test] - fn preferences_round_trip_and_old_settings_remain_readable() { - let old: crate::state::UiState = serde_json::from_str("{}").unwrap(); - assert_eq!(old.language, Locale::En); - // Settings written before the language list grew still load. - let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap(); - assert_eq!(legacy.language, Locale::Ja); - let settings = crate::state::UiState { language: Locale::ZhHans, ..old }; - let saved = serde_json::to_string(&settings).unwrap(); - let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Locale::ZhHans); - } - - /// Smart-album choice values read as words in every language (English "Public Domain"), never as - /// rule ids like `publicDomain`; an id the field doesn't know shows as written. A yes/no value - /// reads Yes / No, or the language's contextual entry for it where it has one. - #[test] - fn choice_values_read_as_words_in_every_language() { - use lightcraft_catalog::rules::{FIELDS, Kind, bool_label}; - assert_eq!(choice_text_in(Locale::En, "copyrightStatus", "publicDomain"), "Public Domain"); - assert_eq!((bool_text_in(Locale::En, true), bool_text_in(Locale::En, false)), ("Yes", "No")); - assert_eq!(tr_ctx_in(Locale::En, BOOL_CONTEXT, "Yes"), "Yes"); - for language in Locale::ALL { - assert_eq!(choice_text_in(*language, "copyrightStatus", "someday"), "someday"); - assert_eq!(tr_ctx_in(*language, "nope", "Yes"), language.tr("Yes"), "no contextual entry: the plain message"); - for b in [true, false] { - let contextual = language.catalog().get(&format!("{BOOL_CONTEXT}|{}", bool_label(b))); - assert_eq!(bool_text_in(*language, b), contextual.map_or(language.tr(bool_label(b)), String::as_str)); - } - for (field, _, kind) in FIELDS { - let Kind::Choice(choices) = kind else { continue }; - for (id, _) in *choices { - assert_ne!(choice_text_in(*language, field, id), *id, "{}: {field} {id}", language.code()); - } - } - } - // a contextual entry names a real value (a typo would silently fall back to the plain Yes / No) - let values = [bool_label(true), bool_label(false)]; - for language in Locale::ALL { - for key in language.catalog().keys() { - if let Some(value) = key.strip_prefix(BOOL_CONTEXT).and_then(|k| k.strip_prefix('|')) { - assert!(values.contains(&value), "{}: {key:?} isn't a yes/no value", language.code()); - } - } - } - } - - /// A rule's problem reads as the field's name and the issue, in the editor's language, joined by - /// a full-width colon in Japanese and Chinese; the sidebar's list puts the rule's position first. - #[test] - fn problems_read_in_every_language() { - use lightcraft_catalog::rules::Issue; - let cat = lightcraft_catalog::Catalog::new(); - let problems = |rules: serde_json::Value| { - serde_json::from_value::(serde_json::json!({"rules": rules})).unwrap().check(&cat) - }; - let p = problems(serde_json::json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "title", "op": "contains", "value": ""}])); - assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); - assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); - for language in Locale::ALL { - let colon = if matches!(language.script(), "Jpan" | "Hans" | "Hant") { ":" } else { ": " }; - let text = format!("{}{colon}{}", language.tr("Title"), language.tr(p[0].issue.text())); - assert_eq!(problem_text_in(*language, &p[0]), text); - assert_eq!(problem_line_in(*language, &p[0]), format!("#2 {text}")); - } - // a problem with the album filter itself has no rule position to show - let filter_loop = - lightcraft_catalog::rules::Problem { path: Vec::new(), field: Some("album".into()), issue: Issue::AlbumLoop, message: String::new() }; - assert!(problem_line_in(Locale::En, &filter_loop).starts_with("Album: "), "{}", problem_line_in(Locale::En, &filter_loop)); - let g = problems(serde_json::json!([{"group": {"rules": []}}])); - assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); - } - - #[test] - fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::Uk); - assert!(text.contains(Locale::Uk.tr("My Photos")) && text.contains(Locale::Uk.tr("All Photos")), "{text}"); - for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::Uk); - assert!(text.contains(Locale::Uk.tr(title)), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "ҐґЄєІіЇї’«»" - .chars() - .chain(Locale::Uk.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{0400}'..='\u{04ff}').contains(ch))) - { - assert!(fonts.has_glyph(&font, ch), "missing Ukrainian glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn italian_panels_dialogs_and_letters_paint_with_bundled_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::It); - assert!(text.contains(Locale::It.tr("My Photos")) && text.contains(Locale::It.tr("All Photos")), "{text}"); - for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::It); - assert!(text.contains(Locale::It.tr(title)), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "àèéìòùÀÈÉÌÒÙ’“”«»" - .chars() - .chain(Locale::It.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) - { - assert!(fonts.has_glyph(&font, ch), "missing Italian glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn finnish_panels_dialogs_and_letters_paint_with_bundled_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::Fi); - assert!(text.contains(Locale::Fi.tr("My Photos")) && text.contains(Locale::Fi.tr("All Photos")), "{text}"); - for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::Fi); - assert!(text.contains(Locale::Fi.tr(title)), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "äöåÄÖÅ”–" - .chars() - .chain(Locale::Fi.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) - { - assert!(fonts.has_glyph(&font, ch), "missing Finnish glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn german_switches_via_menu_and_control_and_persists() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.german", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::De); - assert_eq!(language(), Locale::De); - assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true)); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"de""#)); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::De); - let ctx = egui::Context::default(); - for code in ["de-DE", "de_AT.UTF-8", "de-CH"] { - let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::De); - } - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), Locale::De.tr("Warm Glow")); - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - let rules = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }], - }; - let mut catalog = lightcraft_catalog::Catalog::new(); - catalog - .apply(lightcraft_catalog::Op::AddAlbum { album: lightcraft_catalog::Album::new(lightcraft_catalog::AlbumId(4), "Ausgeschlossen") }) - .unwrap(); - // "Color" is a message too, but a rule's free-text value stays as written - let keywords = lightcraft_catalog::rules::field_label("keywords").unwrap_or("keywords"); - assert_eq!(rules_label(&rules, &catalog), format!("{} {} Color", Locale::De.tr(keywords), Locale::De.tr("contains"))); - let album = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { field: "album".into(), op: "isNot".into(), value: serde_json::json!(4) }], - }; - assert!(rules_label(&album, &catalog).ends_with(" “Ausgeschlossen”"), "{}", rules_label(&album, &catalog)); - let rules = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { - field: "copyrightStatus".into(), - op: "is".into(), - value: serde_json::json!("publicDomain"), - }], - }; - let public_domain = format!(" {}", choice_text_in(Locale::De, "copyrightStatus", "publicDomain")); - assert!(rules_label(&rules, &catalog).ends_with(&public_domain), "{}", rules_label(&rules, &catalog)); - for value in [serde_json::json!(false), serde_json::json!("false")] { - let rules = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { field: "edited".into(), op: "is".into(), value }], - }; - let no = format!(" {}", bool_text_in(Locale::De, false)); - assert!(rules_label(&rules, &catalog).ends_with(&no), "{}", rules_label(&rules, &catalog)); - } - let both = lightcraft_catalog::Filter { - label: Some(lightcraft_catalog::ColorLabel::Red), - labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Red], - ..Default::default() - }; - let name = |label| color_label(&app.session.catalog, label); - let (red, blue, green) = - (name(lightcraft_catalog::ColorLabel::Red), name(lightcraft_catalog::ColorLabel::Blue), name(lightcraft_catalog::ColorLabel::Green)); - let (label, and, or) = (tr("Label"), tr("and"), tr("or")); - assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red}")); - let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue], ..both }; - assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} {blue}")); - let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Green], ..both }; - assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} ({blue} {or} {green})")); - let filter = lightcraft_catalog::Filter { - labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], - ..Default::default() - }; - let blue = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Blue); - let red = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Red); - let label = filter_label(&filter, &app.session.catalog); - assert!(label.contains(&red) && label.contains(&blue), "{label}"); - // a label set's own name is the user's - app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap(); - let label = filter_label(&filter, &app.session.catalog); - assert!(label.contains("Color") && !label.contains(&red) && label.contains(&blue), "{label}"); - set_language(Locale::En); - } - - #[test] - fn german_panels_dialogs_and_glyphs_are_painted_without_extra_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains(Locale::De.tr("My Photos")) && text.contains(Locale::De.tr("All Photos")), "{text}"); - app.ui.view = crate::state::ViewMode::People; - let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains(Locale::De.tr("Named People")), "{text}"); - for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains(Locale::De.tr(title)), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "ÄÖÜäöüß„“".chars() { - assert!(fonts.has_glyph(&font, ch), "missing German glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn german_ukrainian_italian_and_finnish_buttons_fit_the_bottom_bar_and_export_dialog() { - fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { - match shape { - egui::epaint::Shape::Text(shape) => { - bounds.push((shape.galley.job.text.clone(), egui::Rect::from_min_size(shape.pos, shape.galley.size()))) - } - egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, bounds)), - _ => {} - } - } - for language in [Locale::De, Locale::Uk, Locale::It, Locale::Fi] { - let ctx = fonts_ctx(&[]); - let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - app.ui.language = language; - let mut bounds = Vec::new(); - for export in [false, true] { - if export { - app.run("dialog.export", serde_json::json!({})).unwrap(); - } - for frame in 0..4 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - bounds.clear(); - for shape in out.shapes { - collect(&shape.shape, &mut bounds); - } - } - let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1; - if export { - let dialog = rect("dialog:window"); - for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] { - assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id)); - } - } else { - let button = rect("button:copySettings"); - let (_, text) = bounds.iter().find(|entry| entry.0 == language.tr("Copy Edit Settings")).unwrap(); - assert!(button.contains_rect(*text), "{language:?} copy caption overflows its button: {text:?} vs {button:?}"); - assert!(!button.intersects(rect("icon:copyGear"))); - } - } - } - set_language(Locale::En); - } - - /// User-named menu items (presets, albums, label sets) are never translated; built-in labels are. - #[test] - fn menu_labels_translate_but_user_names_survive() { - for language in Locale::ALL { - set_language(*language); - assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); - assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); - } - set_language(Locale::En); - } - - /// The text the whole window paints over a few frames in `language`. - fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String { - fn collect(shape: &egui::epaint::Shape, text: &mut String) { - match shape { - egui::epaint::Shape::Text(shape) => { - text.push_str(&shape.galley.job.text); - text.push('\n'); - } - egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), - _ => {} - } - } - app.ui.language = language; - let mut text = String::new(); - for frame in 0..4 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - // This inspects shapes without a renderer; discard texture uploads explicitly. - out.textures_delta.clear(); - text.clear(); - for shape in out.shapes { - collect(&shape.shape, &mut text); - } - } - text - } - - /// Every language is painted by the real window, switching at runtime reinstalls the fonts - /// (their CJK fallback order follows the language), and command ids never change with it. - #[test] - fn every_language_is_painted_and_menu_ids_stay_the_same() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); - let english_ids = ids(&app); - for language in Locale::ALL { - let text = painted_text(&ctx, &mut app, *language); - for label in ["My Photos", "All Photos"] { - assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label)); - } - assert_eq!(app.font_language, *language, "the fonts follow the language"); - assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); - } - set_language(Locale::En); - } - - /// The UI families, after one frame so the font definitions are loaded. - fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(craft)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - ctx - } - - /// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI - /// family, Monospace included. - #[test] - fn craft_fonts_render_cjk_in_the_ui() { - let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; - for (language, sample) in samples { - if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { - lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); - continue; - } - set_language(language); - let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); - ctx.fonts_mut(|fonts| { - for family in - [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] - { - let font = egui::FontId::new(13.0, family); - for ch in sample.chars() { - assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}"); - } - let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE); - let wide = 13.0 * (sample.chars().count() as f32 - 1.0); - assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size()); - } - }); - } - set_language(Locale::En); - } - - /// Built without craft-fonts, the UI (in every language) still installs its fonts and runs; - /// Latin text keeps Inter. - #[test] - fn the_ui_works_without_craft_fonts() { - let ctx = fonts_ctx(&[]); - ctx.fonts_mut(|fonts| { - // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's - // replacement-glyph face, which there is Hack, the face that draws Latin.) - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); - } - }); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - for language in Locale::ALL { - app.ui.language = *language; - for frame in 0..3 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - } - } - set_language(Locale::En); - } - - /// Traditional Chinese through the menu command and the control channel, persisted, with - /// localised headings; ids and user text are untouched. - #[test] - fn traditional_chinese_switches_through_menu_and_control_and_persists() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::ZhHant); - // Applied at once, not on the next frame. - assert_eq!(language(), Locale::ZhHant); - assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), Locale::ZhHant.tr("All Photos")); - assert_eq!(date_group_label("", false), Locale::ZhHant.tr("Unknown Date")); - assert_eq!(display_time("a-user-value"), "a-user-value"); - assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true)); - assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false)); - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] { - assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color"); - } - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), Locale::ZhHant.tr("Color")); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"zh-hant""#), "{saved}"); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::ZhHant); - let ctx = egui::Context::default(); - app.ui.language = Locale::En; - for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] { - let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; - assert_eq!(reply["ok"], true, "{code}"); - assert_eq!(app.ui.language, Locale::ZhHant, "{code}"); - assert_eq!(language(), Locale::ZhHant, "{code} applies at once"); - } - let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; - assert_eq!(reply["ok"], false); - assert_eq!(app.ui.language, Locale::ZhHant); - let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); - let chinese_ids = ids(&app); - app.run("app.language.english", serde_json::json!({})).unwrap(); - assert_eq!(tr("File"), "File"); - assert_eq!(chinese_ids, ids(&app)); - } - - /// Dates follow every language's own patterns (English keeps the catalog's wording); a key that - /// is not a date never panics and never shows a raw pattern. - #[test] - fn date_headings_in_every_language() { - set_language(Locale::En); - assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026"); - assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20"); - assert_eq!(date_group_label("2026-09", true), "September"); - assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05")); - for language in Locale::ALL { - set_language(*language); - if *language != Locale::En { - let day = date_group_label("2026-09-20", false); - assert!(day.contains("20") && day.contains("2026") && day.contains(tr("Sunday")), "{language:?}: {day}"); - assert!(date_group_label("2026-09-20", true).contains("20"), "{language:?}"); - assert!(date_group_label("2026-09", false).contains("2026"), "{language:?}"); - assert!(date_group_label("2026", false).contains("2026"), "{language:?}"); - assert_eq!(display_time("2026-09-20T16:04:05"), format!("{day} 16:04:05"), "{language:?}"); - } - for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] { - assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}"); - assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}"); - } - } - set_language(Locale::En); - } - - /// Stock presets and panel headers are translated; a user preset keeps its name, also when it - /// shares a stock preset's name. - #[test] - fn traditional_chinese_presets_and_panel_headers_are_painted() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.presets = true; - let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone(); - app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin }); - let zh = Locale::ZhHant; - for (panel, title) in [(crate::state::RightPanel::Activity, "History"), (crate::state::RightPanel::Versions, "Versions")] { - app.ui.right = panel; - let text = painted_text(&ctx, &mut app, zh); - assert!(text.contains(zh.tr(title)), "{text}"); - assert!(text.contains(zh.tr("Warm Glow")), "stock preset: {text}"); - assert!(text.contains("Warm Glow"), "user preset: {text}"); - assert!(text.contains(zh.tr("Color")), "stock group: {text}"); - } - assert_eq!(history_label("Exposure", &app.session.presets), zh.tr("Exposure")); - assert!(history_label("Preset: Warm Film", &app.session.presets).ends_with(zh.tr("Warm Film"))); - assert!(history_label("Preset: Warm Glow", &app.session.presets).ends_with("Warm Glow"), "the user's preset shares the name"); - set_language(Locale::En); - } - - #[test] - fn vietnamese_panels_dialogs_and_diacritics_paint_with_bundled_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::Vi); - assert!(text.contains(Locale::Vi.tr("My Photos")) && text.contains(Locale::Vi.tr("All Photos")), "{text}"); - for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::Vi); - assert!(text.contains(Locale::Vi.tr(title)), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "ĂăÂâÊêÔôƠơƯưĐđáàảãạéèẻẽẹíìỉĩịóòỏõọúùủũụýỳỷỹỵ".chars().chain( - Locale::Vi - .catalog() - .values() - .flat_map(|text| text.chars()) - .filter(|ch| ('\u{00c0}'..='\u{024f}').contains(ch) || ('\u{1ea0}'..='\u{1ef9}').contains(ch)), - ) { - assert!(fonts.has_glyph(&font, ch), "missing Vietnamese glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - /// The messages each language lacks, which it shows in English. Never fails: `cargo xtask ci` - /// runs it with `--nocapture`, so the list lands in the gate log. Plain messages are every key - /// some catalog has plus the labels the UI passes to `tr` from tables (commands, controls, rule - /// fields…); formats are every key some `*-formats.json` has plus every `tr_format!` message in - /// the app's source. An empty translation counts as missing. - #[test] - fn translation_gaps_report() { - let notes = whats_new_lines(); - let messages: std::collections::BTreeSet = plain_messages().into_iter().filter(|m| !notes.contains(&m.as_str())).collect(); - let formats = format_messages(); - let quoted = |list: &[&str]| list.iter().map(|m| format!("{m:?}")).collect::>().join(", "); - let mut out = format!( - "Translation gaps, shown in English ({} messages, {} formats, {} What's New lines):\n", - messages.len(), - formats.len(), - notes.len() - ); - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - let catalog = language.catalog(); - let own_formats = formats_catalog(language.code()); - let missing: Vec<&str> = messages.iter().map(String::as_str).filter(|m| !catalog.contains_key(*m)).collect(); - let missing_formats: Vec<&str> = formats.iter().map(String::as_str).filter(|m| !own_formats.contains_key(*m)).collect(); - let missing_notes = notes.iter().filter(|line| !catalog.contains_key(**line)).count(); - out.push_str(&format!( - "{}: {} messages, {} formats, {} What's New lines\n", - language.code(), - missing.len(), - missing_formats.len(), - missing_notes - )); - if !missing.is_empty() { - out.push_str(&format!(" messages: {}\n", quoted(&missing))); - } - if !missing_formats.is_empty() { - out.push_str(&format!(" formats: {}\n", quoted(&missing_formats))); - } - } - println!("{out}"); - } - - /// Every plain message: the keys of every catalog (but contextual entries, which a language adds - /// only where it needs one) and the labels the UI translates from tables. - fn plain_messages() -> std::collections::BTreeSet { - use lightcraft_catalog::rules; - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in rules::FIELDS { - labels.push(label); - labels.extend(rules::ops_for(*kind).iter().map(|(_, label)| *label)); - if let rules::Kind::Choice(choices) = kind { - labels.extend(choices.iter().map(|(_, label)| *label)); - } - } - labels.extend(rules::FIELD_GROUPS.iter().map(|group| group.0)); - labels.extend(rules::Issue::ALL.iter().map(|issue| issue.text())); - labels.extend([rules::bool_label(true), rules::bool_label(false)]); - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - labels.extend(crate::date_picker::WEEKDAY_SHORT); - labels.extend(["Pick a date", "Year", "Month", "Day", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]); - // the album pickers - labels.extend([ - "Choose an album…", - "No albums yet", - "Search albums", - "No albums match", - "This is the album you're editing", - "It tests this album, so testing it back would loop", - "Fix the marked rules to save this album.", - "None", - "New album", - "New album…", - "Imported Photos", - ]); - labels.extend(crate::panels::keyword_list::VARIABLE_LABELS); - let contextual = format!("{BOOL_CONTEXT}|"); - Locale::ALL - .iter() - .flat_map(|language| language.catalog().keys()) - .filter(|key| !key.starts_with(&contextual)) - .cloned() - .chain(labels.into_iter().map(str::to_string)) - .collect() - } - - /// Help ▸ What's New, line by line as the dialog looks each one up. - fn whats_new_lines() -> Vec<&'static str> { - crate::panels::dialogs::WHATS_NEW - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with("# ")) - .map(|line| line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line)) - .collect() - } - - /// `locales/-formats.json` (the catalog `build.rs` compiles into `tr_format!`), without - /// empty translations. - fn formats_catalog(code: &str) -> BTreeMap { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("locales/{code}-formats.json")); - let mut messages: BTreeMap = - std::fs::read_to_string(path).ok().and_then(|text| serde_json::from_str(&text).ok()).unwrap_or_default(); - messages.retain(|_, text| !text.is_empty()); - messages - } - - /// Every format message: the keys of every formats catalog and the message of every - /// `tr_format!` call in `src/` outside its test modules. - fn format_messages() -> std::collections::BTreeSet { - let mut all: std::collections::BTreeSet = Locale::ALL - .iter() - .filter(|language| **language != Locale::En) - .flat_map(|language| formats_catalog(language.code()).into_keys()) - .collect(); - let mut dirs = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - while let Some(dir) = dirs.pop() { - for path in std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()) { - if path.is_dir() { - dirs.push(path); - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(&path).unwrap(); - let app = source - .split( - "#[cfg(test)] -mod ", - ) - .next() - .unwrap_or_default(); - all.extend(tr_format_messages(app)); - } - } - } - all - } - - /// The first argument of each `tr_format!` call in `source`, when it is a string literal. - fn tr_format_messages(source: &str) -> Vec { - // built from parts, so this file's own source holds no call to find - let call = concat!("tr_format", "!("); - let mut messages = Vec::new(); - for (at, _) in source.match_indices(call) { - let Some(body) = source[at + call.len()..].trim_start().strip_prefix('"') else { continue }; - let mut message = String::new(); - let mut chars = body.chars(); - while let Some(ch) = chars.next() { - match ch { - '"' => break, - '\\' => match chars.next() { - Some('n') => message.push('\n'), - Some('t') => message.push('\t'), - Some('u') => { - let hex: String = chars.by_ref().skip(1).take_while(|ch| *ch != '}').collect(); - message.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)); - } - Some(other) => message.push(other), - None => break, - }, - _ => message.push(ch), - } - } - messages.push(message); - } - messages - } - - #[test] - fn tr_format_messages_are_read_from_source() { - let call = concat!("tr_format", "!("); - let source = format!("{call}\"Imported {{}} photo{{}}\", n, s); crate::i18n::{call}\n \"Say \\\"hi\\\" \\u{{2192}}\", x); {call}name)"); - assert_eq!(tr_format_messages(&source), ["Imported {} photo{}", "Say \"hi\" →"]); - } - - /// A drop's notice comes from what `library.import` reported: a photo added, one brought back from Recently - /// Deleted and a file that could not be read are each named; a drop of duplicates only says nothing. - #[test] - fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_duplicates_only() { - set_language(Locale::En); - let dir = std::env::temp_dir().join(format!("lc-drop-notice-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let png = |name: &str, seed: u8| { - let data: Vec<[u8; 4]> = (0..48 * 32).map(|i| [(i % 48 * 5) as u8, (i / 48 * 7) as u8, seed, 255]).collect(); - let img = lightcraft_raster::Rgba8 { width: 48, height: 32, data }; - let bytes = - lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); - std::fs::write(dir.join(name), bytes).unwrap(); - dir.join(name).to_string_lossy().into_owned() - }; - let (trashed, added) = (png("trashed.png", 1), png("new.png", 2)); - let broken = dir.join("broken.jpg"); - std::fs::write(&broken, b"not a jpeg at all").unwrap(); - let mut s = lightcraft_engine::Session::new().with_fs(); - let r = s.execute("library.import", &serde_json::json!({"paths": [trashed]})).unwrap(); - s.execute("photo.delete", &serde_json::json!({"ids": [r["imported"][0]]})).unwrap(); - let report = s.execute("library.import", &serde_json::json!({"paths": [trashed, added, broken]})).unwrap(); - assert_eq!( - dropped_photos_outcome(&report).as_deref(), - Some("Added 1 photo · Restored 1 photo from Recently Deleted · 1 photo not readable"), - "{report}" - ); - let again = s.execute("library.import", &serde_json::json!({"paths": [added]})).unwrap(); - assert_eq!(dropped_photos_outcome(&again), None, "a duplicate only: {again}"); - let _ = std::fs::remove_dir_all(dir); - } - - /// Polish: the menu and control channel select it, dates read day.month.year, and every count - /// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so - /// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some. - #[test] - fn polish_switches_formats_dates_and_reads_every_count() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.polish", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Pl); - assert_eq!(Locale::Pl.name(), "Polski"); - assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true)); - assert_eq!(tr("File"), "Plik"); - assert_eq!(tr("Exposure"), "Ekspozycja"); - assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026"); - assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20"); - assert_eq!(date_group_label("2026-09", false), "09.2026"); - assert_eq!(date_group_label("2026", false), "2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05"); - for n in [0, 1, 2, 3, 4, 5, 12, 21, 22, 25, 111] { - let s = if n == 1 { "" } else { "s" }; - assert_eq!(tr_format!("Imported {} photo{}", n, s), format!("Zaimportowane zdjęcia: {n}")); - for (one, many, noun) in [ - ("photo waiting", "photos waiting", "Oczekujące zdjęcia"), - ("cached picture", "cached pictures", "Obrazy w pamięci podręcznej"), - ("photo has its denoised picture.", "photos have their denoised picture.", "Zdjęcia z odszumioną wersją"), - ] { - assert_eq!(tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })), format!("{noun}: {n}")); - } - assert_eq!(tr_format!("Delete {n} Photos", n = n), format!("Usuń zdjęcia ({n})")); - assert_eq!(tr_format!("{n} preset{}", s, n = n), format!("Ustawienia: {n}")); - } - let name = String::from("Color 1234.56 {title} 日本語"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22")); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Pl); - let ctx = egui::Context::default(); - for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Pl), "{tag}"); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Pl); - } - let text = painted_text(&ctx, &mut app, Locale::Pl); - assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}"); - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ„”".chars() { - assert!(fonts.has_glyph(&font, ch), "missing Polish glyph {ch}"); - } - } - }); - set_language(Locale::En); - } -} +mod tests; #[cfg(test)] mod picker_order_tests { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs new file mode 100644 index 000000000..b83fb910c --- /dev/null +++ b/crates/ui-egui/src/i18n/tests.rs @@ -0,0 +1,1204 @@ +use super::*; + +/// Each language through its menu command and the control channel's locale tags, saved by its +/// code: built-in labels follow it, user values and ids don't, the window paints its text and +/// the bundled fonts have its letters. +#[test] +fn languages_switch_persist_and_keep_user_values() { + use crate::control::{ControlRequest, Outcome}; + let languages: [(Locale, &str, &[&str], &str); 8] = [ + (Locale::Cs, "czech", &["cs", "cs-CZ", "cs_CZ.UTF-8", "cs-Latn-CZ"], "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“"), + (Locale::Id, "indonesian", &["id", "id-ID", "id_ID.UTF-8", "id-Latn-ID"], ""), + (Locale::Sv, "swedish", &["sv", "sv-SE", "sv_SE.UTF-8", "sv-FI"], "åäöÅÄÖé”’"), + (Locale::Uk, "ukrainian", &["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"], ""), + (Locale::Ka, "georgian", &["ka", "ka-GE", "ka_GE.UTF-8"], ""), + (Locale::It, "italian", &["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"], ""), + (Locale::Fi, "finnish", &["fi", "fi-FI", "fi_FI.UTF-8", "fi-Latn-FI"], ""), + (Locale::Vi, "vietnamese", &["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"], ""), + ]; + for (locale, command, tags, letters) in languages { + let command = format!("app.language.{command}"); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run(&command, serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, locale); + assert_eq!(language(), locale); + assert_eq!(crate::menubar::checked(&app, &command), Some(true)); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), locale.tr("Warm Glow")); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(&format!(r#""language":"{}""#, locale.code())), "{saved}"); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, locale); + let ctx = egui::Context::default(); + for tag in tags { + assert_eq!(Locale::parse_tag(tag), Some(locale), "{tag}"); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), locale); + } + let text = painted_text(&ctx, &mut app, locale); + // (not the menu titles: a language whose titles don't fit shows one Menu button) + assert!(text.contains(locale.tr("Exposure")), "{locale:?}: {text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in letters.chars() { + assert!(fonts.has_glyph(&font, ch), "{locale:?}: missing glyph {ch}"); + } + } + }); + } + set_language(Locale::En); +} + +#[test] +fn sentence_case_keeps_georgian_lowercase() { + assert_eq!(upper_first("its input is not an image"), "Its input is not an image"); + assert_eq!(upper_first("ფაილი ვერ იკითხება"), "ფაილი ვერ იკითხება"); + assert_eq!(upper_first("ᲥᲐᲠᲗᲣᲚᲘ"), "ᲥᲐᲠᲗᲣᲚᲘ"); + assert_eq!(upper_first(""), ""); + assert!(capitalizes('a') && capitalizes('ж') && !capitalizes('ქ') && !capitalizes('Ⴀ') && !capitalizes('ⴀ')); +} + +#[test] +fn every_language_has_a_name_a_script_and_its_own_code() { + assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); + for language in Locale::ALL { + assert!(!language.name().is_empty(), "{language:?} has a name"); + assert!(!language.script().is_empty(), "{language:?} has a script"); + assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code"); + } +} + +/// Every catalog file is a JSON object of strings, each key once: a malformed file would load as +/// no catalog, and a repeated key silently drops one of its translations. +#[test] +fn catalogs_load() { + let files = catalog_files(); + assert_eq!(files.len(), 2 * (Locale::ALL.len() - 1), "a plain and a formats catalog per translation"); + for path in files { + let repeated = repeated_keys(&path); + assert_eq!(repeated, Ok(Vec::new()), "{}", path.display()); + } +} + +/// `locales/*.json`, sorted. +fn catalog_files() -> Vec { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + let mut files: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .collect(); + files.sort(); + files +} + +/// The keys a catalog file names more than once, or why it isn't an object of strings. +fn repeated_keys(path: &std::path::Path) -> Result, String> { + struct Repeated; + impl<'de> serde::de::Visitor<'de> for Repeated { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an object of strings") + } + fn visit_map>(self, mut map: A) -> Result, A::Error> { + let (mut seen, mut repeated) = (std::collections::BTreeSet::new(), Vec::new()); + while let Some((key, _)) = map.next_entry::()? { + if !seen.insert(key.clone()) { + repeated.push(key); + } + } + Ok(repeated) + } + } + let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?; + serde::Deserializer::deserialize_map(&mut serde_json::Deserializer::from_str(&text), Repeated).map_err(|error| error.to_string()) +} + +/// A translation carries the placeholders of the message it translates. +#[test] +fn catalogs_agree_on_placeholders() { + let fields = |text: &str| { + let mut out: Vec = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find('{') { + let Some(end) = rest[start..].find('}') else { break }; + out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string()); + rest = &rest[start + end + 1..]; + } + out.sort(); + out + }; + for language in Locale::ALL { + for (key, value) in language.catalog() { + assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}"); + } + } +} + +/// Locale tags are matched leniently, so a system locale reaches a shipped language. +/// The message box for a desktop start that failed (issue #260) names the error and the log +/// file, in the UI language. +#[test] +fn startup_failure_message_names_the_error_and_the_log() { + let (title, text) = startup_failed_message("no adapter", Some("/home/a/.config/lightcraft/logs/lightcraft.log")); + assert_eq!(title, "LightCraft could not start"); + assert!(text.contains("no adapter") && text.ends_with("lightcraft.log"), "{text}"); + assert!(!startup_failed_message("no adapter", None).1.contains("log file")); +} + +#[test] +fn locale_tags_normalize() { + assert_eq!(Locale::parse_tag("en"), Some(Locale::En)); + assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En)); + assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh-hant"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja)); + assert_eq!(Locale::parse_tag("pt-br"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("fr"), Some(Locale::Fr)); + assert_eq!(Locale::parse_tag("fr-FR"), Some(Locale::Fr)); + assert_eq!(Locale::parse_tag("fr_FR.UTF-8"), Some(Locale::Fr)); + for tag in ["de", "de-DE", "de_AT.UTF-8", "de-CH"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::De), "{tag}"); + } + for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}"); + } + for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}"); + } + for tag in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Vi), "{tag}"); + } + assert_eq!(Locale::parse_tag("xx"), None); +} + +/// The web host's "Auto" (issue #2085): the browser's `navigator.languages` in order, first +/// shipped language wins, unsupported tags are skipped, an empty or foreign list stays English +/// ([`default_language`] fallback). +#[test] +fn locale_from_tags_takes_the_first_shipped_language() { + assert_eq!(locale_from_tags(&[]), None); + assert_eq!(locale_from_tags(&["xx-XX".into()]), None); + assert_eq!(locale_from_tags(&["it-IT".into()]), Some(Locale::It)); + assert_eq!(locale_from_tags(&["xx".into(), "de-DE".into(), "fr".into()]), Some(Locale::De)); + assert_eq!(locale_from_tags(&["zh-TW".into(), "en".into()]), Some(Locale::ZhHant)); + assert_eq!(locale_from_tags(&["ja_JP.UTF-8".into(), "en".into()]), Some(Locale::Ja)); + assert_eq!(locale_from_tags(&["pt".into(), "pt-BR".into()]), Some(Locale::PtBr)); +} + +#[test] +fn switching_language_translates_and_restores() { + for language in Locale::ALL { + set_language(*language); + for source in ["Exposure", "Settings"] { + assert_eq!(tr(source), language.catalog().get(source).map_or(source, String::as_str), "{language:?}"); + } + // Untranslated and data-like text passes through untouched. + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + assert_eq!(tr("A string no catalog has"), "A string no catalog has"); + } + set_language(Locale::En); + assert_eq!(tr("Exposure"), "Exposure"); +} + +/// Format strings carry their values in every language, and a language without a translation +/// falls back to the English format rather than dropping the values. +#[test] +fn formats_render_in_every_language() { + // Every language's format string is checked by `format!` against the call site's values. + set_language(Locale::En); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); + // A message no catalog has formats its English source. + assert_eq!(tr_format!("{} of {} (no catalog has this)", 1, 2), "1 of 2 (no catalog has this)"); + // Format specs survive translation: precision, sign, and values a language reorders; user + // text (an album name with braces and CJK) and counts are never dropped. + for language in Locale::ALL { + set_language(*language); + let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); + assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); + let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); + assert!(merging.contains("42%"), "{language:?}: {merging}"); + let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); + assert!(delta.contains("+5"), "{language:?}: {delta}"); + let reading = tr_format!("Reading photos… {} of {}", 3, 10); + let (three, ten) = (reading.find('3'), reading.find("10")); + assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); + let name = "Color 1234.56 {title} 日本語"; + let added = tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22); + assert!(added.contains(name) && added.contains("22"), "{language:?}: {added}"); + for n in [0, 1, 2, 5, 22] { + let imported = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); + assert!(imported.contains(&n.to_string()), "{language:?}: {imported}"); + } + // The trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + let denoising = tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4); + assert!(denoising.contains("IMG.CR3") && denoising.contains('5'), "{language:?}: {denoising}"); + } + set_language(Locale::En); + assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); +} + +/// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation +/// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK +/// catalogs are skipped (the same degradation the Japanese UI has always had). +#[test] +fn catalog_characters_are_paintable() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + for language in Locale::ALL { + let messages = language.catalog(); + if messages.is_empty() { + continue; + } + // The faces this language's script needs; a language the craft-fonts input does not + // cover (a translation added ahead of its font) is reported, not failed. + let mut faces: Vec<&str> = + lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); + faces.dedup(); + if faces.is_empty() { + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); + continue; + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family.clone()); + let mut missing: Vec = messages + .values() + .flat_map(|message| message.chars()) + .filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch)) + .collect(); + missing.sort_unstable(); + missing.dedup(); + assert!( + missing.is_empty(), + "{} in {family:?} (faces: {faces:?}) lack glyphs: {}", + language.code(), + missing.iter().collect::() + ); + } + }); + } +} + +/// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a +/// translation must not carry a symbol no installed font has (egui's defaults cover the rest). +#[test] +fn interface_symbols_are_paintable() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + // ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07). + ctx.fonts_mut(|fonts| { + for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font"); + } + }); +} + +/// Symbols a translation must not carry, because the craft-fonts faces of at least one shipped +/// script have no glyph for them and egui's bundled fonts do not either: the menu triangles, +/// and the heavy multiplication X (U+2715) that Noto Sans Georgian lacks — `×` (U+00D7) reads +/// the same and every face has it. +const UNPAINTABLE: [char; 3] = ['▸', '▾', '✕']; + +#[test] +fn translated_catalogs_do_not_use_missing_menu_triangles() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_none_or(|ext| ext != "json") { + continue; + } + let messages: BTreeMap = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + for (source, text) in messages { + for bad in UNPAINTABLE { + assert!(!text.contains(bad), "{}: {source:?} uses the unpaintable {bad:?}", path.display()); + } + } + } +} + +/// The language menu covers every language, and a language's own command selects it. +#[test] +fn language_commands_cover_every_language() { + let commands = [ + ("app.language.traditionalChinese", Locale::ZhHant), + ("app.language.english", Locale::En), + ("app.language.simplifiedChinese", Locale::ZhHans), + ("app.language.japanese", Locale::Ja), + ("app.language.portuguese", Locale::PtBr), + ("app.language.french", Locale::Fr), + ("app.language.spanish", Locale::Es), + ("app.language.german", Locale::De), + ("app.language.russian", Locale::Ru), + ("app.language.ukrainian", Locale::Uk), + ("app.language.polish", Locale::Pl), + ("app.language.czech", Locale::Cs), + ("app.language.italian", Locale::It), + ("app.language.georgian", Locale::Ka), + ("app.language.swedish", Locale::Sv), + ("app.language.finnish", Locale::Fi), + ("app.language.indonesian", Locale::Id), + ("app.language.vietnamese", Locale::Vi), + ]; + // One command per language, and every command reachable from the menu table. + assert_eq!(commands.len(), Locale::ALL.len()); + for (id, language) in commands { + assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}"); + assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu"); + } + assert_eq!(crate::menus::language_from_command("app.language.klingon"), None); + assert_eq!(crate::menus::language_from_command("view.detail"), None); +} + +#[test] +fn preferences_round_trip_and_old_settings_remain_readable() { + let old: crate::state::UiState = serde_json::from_str("{}").unwrap(); + assert_eq!(old.language, Locale::En); + // Settings written before the language list grew still load. + let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap(); + assert_eq!(legacy.language, Locale::Ja); + let settings = crate::state::UiState { language: Locale::ZhHans, ..old }; + let saved = serde_json::to_string(&settings).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Locale::ZhHans); +} + +/// Smart-album choice values read as words in every language (English "Public Domain"), never as +/// rule ids like `publicDomain`; an id the field doesn't know shows as written. A yes/no value +/// reads Yes / No, or the language's contextual entry for it where it has one. +#[test] +fn choice_values_read_as_words_in_every_language() { + use lightcraft_catalog::rules::{FIELDS, Kind, bool_label}; + assert_eq!(choice_text_in(Locale::En, "copyrightStatus", "publicDomain"), "Public Domain"); + assert_eq!((bool_text_in(Locale::En, true), bool_text_in(Locale::En, false)), ("Yes", "No")); + assert_eq!(tr_ctx_in(Locale::En, BOOL_CONTEXT, "Yes"), "Yes"); + for language in Locale::ALL { + assert_eq!(choice_text_in(*language, "copyrightStatus", "someday"), "someday"); + assert_eq!(tr_ctx_in(*language, "nope", "Yes"), language.tr("Yes"), "no contextual entry: the plain message"); + for b in [true, false] { + let contextual = language.catalog().get(&format!("{BOOL_CONTEXT}|{}", bool_label(b))); + assert_eq!(bool_text_in(*language, b), contextual.map_or(language.tr(bool_label(b)), String::as_str)); + } + for (field, _, kind) in FIELDS { + let Kind::Choice(choices) = kind else { continue }; + for (id, _) in *choices { + assert_ne!(choice_text_in(*language, field, id), *id, "{}: {field} {id}", language.code()); + } + } + } + // a contextual entry names a real value (a typo would silently fall back to the plain Yes / No) + let values = [bool_label(true), bool_label(false)]; + for language in Locale::ALL { + for key in language.catalog().keys() { + if let Some(value) = key.strip_prefix(BOOL_CONTEXT).and_then(|k| k.strip_prefix('|')) { + assert!(values.contains(&value), "{}: {key:?} isn't a yes/no value", language.code()); + } + } + } +} + +/// A rule's problem reads as the field's name and the issue, in the editor's language, joined by +/// a full-width colon in Japanese and Chinese; the sidebar's list puts the rule's position first. +#[test] +fn problems_read_in_every_language() { + use lightcraft_catalog::rules::Issue; + let cat = lightcraft_catalog::Catalog::new(); + let problems = |rules: serde_json::Value| { + serde_json::from_value::(serde_json::json!({"rules": rules})).unwrap().check(&cat) + }; + let p = problems(serde_json::json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "title", "op": "contains", "value": ""}])); + assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); + assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); + for language in Locale::ALL { + let colon = if matches!(language.script(), "Jpan" | "Hans" | "Hant") { ":" } else { ": " }; + let text = format!("{}{colon}{}", language.tr("Title"), language.tr(p[0].issue.text())); + assert_eq!(problem_text_in(*language, &p[0]), text); + assert_eq!(problem_line_in(*language, &p[0]), format!("#2 {text}")); + } + // a problem with the album filter itself has no rule position to show + let filter_loop = + lightcraft_catalog::rules::Problem { path: Vec::new(), field: Some("album".into()), issue: Issue::AlbumLoop, message: String::new() }; + assert!(problem_line_in(Locale::En, &filter_loop).starts_with("Album: "), "{}", problem_line_in(Locale::En, &filter_loop)); + let g = problems(serde_json::json!([{"group": {"rules": []}}])); + assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); +} + +#[test] +fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::Uk); + assert!(text.contains(Locale::Uk.tr("My Photos")) && text.contains(Locale::Uk.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::Uk); + assert!(text.contains(Locale::Uk.tr(title)), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ҐґЄєІіЇї’«»" + .chars() + .chain(Locale::Uk.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{0400}'..='\u{04ff}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Ukrainian glyph {ch}"); + } + } + }); + set_language(Locale::En); +} + +#[test] +fn italian_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains(Locale::It.tr("My Photos")) && text.contains(Locale::It.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains(Locale::It.tr(title)), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "àèéìòùÀÈÉÌÒÙ’“”«»" + .chars() + .chain(Locale::It.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Italian glyph {ch}"); + } + } + }); + set_language(Locale::En); +} + +#[test] +fn finnish_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::Fi); + assert!(text.contains(Locale::Fi.tr("My Photos")) && text.contains(Locale::Fi.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::Fi); + assert!(text.contains(Locale::Fi.tr(title)), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "äöåÄÖÅ”–" + .chars() + .chain(Locale::Fi.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Finnish glyph {ch}"); + } + } + }); + set_language(Locale::En); +} + +#[test] +fn german_switches_via_menu_and_control_and_persists() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.german", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::De); + assert_eq!(language(), Locale::De); + assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true)); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"de""#)); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::De); + let ctx = egui::Context::default(); + for code in ["de-DE", "de_AT.UTF-8", "de-CH"] { + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::De); + } + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), Locale::De.tr("Warm Glow")); + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + let rules = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }], + }; + let mut catalog = lightcraft_catalog::Catalog::new(); + catalog + .apply(lightcraft_catalog::Op::AddAlbum { album: lightcraft_catalog::Album::new(lightcraft_catalog::AlbumId(4), "Ausgeschlossen") }) + .unwrap(); + // "Color" is a message too, but a rule's free-text value stays as written + let keywords = lightcraft_catalog::rules::field_label("keywords").unwrap_or("keywords"); + assert_eq!(rules_label(&rules, &catalog), format!("{} {} Color", Locale::De.tr(keywords), Locale::De.tr("contains"))); + let album = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "album".into(), op: "isNot".into(), value: serde_json::json!(4) }], + }; + assert!(rules_label(&album, &catalog).ends_with(" “Ausgeschlossen”"), "{}", rules_label(&album, &catalog)); + let rules = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { + field: "copyrightStatus".into(), + op: "is".into(), + value: serde_json::json!("publicDomain"), + }], + }; + let public_domain = format!(" {}", choice_text_in(Locale::De, "copyrightStatus", "publicDomain")); + assert!(rules_label(&rules, &catalog).ends_with(&public_domain), "{}", rules_label(&rules, &catalog)); + for value in [serde_json::json!(false), serde_json::json!("false")] { + let rules = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "edited".into(), op: "is".into(), value }], + }; + let no = format!(" {}", bool_text_in(Locale::De, false)); + assert!(rules_label(&rules, &catalog).ends_with(&no), "{}", rules_label(&rules, &catalog)); + } + let both = lightcraft_catalog::Filter { + label: Some(lightcraft_catalog::ColorLabel::Red), + labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Red], + ..Default::default() + }; + let name = |label| color_label(&app.session.catalog, label); + let (red, blue, green) = + (name(lightcraft_catalog::ColorLabel::Red), name(lightcraft_catalog::ColorLabel::Blue), name(lightcraft_catalog::ColorLabel::Green)); + let (label, and, or) = (tr("Label"), tr("and"), tr("or")); + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red}")); + let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue], ..both }; + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} {blue}")); + let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Green], ..both }; + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} ({blue} {or} {green})")); + let filter = lightcraft_catalog::Filter { + labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], + ..Default::default() + }; + let blue = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Blue); + let red = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Red); + let label = filter_label(&filter, &app.session.catalog); + assert!(label.contains(&red) && label.contains(&blue), "{label}"); + // a label set's own name is the user's + app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap(); + let label = filter_label(&filter, &app.session.catalog); + assert!(label.contains("Color") && !label.contains(&red) && label.contains(&blue), "{label}"); + set_language(Locale::En); +} + +#[test] +fn german_panels_dialogs_and_glyphs_are_painted_without_extra_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::De); + assert!(text.contains(Locale::De.tr("My Photos")) && text.contains(Locale::De.tr("All Photos")), "{text}"); + app.ui.view = crate::state::ViewMode::People; + let text = painted_text(&ctx, &mut app, Locale::De); + assert!(text.contains(Locale::De.tr("Named People")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::De); + assert!(text.contains(Locale::De.tr(title)), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ÄÖÜäöüß„“".chars() { + assert!(fonts.has_glyph(&font, ch), "missing German glyph {ch}"); + } + } + }); + set_language(Locale::En); +} + +#[test] +fn german_ukrainian_italian_and_finnish_buttons_fit_the_bottom_bar_and_export_dialog() { + fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { + match shape { + egui::epaint::Shape::Text(shape) => { + bounds.push((shape.galley.job.text.clone(), egui::Rect::from_min_size(shape.pos, shape.galley.size()))) + } + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, bounds)), + _ => {} + } + } + for language in [Locale::De, Locale::Uk, Locale::It, Locale::Fi] { + let ctx = fonts_ctx(&[]); + let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.language = language; + let mut bounds = Vec::new(); + for export in [false, true] { + if export { + app.run("dialog.export", serde_json::json!({})).unwrap(); + } + for frame in 0..4 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + bounds.clear(); + for shape in out.shapes { + collect(&shape.shape, &mut bounds); + } + } + let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1; + if export { + let dialog = rect("dialog:window"); + for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] { + assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id)); + } + } else { + let button = rect("button:copySettings"); + let (_, text) = bounds.iter().find(|entry| entry.0 == language.tr("Copy Edit Settings")).unwrap(); + assert!(button.contains_rect(*text), "{language:?} copy caption overflows its button: {text:?} vs {button:?}"); + assert!(!button.intersects(rect("icon:copyGear"))); + } + } + } + set_language(Locale::En); +} + +/// User-named menu items (presets, albums, label sets) are never translated; built-in labels are. +#[test] +fn menu_labels_translate_but_user_names_survive() { + for language in Locale::ALL { + set_language(*language); + assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); + } + set_language(Locale::En); +} + +/// The text the whole window paints over a few frames in `language`. +fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String { + fn collect(shape: &egui::epaint::Shape, text: &mut String) { + match shape { + egui::epaint::Shape::Text(shape) => { + text.push_str(&shape.galley.job.text); + text.push('\n'); + } + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + app.ui.language = language; + let mut text = String::new(); + for frame in 0..4 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + // This inspects shapes without a renderer; discard texture uploads explicitly. + out.textures_delta.clear(); + text.clear(); + for shape in out.shapes { + collect(&shape.shape, &mut text); + } + } + text +} + +/// Every language is painted by the real window, switching at runtime reinstalls the fonts +/// (their CJK fallback order follows the language), and command ids never change with it. +#[test] +fn every_language_is_painted_and_menu_ids_stay_the_same() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); + let english_ids = ids(&app); + for language in Locale::ALL { + let text = painted_text(&ctx, &mut app, *language); + for label in ["My Photos", "All Photos"] { + assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label)); + } + assert_eq!(app.font_language, *language, "the fonts follow the language"); + assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); + } + set_language(Locale::En); +} + +/// The UI families, after one frame so the font definitions are loaded. +fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(craft)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + ctx +} + +/// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI +/// family, Monospace included. +#[test] +fn craft_fonts_render_cjk_in_the_ui() { + let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; + for (language, sample) in samples { + if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); + continue; + } + set_language(language); + let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); + ctx.fonts_mut(|fonts| { + for family in + [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] + { + let font = egui::FontId::new(13.0, family); + for ch in sample.chars() { + assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}"); + } + let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE); + let wide = 13.0 * (sample.chars().count() as f32 - 1.0); + assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size()); + } + }); + } + set_language(Locale::En); +} + +/// Built without craft-fonts, the UI (in every language) still installs its fonts and runs; +/// Latin text keeps Inter. +#[test] +fn the_ui_works_without_craft_fonts() { + let ctx = fonts_ctx(&[]); + ctx.fonts_mut(|fonts| { + // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's + // replacement-glyph face, which there is Hack, the face that draws Latin.) + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); + } + }); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + for language in Locale::ALL { + app.ui.language = *language; + for frame in 0..3 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + } + set_language(Locale::En); +} + +/// Traditional Chinese through the menu command and the control channel, persisted, with +/// localised headings; ids and user text are untouched. +#[test] +fn traditional_chinese_switches_through_menu_and_control_and_persists() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::ZhHant); + // Applied at once, not on the next frame. + assert_eq!(language(), Locale::ZhHant); + assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), Locale::ZhHant.tr("All Photos")); + assert_eq!(date_group_label("", false), Locale::ZhHant.tr("Unknown Date")); + assert_eq!(display_time("a-user-value"), "a-user-value"); + assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true)); + assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false)); + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] { + assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color"); + } + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), Locale::ZhHant.tr("Color")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"zh-hant""#), "{saved}"); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::ZhHant); + let ctx = egui::Context::default(); + app.ui.language = Locale::En; + for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] { + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; + assert_eq!(reply["ok"], true, "{code}"); + assert_eq!(app.ui.language, Locale::ZhHant, "{code}"); + assert_eq!(language(), Locale::ZhHant, "{code} applies at once"); + } + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.ui.language, Locale::ZhHant); + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); + let chinese_ids = ids(&app); + app.run("app.language.english", serde_json::json!({})).unwrap(); + assert_eq!(tr("File"), "File"); + assert_eq!(chinese_ids, ids(&app)); +} + +/// Dates follow every language's own patterns (English keeps the catalog's wording); a key that +/// is not a date never panics and never shows a raw pattern. +#[test] +fn date_headings_in_every_language() { + set_language(Locale::En); + assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026"); + assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20"); + assert_eq!(date_group_label("2026-09", true), "September"); + assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05")); + for language in Locale::ALL { + set_language(*language); + if *language != Locale::En { + let day = date_group_label("2026-09-20", false); + assert!(day.contains("20") && day.contains("2026") && day.contains(tr("Sunday")), "{language:?}: {day}"); + assert!(date_group_label("2026-09-20", true).contains("20"), "{language:?}"); + assert!(date_group_label("2026-09", false).contains("2026"), "{language:?}"); + assert!(date_group_label("2026", false).contains("2026"), "{language:?}"); + assert_eq!(display_time("2026-09-20T16:04:05"), format!("{day} 16:04:05"), "{language:?}"); + } + for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] { + assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}"); + assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}"); + } + } + set_language(Locale::En); +} + +/// Stock presets and panel headers are translated; a user preset keeps its name, also when it +/// shares a stock preset's name. +#[test] +fn traditional_chinese_presets_and_panel_headers_are_painted() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.presets = true; + let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone(); + app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin }); + let zh = Locale::ZhHant; + for (panel, title) in [(crate::state::RightPanel::Activity, "History"), (crate::state::RightPanel::Versions, "Versions")] { + app.ui.right = panel; + let text = painted_text(&ctx, &mut app, zh); + assert!(text.contains(zh.tr(title)), "{text}"); + assert!(text.contains(zh.tr("Warm Glow")), "stock preset: {text}"); + assert!(text.contains("Warm Glow"), "user preset: {text}"); + assert!(text.contains(zh.tr("Color")), "stock group: {text}"); + } + assert_eq!(history_label("Exposure", &app.session.presets), zh.tr("Exposure")); + assert!(history_label("Preset: Warm Film", &app.session.presets).ends_with(zh.tr("Warm Film"))); + assert!(history_label("Preset: Warm Glow", &app.session.presets).ends_with("Warm Glow"), "the user's preset shares the name"); + set_language(Locale::En); +} + +#[test] +fn vietnamese_panels_dialogs_and_diacritics_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::Vi); + assert!(text.contains(Locale::Vi.tr("My Photos")) && text.contains(Locale::Vi.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::Vi); + assert!(text.contains(Locale::Vi.tr(title)), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ĂăÂâÊêÔôƠơƯưĐđáàảãạéèẻẽẹíìỉĩịóòỏõọúùủũụýỳỷỹỵ".chars().chain( + Locale::Vi + .catalog() + .values() + .flat_map(|text| text.chars()) + .filter(|ch| ('\u{00c0}'..='\u{024f}').contains(ch) || ('\u{1ea0}'..='\u{1ef9}').contains(ch)), + ) { + assert!(fonts.has_glyph(&font, ch), "missing Vietnamese glyph {ch}"); + } + } + }); + set_language(Locale::En); +} +/// The messages each language lacks, which it shows in English. Never fails: `cargo xtask ci` +/// runs it with `--nocapture`, so the list lands in the gate log. Plain messages are every key +/// some catalog has plus the labels the UI passes to `tr` from tables (commands, controls, rule +/// fields…); formats are every key some `*-formats.json` has plus every `tr_format!` message in +/// the app's source. An empty translation counts as missing. +#[test] +fn translation_gaps_report() { + let notes = whats_new_lines(); + let messages: std::collections::BTreeSet = plain_messages().into_iter().filter(|m| !notes.contains(&m.as_str())).collect(); + let formats = format_messages(); + let quoted = |list: &[&str]| list.iter().map(|m| format!("{m:?}")).collect::>().join(", "); + let mut out = format!( + "Translation gaps, shown in English ({} messages, {} formats, {} What's New lines):\n", + messages.len(), + formats.len(), + notes.len() + ); + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + let catalog = language.catalog(); + let own_formats = formats_catalog(language.code()); + let missing: Vec<&str> = messages.iter().map(String::as_str).filter(|m| !catalog.contains_key(*m)).collect(); + let missing_formats: Vec<&str> = formats.iter().map(String::as_str).filter(|m| !own_formats.contains_key(*m)).collect(); + let missing_notes = notes.iter().filter(|line| !catalog.contains_key(**line)).count(); + out.push_str(&format!( + "{}: {} messages, {} formats, {} What's New lines\n", + language.code(), + missing.len(), + missing_formats.len(), + missing_notes + )); + if !missing.is_empty() { + out.push_str(&format!(" messages: {}\n", quoted(&missing))); + } + if !missing_formats.is_empty() { + out.push_str(&format!(" formats: {}\n", quoted(&missing_formats))); + } + } + println!("{out}"); +} + +/// Every plain message: the keys of every catalog (but contextual entries, which a language adds +/// only where it needs one) and the labels the UI translates from tables. +fn plain_messages() -> std::collections::BTreeSet { + use lightcraft_catalog::rules; + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in rules::FIELDS { + labels.push(label); + labels.extend(rules::ops_for(*kind).iter().map(|(_, label)| *label)); + if let rules::Kind::Choice(choices) = kind { + labels.extend(choices.iter().map(|(_, label)| *label)); + } + } + labels.extend(rules::FIELD_GROUPS.iter().map(|group| group.0)); + labels.extend(rules::Issue::ALL.iter().map(|issue| issue.text())); + labels.extend([rules::bool_label(true), rules::bool_label(false)]); + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + labels.extend(crate::date_picker::WEEKDAY_SHORT); + labels.extend(["Pick a date", "Year", "Month", "Day", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]); + // the album pickers + labels.extend([ + "Choose an album…", + "No albums yet", + "Search albums", + "No albums match", + "This is the album you're editing", + "It tests this album, so testing it back would loop", + "Fix the marked rules to save this album.", + "None", + "New album", + "New album…", + "Imported Photos", + ]); + labels.extend(crate::panels::keyword_list::VARIABLE_LABELS); + let contextual = format!("{BOOL_CONTEXT}|"); + Locale::ALL + .iter() + .flat_map(|language| language.catalog().keys()) + .filter(|key| !key.starts_with(&contextual)) + .cloned() + .chain(labels.into_iter().map(str::to_string)) + .collect() +} + +/// Help ▸ What's New, line by line as the dialog looks each one up. +fn whats_new_lines() -> Vec<&'static str> { + crate::panels::dialogs::WHATS_NEW + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with("# ")) + .map(|line| line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line)) + .collect() +} + +/// `locales/-formats.json` (the catalog `build.rs` compiles into `tr_format!`), without +/// empty translations. +fn formats_catalog(code: &str) -> BTreeMap { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("locales/{code}-formats.json")); + let mut messages: BTreeMap = + std::fs::read_to_string(path).ok().and_then(|text| serde_json::from_str(&text).ok()).unwrap_or_default(); + messages.retain(|_, text| !text.is_empty()); + messages +} + +/// Every format message: the keys of every formats catalog and the message of every +/// `tr_format!` call in `src/` outside its test modules. +fn format_messages() -> std::collections::BTreeSet { + let mut all: std::collections::BTreeSet = Locale::ALL + .iter() + .filter(|language| **language != Locale::En) + .flat_map(|language| formats_catalog(language.code()).into_keys()) + .collect(); + let mut dirs = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; + while let Some(dir) = dirs.pop() { + for path in std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()) { + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let source = std::fs::read_to_string(&path).unwrap(); + let app = source + .split( + "#[cfg(test)] +mod ", + ) + .next() + .unwrap_or_default(); + all.extend(tr_format_messages(app)); + } + } + } + all +} + +/// The first argument of each `tr_format!` call in `source`, when it is a string literal. +fn tr_format_messages(source: &str) -> Vec { + // built from parts, so this file's own source holds no call to find + let call = concat!("tr_format", "!("); + let mut messages = Vec::new(); + for (at, _) in source.match_indices(call) { + let Some(body) = source[at + call.len()..].trim_start().strip_prefix('"') else { continue }; + let mut message = String::new(); + let mut chars = body.chars(); + while let Some(ch) = chars.next() { + match ch { + '"' => break, + '\\' => match chars.next() { + Some('n') => message.push('\n'), + Some('t') => message.push('\t'), + Some('u') => { + let hex: String = chars.by_ref().skip(1).take_while(|ch| *ch != '}').collect(); + message.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)); + } + Some(other) => message.push(other), + None => break, + }, + _ => message.push(ch), + } + } + messages.push(message); + } + messages +} + +#[test] +fn tr_format_messages_are_read_from_source() { + let call = concat!("tr_format", "!("); + let source = format!("{call}\"Imported {{}} photo{{}}\", n, s); crate::i18n::{call}\n \"Say \\\"hi\\\" \\u{{2192}}\", x); {call}name)"); + assert_eq!(tr_format_messages(&source), ["Imported {} photo{}", "Say \"hi\" →"]); +} + +/// A drop's notice comes from what `library.import` reported: a photo added, one brought back from Recently +/// Deleted and a file that could not be read are each named; a drop of duplicates only says nothing. +#[test] +fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_duplicates_only() { + set_language(Locale::En); + let dir = std::env::temp_dir().join(format!("lc-drop-notice-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let png = |name: &str, seed: u8| { + let data: Vec<[u8; 4]> = (0..48 * 32).map(|i| [(i % 48 * 5) as u8, (i / 48 * 7) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: 48, height: 32, data }; + let bytes = + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(name), bytes).unwrap(); + dir.join(name).to_string_lossy().into_owned() + }; + let (trashed, added) = (png("trashed.png", 1), png("new.png", 2)); + let broken = dir.join("broken.jpg"); + std::fs::write(&broken, b"not a jpeg at all").unwrap(); + let mut s = lightcraft_engine::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [trashed]})).unwrap(); + s.execute("photo.delete", &serde_json::json!({"ids": [r["imported"][0]]})).unwrap(); + let report = s.execute("library.import", &serde_json::json!({"paths": [trashed, added, broken]})).unwrap(); + assert_eq!( + dropped_photos_outcome(&report).as_deref(), + Some("Added 1 photo · Restored 1 photo from Recently Deleted · 1 photo not readable"), + "{report}" + ); + let again = s.execute("library.import", &serde_json::json!({"paths": [added]})).unwrap(); + assert_eq!(dropped_photos_outcome(&again), None, "a duplicate only: {again}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// Polish: the menu and control channel select it, dates read day.month.year, and every count +/// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so +/// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some. +#[test] +fn polish_switches_formats_dates_and_reads_every_count() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.polish", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Pl); + assert_eq!(Locale::Pl.name(), "Polski"); + assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true)); + assert_eq!(tr("File"), "Plik"); + assert_eq!(tr("Exposure"), "Ekspozycja"); + assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026"); + assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20"); + assert_eq!(date_group_label("2026-09", false), "09.2026"); + assert_eq!(date_group_label("2026", false), "2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05"); + for n in [0, 1, 2, 3, 4, 5, 12, 21, 22, 25, 111] { + let s = if n == 1 { "" } else { "s" }; + assert_eq!(tr_format!("Imported {} photo{}", n, s), format!("Zaimportowane zdjęcia: {n}")); + for (one, many, noun) in [ + ("photo waiting", "photos waiting", "Oczekujące zdjęcia"), + ("cached picture", "cached pictures", "Obrazy w pamięci podręcznej"), + ("photo has its denoised picture.", "photos have their denoised picture.", "Zdjęcia z odszumioną wersją"), + ] { + assert_eq!(tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })), format!("{noun}: {n}")); + } + assert_eq!(tr_format!("Delete {n} Photos", n = n), format!("Usuń zdjęcia ({n})")); + assert_eq!(tr_format!("{n} preset{}", s, n = n), format!("Ustawienia: {n}")); + } + let name = String::from("Color 1234.56 {title} 日本語"); + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Pl); + let ctx = egui::Context::default(); + for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Pl), "{tag}"); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Pl); + } + let text = painted_text(&ctx, &mut app, Locale::Pl); + assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ„”".chars() { + assert!(fonts.has_glyph(&font, ch), "missing Polish glyph {ch}"); + } + } + }); + set_language(Locale::En); +} From 98efa9e648a6619d38edd9f43cb2bd2a9df3f787 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:06 +0000 Subject: [PATCH 063/191] refactor(lightcraft-ui-egui): move window_place.rs tests to crates/ui-egui/src/window_place/tests.rs --- crates/ui-egui/src/window_place.rs | 599 +---------------------- crates/ui-egui/src/window_place/tests.rs | 596 ++++++++++++++++++++++ 2 files changed, 597 insertions(+), 598 deletions(-) create mode 100644 crates/ui-egui/src/window_place/tests.rs diff --git a/crates/ui-egui/src/window_place.rs b/crates/ui-egui/src/window_place.rs index e88cca9a1..c37f6064b 100644 --- a/crates/ui-egui/src/window_place.rs +++ b/crates/ui-egui/src/window_place.rs @@ -319,601 +319,4 @@ impl WindowWatch { } #[cfg(test)] -mod tests { - use super::*; - - const SCREEN: [f32; 2] = [2560.0, 1440.0]; - - fn window(size: [f32; 2]) -> Seen { - Seen { size: Some(size), screen: Some(SCREEN), ..Default::default() } - } - - fn place(size: [f32; 2], zoomed: bool) -> WindowPlace { - WindowPlace { size, zoomed } - } - - /// Given a window that is resized and comes to rest, when it is watched, then its new size - /// is remembered once it has rested for a moment, and none of the sizes it passed through. - #[test] - fn a_window_is_remembered_at_the_size_it_comes_to_rest_at() { - for live in [true, false] { - let mut watch = WindowWatch::told_live(live); - let mut remembered = None; - for (i, width) in [1000.0, 1100.0, 1200.0, 1300.0].into_iter().enumerate() { - remembered = watch.note(remembered, &window([width, 800.0]), i as f64 * 0.1); - assert_eq!(remembered, None, "passing through {width}"); - assert!(watch.waits(i as f64 * 0.1).is_some(), "and the app looks again"); - } - remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.5); - assert_eq!(remembered, None, "not yet at rest"); - // (it has been this size since 0.3: the app looks again just after 0.8) - let wait = watch.waits(0.5).map(|w| w.as_secs_f64()).unwrap(); - assert!((wait - 0.35).abs() < 1e-6, "{wait}"); - remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.9); - assert_eq!(remembered, Some(place([1300.0, 800.0], false))); - assert!(watch.waits(0.9).is_none()); - // as it is: nothing to wait for - assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 5.0), remembered); - assert!(watch.waits(5.0).is_none()); - } - } - - /// Given a window at rest that is zoomed (the top bar double-clicked), when it is watched - /// where the window system says so, then it is remembered as zoomed at once, with the size - /// it had before, though it grew through other sizes on the way; un-zoomed, it is no longer - /// zoomed at once and keeps that size. - #[test] - fn a_zoomed_window_keeps_the_size_it_goes_back_to() { - let mut watch = WindowWatch::told_live(true); - let rest = place([1300.0, 800.0], false); - let mut remembered = Some(rest); - for (i, width) in [1600.0, 2000.0, 2400.0].into_iter().enumerate() { - remembered = watch.note(remembered, &window([width, 1300.0]), 10.0 + i as f64 * 0.05); - assert_eq!(remembered, Some(rest)); - } - let zoomed = Seen { zoomed: true, ..window([2560.0, 1412.0]) }; - remembered = watch.note(remembered, &zoomed, 10.2); - assert_eq!(remembered, Some(place([1300.0, 800.0], true))); - assert_eq!(watch.note(remembered, &zoomed, 30.0), Some(place([1300.0, 800.0], true)), "however long it stays zoomed"); - assert!(watch.waits(30.0).is_none()); - remembered = watch.note(remembered, &window([2000.0, 1100.0]), 31.0); - assert_eq!(remembered, Some(rest), "no longer zoomed, and the size it had"); - assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 31.2), Some(rest)); - // zoomed before anything was remembered: it opens zoomed, and goes back to the usual size - assert_eq!(WindowWatch::told_live(true).note(None, &zoomed, 0.0), Some(place(DEFAULT_SIZE, true))); - // the system's word is the answer there: a window as large as its screen is a window - assert_eq!(WindowWatch::told_live(true).note(Some(rest), &window([2560.0, 1335.0]), 0.0).map(|p| p.zoomed), Some(false)); - } - - /// Given macOS, where the window system says whether the window is zoomed only as it opens - /// and when the app zooms it itself: when a window that opened zoomed is resized by its edge, - /// then it is a window again, remembered at the size it was given, though the system's word - /// still says zoomed; zoomed once more, it is zoomed. - #[test] - fn a_zoomed_window_resized_by_its_edge_is_a_window_again_where_the_system_does_not_say() { - let mut watch = WindowWatch::told_live(false); - let rest = place([1300.0, 800.0], true); - let zoomed = Seen { zoomed: true, ..window([2560.0, 1335.0]) }; - let mut remembered = watch.note(Some(rest), &zoomed, 0.0); - assert_eq!(remembered, Some(rest)); - assert!(watch.waits(0.0).is_some(), "the size it is zoomed at is still to be seen at rest"); - remembered = watch.note(remembered, &zoomed, 0.6); - assert_eq!(remembered, Some(rest)); - assert!(watch.waits(0.6).is_none()); - assert_eq!(watch.note(remembered, &zoomed, 60.0), Some(rest), "however long it stays zoomed"); - // (a size goes through the screen's pixels and back: a point off is the same size) - assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([2559.0, 1336.0]) }, 60.5), Some(rest)); - // its right edge dragged in a little, far less than a Dock is wide; the word is unchanged - let dragged = Seen { zoomed: true, ..window([2500.0, 1335.0]) }; - remembered = watch.note(remembered, &dragged, 61.0); - assert_eq!(remembered, Some(place([1300.0, 800.0], false)), "not zoomed any more"); - remembered = watch.note(remembered, &dragged, 61.6); - assert_eq!(remembered, Some(place([2500.0, 1335.0], false)), "and at the size it was given"); - assert!(watch.waits(61.6).is_none()); - // zoomed again from the Window menu: back at the size it is zoomed at - assert_eq!(watch.note(remembered, &zoomed, 70.0), Some(place([2500.0, 1335.0], true))); - } - - /// Given macOS, when the app zooms the window itself (the top bar double-clicked), then the - /// system says so at once and the window is zoomed through all the sizes it grows through, - /// with the size it had, wherever the Dock is; un-zoomed the same way, it is a window again - /// at that size. - #[test] - fn a_window_the_app_zooms_is_zoomed_at_once_where_the_system_does_not_say() { - let mut watch = WindowWatch::told_live(false); - let rest = place([1300.0, 800.0], false); - let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); - // (a Dock at the left: zoomed, the window is narrower than its screen) - for (i, width) in [1300.0, 1700.0, 2100.0, 2410.0].into_iter().enumerate() { - remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 1415.0]) }, 1.0 + i as f64 * 0.05); - assert_eq!(remembered, Some(place([1300.0, 800.0], true)), "growing through {width}"); - } - remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2410.0, 1415.0]) }, 2.0); - assert_eq!(remembered, Some(place([1300.0, 800.0], true))); - assert!(watch.waits(2.0).is_none()); - for (i, width) in [2100.0, 1700.0, 1300.0].into_iter().enumerate() { - remembered = watch.note(remembered, &window([width, 800.0]), 3.0 + i as f64 * 0.05); - assert_eq!(remembered, Some(rest), "shrinking through {width}"); - } - assert!(watch.waits(3.1).is_none(), "back at the size it had: nothing to wait for"); - } - - /// Given macOS and a window zoomed from the Window menu (the system's word stays "not - /// zoomed"), when it is seen to come to fill its screen from side to side, then it is zoomed, - /// with the size it had, and zoomed back it is a window at that size again. A window that - /// only grows large is not zoomed, on any screen. - #[test] - fn a_window_that_comes_to_fill_its_screen_is_zoomed_where_the_system_does_not_say() { - let rest = place([1300.0, 800.0], false); - // a window of 1300 × 800 on `screen` that is next seen at `size` - let zoomed_on = |screen: [f32; 2], size: [f32; 2]| { - let mut watch = WindowWatch::told_live(false); - let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; - watch.note(Some(rest), &seen([1300.0, 800.0]), 0.0); - watch.note(Some(rest), &seen(size), 0.2).map(|p| p.zoomed) - }; - // under the menu bar, with the Dock hidden, small, or as large as it gets - for (screen, size) in [ - ([2560.0, 1440.0], [2560.0, 1415.0]), - ([2560.0, 1440.0], [2560.0, 1335.0]), - ([2560.0, 1440.0], [2559.0, 1335.0]), - ([1728.0, 1117.0], [1728.0, 1084.0]), - ([1512.0, 982.0], [1512.0, 794.0]), - ] { - assert_eq!(zoomed_on(screen, size), Some(true), "{size:?} on {screen:?}"); - } - // the usual size on a laptop's screen, windows sized by hand to nearly the screen, one - // that is a little narrower than it, and one as wide as it and far from as tall - for (screen, size) in [ - ([1728.0, 1117.0], DEFAULT_SIZE), - ([1710.0, 1107.0], DEFAULT_SIZE), - ([1512.0, 982.0], [1400.0, 820.0]), - ([1470.0, 956.0], [1340.0, 780.0]), - ([2560.0, 1440.0], [2430.0, 1270.0]), - ([2560.0, 1440.0], [2300.0, 1200.0]), - ([2560.0, 1440.0], [2550.0, 1415.0]), - ([2560.0, 1440.0], [2560.0, 1190.0]), - ] { - assert_eq!(zoomed_on(screen, size), Some(false), "{size:?} on {screen:?}"); - } - let mut watch = WindowWatch::told_live(false); - let filling = window([2560.0, 1335.0]); - let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); - remembered = watch.note(remembered, &window([2000.0, 1100.0]), 0.9); - remembered = watch.note(remembered, &filling, 1.0); - assert_eq!(remembered, Some(place([1300.0, 800.0], true))); - assert_eq!(watch.note(remembered, &filling, 9.0), remembered, "and its own size is not written over"); - remembered = watch.note(remembered, &window([1900.0, 1000.0]), 10.0); - assert_eq!(remembered, Some(rest), "zoomed back: a window, at the size it had"); - assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 10.2), Some(rest)); - assert!(watch.waits(10.2).is_none()); - // not told apart: zoomed from the Window menu beside a Dock at the side, it is remembered - // as a window of that size - let mut watch = WindowWatch::told_live(false); - let beside_a_dock = window([2410.0, 1415.0]); - watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); - watch.note(Some(rest), &beside_a_dock, 0.3); - assert_eq!(watch.note(Some(rest), &beside_a_dock, 0.9), Some(place([2410.0, 1415.0], false))); - // without a screen's size to go by, the system's word is all there is - let no_screen = Seen { size: Some([2560.0, 1335.0]), screen: None, zoomed: true, ..Default::default() }; - assert_eq!(WindowWatch::told_live(false).note(Some(rest), &no_screen, 0.0), Some(place([1300.0, 800.0], true))); - } - - /// Given macOS and a window that was left as wide as its screen and a little shorter (its - /// lower edge dragged up after a zoom), when the app opens it at that size, then it is a - /// window of that size, launch after launch: filling the screen is no sign of a zoom unless - /// the window was seen to come to it. Dragged shorter still, it is remembered at that. - #[test] - fn a_window_that_opens_as_wide_as_its_screen_is_a_window_where_the_system_does_not_say() { - for (screen, size) in [([1728.0, 1117.0], [1728.0, 1054.0]), ([1512.0, 982.0], [1512.0, 760.0]), ([3440.0, 1440.0], [3440.0, 1200.0])] { - let left = place(size, false); - let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; - let mut watch = WindowWatch::told_live(false); - for frame in 0..5 { - assert_eq!(watch.note(Some(left), &seen(size), f64::from(frame) * 0.3), Some(left), "{size:?} on {screen:?}"); - } - let shorter = [size[0], size[1] - 20.0]; - watch.note(Some(left), &seen(shorter), 2.0); - assert_eq!(watch.note(Some(left), &seen(shorter), 2.6), Some(place(shorter, false))); - } - // the same after a double-click on the top bar that changed nothing (the system's word was - // stale, and is now "not zoomed"): still the window it is - let mut watch = WindowWatch::told_live(false); - let screen = [1728.0, 1117.0]; - let left = place([1728.0, 1054.0], false); - watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 0.0); - watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 1.0); - // (taken for zoomed on the system's word, until that word is taken back) - let unsaid = Seen { size: Some(left.size), screen: Some(screen), zoomed: false, ..Default::default() }; - assert_eq!(watch.note(Some(left), &unsaid, 2.0), Some(left)); - assert_eq!(watch.note(Some(left), &unsaid, 9.0), Some(left)); - } - - /// Given macOS and a zoomed window, when it is zoomed back from the Window menu (or resized by - /// its edge) before it was ever seen at rest, then it is a window at once, and the size it - /// comes to rest at is the window's, not taken for the size it is zoomed at. - #[test] - fn a_window_zoomed_back_at_once_is_a_window_where_the_system_does_not_say() { - let mut watch = WindowWatch::told_live(false); - let rest = place([1240.0, 820.0], true); - let mut remembered = watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); - assert_eq!(remembered, Some(rest)); - for (i, width) in [2200.0, 1700.0, 1240.0, 1240.0, 1240.0].into_iter().enumerate() { - remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 820.0]) }, 0.3 + i as f64 * 0.05); - assert_eq!(remembered, Some(place([1240.0, 820.0], false)), "{width}"); - } - assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([1240.0, 820.0]) }, 60.0), Some(place([1240.0, 820.0], false))); - assert!(watch.waits(60.0).is_none()); - } - - /// Given macOS and a zoomed window, when its screen changes (the display unplugged, another - /// resolution) and it is fitted to the new one, then it is still zoomed and keeps the size - /// it goes back to. Moved to a larger screen that it does not fill, it is a window. - #[test] - fn a_zoomed_window_is_still_zoomed_on_another_screen_where_the_system_does_not_say() { - for said in [true, false] { - let rest = place([1240.0, 820.0], false); - let on = |screen: [f32; 2], size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), zoomed: said, ..Default::default() }; - let mut watch = WindowWatch::told_live(false); - let mut remembered = watch.note(Some(rest), &on([2560.0, 1440.0], [1240.0, 820.0]), 0.0); - if said { - // (the system's word as the window opened: a window; the app zooms it later) - remembered = watch.note(remembered, &Seen { zoomed: false, ..on([2560.0, 1440.0], [1240.0, 820.0]) }, 0.0); - } - for now in [1.0, 2.0] { - remembered = watch.note(remembered, &on([2560.0, 1440.0], [2560.0, 1415.0]), now); - } - assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "said: {said}"); - for now in [10.0, 10.6, 60.0] { - remembered = watch.note(remembered, &on([1728.0, 1117.0], [1728.0, 1084.0]), now); - assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "on the smaller screen at {now}, said: {said}"); - } - assert!(watch.waits(60.0).is_none()); - // dragged over to a screen it does not fill - remembered = watch.note(remembered, &on([3440.0, 1440.0], [1728.0, 1084.0]), 70.0); - assert_eq!(remembered.map(|p| p.zoomed), Some(false), "said: {said}"); - } - // seen zoomed in one frame only, and fitted to another screen long after: it had rested - let mut watch = WindowWatch::told_live(false); - let rest = place([1240.0, 820.0], true); - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); - let fitted = Seen { size: Some([1728.0, 1084.0]), screen: Some([1728.0, 1117.0]), zoomed: true, ..Default::default() }; - assert_eq!(watch.note(Some(rest), &fitted, 5.0), Some(rest)); - } - - /// Given a size the window has not rested at yet, when the window goes full screen or is - /// minimized, or reports no size, then nothing waits (the app is not woken again and again - /// for as long as that lasts), and back as a window its size is seen afresh. - #[test] - fn nothing_waits_while_the_window_is_full_screen_or_minimized() { - let rest = place([1300.0, 800.0], false); - for live in [true, false] { - for away in [ - Seen { full_screen: true, ..window(SCREEN) }, - Seen { minimized: true, ..window([160.0, 28.0]) }, - Seen { size: None, ..window(SCREEN) }, - ] { - let mut watch = WindowWatch::told_live(live); - watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); - assert!(watch.waits(1.0).is_some()); - assert_eq!(watch.note(Some(rest), &away, 1.1), Some(rest)); - assert!(watch.waits(1.1).is_none(), "{away:?}"); - assert_eq!(watch.note(Some(rest), &away, 900.0), Some(rest)); - assert!(watch.waits(900.0).is_none(), "{away:?}"); - assert_eq!(watch.note(Some(rest), &window([1500.0, 900.0]), 901.0), Some(rest)); - let mut remembered = Some(rest); - for now in [902.2, 902.8] { - remembered = watch.note(remembered, &window([1500.0, 900.0]), now); - } - assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?}"); - assert!(watch.waits(902.8).is_none()); - } - } - // (macOS: the same of a zoomed window's size that was still to be seen at rest) - for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { - let mut watch = WindowWatch::told_live(false); - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, 0.0); - assert!(watch.waits(0.0).is_some()); - watch.note(Some(rest), &away, 0.1); - assert!(watch.waits(0.1).is_none(), "{away:?}"); - } - } - - /// Given a zoomed window that was full screen, when it leaves full screen, then for a moment - /// it is not looked at (the system animates the way back, at sizes that are neither the - /// window's nor the zoomed one), the app looks again once that is over, and the window is - /// zoomed with the size it goes back to, as before. - #[test] - fn a_window_that_has_just_left_full_screen_is_not_looked_at() { - for live in [true, false] { - let mut watch = WindowWatch::told_live(live); - let rest = place([1240.0, 820.0], true); - let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; - for now in [0.0, 1.0] { - assert_eq!(watch.note(Some(rest), &zoomed, now), Some(rest)); - } - assert_eq!(watch.note(Some(rest), &Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, 5.0), Some(rest)); - // on the way back the system no longer says "full screen", nor "zoomed" where its word is live - for now in [9.0, 9.3, 9.6, 9.9] { - assert_eq!(watch.note(Some(rest), &Seen { zoomed: !live, ..window(SCREEN) }, now), Some(rest), "at {now}"); - let wait = watch.waits(now).map(|w| w.as_secs_f64()).unwrap(); - assert!((wait - (10.05 - now)).abs() < 1e-6, "at {now}: looks again after the moment, {wait}"); - } - assert_eq!(watch.note(Some(rest), &zoomed, 10.1), Some(rest)); - assert_eq!(watch.note(Some(rest), &zoomed, 11.0), Some(rest)); - assert!(watch.waits(11.0).is_none()); - } - } - - /// Given macOS and a window dragged by its corner, when it reaches the screen's width and is - /// then dragged on, taller and little by little narrower again, and left narrower than the - /// screen, then it is a window at the size it was left at: a zoom only grows on its way, so - /// what gets narrower again is somebody's hand. - #[test] - fn a_window_dragged_wide_and_narrower_again_is_a_window_where_the_system_does_not_say() { - for per_frame in [1.0, 1.4] { - let screen = [1728.0, 1117.0]; - let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; - let rest = place([1600.0, 950.0], false); - let mut watch = WindowWatch::told_live(false); - let mut remembered = watch.note(Some(rest), &seen(rest.size), 0.0); - let mut size = [1728.0, 950.0]; - let mut now = 1.0; - for _ in 0..40 { - remembered = watch.note(remembered, &seen(size), now); - size = [size[0] - per_frame, size[1] + 3.0]; - now += 1.0 / 60.0; - } - for _ in 0..3 { - remembered = watch.note(remembered, &seen(size), now); - now += 0.3; - } - assert_eq!(remembered, Some(place(size, false)), "{per_frame} pt narrower a frame"); - } - // a zoomed window whose edge is dragged in slowly: a window once it is no longer the size it was zoomed at - let mut watch = WindowWatch::told_live(false); - let rest = place([1300.0, 800.0], true); - for now in [0.0, 1.0] { - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); - } - let mut remembered = Some(rest); - for frame in 1..=10 { - remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2560.0 - frame as f32, 1335.0]) }, 2.0 + f64::from(frame) / 60.0); - } - assert_eq!(remembered.map(|p| p.zoomed), Some(false), "ten points narrower, a point at a time"); - // and one whose lower edge is dragged up a little: it still fills its screen, and is a window - let mut watch = WindowWatch::told_live(false); - for now in [0.0, 1.0] { - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); - } - let shorter = Seen { zoomed: true, ..window([2560.0, 1385.0]) }; - watch.note(Some(rest), &shorter, 2.0); - assert_eq!(watch.note(Some(rest), &shorter, 2.6), Some(place([2560.0, 1385.0], false))); - } - - /// Given macOS and a window that opened zoomed, when its edge is dragged in before it was - /// seen at rest (one side only gets smaller), or it is carried to a larger screen in that - /// moment, then it is a window; and a frame without the screen's size costs a zoomed window - /// nothing. - #[test] - fn a_zoomed_window_changed_before_it_rested_is_a_window_where_the_system_does_not_say() { - let rest = place([1240.0, 820.0], true); - let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; - for at in [0.1, 0.3] { - let mut watch = WindowWatch::told_live(false); - watch.note(Some(rest), &zoomed, 0.0); - let dragged = Seen { zoomed: true, ..window([2000.0, 1415.0]) }; - assert_eq!(watch.note(Some(rest), &dragged, at), Some(place([1240.0, 820.0], false)), "dragged at {at}"); - assert_eq!(watch.note(Some(rest), &dragged, at + 0.6), Some(place([2000.0, 1415.0], false))); - } - // carried to a larger screen, as large as it is, a moment after it opened: no frame came in between - for at in [0.2, 0.52, 0.9] { - let mut watch = WindowWatch::told_live(false); - watch.note(Some(rest), &zoomed, 0.0); - let carried = Seen { size: Some([2560.0, 1415.0]), screen: Some([5120.0, 2880.0]), zoomed: true, ..Default::default() }; - assert_eq!(watch.note(Some(rest), &carried, at).map(|p| p.zoomed), Some(false), "carried at {at}"); - } - // the screen's size missing for a frame - let mut watch = WindowWatch::told_live(false); - for now in [0.0, 1.0] { - watch.note(Some(rest), &zoomed, now); - } - assert_eq!(watch.note(Some(rest), &Seen { screen: None, ..zoomed }, 2.0), Some(rest)); - assert_eq!(watch.note(Some(rest), &zoomed, 2.1), Some(rest)); - } - - /// Given macOS and a window that opened zoomed and was then resized by hand (the system's - /// word still says zoomed), when it goes full screen and back, or reports no size for a - /// frame, then it is still the window it was: what was made out of it is not let go of for - /// the system's old word. - #[test] - fn a_window_made_out_is_not_taken_for_zoomed_again_on_the_systems_old_word() { - for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { - let mut watch = WindowWatch::told_live(false); - let rest = place([1240.0, 820.0], true); - for now in [0.0, 1.0] { - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); - } - let dragged = Seen { zoomed: true, ..window([1500.0, 900.0]) }; - let mut remembered = watch.note(Some(rest), &dragged, 2.0); - remembered = watch.note(remembered, &dragged, 2.6); - assert_eq!(remembered, Some(place([1500.0, 900.0], false))); - remembered = watch.note(remembered, &away, 5.0); - for now in [8.0, 8.6, 9.2, 30.0] { - remembered = watch.note(remembered, &dragged, now); - assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?} at {now}"); - } - } - } - - /// Given a platform where the system says whether the window is maximized, when a window - /// grows until it fills its screen, then it is a window all the same: only the system's - /// word counts there. And the watch the app makes goes by that word everywhere but on macOS. - #[test] - fn where_the_system_says_only_its_word_counts() { - let rest = place([1300.0, 800.0], false); - let mut watch = WindowWatch::told_live(true); - let mut remembered = Some(rest); - for (i, size) in [[1300.0, 800.0], [2000.0, 1100.0], [2560.0, 1335.0], [2560.0, 1335.0], [2560.0, 1335.0]].into_iter().enumerate() { - remembered = watch.note(remembered, &window(size), i as f64 * 0.4); - assert_eq!(remembered.map(|p| p.zoomed), Some(false)); - } - assert_eq!(remembered, Some(place([2560.0, 1335.0], false))); - // the app's own watch: opened zoomed and then seen at a hand size, the system's word unchanged - let mut watch = WindowWatch::default(); - for now in [0.0, 1.0] { - watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); - } - let zoomed = watch.note(Some(rest), &Seen { zoomed: true, ..window([1500.0, 900.0]) }, 2.0).map(|p| p.zoomed); - assert_eq!(zoomed, Some(!cfg!(target_os = "macos")), "macOS goes by the size, the others by the word"); - } - - /// Given a window that left full screen a moment ago, when it is minimized or goes full - /// screen again, then nothing waits; and a window that covers its screen altogether (full - /// screen, though nothing says so) is not remembered as a size, however long it stays. - #[test] - fn nothing_waits_when_a_window_just_back_from_full_screen_goes_away_again() { - let rest = place([1300.0, 800.0], false); - for live in [true, false] { - for away in [Seen { minimized: true, ..window([160.0, 28.0]) }, Seen { full_screen: true, ..window(SCREEN) }] { - let mut watch = WindowWatch::told_live(live); - watch.note(Some(rest), &Seen { full_screen: true, ..window(SCREEN) }, 1.0); - watch.note(Some(rest), &window([1300.0, 800.0]), 2.0); - assert!(watch.waits(2.0).is_some(), "the moment after full screen"); - for now in [2.1, 2.7, 60.0] { - assert_eq!(watch.note(Some(rest), &away, now), Some(rest)); - assert!(watch.waits(now).is_none(), "{away:?} at {now}"); - } - } - let mut watch = WindowWatch::told_live(live); - watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); - for now in [1.0, 1.6, 2.2, 60.0] { - // (where the system does not say, a window that comes to cover its screen is zoomed) - assert_eq!(watch.note(Some(rest), &window(SCREEN), now).map(|p| p.size), Some(rest.size), "covering its screen at {now}"); - } - assert!(watch.waits(60.0).is_none()); - } - } - - /// Given a window smaller than the app's smallest (a window manager that does not keep to - /// it, a script), when it rests, then the smallest size is remembered, once: what is - /// remembered and what is seen agree from then on, and nothing waits. - #[test] - fn a_window_smaller_than_the_smallest_is_remembered_once() { - for live in [true, false] { - let mut watch = WindowWatch::told_live(live); - let small = window([640.0, 700.0]); - let mut remembered = watch.note(Some(place([1300.0, 800.0], false)), &small, 0.0); - remembered = watch.note(remembered.and_then(WindowPlace::checked), &small, 0.6); - assert_eq!(remembered, Some(place([MIN_SIZE[0], 700.0], false))); - for frame in 0..20 { - let now = 1.0 + f64::from(frame) * 0.1; - assert_eq!(watch.note(remembered.and_then(WindowPlace::checked), &small, now), remembered); - assert!(watch.waits(now).is_none(), "frame {frame}"); - } - } - } - - /// Full screen and a minimized window are not sizes to open at: nothing is remembered of - /// them. Nor of a window that reports no size. - #[test] - fn full_screen_and_minimized_windows_are_not_remembered() { - let rest = place([1300.0, 800.0], false); - for seen in [ - Seen { full_screen: true, ..window(SCREEN) }, - Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, - Seen { minimized: true, ..window([160.0, 28.0]) }, - Seen { size: None, ..window([0.0, 0.0]) }, - ] { - for live in [true, false] { - let mut watch = WindowWatch::told_live(live); - assert_eq!(watch.note(Some(rest), &seen, 0.0), Some(rest), "{seen:?}"); - assert_eq!(watch.note(Some(rest), &seen, 5.0), Some(rest), "{seen:?}"); - assert_eq!(watch.at_exit(Some(rest), 6.0), Some(rest)); - } - } - } - - /// Given a window that was resized, when the app quits before the size has rested its - /// moment, then the size is remembered if the window had been let go of, and not if it was - /// still changing with every frame (a zoom the system was animating). - #[test] - fn a_size_the_window_rested_at_is_remembered_when_the_app_quits() { - let rest = place([1300.0, 800.0], false); - let mut watch = WindowWatch::told_live(true); - watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); - assert_eq!(watch.at_exit(Some(rest), 1.3), Some(place([1500.0, 900.0], false))); - assert_eq!(watch.at_exit(Some(rest), 9.0), Some(rest), "said once"); - let mut watch = WindowWatch::told_live(true); - watch.note(Some(rest), &window([1500.0, 900.0]), 1.00); - watch.note(Some(rest), &window([1700.0, 1000.0]), 1.02); - assert_eq!(watch.at_exit(Some(rest), 1.04), Some(rest), "a size it was passing through"); - assert_eq!(WindowWatch::told_live(true).at_exit(None, 3.0), None); - } - - /// What egui holds of the window is read in the system's points: a size every platform - /// reports (the rectangle the app draws in), brought back from the interface's own scale. - #[test] - fn the_window_is_seen_in_the_systems_points() { - use egui::{Rect, ViewportInfo, pos2, vec2}; - let info = ViewportInfo { monitor_size: Some(vec2(1280.0, 720.0)), maximized: Some(true), fullscreen: Some(false), ..Default::default() }; - let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(650.0, 400.0)); - // (the interface at twice its size: egui's points are half the system's) - let seen = Seen::of(&info, content, 2.0); - assert_eq!(seen, Seen { size: Some([1300.0, 800.0]), screen: Some([2560.0, 1440.0]), zoomed: true, full_screen: false, minimized: false }); - let away = ViewportInfo { maximized: Some(false), fullscreen: Some(true), minimized: Some(true), ..Default::default() }; - let seen = Seen::of(&away, content, 1.0); - assert!(!seen.zoomed && seen.full_screen && seen.minimized); - // nothing reported but the content (Wayland): the size is there all the same - assert_eq!(Seen::of(&ViewportInfo::default(), content, 1.0).size, Some([650.0, 400.0])); - // a scale or a size that is none - assert_eq!(Seen::of(&info, content, f32::NAN).size, Some([650.0, 400.0])); - assert_eq!(Seen::of(&info, Rect::NOTHING, 1.0).size, None); - assert_eq!(Seen::of(&info, Rect::from_min_size(pos2(0.0, 0.0), vec2(0.0, 0.0)), 1.0).size, None); - } - - /// What the settings hold is not trusted as it is: a size is brought within what a window can - /// be, and anything that is no window reads as nothing remembered, without an error (which - /// would have the whole settings file taken for damaged). - #[test] - fn what_was_remembered_is_checked_and_never_costs_the_other_settings() { - #[derive(Deserialize, Debug, PartialEq)] - struct Settings { - #[serde(default, deserialize_with = "WindowPlace::lenient")] - window: Option, - other: u8, - } - let read = |window: &str| serde_json::from_str::(&format!(r#"{{"window":{window},"other":7}}"#)).map(|s| s.window); - assert_eq!(read(r#"{"size":[1300.0,800.0],"zoomed":true}"#).unwrap(), Some(place([1300.0, 800.0], true))); - assert_eq!(read(r#"{"size":[1300,800]}"#).unwrap(), Some(place([1300.0, 800.0], false))); - // (a place on the desktop, as a version that remembered one would write it, is passed over) - assert_eq!(read(r#"{"size":[1300,800],"position":[40,60],"zoomed":false}"#).unwrap(), Some(place([1300.0, 800.0], false))); - assert_eq!(read(r#"{"size":[200,100]}"#).unwrap().map(|p| p.size), Some(MIN_SIZE), "too small to be the app's window"); - for odd in [ - "null", - "{}", - r#""x""#, - "5", - "[]", - "true", - r#"{"size":[1,2,3]}"#, - r#"{"size":[1]}"#, - r#"{"size":"big"}"#, - r#"{"size":[null,800]}"#, - r#"{"size":[1300,800],"zoomed":1}"#, - r#"{"size":[0,0]}"#, - r#"{"size":[-5,800]}"#, - r#"{"size":[1e39,800]}"#, - r#"{"size":[99999,800]}"#, - ] { - assert_eq!(read(odd).unwrap(), None, "{odd}"); - } - assert_eq!(serde_json::from_str::(r#"{"other":7}"#).unwrap(), Settings { window: None, other: 7 }, "settings from before"); - // and what is written is read back - let written = serde_json::to_string(&place([1300.5, 800.25], true)).unwrap(); - assert_eq!(read(&written).unwrap(), Some(place([1300.5, 800.25], true))); - for size in [[f32::NAN, 800.0], [f32::INFINITY, 800.0], [0.5, 800.0]] { - assert_eq!(place(size, false).checked(), None, "{size:?}"); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/window_place/tests.rs b/crates/ui-egui/src/window_place/tests.rs new file mode 100644 index 000000000..0fba2b0c7 --- /dev/null +++ b/crates/ui-egui/src/window_place/tests.rs @@ -0,0 +1,596 @@ +use super::*; + +const SCREEN: [f32; 2] = [2560.0, 1440.0]; + +fn window(size: [f32; 2]) -> Seen { + Seen { size: Some(size), screen: Some(SCREEN), ..Default::default() } +} + +fn place(size: [f32; 2], zoomed: bool) -> WindowPlace { + WindowPlace { size, zoomed } +} + +/// Given a window that is resized and comes to rest, when it is watched, then its new size +/// is remembered once it has rested for a moment, and none of the sizes it passed through. +#[test] +fn a_window_is_remembered_at_the_size_it_comes_to_rest_at() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let mut remembered = None; + for (i, width) in [1000.0, 1100.0, 1200.0, 1300.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 800.0]), i as f64 * 0.1); + assert_eq!(remembered, None, "passing through {width}"); + assert!(watch.waits(i as f64 * 0.1).is_some(), "and the app looks again"); + } + remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.5); + assert_eq!(remembered, None, "not yet at rest"); + // (it has been this size since 0.3: the app looks again just after 0.8) + let wait = watch.waits(0.5).map(|w| w.as_secs_f64()).unwrap(); + assert!((wait - 0.35).abs() < 1e-6, "{wait}"); + remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.9); + assert_eq!(remembered, Some(place([1300.0, 800.0], false))); + assert!(watch.waits(0.9).is_none()); + // as it is: nothing to wait for + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 5.0), remembered); + assert!(watch.waits(5.0).is_none()); + } +} + +/// Given a window at rest that is zoomed (the top bar double-clicked), when it is watched +/// where the window system says so, then it is remembered as zoomed at once, with the size +/// it had before, though it grew through other sizes on the way; un-zoomed, it is no longer +/// zoomed at once and keeps that size. +#[test] +fn a_zoomed_window_keeps_the_size_it_goes_back_to() { + let mut watch = WindowWatch::told_live(true); + let rest = place([1300.0, 800.0], false); + let mut remembered = Some(rest); + for (i, width) in [1600.0, 2000.0, 2400.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 1300.0]), 10.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(rest)); + } + let zoomed = Seen { zoomed: true, ..window([2560.0, 1412.0]) }; + remembered = watch.note(remembered, &zoomed, 10.2); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert_eq!(watch.note(remembered, &zoomed, 30.0), Some(place([1300.0, 800.0], true)), "however long it stays zoomed"); + assert!(watch.waits(30.0).is_none()); + remembered = watch.note(remembered, &window([2000.0, 1100.0]), 31.0); + assert_eq!(remembered, Some(rest), "no longer zoomed, and the size it had"); + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 31.2), Some(rest)); + // zoomed before anything was remembered: it opens zoomed, and goes back to the usual size + assert_eq!(WindowWatch::told_live(true).note(None, &zoomed, 0.0), Some(place(DEFAULT_SIZE, true))); + // the system's word is the answer there: a window as large as its screen is a window + assert_eq!(WindowWatch::told_live(true).note(Some(rest), &window([2560.0, 1335.0]), 0.0).map(|p| p.zoomed), Some(false)); +} + +/// Given macOS, where the window system says whether the window is zoomed only as it opens +/// and when the app zooms it itself: when a window that opened zoomed is resized by its edge, +/// then it is a window again, remembered at the size it was given, though the system's word +/// still says zoomed; zoomed once more, it is zoomed. +#[test] +fn a_zoomed_window_resized_by_its_edge_is_a_window_again_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1335.0]) }; + let mut remembered = watch.note(Some(rest), &zoomed, 0.0); + assert_eq!(remembered, Some(rest)); + assert!(watch.waits(0.0).is_some(), "the size it is zoomed at is still to be seen at rest"); + remembered = watch.note(remembered, &zoomed, 0.6); + assert_eq!(remembered, Some(rest)); + assert!(watch.waits(0.6).is_none()); + assert_eq!(watch.note(remembered, &zoomed, 60.0), Some(rest), "however long it stays zoomed"); + // (a size goes through the screen's pixels and back: a point off is the same size) + assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([2559.0, 1336.0]) }, 60.5), Some(rest)); + // its right edge dragged in a little, far less than a Dock is wide; the word is unchanged + let dragged = Seen { zoomed: true, ..window([2500.0, 1335.0]) }; + remembered = watch.note(remembered, &dragged, 61.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], false)), "not zoomed any more"); + remembered = watch.note(remembered, &dragged, 61.6); + assert_eq!(remembered, Some(place([2500.0, 1335.0], false)), "and at the size it was given"); + assert!(watch.waits(61.6).is_none()); + // zoomed again from the Window menu: back at the size it is zoomed at + assert_eq!(watch.note(remembered, &zoomed, 70.0), Some(place([2500.0, 1335.0], true))); +} + +/// Given macOS, when the app zooms the window itself (the top bar double-clicked), then the +/// system says so at once and the window is zoomed through all the sizes it grows through, +/// with the size it had, wherever the Dock is; un-zoomed the same way, it is a window again +/// at that size. +#[test] +fn a_window_the_app_zooms_is_zoomed_at_once_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], false); + let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + // (a Dock at the left: zoomed, the window is narrower than its screen) + for (i, width) in [1300.0, 1700.0, 2100.0, 2410.0].into_iter().enumerate() { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 1415.0]) }, 1.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(place([1300.0, 800.0], true)), "growing through {width}"); + } + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2410.0, 1415.0]) }, 2.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert!(watch.waits(2.0).is_none()); + for (i, width) in [2100.0, 1700.0, 1300.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 800.0]), 3.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(rest), "shrinking through {width}"); + } + assert!(watch.waits(3.1).is_none(), "back at the size it had: nothing to wait for"); +} + +/// Given macOS and a window zoomed from the Window menu (the system's word stays "not +/// zoomed"), when it is seen to come to fill its screen from side to side, then it is zoomed, +/// with the size it had, and zoomed back it is a window at that size again. A window that +/// only grows large is not zoomed, on any screen. +#[test] +fn a_window_that_comes_to_fill_its_screen_is_zoomed_where_the_system_does_not_say() { + let rest = place([1300.0, 800.0], false); + // a window of 1300 × 800 on `screen` that is next seen at `size` + let zoomed_on = |screen: [f32; 2], size: [f32; 2]| { + let mut watch = WindowWatch::told_live(false); + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + watch.note(Some(rest), &seen([1300.0, 800.0]), 0.0); + watch.note(Some(rest), &seen(size), 0.2).map(|p| p.zoomed) + }; + // under the menu bar, with the Dock hidden, small, or as large as it gets + for (screen, size) in [ + ([2560.0, 1440.0], [2560.0, 1415.0]), + ([2560.0, 1440.0], [2560.0, 1335.0]), + ([2560.0, 1440.0], [2559.0, 1335.0]), + ([1728.0, 1117.0], [1728.0, 1084.0]), + ([1512.0, 982.0], [1512.0, 794.0]), + ] { + assert_eq!(zoomed_on(screen, size), Some(true), "{size:?} on {screen:?}"); + } + // the usual size on a laptop's screen, windows sized by hand to nearly the screen, one + // that is a little narrower than it, and one as wide as it and far from as tall + for (screen, size) in [ + ([1728.0, 1117.0], DEFAULT_SIZE), + ([1710.0, 1107.0], DEFAULT_SIZE), + ([1512.0, 982.0], [1400.0, 820.0]), + ([1470.0, 956.0], [1340.0, 780.0]), + ([2560.0, 1440.0], [2430.0, 1270.0]), + ([2560.0, 1440.0], [2300.0, 1200.0]), + ([2560.0, 1440.0], [2550.0, 1415.0]), + ([2560.0, 1440.0], [2560.0, 1190.0]), + ] { + assert_eq!(zoomed_on(screen, size), Some(false), "{size:?} on {screen:?}"); + } + let mut watch = WindowWatch::told_live(false); + let filling = window([2560.0, 1335.0]); + let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + remembered = watch.note(remembered, &window([2000.0, 1100.0]), 0.9); + remembered = watch.note(remembered, &filling, 1.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert_eq!(watch.note(remembered, &filling, 9.0), remembered, "and its own size is not written over"); + remembered = watch.note(remembered, &window([1900.0, 1000.0]), 10.0); + assert_eq!(remembered, Some(rest), "zoomed back: a window, at the size it had"); + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 10.2), Some(rest)); + assert!(watch.waits(10.2).is_none()); + // not told apart: zoomed from the Window menu beside a Dock at the side, it is remembered + // as a window of that size + let mut watch = WindowWatch::told_live(false); + let beside_a_dock = window([2410.0, 1415.0]); + watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + watch.note(Some(rest), &beside_a_dock, 0.3); + assert_eq!(watch.note(Some(rest), &beside_a_dock, 0.9), Some(place([2410.0, 1415.0], false))); + // without a screen's size to go by, the system's word is all there is + let no_screen = Seen { size: Some([2560.0, 1335.0]), screen: None, zoomed: true, ..Default::default() }; + assert_eq!(WindowWatch::told_live(false).note(Some(rest), &no_screen, 0.0), Some(place([1300.0, 800.0], true))); +} + +/// Given macOS and a window that was left as wide as its screen and a little shorter (its +/// lower edge dragged up after a zoom), when the app opens it at that size, then it is a +/// window of that size, launch after launch: filling the screen is no sign of a zoom unless +/// the window was seen to come to it. Dragged shorter still, it is remembered at that. +#[test] +fn a_window_that_opens_as_wide_as_its_screen_is_a_window_where_the_system_does_not_say() { + for (screen, size) in [([1728.0, 1117.0], [1728.0, 1054.0]), ([1512.0, 982.0], [1512.0, 760.0]), ([3440.0, 1440.0], [3440.0, 1200.0])] { + let left = place(size, false); + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + let mut watch = WindowWatch::told_live(false); + for frame in 0..5 { + assert_eq!(watch.note(Some(left), &seen(size), f64::from(frame) * 0.3), Some(left), "{size:?} on {screen:?}"); + } + let shorter = [size[0], size[1] - 20.0]; + watch.note(Some(left), &seen(shorter), 2.0); + assert_eq!(watch.note(Some(left), &seen(shorter), 2.6), Some(place(shorter, false))); + } + // the same after a double-click on the top bar that changed nothing (the system's word was + // stale, and is now "not zoomed"): still the window it is + let mut watch = WindowWatch::told_live(false); + let screen = [1728.0, 1117.0]; + let left = place([1728.0, 1054.0], false); + watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 0.0); + watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 1.0); + // (taken for zoomed on the system's word, until that word is taken back) + let unsaid = Seen { size: Some(left.size), screen: Some(screen), zoomed: false, ..Default::default() }; + assert_eq!(watch.note(Some(left), &unsaid, 2.0), Some(left)); + assert_eq!(watch.note(Some(left), &unsaid, 9.0), Some(left)); +} + +/// Given macOS and a zoomed window, when it is zoomed back from the Window menu (or resized by +/// its edge) before it was ever seen at rest, then it is a window at once, and the size it +/// comes to rest at is the window's, not taken for the size it is zoomed at. +#[test] +fn a_window_zoomed_back_at_once_is_a_window_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + let mut remembered = watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); + assert_eq!(remembered, Some(rest)); + for (i, width) in [2200.0, 1700.0, 1240.0, 1240.0, 1240.0].into_iter().enumerate() { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 820.0]) }, 0.3 + i as f64 * 0.05); + assert_eq!(remembered, Some(place([1240.0, 820.0], false)), "{width}"); + } + assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([1240.0, 820.0]) }, 60.0), Some(place([1240.0, 820.0], false))); + assert!(watch.waits(60.0).is_none()); +} + +/// Given macOS and a zoomed window, when its screen changes (the display unplugged, another +/// resolution) and it is fitted to the new one, then it is still zoomed and keeps the size +/// it goes back to. Moved to a larger screen that it does not fill, it is a window. +#[test] +fn a_zoomed_window_is_still_zoomed_on_another_screen_where_the_system_does_not_say() { + for said in [true, false] { + let rest = place([1240.0, 820.0], false); + let on = |screen: [f32; 2], size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), zoomed: said, ..Default::default() }; + let mut watch = WindowWatch::told_live(false); + let mut remembered = watch.note(Some(rest), &on([2560.0, 1440.0], [1240.0, 820.0]), 0.0); + if said { + // (the system's word as the window opened: a window; the app zooms it later) + remembered = watch.note(remembered, &Seen { zoomed: false, ..on([2560.0, 1440.0], [1240.0, 820.0]) }, 0.0); + } + for now in [1.0, 2.0] { + remembered = watch.note(remembered, &on([2560.0, 1440.0], [2560.0, 1415.0]), now); + } + assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "said: {said}"); + for now in [10.0, 10.6, 60.0] { + remembered = watch.note(remembered, &on([1728.0, 1117.0], [1728.0, 1084.0]), now); + assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "on the smaller screen at {now}, said: {said}"); + } + assert!(watch.waits(60.0).is_none()); + // dragged over to a screen it does not fill + remembered = watch.note(remembered, &on([3440.0, 1440.0], [1728.0, 1084.0]), 70.0); + assert_eq!(remembered.map(|p| p.zoomed), Some(false), "said: {said}"); + } + // seen zoomed in one frame only, and fitted to another screen long after: it had rested + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); + let fitted = Seen { size: Some([1728.0, 1084.0]), screen: Some([1728.0, 1117.0]), zoomed: true, ..Default::default() }; + assert_eq!(watch.note(Some(rest), &fitted, 5.0), Some(rest)); +} + +/// Given a size the window has not rested at yet, when the window goes full screen or is +/// minimized, or reports no size, then nothing waits (the app is not woken again and again +/// for as long as that lasts), and back as a window its size is seen afresh. +#[test] +fn nothing_waits_while_the_window_is_full_screen_or_minimized() { + let rest = place([1300.0, 800.0], false); + for live in [true, false] { + for away in [ + Seen { full_screen: true, ..window(SCREEN) }, + Seen { minimized: true, ..window([160.0, 28.0]) }, + Seen { size: None, ..window(SCREEN) }, + ] { + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); + assert!(watch.waits(1.0).is_some()); + assert_eq!(watch.note(Some(rest), &away, 1.1), Some(rest)); + assert!(watch.waits(1.1).is_none(), "{away:?}"); + assert_eq!(watch.note(Some(rest), &away, 900.0), Some(rest)); + assert!(watch.waits(900.0).is_none(), "{away:?}"); + assert_eq!(watch.note(Some(rest), &window([1500.0, 900.0]), 901.0), Some(rest)); + let mut remembered = Some(rest); + for now in [902.2, 902.8] { + remembered = watch.note(remembered, &window([1500.0, 900.0]), now); + } + assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?}"); + assert!(watch.waits(902.8).is_none()); + } + } + // (macOS: the same of a zoomed window's size that was still to be seen at rest) + for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, 0.0); + assert!(watch.waits(0.0).is_some()); + watch.note(Some(rest), &away, 0.1); + assert!(watch.waits(0.1).is_none(), "{away:?}"); + } +} + +/// Given a zoomed window that was full screen, when it leaves full screen, then for a moment +/// it is not looked at (the system animates the way back, at sizes that are neither the +/// window's nor the zoomed one), the app looks again once that is over, and the window is +/// zoomed with the size it goes back to, as before. +#[test] +fn a_window_that_has_just_left_full_screen_is_not_looked_at() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let rest = place([1240.0, 820.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; + for now in [0.0, 1.0] { + assert_eq!(watch.note(Some(rest), &zoomed, now), Some(rest)); + } + assert_eq!(watch.note(Some(rest), &Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, 5.0), Some(rest)); + // on the way back the system no longer says "full screen", nor "zoomed" where its word is live + for now in [9.0, 9.3, 9.6, 9.9] { + assert_eq!(watch.note(Some(rest), &Seen { zoomed: !live, ..window(SCREEN) }, now), Some(rest), "at {now}"); + let wait = watch.waits(now).map(|w| w.as_secs_f64()).unwrap(); + assert!((wait - (10.05 - now)).abs() < 1e-6, "at {now}: looks again after the moment, {wait}"); + } + assert_eq!(watch.note(Some(rest), &zoomed, 10.1), Some(rest)); + assert_eq!(watch.note(Some(rest), &zoomed, 11.0), Some(rest)); + assert!(watch.waits(11.0).is_none()); + } +} + +/// Given macOS and a window dragged by its corner, when it reaches the screen's width and is +/// then dragged on, taller and little by little narrower again, and left narrower than the +/// screen, then it is a window at the size it was left at: a zoom only grows on its way, so +/// what gets narrower again is somebody's hand. +#[test] +fn a_window_dragged_wide_and_narrower_again_is_a_window_where_the_system_does_not_say() { + for per_frame in [1.0, 1.4] { + let screen = [1728.0, 1117.0]; + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + let rest = place([1600.0, 950.0], false); + let mut watch = WindowWatch::told_live(false); + let mut remembered = watch.note(Some(rest), &seen(rest.size), 0.0); + let mut size = [1728.0, 950.0]; + let mut now = 1.0; + for _ in 0..40 { + remembered = watch.note(remembered, &seen(size), now); + size = [size[0] - per_frame, size[1] + 3.0]; + now += 1.0 / 60.0; + } + for _ in 0..3 { + remembered = watch.note(remembered, &seen(size), now); + now += 0.3; + } + assert_eq!(remembered, Some(place(size, false)), "{per_frame} pt narrower a frame"); + } + // a zoomed window whose edge is dragged in slowly: a window once it is no longer the size it was zoomed at + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], true); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); + } + let mut remembered = Some(rest); + for frame in 1..=10 { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2560.0 - frame as f32, 1335.0]) }, 2.0 + f64::from(frame) / 60.0); + } + assert_eq!(remembered.map(|p| p.zoomed), Some(false), "ten points narrower, a point at a time"); + // and one whose lower edge is dragged up a little: it still fills its screen, and is a window + let mut watch = WindowWatch::told_live(false); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); + } + let shorter = Seen { zoomed: true, ..window([2560.0, 1385.0]) }; + watch.note(Some(rest), &shorter, 2.0); + assert_eq!(watch.note(Some(rest), &shorter, 2.6), Some(place([2560.0, 1385.0], false))); +} + +/// Given macOS and a window that opened zoomed, when its edge is dragged in before it was +/// seen at rest (one side only gets smaller), or it is carried to a larger screen in that +/// moment, then it is a window; and a frame without the screen's size costs a zoomed window +/// nothing. +#[test] +fn a_zoomed_window_changed_before_it_rested_is_a_window_where_the_system_does_not_say() { + let rest = place([1240.0, 820.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; + for at in [0.1, 0.3] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &zoomed, 0.0); + let dragged = Seen { zoomed: true, ..window([2000.0, 1415.0]) }; + assert_eq!(watch.note(Some(rest), &dragged, at), Some(place([1240.0, 820.0], false)), "dragged at {at}"); + assert_eq!(watch.note(Some(rest), &dragged, at + 0.6), Some(place([2000.0, 1415.0], false))); + } + // carried to a larger screen, as large as it is, a moment after it opened: no frame came in between + for at in [0.2, 0.52, 0.9] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &zoomed, 0.0); + let carried = Seen { size: Some([2560.0, 1415.0]), screen: Some([5120.0, 2880.0]), zoomed: true, ..Default::default() }; + assert_eq!(watch.note(Some(rest), &carried, at).map(|p| p.zoomed), Some(false), "carried at {at}"); + } + // the screen's size missing for a frame + let mut watch = WindowWatch::told_live(false); + for now in [0.0, 1.0] { + watch.note(Some(rest), &zoomed, now); + } + assert_eq!(watch.note(Some(rest), &Seen { screen: None, ..zoomed }, 2.0), Some(rest)); + assert_eq!(watch.note(Some(rest), &zoomed, 2.1), Some(rest)); +} + +/// Given macOS and a window that opened zoomed and was then resized by hand (the system's +/// word still says zoomed), when it goes full screen and back, or reports no size for a +/// frame, then it is still the window it was: what was made out of it is not let go of for +/// the system's old word. +#[test] +fn a_window_made_out_is_not_taken_for_zoomed_again_on_the_systems_old_word() { + for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); + } + let dragged = Seen { zoomed: true, ..window([1500.0, 900.0]) }; + let mut remembered = watch.note(Some(rest), &dragged, 2.0); + remembered = watch.note(remembered, &dragged, 2.6); + assert_eq!(remembered, Some(place([1500.0, 900.0], false))); + remembered = watch.note(remembered, &away, 5.0); + for now in [8.0, 8.6, 9.2, 30.0] { + remembered = watch.note(remembered, &dragged, now); + assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?} at {now}"); + } + } +} + +/// Given a platform where the system says whether the window is maximized, when a window +/// grows until it fills its screen, then it is a window all the same: only the system's +/// word counts there. And the watch the app makes goes by that word everywhere but on macOS. +#[test] +fn where_the_system_says_only_its_word_counts() { + let rest = place([1300.0, 800.0], false); + let mut watch = WindowWatch::told_live(true); + let mut remembered = Some(rest); + for (i, size) in [[1300.0, 800.0], [2000.0, 1100.0], [2560.0, 1335.0], [2560.0, 1335.0], [2560.0, 1335.0]].into_iter().enumerate() { + remembered = watch.note(remembered, &window(size), i as f64 * 0.4); + assert_eq!(remembered.map(|p| p.zoomed), Some(false)); + } + assert_eq!(remembered, Some(place([2560.0, 1335.0], false))); + // the app's own watch: opened zoomed and then seen at a hand size, the system's word unchanged + let mut watch = WindowWatch::default(); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); + } + let zoomed = watch.note(Some(rest), &Seen { zoomed: true, ..window([1500.0, 900.0]) }, 2.0).map(|p| p.zoomed); + assert_eq!(zoomed, Some(!cfg!(target_os = "macos")), "macOS goes by the size, the others by the word"); +} + +/// Given a window that left full screen a moment ago, when it is minimized or goes full +/// screen again, then nothing waits; and a window that covers its screen altogether (full +/// screen, though nothing says so) is not remembered as a size, however long it stays. +#[test] +fn nothing_waits_when_a_window_just_back_from_full_screen_goes_away_again() { + let rest = place([1300.0, 800.0], false); + for live in [true, false] { + for away in [Seen { minimized: true, ..window([160.0, 28.0]) }, Seen { full_screen: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &Seen { full_screen: true, ..window(SCREEN) }, 1.0); + watch.note(Some(rest), &window([1300.0, 800.0]), 2.0); + assert!(watch.waits(2.0).is_some(), "the moment after full screen"); + for now in [2.1, 2.7, 60.0] { + assert_eq!(watch.note(Some(rest), &away, now), Some(rest)); + assert!(watch.waits(now).is_none(), "{away:?} at {now}"); + } + } + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + for now in [1.0, 1.6, 2.2, 60.0] { + // (where the system does not say, a window that comes to cover its screen is zoomed) + assert_eq!(watch.note(Some(rest), &window(SCREEN), now).map(|p| p.size), Some(rest.size), "covering its screen at {now}"); + } + assert!(watch.waits(60.0).is_none()); + } +} + +/// Given a window smaller than the app's smallest (a window manager that does not keep to +/// it, a script), when it rests, then the smallest size is remembered, once: what is +/// remembered and what is seen agree from then on, and nothing waits. +#[test] +fn a_window_smaller_than_the_smallest_is_remembered_once() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let small = window([640.0, 700.0]); + let mut remembered = watch.note(Some(place([1300.0, 800.0], false)), &small, 0.0); + remembered = watch.note(remembered.and_then(WindowPlace::checked), &small, 0.6); + assert_eq!(remembered, Some(place([MIN_SIZE[0], 700.0], false))); + for frame in 0..20 { + let now = 1.0 + f64::from(frame) * 0.1; + assert_eq!(watch.note(remembered.and_then(WindowPlace::checked), &small, now), remembered); + assert!(watch.waits(now).is_none(), "frame {frame}"); + } + } +} + +/// Full screen and a minimized window are not sizes to open at: nothing is remembered of +/// them. Nor of a window that reports no size. +#[test] +fn full_screen_and_minimized_windows_are_not_remembered() { + let rest = place([1300.0, 800.0], false); + for seen in [ + Seen { full_screen: true, ..window(SCREEN) }, + Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, + Seen { minimized: true, ..window([160.0, 28.0]) }, + Seen { size: None, ..window([0.0, 0.0]) }, + ] { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + assert_eq!(watch.note(Some(rest), &seen, 0.0), Some(rest), "{seen:?}"); + assert_eq!(watch.note(Some(rest), &seen, 5.0), Some(rest), "{seen:?}"); + assert_eq!(watch.at_exit(Some(rest), 6.0), Some(rest)); + } + } +} + +/// Given a window that was resized, when the app quits before the size has rested its +/// moment, then the size is remembered if the window had been let go of, and not if it was +/// still changing with every frame (a zoom the system was animating). +#[test] +fn a_size_the_window_rested_at_is_remembered_when_the_app_quits() { + let rest = place([1300.0, 800.0], false); + let mut watch = WindowWatch::told_live(true); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); + assert_eq!(watch.at_exit(Some(rest), 1.3), Some(place([1500.0, 900.0], false))); + assert_eq!(watch.at_exit(Some(rest), 9.0), Some(rest), "said once"); + let mut watch = WindowWatch::told_live(true); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.00); + watch.note(Some(rest), &window([1700.0, 1000.0]), 1.02); + assert_eq!(watch.at_exit(Some(rest), 1.04), Some(rest), "a size it was passing through"); + assert_eq!(WindowWatch::told_live(true).at_exit(None, 3.0), None); +} + +/// What egui holds of the window is read in the system's points: a size every platform +/// reports (the rectangle the app draws in), brought back from the interface's own scale. +#[test] +fn the_window_is_seen_in_the_systems_points() { + use egui::{Rect, ViewportInfo, pos2, vec2}; + let info = ViewportInfo { monitor_size: Some(vec2(1280.0, 720.0)), maximized: Some(true), fullscreen: Some(false), ..Default::default() }; + let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(650.0, 400.0)); + // (the interface at twice its size: egui's points are half the system's) + let seen = Seen::of(&info, content, 2.0); + assert_eq!(seen, Seen { size: Some([1300.0, 800.0]), screen: Some([2560.0, 1440.0]), zoomed: true, full_screen: false, minimized: false }); + let away = ViewportInfo { maximized: Some(false), fullscreen: Some(true), minimized: Some(true), ..Default::default() }; + let seen = Seen::of(&away, content, 1.0); + assert!(!seen.zoomed && seen.full_screen && seen.minimized); + // nothing reported but the content (Wayland): the size is there all the same + assert_eq!(Seen::of(&ViewportInfo::default(), content, 1.0).size, Some([650.0, 400.0])); + // a scale or a size that is none + assert_eq!(Seen::of(&info, content, f32::NAN).size, Some([650.0, 400.0])); + assert_eq!(Seen::of(&info, Rect::NOTHING, 1.0).size, None); + assert_eq!(Seen::of(&info, Rect::from_min_size(pos2(0.0, 0.0), vec2(0.0, 0.0)), 1.0).size, None); +} + +/// What the settings hold is not trusted as it is: a size is brought within what a window can +/// be, and anything that is no window reads as nothing remembered, without an error (which +/// would have the whole settings file taken for damaged). +#[test] +fn what_was_remembered_is_checked_and_never_costs_the_other_settings() { + #[derive(Deserialize, Debug, PartialEq)] + struct Settings { + #[serde(default, deserialize_with = "WindowPlace::lenient")] + window: Option, + other: u8, + } + let read = |window: &str| serde_json::from_str::(&format!(r#"{{"window":{window},"other":7}}"#)).map(|s| s.window); + assert_eq!(read(r#"{"size":[1300.0,800.0],"zoomed":true}"#).unwrap(), Some(place([1300.0, 800.0], true))); + assert_eq!(read(r#"{"size":[1300,800]}"#).unwrap(), Some(place([1300.0, 800.0], false))); + // (a place on the desktop, as a version that remembered one would write it, is passed over) + assert_eq!(read(r#"{"size":[1300,800],"position":[40,60],"zoomed":false}"#).unwrap(), Some(place([1300.0, 800.0], false))); + assert_eq!(read(r#"{"size":[200,100]}"#).unwrap().map(|p| p.size), Some(MIN_SIZE), "too small to be the app's window"); + for odd in [ + "null", + "{}", + r#""x""#, + "5", + "[]", + "true", + r#"{"size":[1,2,3]}"#, + r#"{"size":[1]}"#, + r#"{"size":"big"}"#, + r#"{"size":[null,800]}"#, + r#"{"size":[1300,800],"zoomed":1}"#, + r#"{"size":[0,0]}"#, + r#"{"size":[-5,800]}"#, + r#"{"size":[1e39,800]}"#, + r#"{"size":[99999,800]}"#, + ] { + assert_eq!(read(odd).unwrap(), None, "{odd}"); + } + assert_eq!(serde_json::from_str::(r#"{"other":7}"#).unwrap(), Settings { window: None, other: 7 }, "settings from before"); + // and what is written is read back + let written = serde_json::to_string(&place([1300.5, 800.25], true)).unwrap(); + assert_eq!(read(&written).unwrap(), Some(place([1300.5, 800.25], true))); + for size in [[f32::NAN, 800.0], [f32::INFINITY, 800.0], [0.5, 800.0]] { + assert_eq!(place(size, false).checked(), None, "{size:?}"); + } +} From c619470bbb13f8c95349cfffb858a5bbe41c913d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:06 +0000 Subject: [PATCH 064/191] refactor(lightcraft-catalog): move keywords.rs tests to crates/catalog/src/keywords/tests.rs --- crates/catalog/src/keywords.rs | 712 +-------------------------- crates/catalog/src/keywords/tests.rs | 709 ++++++++++++++++++++++++++ 2 files changed, 710 insertions(+), 711 deletions(-) create mode 100644 crates/catalog/src/keywords/tests.rs diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 6b0c97f5a..40e001538 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -822,714 +822,4 @@ impl Catalog { } #[cfg(test)] -mod tests { - use super::*; - use crate::{Filter, Photo, PhotoId, Source}; - - fn lib(kws: &[&[&str]]) -> (Catalog, Vec) { - let mut c = Catalog::new(); - let mut ids = Vec::new(); - for k in kws { - let id = c.alloc_photo_id(); - let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); - p.meta.keywords = k.iter().map(|s| s.to_string()).collect(); - c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); - ids.push(id); - } - (c, ids) - } - fn kws(c: &Catalog, id: PhotoId) -> Vec { - c.photo(id).unwrap().meta.keywords.clone() - } - - #[test] - fn tree_counts_photos_per_level() { - let (c, _) = lib(&[&["travel|Italy|Rome", "beach"], &["Travel|italy"], &["travel|France", "travel|italy|rome"], &["beach"]]); - let t = c.keyword_tree(); - assert_eq!(t.iter().map(|n| (n.name.as_str(), n.count)).collect::>(), vec![("beach", 2), ("travel", 3)]); - let travel = &t[1]; - assert_eq!(travel.children.iter().map(|n| (n.path.as_str(), n.count)).collect::>(), vec![("travel|France", 1), ("travel|Italy", 3)]); - assert_eq!(travel.children[1].children[0].path, "travel|Italy|Rome"); - assert_eq!(travel.children[1].children[0].count, 2); - // filtering by a parent finds its children - let f = Filter { keyword: Some("travel|italy".into()), ..Default::default() }; - assert_eq!(c.query(&f, &Default::default()).len(), 3); - } - - /// A keyword written twice on a photo, or in two spellings, counts the photo once; the next - /// photo with it counts again. - #[test] - fn tree_counts_a_repeated_keyword_once_per_photo() { - let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]); - let t = c.keyword_tree(); - assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]); - } - - #[test] - fn rename_delete_merge_are_single_undoable_batches() { - let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]); - let before = c.to_snapshot(); - let op = c.rename_keyword_ops("travel|italy", "Europe|Italy").unwrap(); - let Op::Batch { ops } = &op else { panic!() }; - assert_eq!(ops.len(), 3, "only photos that change"); - let inv = c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Europe|Italy|rome", "beach"]); - assert_eq!(kws(&c, ids[1]), ["Europe|Italy"]); - c.apply(inv).unwrap(); - assert_eq!(c.to_snapshot(), before); - // merge `italia` into the existing hierarchical keyword: duplicates collapse - let op = c.merge_keywords_ops(&["italia".into()], "travel|italy").unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[2]), ["travel|italy"]); - // delete removes the keyword and its children everywhere - let op = c.delete_keyword_ops("TRAVEL").unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["beach"]); - assert!(kws(&c, ids[1]).is_empty()); - assert_eq!(c.keywords(), vec![("beach".to_string(), 1), ("sea".to_string(), 1)]); - // invalid requests - assert!(c.rename_keyword_ops("beach", " ").is_err()); - assert!(c.rename_keyword_ops("a", "a|b").is_err()); - assert!(c.merge_keywords_ops(&["a".into()], "a|b").is_err()); - assert_eq!(clean(" a | |b "), "a|b"); - } - - fn tree_paths(nodes: &[KeywordNode]) -> Vec<(String, usize)> { - let mut out = Vec::new(); - for n in nodes { - out.push((n.path.clone(), n.count)); - out.extend(tree_paths(&n.children)); - } - out - } - - /// A keyword can be listed before any photo has it (Lightroom Classic's Create Keyword Tag): - /// it is in the tree with no photos, its parents too, and undo takes it away. - #[test] - fn a_keyword_listed_with_no_photos_is_in_the_tree() { - let (mut c, _) = lib(&[&["travel|France"]]); - let undo = c.apply(Op::SetKeyword { path: " travel | Italy ".into(), info: Some(KeywordInfo::default()) }).unwrap(); - assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1), ("travel|Italy".to_string(), 0)]); - assert_eq!(c.keyword_info("TRAVEL|italy"), Some(&KeywordInfo::default()), "found whatever the case"); - c.apply(undo).unwrap(); - assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1)]); - assert_eq!(c.keyword_info("travel|Italy"), None); - } - - /// A listed keyword needs a name, and its attributes start as Lightroom Classic's do: exported, - /// with the keywords containing it and its synonyms; not a person. - #[test] - fn a_listed_keyword_needs_a_name_and_starts_exported() { - let mut c = Catalog::new(); - assert!(c.apply(Op::SetKeyword { path: " | ".into(), info: Some(KeywordInfo::default()) }).is_err()); - let info = KeywordInfo::default(); - assert!(info.include_on_export && info.export_containing && info.export_synonyms && !info.person && info.synonyms.is_empty()); - } - - /// Changing a listed keyword's attributes is undone to the attributes it had, under the name as - /// it was written. - #[test] - fn changing_a_listed_keyword_is_undone_to_what_it_was() { - let mut c = Catalog::new(); - c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - let person = KeywordInfo { person: true, synonyms: vec!["Marriage".into()], ..KeywordInfo::default() }; - let undo = c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(person.clone()) }).unwrap(); - assert_eq!(c.keyword_info("Weddings"), Some(&person)); - assert_eq!(undo, Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }); - c.apply(undo).unwrap(); - assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default())); - } - - fn listed(c: &Catalog) -> Vec<(String, KeywordInfo)> { - let mut v: Vec<(String, KeywordInfo)> = c.listed_keywords().map(|k| (k.path.clone(), k.info.clone())).collect(); - v.sort_by_key(|(p, _)| p.to_lowercase()); - v - } - - fn with_synonyms(words: &[&str]) -> KeywordInfo { - KeywordInfo { synonyms: words.iter().map(|w| w.to_string()).collect(), ..KeywordInfo::default() } - } - - /// Renaming (or moving) a keyword takes its attributes and its listed children along, photos - /// or not, in the same undo step. - #[test] - fn renaming_a_keyword_carries_its_listing_along() { - let (mut c, ids) = lib(&[&["travel|italy"]]); - c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["trip"])) }).unwrap(); - c.apply(Op::SetKeyword { path: "travel|Spain".into(), info: Some(KeywordInfo::default()) }).unwrap(); - let before = c.to_snapshot(); - let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap(); - let undo = c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]); - assert_eq!( - listed(&c), - [("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())] - ); - c.apply(undo).unwrap(); - assert_eq!(c.to_snapshot(), before); - } - - /// A keyword only the list holds (no photos) can be renamed too. - #[test] - fn a_keyword_without_photos_can_be_renamed() { - let mut c = Catalog::new(); - c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - let op = c.rename_keyword_ops("weddings", "Events|Weddings").unwrap(); - c.apply(op).unwrap(); - assert_eq!(listed(&c), [("Events|Weddings".to_string(), KeywordInfo::default())]); - } - - /// Merging keeps the target's attributes and adds the merged keyword's synonyms to them; a target - /// not listed yet takes the merged keyword's attributes. - #[test] - fn merging_keeps_the_targets_attributes_and_gathers_synonyms() { - let (mut c, _) = lib(&[&["holiday"], &["travel"]]); - let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) }; - c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap(); - c.apply(Op::SetKeyword { - path: "holiday".into(), - info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }), - }) - .unwrap(); - let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap(); - c.apply(op).unwrap(); - assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]); - // onto a keyword that isn't listed: it takes the merged one's attributes - let mut c = Catalog::new(); - c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(with_synonyms(&["vacation"])) }).unwrap(); - let op = c.merge_keywords_ops(&["holiday".into()], "Travel").unwrap(); - c.apply(op).unwrap(); - assert_eq!(listed(&c), [("Travel".to_string(), with_synonyms(&["vacation"]))]); - } - - /// Deleting a keyword takes it, and the keywords below it, off the list as well as off photos. - #[test] - fn deleting_a_keyword_takes_it_off_the_list() { - let (mut c, _) = lib(&[&["travel|italy"], &["beach"]]); - for path in ["travel", "travel|spain", "beach"] { - c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); - } - let before = c.to_snapshot(); - let op = c.delete_keyword_ops("travel").unwrap(); - let undo = c.apply(op).unwrap(); - assert_eq!(listed(&c), [("beach".to_string(), KeywordInfo::default())]); - assert_eq!(tree_paths(&c.keyword_tree()), [("beach".to_string(), 1)]); - c.apply(undo).unwrap(); - assert_eq!(c.to_snapshot(), before); - } - - /// Creating a keyword lists it with its attributes, and can tag photos with it in the same undo - /// step. A keyword that exists already (listed, or on a photo) isn't created again. - #[test] - fn creating_a_keyword_lists_it_and_can_tag_photos() { - let (mut c, ids) = lib(&[&["beach"], &[]]); - let before = c.to_snapshot(); - let op = c.create_keyword_ops("Events | Weddings", with_synonyms(&["marriage"]), &[ids[1]]).unwrap(); - let undo = c.apply(op).unwrap(); - assert_eq!(listed(&c), [("Events|Weddings".to_string(), with_synonyms(&["marriage"]))]); - assert_eq!(kws(&c, ids[1]), ["Events|Weddings"]); - c.apply(undo).unwrap(); - assert_eq!(c.to_snapshot(), before); - for exists in ["BEACH", "events|weddings"] { - if exists == "events|weddings" { - let op = c.create_keyword_ops("Events|Weddings", KeywordInfo::default(), &[]).unwrap(); - c.apply(op).unwrap(); - } - assert!(c.create_keyword_ops(exists, KeywordInfo::default(), &[]).is_err(), "{exists}"); - } - assert!(c.create_keyword_ops(" | ", KeywordInfo::default(), &[]).is_err()); - } - - /// Moving a keyword nests it inside another (or takes it to the top level), with its children, - /// on photos and in the list. It can't go inside itself or one of its children, and moving it - /// where it already is changes nothing. - #[test] - fn moving_a_keyword_nests_it_inside_another() { - let (mut c, ids) = lib(&[&["Italy|Rome"], &["Europe"]]); - // typed in any case, the keywords keep the spelling the library has - let op = c.move_keyword_ops("italy", Some("EUROPE"), false).unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Europe|Italy|Rome"]); - let op = c.move_keyword_ops("europe|italy", None, false).unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Italy|Rome"], "back at the top level"); - assert!(c.move_keyword_ops("italy", Some("italy|rome"), false).is_err(), "inside its own child"); - assert!(c.move_keyword_ops("italy", Some("Italy"), false).is_err(), "inside itself"); - assert_eq!(c.move_keyword_ops("italy|rome", Some("italy"), false).unwrap(), Op::Batch { ops: vec![] }, "already there"); - assert!(c.move_keyword_ops("lisbon", Some("europe"), false).is_err(), "no such keyword"); - } - - /// Moving onto a name that is taken (Europe already has a Rome) merges the two, so the move - /// asks first: refused unless merging is allowed. - #[test] - fn moving_onto_a_taken_name_merges_only_when_allowed() { - let (mut c, ids) = lib(&[&["rome"], &["europe|rome"]]); - let err = c.move_keyword_ops("rome", Some("europe"), false).unwrap_err(); - assert!(matches!(err, CatalogError::KeywordExists(ref k) if k == "europe|rome"), "{err:?}"); - let op = c.move_keyword_ops("rome", Some("europe"), true).unwrap(); - c.apply(op).unwrap(); - assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["europe|rome".to_string()], vec!["europe|rome".to_string()])); - } - - /// Editing a keyword renames it (its last level: it stays where it is) and sets its attributes, - /// in one undo step. A name that is taken is refused: merging is its own action. - #[test] - fn editing_a_keyword_renames_it_and_sets_its_attributes() { - let (mut c, ids) = lib(&[&["travel|italy"], &["travel|spain"]]); - let before = c.to_snapshot(); - let person = KeywordInfo { person: true, ..KeywordInfo::default() }; - let op = c.edit_keyword_ops("travel|italy", "Italia", person.clone()).unwrap(); - let undo = c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["travel|Italia"]); - assert_eq!(c.keyword_info("travel|italia"), Some(&person)); - c.apply(undo).unwrap(); - assert_eq!(c.to_snapshot(), before); - // attributes alone - let op = c.edit_keyword_ops("travel", "travel", with_synonyms(&["trip"])).unwrap(); - c.apply(op).unwrap(); - assert_eq!(listed(&c), [("travel".to_string(), with_synonyms(&["trip"]))]); - assert!(matches!(c.edit_keyword_ops("travel|italy", "Spain", KeywordInfo::default()), Err(CatalogError::KeywordExists(_)))); - assert!(c.edit_keyword_ops("travel|italy", "a|b", KeywordInfo::default()).is_err(), "a name, not a path"); - assert!(c.edit_keyword_ops("travel|italy", " ", KeywordInfo::default()).is_err()); - } - - /// Purging takes off the list the keywords no photo has (nor any keyword below them), in one - /// undo step; keywords photos carry stay. - #[test] - fn purging_takes_unused_keywords_off_the_list() { - let (mut c, _) = lib(&[&["travel|italy"]]); - for path in ["travel", "travel|italy", "travel|spain", "weddings"] { - c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); - } - let op = c.purge_unused_keywords_ops(); - c.apply(op).unwrap(); - assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel", "travel|italy"]); - assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|italy".to_string(), 1)]); - } - - /// Renaming matches names whatever their case, also where a letter's case changes its length - /// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter. - #[test] - fn renaming_keeps_the_levels_below_whatever_the_case() { - let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]); - let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Road|nord"]); - let op = c.rename_keyword_ops("ẞA", "Weg").unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[1]), ["Weg|ü"]); - } - - fn exported(c: &Catalog, keywords: &[&str]) -> (Vec, Vec) { - let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::>()); - (e.flat, e.hierarchical) - } - - /// By default a keyword is exported as its name together with the keywords containing it (flat, - /// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once. - #[test] - fn a_keyword_exports_its_name_its_parents_and_its_path() { - let c = Catalog::new(); - let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]); - assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]); - assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]); - } - - /// A keyword not included on export is left out, path and all; a parent left out (a keyword that - /// only organizes others) isn't exported as a containing keyword, but its children are. - #[test] - fn keywords_left_out_of_export_stay_out() { - let mut c = Catalog::new(); - let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; - c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap(); - c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap(); - let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]); - assert_eq!(flat, ["Lisbon"]); - assert_eq!(paths, ["Places|Lisbon"]); - } - - /// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go - /// out with it unless "export synonyms" is off. - #[test] - fn containing_keywords_and_synonyms_follow_their_options() { - let mut c = Catalog::new(); - let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) }; - c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap(); - let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) }; - c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap(); - let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]); - assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]); - } - - /// Names match whatever the case of any letter, not only ASCII ones: changing only the case of - /// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing. - #[test] - fn names_match_whatever_the_case_of_any_letter() { - let (mut c, ids) = lib(&[&["Ärzte|Wien"]]); - let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]); - assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] }); - let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap(); - c.apply(op).unwrap(); - assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]); - assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists"); - } - - /// A keyword that is only the parent of a listed one ("Events" of "Events|Weddings") is a - /// keyword like any other: it is in the tree, so it can be found, moved and renamed. - #[test] - fn the_parent_of_a_listed_keyword_is_a_keyword() { - let mut c = Catalog::new(); - c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - assert!(c.has_keyword("events")); - assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events")); - let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap(); - c.apply(op).unwrap(); - assert_eq!(tree_paths(&c.keyword_tree()), [("Occasions".to_string(), 0), ("Occasions|Weddings".to_string(), 0)]); - } - - /// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it - /// can be created again and is purged as unused, and its spelling there isn't the library's. - #[test] - fn deleted_photos_dont_hold_keywords() { - let (mut c, ids) = lib(&[&["Weddings"], &["travel"]]); - c.apply(Op::SetDeleted { id: ids[0], deleted: true }).unwrap(); - assert!(!c.has_keyword("weddings")); - assert!(c.create_keyword_ops("weddings", KeywordInfo::default(), &[]).is_ok()); - c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - let op = c.purge_unused_keywords_ops(); - c.apply(op).unwrap(); - assert!(listed(&c).is_empty(), "only a deleted photo has it: unused"); - } - - /// Merging several keywords moves each keyword under them by the closest one, whatever the - /// order they are given in, and every listing lands where its keyword does: one landing where - /// another is about to leave isn't merged into it. - #[test] - fn merging_overlapping_keywords_keeps_every_listing() { - let (mut c, ids) = lib(&[&["a|b|c"], &["a|c"]]); - let person = KeywordInfo { person: true, ..KeywordInfo::default() }; - c.apply(Op::SetKeyword { path: "a|b|c".into(), info: Some(person.clone()) }).unwrap(); - c.apply(Op::SetKeyword { path: "a|c".into(), info: Some(with_synonyms(&["sea"])) }).unwrap(); - let op = c.merge_keywords_ops(&["a|b".into(), "a|c".into()], "a").unwrap(); - c.apply(op).unwrap(); - assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["a|c".to_string()], vec!["a".to_string()])); - assert_eq!(listed(&c), [("a".to_string(), with_synonyms(&["sea"])), ("a|c".to_string(), person)]); - // the order of the keywords merged doesn't matter - let merged = |from: [&str; 2]| { - let (mut c, ids) = lib(&[&["a|b|c", "a|d"]]); - let op = c.merge_keywords_ops(&from.map(String::from), "x").unwrap(); - c.apply(op).unwrap(); - kws(&c, ids[0]) - }; - assert_eq!(merged(["a", "a|b"]), merged(["a|b", "a"])); - assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]); - } - - /// One keyword at most is where new keywords go (Put New Keywords Inside This Keyword). Making - /// another one so is one undo step; the mark moves with its keyword (also into a merge) and - /// goes with it. - #[test] - fn one_keyword_is_where_new_keywords_go() { - let (mut c, _) = lib(&[&["Events|Weddings"], &["Travel"]]); - let op = c.set_default_parent_ops(Some("events")).unwrap(); - let undo = c.apply(op).unwrap(); - assert_eq!(c.default_keyword_parent().as_deref(), Some("Events")); - let op = c.set_default_parent_ops(Some("travel")).unwrap(); - c.apply(op).unwrap(); - assert_eq!(c.default_keyword_parent().as_deref(), Some("Travel"), "only one"); - assert!(c.set_default_parent_ops(Some("lisbon")).is_err()); - let op = c.rename_keyword_ops("travel", "Trips").unwrap(); - c.apply(op).unwrap(); - assert_eq!(c.default_keyword_parent().as_deref(), Some("Trips")); - let op = c.merge_keywords_ops(&["trips".into()], "Events").unwrap(); - c.apply(op).unwrap(); - assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"), "into the merge"); - let op = c.delete_keyword_ops("events").unwrap(); - c.apply(op).unwrap(); - assert_eq!(c.default_keyword_parent(), None); - let op = c.set_default_parent_ops(None).unwrap(); - assert_eq!(op, Op::Batch { ops: vec![] }, "none to clear"); - c.apply(undo).unwrap(); - assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was"); - } - - /// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the - /// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes - /// back as one keyword, not three). Without paths, the names as they are. - #[test] - fn a_files_keywords_keep_their_hierarchy() { - let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); - assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]); - assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned"); - assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]); - assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat"); - } - - /// A new keyword inside one the library has takes the library's spelling of it: creating - /// "TRAVEL|rome" next to "travel|italy" doesn't respell "travel". - #[test] - fn a_new_keyword_keeps_the_spelling_of_its_parents() { - let (mut c, _) = lib(&[&["travel|italy"]]); - let op = c.create_keyword_ops("TRAVEL|Rome", KeywordInfo::default(), &[]).unwrap(); - c.apply(op).unwrap(); - assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel|Rome"]); - assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel"); - } - - /// An edit that changes nothing is nothing: no listing, no undo step. - #[test] - fn an_edit_that_changes_nothing_is_nothing() { - let (mut c, _) = lib(&[&["beach"]]); - assert_eq!(c.edit_keyword_ops("beach", "beach", KeywordInfo::default()).unwrap(), Op::Batch { ops: vec![] }); - c.apply(Op::SetKeyword { path: "beach".into(), info: Some(with_synonyms(&["shore"])) }).unwrap(); - assert_eq!(c.edit_keyword_ops("BEACH", "beach", with_synonyms(&["shore"])).unwrap(), Op::Batch { ops: vec![] }); - // renamed with the default attributes: the photos change, nothing is listed - let (mut c, _) = lib(&[&["beach"]]); - let op = c.edit_keyword_ops("beach", "Shore", KeywordInfo::default()).unwrap(); - c.apply(op).unwrap(); - assert!(listed(&c).is_empty()); - } - - /// Typing suggests keywords no photo has yet too (created in the Keyword List). - #[test] - fn suggestions_include_keywords_without_photos() { - let (mut c, _) = lib(&[&["beach"]]); - c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - assert_eq!(c.keyword_suggestions(&[], "wed", 5), ["Events|Weddings"]); - assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has"); - } - - /// A new name typed for a photo goes inside the default parent; a keyword the library has, and - /// a path typed whole, don't. - #[test] - fn typed_new_keywords_go_inside_the_default_parent() { - let (mut c, _) = lib(&[&["beach"], &["Events"]]); - assert_eq!(c.typed_keyword(" Weddings "), "Weddings", "no default parent"); - let op = c.set_default_parent_ops(Some("events")).unwrap(); - c.apply(op).unwrap(); - assert_eq!(c.typed_keyword("Weddings"), "Events|Weddings"); - assert_eq!(c.typed_keyword("BEACH"), "BEACH", "the library has it"); - assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon"); - } - - /// The keyword list as a text file, as Lightroom Classic writes one: a keyword a line, the - /// keywords inside it indented one tab more, a synonym in braces on a line of its own under its - /// keyword, a keyword left out of export in square brackets; by name. - #[test] - fn the_keyword_list_is_written_as_tab_indented_text() { - let (mut c, _) = lib(&[&["Places|Lisbon", "beach"]]); - c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); - c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(with_synonyms(&["Lisboa"])) }).unwrap(); - c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); - assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n"); - } - - /// Reading a list gives each keyword's path and attributes: what was written comes back, and a - /// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too. - #[test] - fn a_keyword_list_reads_back() { - let read = parse_keyword_list("\u{feff}beach\r\n\r\nEvents\r\n\tWeddings\r\n\t\t{marriage}\r\n[Places]\r\n\tLisbon\r\n").unwrap(); - let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; - assert_eq!( - read, - [ - ("beach".to_string(), KeywordInfo::default()), - ("Events".to_string(), KeywordInfo::default()), - ("Events|Weddings".to_string(), with_synonyms(&["marriage"])), - ("Places".to_string(), out), - ("Places|Lisbon".to_string(), KeywordInfo::default()), - ] - ); - } - - /// Photo Supreme's Formatted Vocabulary File is the same format: plain text with Windows line - /// ends and no byte-order mark, a top-level category in square brackets (an organizing keyword, - /// left out of export), keywords a tab further in at each level. - #[test] - fn a_photo_supreme_vocabulary_file_reads() { - let file = "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n\t\t\tAlfama\r\n\t\tPorto\r\n\tSpain\r\n\t\tSeville\r\n"; - let read = parse_keyword_list(file).unwrap(); - let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect(); - assert_eq!( - paths, - ["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"] - .map(|p| if p == "Places" { p.to_string() } else { format!("Places|{p}") }) - ); - assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported"); - assert!(read[1..].iter().all(|(_, i)| i.include_on_export)); - } - - /// A list that can't be read says which line and why, and nothing of it is taken. - #[test] - fn a_bad_keyword_list_says_which_line() { - let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string(); - assert!(err("Events\n\t\tWeddings\n").contains("line 2"), "two levels down at once"); - assert!(err("{marriage}\n").contains("line 1"), "a synonym of nothing"); - assert!(err("Events\nTravel|Lisbon\n").contains("line 2"), "“|” separates levels here"); - let deep: String = (0..70).map(|i| format!("{}k{i}\n", "\t".repeat(i))).collect(); - assert!(err(&deep).contains("line 65"), "a file is input: 64 levels at most"); - } - - /// Files from elsewhere read as they were meant or say why not: classic Mac line ends (a lone - /// carriage return) are lines; indenting with spaces, which would flatten the hierarchy, is - /// refused at its line; so are control characters in a name and "|" in a synonym. - #[test] - fn a_keyword_list_from_elsewhere_reads_or_says_why_not() { - let paths = |text: &str| parse_keyword_list(text).unwrap().into_iter().map(|(p, _)| p).collect::>(); - assert_eq!(paths("Events\r\tWeddings\rPlaces\r"), ["Events", "Events|Weddings", "Places"]); - let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string(); - assert!(err("Events\n Weddings\n").contains("line 2"), "space indentation"); - assert!(err("Events\n\t Weddings\n").contains("line 2"), "a space after the tabs"); - assert!(err("Events\nx\u{0}y\n").contains("line 2"), "a control character"); - assert!(err("Events\n\t{sea|shore}\n").contains("line 2"), "“|” in a synonym"); - } - - /// A keyword repeated in another case is one keyword, and the keywords inside either take the - /// first spelling. - #[test] - fn a_repeated_keyword_keeps_its_first_spelling() { - let read = parse_keyword_list("Events\n\tWeddings\nevents\n\tBirthdays\n").unwrap(); - assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Events", "Events|Weddings", "Events|Birthdays"]); - } - - /// What the list can't hold is left out and named, so the file always reads back: a name the - /// format would read as something else (in brackets or braces), one with a line break, a - /// synonym with one, a keyword nested deeper than a list goes; and the keywords inside one left - /// out go with it. - #[test] - fn an_exported_keyword_list_always_reads_back() { - let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::>().join("|"); - let (mut c, _) = lib(&[&["Travel|[draft]", "{todo}|Lisbon", "Weddings", "Line\nbreak", deep.as_str()]]); - c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["marriage", "two\nlines"])) }).unwrap(); - let KeywordListFile { text, unwritable, .. } = c.keyword_list_text(); - let read = parse_keyword_list(&text).unwrap_or_else(|e| panic!("{e}\n{text}")); - assert!(read.iter().any(|(p, _)| p == "Travel")); - assert!(read.iter().all(|(p, _)| !p.contains("draft") && !p.contains("todo") && !p.contains("Line")), "{text}"); - let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone()); - assert_eq!(weddings, Some(vec!["marriage".to_string()])); - let deepest = read.iter().map(|(p, _)| p.split(SEP).count()).max(); - assert_eq!(deepest, Some(MAX_LEVELS)); - assert!(unwritable.contains(&"Travel|[draft]".to_string()), "{unwritable:?}"); - assert!(unwritable.contains(&"{todo}".to_string()), "{unwritable:?}"); - assert!(unwritable.contains(&"Line\nbreak".to_string()), "{unwritable:?}"); - assert!(unwritable.contains(&"Weddings {two\nlines}".to_string()), "{unwritable:?}"); - assert!(unwritable.iter().any(|u| u.ends_with(&format!("level {MAX_LEVELS}"))), "{unwritable:?}"); - } - - /// The list leaves out only what it must, and names only what's lost: a name starting with a - /// byte-order mark would lose it, but a synonym in braces reads back, and a synonym that's empty - /// or another's in a different case is no loss. It also says which keywords it wrote. - #[test] - fn an_exported_keyword_list_names_only_what_it_loses() { - let (mut c, _) = lib(&[&["\u{feff}Albums", "{draft", "Weddings"]]); - c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["Marriage", "marriage", " ", "{vows}"])) }).unwrap(); - let out = c.keyword_list_text(); - let read = parse_keyword_list(&out.text).unwrap_or_else(|e| panic!("{e}\n{}", out.text)); - assert_eq!(out.unwritable, ["\u{feff}Albums"]); - assert_eq!(out.written, ["Weddings", "{draft"]); - assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Weddings", "{draft"]); - let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone()); - assert_eq!(weddings, Some(vec!["Marriage".to_string(), "{vows}".to_string()])); - } - - /// A big list reads and imports at once, against a big library too: 40,000 keywords used to take - /// half a minute to read, and checking 5,000 new ones against 20,000 photos over a minute, with - /// the app frozen. - #[test] - fn a_big_keyword_list_imports_quickly() { - let mut text = String::new(); - for g in 0..400 { - text.push_str(&format!("group {g}\n")); - for k in 0..99 { - text.push_str(&format!("\tkeyword {g}-{k}\n")); - } - } - let t0 = std::time::Instant::now(); - let read = parse_keyword_list(&text).unwrap(); - assert_eq!(read.len(), 40_000); - let mut c = Catalog::new(); - let mut ops = Vec::new(); - for i in 0..20_000u32 { - let id = c.alloc_photo_id(); - let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); - p.meta.keywords = (0..5).map(|k| format!("library {}|tag {}", k, (i + k) % 3000)).collect(); - ops.push(Op::AddPhoto { photo: Box::new(p) }); - } - c.apply(Op::Batch { ops }).unwrap(); - let (op, added, _) = c.import_keywords_ops(&read[..5_000]); - c.apply(op).unwrap(); - assert_eq!(added, 5_000); - let took = t0.elapsed(); - assert!(took < std::time::Duration::from_secs(10), "{took:?}"); - } - - /// Importing a list adds the keywords the library doesn't have, with their attributes, and gives - /// those it has the list's synonyms (their own attributes stay), in one undo step. - #[test] - fn importing_a_keyword_list_adds_what_is_missing() { - let (mut c, _) = lib(&[&["Events|Weddings"]]); - c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo { person: true, ..with_synonyms(&["marriage"]) }) }).unwrap(); - let before = c.to_snapshot(); - let read = parse_keyword_list("events\n\tweddings\n\t\t{nuptials}\n\tBirthdays\n[Drafts]\n").unwrap(); - let (op, added, updated) = c.import_keywords_ops(&read); - assert_eq!((added, updated), (2, 1)); - let undo = c.apply(op).unwrap(); - assert_eq!(c.keyword_info("Events|Weddings"), Some(&KeywordInfo { person: true, ..with_synonyms(&["marriage", "nuptials"]) })); - assert!(c.has_keyword("Events|Birthdays")); - assert_eq!( - listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), - ["Drafts", "Events|Birthdays", "Events|Weddings"], - "in the library's spelling" - ); - assert!(!c.keyword_info("Drafts").unwrap().include_on_export); - c.apply(undo).unwrap(); - assert_eq!(c.to_snapshot(), before); - } - - #[test] - fn suggestions_rank_co_occurrence_and_prefixes() { - let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); - // co-occurring with `dog` first (park twice), then the most used - let s = c.keyword_suggestions(&["dog".into()], "", 3); - assert_eq!(s, ["park", "ball", "beach"]); - let s = c.keyword_suggestions(&[], "", 2); - assert_eq!(s, ["cat", "dog"]); - let s = c.keyword_suggestions(&["park".into()], "pa", 5); - assert_eq!(s, ["parade"]); - let s = c.keyword_suggestions(&[], "E", 5); - assert_eq!(s, ["eagle", "beach", "parade"], "prefix matches before substring matches"); - } - - /// A flat keyword already covered by a hierarchical level only in another spelling is not kept - /// again, whatever the size of either list (the per-flat-keyword search of every level, twice - /// lower-cased per comparison, was quadratic: CWE-407). - #[test] - fn from_file_matches_levels_in_any_case() { - let flat = ["Rome".to_string(), "Seaside".to_string()]; - let hierarchical = ["travel|ITALY|rome".to_string()]; - assert_eq!(from_file(&flat, &hierarchical), ["travel|ITALY|rome", "Seaside"]); - } - - /// The same synonym, in any spelling, is kept once (the search of each keyword's growing - /// synonym list per line was quadratic, with lower-cased copies made per comparison).(CWE-407) - #[test] - fn parse_keyword_list_keeps_a_synonym_once() { - let read = parse_keyword_list("Travel\n\t{Trip}\n\t{journey}\n\t{TRIP}\n").unwrap(); - assert_eq!(read.len(), 1); - assert_eq!(read[0].1.synonyms, ["Trip", "journey"]); - } - - /// A keyword split into far more levels than a list file accepts is cut at [`MAX_LEVELS`] in the - /// tree, whose level rows hold every prefix twice: unbounded it made the stored bytes quadratic - /// in the level count of one short keyword (CWE-400). - #[test] - fn keyword_tree_caps_a_keyword_at_the_list_depth() { - let deep: Vec = (0..4096).map(|i| format!("k{i}")).collect(); - let joined = deep.join("|"); - let (c, _) = lib(&[&[joined.as_str()]]); - let deepest = tree_paths(&c.keyword_tree()).iter().map(|(p, _)| p.split(SEP).count()).max().unwrap_or(0); - assert_eq!(deepest, MAX_LEVELS); - } -} +mod tests; diff --git a/crates/catalog/src/keywords/tests.rs b/crates/catalog/src/keywords/tests.rs new file mode 100644 index 000000000..a177e6171 --- /dev/null +++ b/crates/catalog/src/keywords/tests.rs @@ -0,0 +1,709 @@ +use super::*; +use crate::{Filter, Photo, PhotoId, Source}; + +fn lib(kws: &[&[&str]]) -> (Catalog, Vec) { + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for k in kws { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); + p.meta.keywords = k.iter().map(|s| s.to_string()).collect(); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + (c, ids) +} +fn kws(c: &Catalog, id: PhotoId) -> Vec { + c.photo(id).unwrap().meta.keywords.clone() +} + +#[test] +fn tree_counts_photos_per_level() { + let (c, _) = lib(&[&["travel|Italy|Rome", "beach"], &["Travel|italy"], &["travel|France", "travel|italy|rome"], &["beach"]]); + let t = c.keyword_tree(); + assert_eq!(t.iter().map(|n| (n.name.as_str(), n.count)).collect::>(), vec![("beach", 2), ("travel", 3)]); + let travel = &t[1]; + assert_eq!(travel.children.iter().map(|n| (n.path.as_str(), n.count)).collect::>(), vec![("travel|France", 1), ("travel|Italy", 3)]); + assert_eq!(travel.children[1].children[0].path, "travel|Italy|Rome"); + assert_eq!(travel.children[1].children[0].count, 2); + // filtering by a parent finds its children + let f = Filter { keyword: Some("travel|italy".into()), ..Default::default() }; + assert_eq!(c.query(&f, &Default::default()).len(), 3); +} + +/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next +/// photo with it counts again. +#[test] +fn tree_counts_a_repeated_keyword_once_per_photo() { + let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]); + let t = c.keyword_tree(); + assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]); +} + +#[test] +fn rename_delete_merge_are_single_undoable_batches() { + let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]); + let before = c.to_snapshot(); + let op = c.rename_keyword_ops("travel|italy", "Europe|Italy").unwrap(); + let Op::Batch { ops } = &op else { panic!() }; + assert_eq!(ops.len(), 3, "only photos that change"); + let inv = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Europe|Italy|rome", "beach"]); + assert_eq!(kws(&c, ids[1]), ["Europe|Italy"]); + c.apply(inv).unwrap(); + assert_eq!(c.to_snapshot(), before); + // merge `italia` into the existing hierarchical keyword: duplicates collapse + let op = c.merge_keywords_ops(&["italia".into()], "travel|italy").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[2]), ["travel|italy"]); + // delete removes the keyword and its children everywhere + let op = c.delete_keyword_ops("TRAVEL").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["beach"]); + assert!(kws(&c, ids[1]).is_empty()); + assert_eq!(c.keywords(), vec![("beach".to_string(), 1), ("sea".to_string(), 1)]); + // invalid requests + assert!(c.rename_keyword_ops("beach", " ").is_err()); + assert!(c.rename_keyword_ops("a", "a|b").is_err()); + assert!(c.merge_keywords_ops(&["a".into()], "a|b").is_err()); + assert_eq!(clean(" a | |b "), "a|b"); +} + +fn tree_paths(nodes: &[KeywordNode]) -> Vec<(String, usize)> { + let mut out = Vec::new(); + for n in nodes { + out.push((n.path.clone(), n.count)); + out.extend(tree_paths(&n.children)); + } + out +} + +/// A keyword can be listed before any photo has it (Lightroom Classic's Create Keyword Tag): +/// it is in the tree with no photos, its parents too, and undo takes it away. +#[test] +fn a_keyword_listed_with_no_photos_is_in_the_tree() { + let (mut c, _) = lib(&[&["travel|France"]]); + let undo = c.apply(Op::SetKeyword { path: " travel | Italy ".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1), ("travel|Italy".to_string(), 0)]); + assert_eq!(c.keyword_info("TRAVEL|italy"), Some(&KeywordInfo::default()), "found whatever the case"); + c.apply(undo).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1)]); + assert_eq!(c.keyword_info("travel|Italy"), None); +} + +/// A listed keyword needs a name, and its attributes start as Lightroom Classic's do: exported, +/// with the keywords containing it and its synonyms; not a person. +#[test] +fn a_listed_keyword_needs_a_name_and_starts_exported() { + let mut c = Catalog::new(); + assert!(c.apply(Op::SetKeyword { path: " | ".into(), info: Some(KeywordInfo::default()) }).is_err()); + let info = KeywordInfo::default(); + assert!(info.include_on_export && info.export_containing && info.export_synonyms && !info.person && info.synonyms.is_empty()); +} + +/// Changing a listed keyword's attributes is undone to the attributes it had, under the name as +/// it was written. +#[test] +fn changing_a_listed_keyword_is_undone_to_what_it_was() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let person = KeywordInfo { person: true, synonyms: vec!["Marriage".into()], ..KeywordInfo::default() }; + let undo = c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(person.clone()) }).unwrap(); + assert_eq!(c.keyword_info("Weddings"), Some(&person)); + assert_eq!(undo, Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }); + c.apply(undo).unwrap(); + assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default())); +} + +fn listed(c: &Catalog) -> Vec<(String, KeywordInfo)> { + let mut v: Vec<(String, KeywordInfo)> = c.listed_keywords().map(|k| (k.path.clone(), k.info.clone())).collect(); + v.sort_by_key(|(p, _)| p.to_lowercase()); + v +} + +fn with_synonyms(words: &[&str]) -> KeywordInfo { + KeywordInfo { synonyms: words.iter().map(|w| w.to_string()).collect(), ..KeywordInfo::default() } +} + +/// Renaming (or moving) a keyword takes its attributes and its listed children along, photos +/// or not, in the same undo step. +#[test] +fn renaming_a_keyword_carries_its_listing_along() { + let (mut c, ids) = lib(&[&["travel|italy"]]); + c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["trip"])) }).unwrap(); + c.apply(Op::SetKeyword { path: "travel|Spain".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let before = c.to_snapshot(); + let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]); + assert_eq!( + listed(&c), + [("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())] + ); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +/// A keyword only the list holds (no photos) can be renamed too. +#[test] +fn a_keyword_without_photos_can_be_renamed() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let op = c.rename_keyword_ops("weddings", "Events|Weddings").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Events|Weddings".to_string(), KeywordInfo::default())]); +} + +/// Merging keeps the target's attributes and adds the merged keyword's synonyms to them; a target +/// not listed yet takes the merged keyword's attributes. +#[test] +fn merging_keeps_the_targets_attributes_and_gathers_synonyms() { + let (mut c, _) = lib(&[&["holiday"], &["travel"]]); + let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) }; + c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap(); + c.apply(Op::SetKeyword { + path: "holiday".into(), + info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }), + }) + .unwrap(); + let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]); + // onto a keyword that isn't listed: it takes the merged one's attributes + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(with_synonyms(&["vacation"])) }).unwrap(); + let op = c.merge_keywords_ops(&["holiday".into()], "Travel").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Travel".to_string(), with_synonyms(&["vacation"]))]); +} + +/// Deleting a keyword takes it, and the keywords below it, off the list as well as off photos. +#[test] +fn deleting_a_keyword_takes_it_off_the_list() { + let (mut c, _) = lib(&[&["travel|italy"], &["beach"]]); + for path in ["travel", "travel|spain", "beach"] { + c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); + } + let before = c.to_snapshot(); + let op = c.delete_keyword_ops("travel").unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(listed(&c), [("beach".to_string(), KeywordInfo::default())]); + assert_eq!(tree_paths(&c.keyword_tree()), [("beach".to_string(), 1)]); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +/// Creating a keyword lists it with its attributes, and can tag photos with it in the same undo +/// step. A keyword that exists already (listed, or on a photo) isn't created again. +#[test] +fn creating_a_keyword_lists_it_and_can_tag_photos() { + let (mut c, ids) = lib(&[&["beach"], &[]]); + let before = c.to_snapshot(); + let op = c.create_keyword_ops("Events | Weddings", with_synonyms(&["marriage"]), &[ids[1]]).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Events|Weddings".to_string(), with_synonyms(&["marriage"]))]); + assert_eq!(kws(&c, ids[1]), ["Events|Weddings"]); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + for exists in ["BEACH", "events|weddings"] { + if exists == "events|weddings" { + let op = c.create_keyword_ops("Events|Weddings", KeywordInfo::default(), &[]).unwrap(); + c.apply(op).unwrap(); + } + assert!(c.create_keyword_ops(exists, KeywordInfo::default(), &[]).is_err(), "{exists}"); + } + assert!(c.create_keyword_ops(" | ", KeywordInfo::default(), &[]).is_err()); +} + +/// Moving a keyword nests it inside another (or takes it to the top level), with its children, +/// on photos and in the list. It can't go inside itself or one of its children, and moving it +/// where it already is changes nothing. +#[test] +fn moving_a_keyword_nests_it_inside_another() { + let (mut c, ids) = lib(&[&["Italy|Rome"], &["Europe"]]); + // typed in any case, the keywords keep the spelling the library has + let op = c.move_keyword_ops("italy", Some("EUROPE"), false).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Europe|Italy|Rome"]); + let op = c.move_keyword_ops("europe|italy", None, false).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Italy|Rome"], "back at the top level"); + assert!(c.move_keyword_ops("italy", Some("italy|rome"), false).is_err(), "inside its own child"); + assert!(c.move_keyword_ops("italy", Some("Italy"), false).is_err(), "inside itself"); + assert_eq!(c.move_keyword_ops("italy|rome", Some("italy"), false).unwrap(), Op::Batch { ops: vec![] }, "already there"); + assert!(c.move_keyword_ops("lisbon", Some("europe"), false).is_err(), "no such keyword"); +} + +/// Moving onto a name that is taken (Europe already has a Rome) merges the two, so the move +/// asks first: refused unless merging is allowed. +#[test] +fn moving_onto_a_taken_name_merges_only_when_allowed() { + let (mut c, ids) = lib(&[&["rome"], &["europe|rome"]]); + let err = c.move_keyword_ops("rome", Some("europe"), false).unwrap_err(); + assert!(matches!(err, CatalogError::KeywordExists(ref k) if k == "europe|rome"), "{err:?}"); + let op = c.move_keyword_ops("rome", Some("europe"), true).unwrap(); + c.apply(op).unwrap(); + assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["europe|rome".to_string()], vec!["europe|rome".to_string()])); +} + +/// Editing a keyword renames it (its last level: it stays where it is) and sets its attributes, +/// in one undo step. A name that is taken is refused: merging is its own action. +#[test] +fn editing_a_keyword_renames_it_and_sets_its_attributes() { + let (mut c, ids) = lib(&[&["travel|italy"], &["travel|spain"]]); + let before = c.to_snapshot(); + let person = KeywordInfo { person: true, ..KeywordInfo::default() }; + let op = c.edit_keyword_ops("travel|italy", "Italia", person.clone()).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["travel|Italia"]); + assert_eq!(c.keyword_info("travel|italia"), Some(&person)); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + // attributes alone + let op = c.edit_keyword_ops("travel", "travel", with_synonyms(&["trip"])).unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("travel".to_string(), with_synonyms(&["trip"]))]); + assert!(matches!(c.edit_keyword_ops("travel|italy", "Spain", KeywordInfo::default()), Err(CatalogError::KeywordExists(_)))); + assert!(c.edit_keyword_ops("travel|italy", "a|b", KeywordInfo::default()).is_err(), "a name, not a path"); + assert!(c.edit_keyword_ops("travel|italy", " ", KeywordInfo::default()).is_err()); +} + +/// Purging takes off the list the keywords no photo has (nor any keyword below them), in one +/// undo step; keywords photos carry stay. +#[test] +fn purging_takes_unused_keywords_off_the_list() { + let (mut c, _) = lib(&[&["travel|italy"]]); + for path in ["travel", "travel|italy", "travel|spain", "weddings"] { + c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); + } + let op = c.purge_unused_keywords_ops(); + c.apply(op).unwrap(); + assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel", "travel|italy"]); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|italy".to_string(), 1)]); +} + +/// Renaming matches names whatever their case, also where a letter's case changes its length +/// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter. +#[test] +fn renaming_keeps_the_levels_below_whatever_the_case() { + let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]); + let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Road|nord"]); + let op = c.rename_keyword_ops("ẞA", "Weg").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[1]), ["Weg|ü"]); +} + +fn exported(c: &Catalog, keywords: &[&str]) -> (Vec, Vec) { + let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::>()); + (e.flat, e.hierarchical) +} + +/// By default a keyword is exported as its name together with the keywords containing it (flat, +/// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once. +#[test] +fn a_keyword_exports_its_name_its_parents_and_its_path() { + let c = Catalog::new(); + let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]); + assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]); + assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]); +} + +/// A keyword not included on export is left out, path and all; a parent left out (a keyword that +/// only organizes others) isn't exported as a containing keyword, but its children are. +#[test] +fn keywords_left_out_of_export_stay_out() { + let mut c = Catalog::new(); + let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap(); + let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]); + assert_eq!(flat, ["Lisbon"]); + assert_eq!(paths, ["Places|Lisbon"]); +} + +/// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go +/// out with it unless "export synonyms" is off. +#[test] +fn containing_keywords_and_synonyms_follow_their_options() { + let mut c = Catalog::new(); + let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) }; + c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap(); + let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) }; + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap(); + let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]); + assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]); +} + +/// Names match whatever the case of any letter, not only ASCII ones: changing only the case of +/// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing. +#[test] +fn names_match_whatever_the_case_of_any_letter() { + let (mut c, ids) = lib(&[&["Ärzte|Wien"]]); + let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]); + assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] }); + let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]); + assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists"); +} + +/// A keyword that is only the parent of a listed one ("Events" of "Events|Weddings") is a +/// keyword like any other: it is in the tree, so it can be found, moved and renamed. +#[test] +fn the_parent_of_a_listed_keyword_is_a_keyword() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert!(c.has_keyword("events")); + assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events")); + let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("Occasions".to_string(), 0), ("Occasions|Weddings".to_string(), 0)]); +} + +/// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it +/// can be created again and is purged as unused, and its spelling there isn't the library's. +#[test] +fn deleted_photos_dont_hold_keywords() { + let (mut c, ids) = lib(&[&["Weddings"], &["travel"]]); + c.apply(Op::SetDeleted { id: ids[0], deleted: true }).unwrap(); + assert!(!c.has_keyword("weddings")); + assert!(c.create_keyword_ops("weddings", KeywordInfo::default(), &[]).is_ok()); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let op = c.purge_unused_keywords_ops(); + c.apply(op).unwrap(); + assert!(listed(&c).is_empty(), "only a deleted photo has it: unused"); +} + +/// Merging several keywords moves each keyword under them by the closest one, whatever the +/// order they are given in, and every listing lands where its keyword does: one landing where +/// another is about to leave isn't merged into it. +#[test] +fn merging_overlapping_keywords_keeps_every_listing() { + let (mut c, ids) = lib(&[&["a|b|c"], &["a|c"]]); + let person = KeywordInfo { person: true, ..KeywordInfo::default() }; + c.apply(Op::SetKeyword { path: "a|b|c".into(), info: Some(person.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "a|c".into(), info: Some(with_synonyms(&["sea"])) }).unwrap(); + let op = c.merge_keywords_ops(&["a|b".into(), "a|c".into()], "a").unwrap(); + c.apply(op).unwrap(); + assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["a|c".to_string()], vec!["a".to_string()])); + assert_eq!(listed(&c), [("a".to_string(), with_synonyms(&["sea"])), ("a|c".to_string(), person)]); + // the order of the keywords merged doesn't matter + let merged = |from: [&str; 2]| { + let (mut c, ids) = lib(&[&["a|b|c", "a|d"]]); + let op = c.merge_keywords_ops(&from.map(String::from), "x").unwrap(); + c.apply(op).unwrap(); + kws(&c, ids[0]) + }; + assert_eq!(merged(["a", "a|b"]), merged(["a|b", "a"])); + assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]); +} + +/// One keyword at most is where new keywords go (Put New Keywords Inside This Keyword). Making +/// another one so is one undo step; the mark moves with its keyword (also into a merge) and +/// goes with it. +#[test] +fn one_keyword_is_where_new_keywords_go() { + let (mut c, _) = lib(&[&["Events|Weddings"], &["Travel"]]); + let op = c.set_default_parent_ops(Some("events")).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Events")); + let op = c.set_default_parent_ops(Some("travel")).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Travel"), "only one"); + assert!(c.set_default_parent_ops(Some("lisbon")).is_err()); + let op = c.rename_keyword_ops("travel", "Trips").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Trips")); + let op = c.merge_keywords_ops(&["trips".into()], "Events").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"), "into the merge"); + let op = c.delete_keyword_ops("events").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent(), None); + let op = c.set_default_parent_ops(None).unwrap(); + assert_eq!(op, Op::Batch { ops: vec![] }, "none to clear"); + c.apply(undo).unwrap(); + assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was"); +} + +/// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the +/// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes +/// back as one keyword, not three). Without paths, the names as they are. +#[test] +fn a_files_keywords_keep_their_hierarchy() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]); + assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned"); + assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]); + assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat"); +} + +/// A new keyword inside one the library has takes the library's spelling of it: creating +/// "TRAVEL|rome" next to "travel|italy" doesn't respell "travel". +#[test] +fn a_new_keyword_keeps_the_spelling_of_its_parents() { + let (mut c, _) = lib(&[&["travel|italy"]]); + let op = c.create_keyword_ops("TRAVEL|Rome", KeywordInfo::default(), &[]).unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel|Rome"]); + assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel"); +} + +/// An edit that changes nothing is nothing: no listing, no undo step. +#[test] +fn an_edit_that_changes_nothing_is_nothing() { + let (mut c, _) = lib(&[&["beach"]]); + assert_eq!(c.edit_keyword_ops("beach", "beach", KeywordInfo::default()).unwrap(), Op::Batch { ops: vec![] }); + c.apply(Op::SetKeyword { path: "beach".into(), info: Some(with_synonyms(&["shore"])) }).unwrap(); + assert_eq!(c.edit_keyword_ops("BEACH", "beach", with_synonyms(&["shore"])).unwrap(), Op::Batch { ops: vec![] }); + // renamed with the default attributes: the photos change, nothing is listed + let (mut c, _) = lib(&[&["beach"]]); + let op = c.edit_keyword_ops("beach", "Shore", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert!(listed(&c).is_empty()); +} + +/// Typing suggests keywords no photo has yet too (created in the Keyword List). +#[test] +fn suggestions_include_keywords_without_photos() { + let (mut c, _) = lib(&[&["beach"]]); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(c.keyword_suggestions(&[], "wed", 5), ["Events|Weddings"]); + assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has"); +} + +/// A new name typed for a photo goes inside the default parent; a keyword the library has, and +/// a path typed whole, don't. +#[test] +fn typed_new_keywords_go_inside_the_default_parent() { + let (mut c, _) = lib(&[&["beach"], &["Events"]]); + assert_eq!(c.typed_keyword(" Weddings "), "Weddings", "no default parent"); + let op = c.set_default_parent_ops(Some("events")).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.typed_keyword("Weddings"), "Events|Weddings"); + assert_eq!(c.typed_keyword("BEACH"), "BEACH", "the library has it"); + assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon"); +} + +/// The keyword list as a text file, as Lightroom Classic writes one: a keyword a line, the +/// keywords inside it indented one tab more, a synonym in braces on a line of its own under its +/// keyword, a keyword left out of export in square brackets; by name. +#[test] +fn the_keyword_list_is_written_as_tab_indented_text() { + let (mut c, _) = lib(&[&["Places|Lisbon", "beach"]]); + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(with_synonyms(&["Lisboa"])) }).unwrap(); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n"); +} + +/// Reading a list gives each keyword's path and attributes: what was written comes back, and a +/// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too. +#[test] +fn a_keyword_list_reads_back() { + let read = parse_keyword_list("\u{feff}beach\r\n\r\nEvents\r\n\tWeddings\r\n\t\t{marriage}\r\n[Places]\r\n\tLisbon\r\n").unwrap(); + let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; + assert_eq!( + read, + [ + ("beach".to_string(), KeywordInfo::default()), + ("Events".to_string(), KeywordInfo::default()), + ("Events|Weddings".to_string(), with_synonyms(&["marriage"])), + ("Places".to_string(), out), + ("Places|Lisbon".to_string(), KeywordInfo::default()), + ] + ); +} + +/// Photo Supreme's Formatted Vocabulary File is the same format: plain text with Windows line +/// ends and no byte-order mark, a top-level category in square brackets (an organizing keyword, +/// left out of export), keywords a tab further in at each level. +#[test] +fn a_photo_supreme_vocabulary_file_reads() { + let file = "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n\t\t\tAlfama\r\n\t\tPorto\r\n\tSpain\r\n\t\tSeville\r\n"; + let read = parse_keyword_list(file).unwrap(); + let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect(); + assert_eq!( + paths, + ["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"] + .map(|p| if p == "Places" { p.to_string() } else { format!("Places|{p}") }) + ); + assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported"); + assert!(read[1..].iter().all(|(_, i)| i.include_on_export)); +} + +/// A list that can't be read says which line and why, and nothing of it is taken. +#[test] +fn a_bad_keyword_list_says_which_line() { + let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string(); + assert!(err("Events\n\t\tWeddings\n").contains("line 2"), "two levels down at once"); + assert!(err("{marriage}\n").contains("line 1"), "a synonym of nothing"); + assert!(err("Events\nTravel|Lisbon\n").contains("line 2"), "“|” separates levels here"); + let deep: String = (0..70).map(|i| format!("{}k{i}\n", "\t".repeat(i))).collect(); + assert!(err(&deep).contains("line 65"), "a file is input: 64 levels at most"); +} + +/// Files from elsewhere read as they were meant or say why not: classic Mac line ends (a lone +/// carriage return) are lines; indenting with spaces, which would flatten the hierarchy, is +/// refused at its line; so are control characters in a name and "|" in a synonym. +#[test] +fn a_keyword_list_from_elsewhere_reads_or_says_why_not() { + let paths = |text: &str| parse_keyword_list(text).unwrap().into_iter().map(|(p, _)| p).collect::>(); + assert_eq!(paths("Events\r\tWeddings\rPlaces\r"), ["Events", "Events|Weddings", "Places"]); + let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string(); + assert!(err("Events\n Weddings\n").contains("line 2"), "space indentation"); + assert!(err("Events\n\t Weddings\n").contains("line 2"), "a space after the tabs"); + assert!(err("Events\nx\u{0}y\n").contains("line 2"), "a control character"); + assert!(err("Events\n\t{sea|shore}\n").contains("line 2"), "“|” in a synonym"); +} + +/// A keyword repeated in another case is one keyword, and the keywords inside either take the +/// first spelling. +#[test] +fn a_repeated_keyword_keeps_its_first_spelling() { + let read = parse_keyword_list("Events\n\tWeddings\nevents\n\tBirthdays\n").unwrap(); + assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Events", "Events|Weddings", "Events|Birthdays"]); +} + +/// What the list can't hold is left out and named, so the file always reads back: a name the +/// format would read as something else (in brackets or braces), one with a line break, a +/// synonym with one, a keyword nested deeper than a list goes; and the keywords inside one left +/// out go with it. +#[test] +fn an_exported_keyword_list_always_reads_back() { + let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::>().join("|"); + let (mut c, _) = lib(&[&["Travel|[draft]", "{todo}|Lisbon", "Weddings", "Line\nbreak", deep.as_str()]]); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["marriage", "two\nlines"])) }).unwrap(); + let KeywordListFile { text, unwritable, .. } = c.keyword_list_text(); + let read = parse_keyword_list(&text).unwrap_or_else(|e| panic!("{e}\n{text}")); + assert!(read.iter().any(|(p, _)| p == "Travel")); + assert!(read.iter().all(|(p, _)| !p.contains("draft") && !p.contains("todo") && !p.contains("Line")), "{text}"); + let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone()); + assert_eq!(weddings, Some(vec!["marriage".to_string()])); + let deepest = read.iter().map(|(p, _)| p.split(SEP).count()).max(); + assert_eq!(deepest, Some(MAX_LEVELS)); + assert!(unwritable.contains(&"Travel|[draft]".to_string()), "{unwritable:?}"); + assert!(unwritable.contains(&"{todo}".to_string()), "{unwritable:?}"); + assert!(unwritable.contains(&"Line\nbreak".to_string()), "{unwritable:?}"); + assert!(unwritable.contains(&"Weddings {two\nlines}".to_string()), "{unwritable:?}"); + assert!(unwritable.iter().any(|u| u.ends_with(&format!("level {MAX_LEVELS}"))), "{unwritable:?}"); +} + +/// The list leaves out only what it must, and names only what's lost: a name starting with a +/// byte-order mark would lose it, but a synonym in braces reads back, and a synonym that's empty +/// or another's in a different case is no loss. It also says which keywords it wrote. +#[test] +fn an_exported_keyword_list_names_only_what_it_loses() { + let (mut c, _) = lib(&[&["\u{feff}Albums", "{draft", "Weddings"]]); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["Marriage", "marriage", " ", "{vows}"])) }).unwrap(); + let out = c.keyword_list_text(); + let read = parse_keyword_list(&out.text).unwrap_or_else(|e| panic!("{e}\n{}", out.text)); + assert_eq!(out.unwritable, ["\u{feff}Albums"]); + assert_eq!(out.written, ["Weddings", "{draft"]); + assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Weddings", "{draft"]); + let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone()); + assert_eq!(weddings, Some(vec!["Marriage".to_string(), "{vows}".to_string()])); +} + +/// A big list reads and imports at once, against a big library too: 40,000 keywords used to take +/// half a minute to read, and checking 5,000 new ones against 20,000 photos over a minute, with +/// the app frozen. +#[test] +fn a_big_keyword_list_imports_quickly() { + let mut text = String::new(); + for g in 0..400 { + text.push_str(&format!("group {g}\n")); + for k in 0..99 { + text.push_str(&format!("\tkeyword {g}-{k}\n")); + } + } + let t0 = std::time::Instant::now(); + let read = parse_keyword_list(&text).unwrap(); + assert_eq!(read.len(), 40_000); + let mut c = Catalog::new(); + let mut ops = Vec::new(); + for i in 0..20_000u32 { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); + p.meta.keywords = (0..5).map(|k| format!("library {}|tag {}", k, (i + k) % 3000)).collect(); + ops.push(Op::AddPhoto { photo: Box::new(p) }); + } + c.apply(Op::Batch { ops }).unwrap(); + let (op, added, _) = c.import_keywords_ops(&read[..5_000]); + c.apply(op).unwrap(); + assert_eq!(added, 5_000); + let took = t0.elapsed(); + assert!(took < std::time::Duration::from_secs(10), "{took:?}"); +} + +/// Importing a list adds the keywords the library doesn't have, with their attributes, and gives +/// those it has the list's synonyms (their own attributes stay), in one undo step. +#[test] +fn importing_a_keyword_list_adds_what_is_missing() { + let (mut c, _) = lib(&[&["Events|Weddings"]]); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo { person: true, ..with_synonyms(&["marriage"]) }) }).unwrap(); + let before = c.to_snapshot(); + let read = parse_keyword_list("events\n\tweddings\n\t\t{nuptials}\n\tBirthdays\n[Drafts]\n").unwrap(); + let (op, added, updated) = c.import_keywords_ops(&read); + assert_eq!((added, updated), (2, 1)); + let undo = c.apply(op).unwrap(); + assert_eq!(c.keyword_info("Events|Weddings"), Some(&KeywordInfo { person: true, ..with_synonyms(&["marriage", "nuptials"]) })); + assert!(c.has_keyword("Events|Birthdays")); + assert_eq!( + listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), + ["Drafts", "Events|Birthdays", "Events|Weddings"], + "in the library's spelling" + ); + assert!(!c.keyword_info("Drafts").unwrap().include_on_export); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +#[test] +fn suggestions_rank_co_occurrence_and_prefixes() { + let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); + // co-occurring with `dog` first (park twice), then the most used + let s = c.keyword_suggestions(&["dog".into()], "", 3); + assert_eq!(s, ["park", "ball", "beach"]); + let s = c.keyword_suggestions(&[], "", 2); + assert_eq!(s, ["cat", "dog"]); + let s = c.keyword_suggestions(&["park".into()], "pa", 5); + assert_eq!(s, ["parade"]); + let s = c.keyword_suggestions(&[], "E", 5); + assert_eq!(s, ["eagle", "beach", "parade"], "prefix matches before substring matches"); +} + +/// A flat keyword already covered by a hierarchical level only in another spelling is not kept +/// again, whatever the size of either list (the per-flat-keyword search of every level, twice +/// lower-cased per comparison, was quadratic: CWE-407). +#[test] +fn from_file_matches_levels_in_any_case() { + let flat = ["Rome".to_string(), "Seaside".to_string()]; + let hierarchical = ["travel|ITALY|rome".to_string()]; + assert_eq!(from_file(&flat, &hierarchical), ["travel|ITALY|rome", "Seaside"]); +} + +/// The same synonym, in any spelling, is kept once (the search of each keyword's growing +/// synonym list per line was quadratic, with lower-cased copies made per comparison).(CWE-407) +#[test] +fn parse_keyword_list_keeps_a_synonym_once() { + let read = parse_keyword_list("Travel\n\t{Trip}\n\t{journey}\n\t{TRIP}\n").unwrap(); + assert_eq!(read.len(), 1); + assert_eq!(read[0].1.synonyms, ["Trip", "journey"]); +} + +/// A keyword split into far more levels than a list file accepts is cut at [`MAX_LEVELS`] in the +/// tree, whose level rows hold every prefix twice: unbounded it made the stored bytes quadratic +/// in the level count of one short keyword (CWE-400). +#[test] +fn keyword_tree_caps_a_keyword_at_the_list_depth() { + let deep: Vec = (0..4096).map(|i| format!("k{i}")).collect(); + let joined = deep.join("|"); + let (c, _) = lib(&[&[joined.as_str()]]); + let deepest = tree_paths(&c.keyword_tree()).iter().map(|(p, _)| p.split(SEP).count()).max().unwrap_or(0); + assert_eq!(deepest, MAX_LEVELS); +} From c751fd84e998f0289adeb5b97561d8b2321cda80 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:07 +0000 Subject: [PATCH 065/191] refactor(lightcraft-catalog): move rules.rs tests to crates/catalog/src/rules/tests.rs --- crates/catalog/src/rules.rs | 787 +----------------------------- crates/catalog/src/rules/tests.rs | 784 +++++++++++++++++++++++++++++ 2 files changed, 785 insertions(+), 786 deletions(-) create mode 100644 crates/catalog/src/rules/tests.rs diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 2976a2659..ec92dc724 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -1179,789 +1179,4 @@ impl RuleSet { } #[cfg(test)] -mod tests { - use super::*; - use crate::{ColorLabel, Flag, PhotoId, Source}; - use serde_json::json; - - fn photo(id: u64) -> Photo { - let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00"); - p.rating = 4; - p.flag = Flag::Pick; - p.label = Some(ColorLabel::Red); - p.captured = Some("2026-08-14T18:30:00".into()); - p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()]; - p.meta.camera = "Model X2".into(); - p.meta.iso = Some(1600); - p.meta.aperture = Some(2.8); - p - } - - fn rs(v: serde_json::Value) -> RuleSet { - serde_json::from_value(v).unwrap() - } - - #[test] - fn f32_boundary_values_match_gte_lte() { - let cat = Catalog::new(); - let matches = - |p: &Photo, field: &str, op: &str, value: f64| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat); - for v in [1.4f32, 1.8, 2.8, 5.6, 0.95] { - let mut p = photo(1); - p.meta.aperture = Some(v); - p.meta.focal_mm = Some(v); - let w = v.to_string().parse::().unwrap(); - for field in ["aperture", "focalLength"] { - assert!(matches(&p, field, "gte", w), "{field} gte {w}"); - assert!(matches(&p, field, "lte", w), "{field} lte {w}"); - assert!(!matches(&p, field, "gt", w), "{field} gt {w}"); - assert!(!matches(&p, field, "lt", w), "{field} lt {w}"); - } - } - } - - #[test] - fn fields_ops_and_groups() { - let cat = Catalog::new(); - let p = photo(1); - let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}"); - let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}"); - yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]})); - no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]})); - yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]})); - no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]})); - yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]})); - yes( - json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}), - ); - no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]})); - yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]})); - // file path: the whole string (spaces included), either separator, case-insensitive - let mut fp = photo(3); - fp.source = Source::File { path: "D:\\Photos\\Lake Como Wedding-Day\\2026\\IMG_0042.CR2".into() }; - let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat); - assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake Como Wedding-Day/"}]}))); - assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Como Lake/"}]}))); - assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Wedding Day"}]}))); - assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]}))); - assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]}))); - no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake/"}]})); // demo photo has no path - yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]})); - yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]})); - yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]})); - yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]})); - yes( - json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}), - ); - yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]})); - no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]})); - yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]})); - // copyright status (unknown until set) - yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]})); - no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]})); - let mut pd = photo(2); - pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain; - assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat)); - assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat)); - // nested: rating ≥ 4 and (label is blue or keywords contain food) - yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [ - {"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]})); - // in the last N days, relative to now - set_now(Some("2026-09-01T00:00:00".into())); - yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]})); - no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]})); - yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]})); - set_now(None); - // an empty rule list: all → everything, any → nothing - yes(json!({"rules": []})); - no(json!({"match": "any", "rules": []})); - } - - #[test] - fn problems_and_description() { - let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]})); - let messages: Vec = r.check(&Catalog::new()).into_iter().map(|p| p.message).collect(); - assert_eq!(messages, vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]); - let r = rs( - json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}), - ); - assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks"); - // every field has a kind with operators - for (f, _, k) in FIELDS { - assert!(!ops_for(*k).is_empty(), "{f}"); - } - } - - /// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path - /// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one. - #[test] - fn keyword_count_rules() { - let cat = Catalog::new(); - let mut p = photo(1); // travel|italy|rome, food - let m = |p: &Photo, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2))); - assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2]))); - p.meta.keywords.push("Food".into()); - assert!(m(&p, "is", json!(2)), "Food and food are one keyword"); - p.meta.keywords.clear(); - assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2))); - assert_eq!(field_group("keywordCount"), Some("Keywords & People")); - } - - /// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed - /// faces are not people; People Count counts each person once. - #[test] - fn people_rules() { - use lightcraft_meta::{Rect, Region, RegionKind}; - let cat = Catalog::new(); - let region = |name: Option<&str>, kind: RegionKind| Region { - rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, - kind, - name: name.map(str::to_string), - description: None, - }; - let mut p = photo(1); - let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0))); - p.meta.regions = vec![ - region(Some("Ana Lima"), RegionKind::Face), - region(Some(" ana lima "), RegionKind::Face), - region(Some("Rex"), RegionKind::Pet), - region(None, RegionKind::Face), - region(Some("Bo"), RegionKind::Face), - ]; - assert!(m(&p, "person", "contains", json!("ana"))); - assert!(m(&p, "person", "is", json!("ANA LIMA"))); - assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person"); - assert!(m(&p, "person", "notContains", json!("rex"))); - assert!(m(&p, "person", "isNotEmpty", json!(null))); - assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face"); - assert_eq!(field_group("person"), Some("Keywords & People")); - } - - /// Shutter Speed compares exposure times in seconds, written as the camera shows them: a - /// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125". - #[test] - fn shutter_speed_rules() { - let cat = Catalog::new(); - let mut p = photo(1); - let m = |p: &Photo, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded"); - p.meta.shutter = "1/250".into(); - assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60"))); - assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01))); - assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing"); - p.meta.shutter = "2\"".into(); - assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2))); - assert_eq!(field_group("shutterSpeed"), Some("Camera Info")); - } - - #[test] - fn shutter_seconds_parses_camera_notation() { - for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] { - assert_eq!(crate::parse_shutter_seconds(s), want, "{s}"); - } - for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] { - assert_eq!(crate::parse_shutter_seconds(s), None, "{s}"); - } - } - - /// File Extension ignores case and a leading dot; Copy Name, Alt Text, City, State / - /// Province and Country are text fields of their own (Location still matches all of them). - #[test] - fn file_source_description_and_location_rules() { - let cat = Catalog::new(); - let mut p = photo(1); // IMG_0042.CR2 - let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(m(&p, "extension", "is", json!("cr2")) && m(&p, "extension", "is", json!(".CR2"))); - assert!(!m(&p, "extension", "is", json!("jpg"))); - p.file_name = "README".into(); - assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension"); - p.file_name = "archive.tar.gz".into(); - assert!(m(&p, "extension", "is", json!("gz"))); - p.file_name = ".hidden".into(); - assert!(m(&p, "extension", "isEmpty", json!(null)), "a dot file has no extension"); - assert!(m(&p, "copyName", "isEmpty", json!(null))); - p.copy_name = Some("Black and white".into()); - assert!(m(&p, "copyName", "contains", json!("black"))); - p.meta.alt_text = "A red kite over a hill".into(); - assert!(m(&p, "altText", "contains", json!("kite"))); - p.meta.city = "Porto".into(); - p.meta.state = "Porto District".into(); - p.meta.country = "Portugal".into(); - assert!(m(&p, "city", "is", json!("porto")) && m(&p, "state", "startsWith", json!("porto")) && m(&p, "country", "is", json!("portugal"))); - assert!(!m(&p, "city", "is", json!("portugal")), "City tests only the city"); - assert!(m(&p, "location", "contains", json!("portugal")), "Location still covers the country"); - } - - /// In a Stack, Video Duration (seconds; photos have none), Cropped and Treatment. - #[test] - fn source_file_and_develop_rules() { - use crate::{Op, Stack, StackId}; - let mut cat = Catalog::new(); - let a = photo(1); - let b = photo(2); - let c = photo(3); - for p in [&a, &b, &c] { - cat.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); - } - cat.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![a.id, b.id], collapsed: false } }).unwrap(); - let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(m(&a, "stacked", "is", json!(true)) && m(&b, "stacked", "is", json!(true))); - assert!(m(&c, "stacked", "is", json!(false))); - let mut v = photo(4); - assert!(!m(&v, "duration", "gte", json!(0)), "a photo has no duration"); - v.duration = Some(95.0); - assert!(m(&v, "duration", "gt", json!(60)) && m(&v, "duration", "between", json!([90, 120]))); - let mut d = photo(5); - assert!(m(&d, "cropped", "is", json!(false)) && m(&d, "treatment", "is", json!("color"))); - let mut s = (*d.develop).clone(); - s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9, 1.0); - s.treatment = lightcraft_develop::Treatment::Bw; - d.develop = std::sync::Arc::new(s); - assert!(m(&d, "cropped", "is", json!(true)) && m(&d, "treatment", "is", json!("monochrome"))); - assert!(m(&d, "treatment", "isNot", json!("color"))); - let mut s = (*d.develop).clone(); - s.crop = Default::default(); - s.crop.geometry.angle = 2.0; - d.develop = std::sync::Arc::new(s); - assert!(m(&d, "cropped", "is", json!(true)), "straightening counts as a crop"); - } - - /// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and - /// a crop changes its edges and its megapixels. Long / Short Edge are in pixels. - #[test] - fn size_rules_follow_orientation_and_crop() { - let cat = Catalog::new(); - let mut p = photo(1); // 6000 × 4000 - let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) - }; - assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000))); - assert!(m(&p, "megapixels", "is", json!(24))); - let mut s = (*p.develop).clone(); - s.orientation = lightcraft_geom::Orientation::Rotate90; - p.develop = std::sync::Arc::new(s.clone()); - assert!(m(&p, "aspect", "is", json!("portrait")) && m(&p, "longEdge", "is", json!(6000))); - // a square crop of the rotated frame: 4000 wide, 4000 of its 6000 tall - s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0); - p.develop = std::sync::Arc::new(s); - assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000))); - assert!(m(&p, "megapixels", "is", json!(16)), "a 4000 × 4000 crop is 16 MP, not the sensor's 24"); - assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24))); - assert!(m(&p, "aspect", "isNot", json!("portrait"))); - let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00"); - assert!( - !m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0)) - ); - assert!(m(&empty, "megapixels", "is", json!(0))); - } - - /// Any Searchable Text also finds the state / province, the alt text, a person on a face and - /// a virtual copy's name, as it already finds the city and country. - #[test] - fn any_searchable_text_covers_new_fields() { - let cat = Catalog::new(); - let mut p = photo(1); - let m = |p: &Photo, value: &str| rs(json!({"rules": [{"field": "text", "op": "contains", "value": value}]})).matches(p, &cat); - for word in ["oregon", "kite", "ana", "bluish"] { - assert!(!m(&p, word), "{word}"); - } - p.meta.state = "Oregon".into(); - p.meta.alt_text = "A kite".into(); - p.meta.regions = vec![lightcraft_meta::Region { - rect: lightcraft_meta::Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, - kind: lightcraft_meta::RegionKind::Face, - name: Some("Ana".into()), - description: None, - }]; - p.copy_name = Some("Bluish".into()); - for word in ["oregon", "kite", "ana", "bluish"] { - assert!(m(&p, word), "{word}"); - } - } - - /// Choice values have readable labels: the editor and summaries show "Public Domain" and - /// "Black & White", never ids like `publicDomain`; rules still store and accept the ids. - #[test] - fn choices_have_readable_labels() { - assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain")); - assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White")); - // own labels where a shared word would translate wrongly: "Color" is the colour noun (the - // Color panel) and "None" agrees with other nouns in gendered languages - assert_eq!(choice_label("treatment", "color"), Some("In Color")); - assert_eq!(choice_label("label", "none"), Some("No Label")); - assert_eq!(choice_label("flag", "pick"), Some("Picked")); - assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)")); - assert_eq!(choice_label("flag", "nope"), None); - assert_eq!(choice_label("rating", "pick"), None, "not a choice field"); - for (field, _, kind) in FIELDS { - let Kind::Choice(choices) = kind else { continue }; - for (id, label) in *choices { - assert!(!label.is_empty() && label != id, "{field}: {id} has no label of its own"); - assert!(label.starts_with(|c: char| c.is_uppercase()), "{field}: {label}"); - } - } - let r = rs( - json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}, {"field": "flag", "op": "isNot", "value": "reject"}]}), - ); - assert_eq!(r.describe(), "copyright status is public domain and pick flag isn't rejected"); - let mut p = photo(1); - p.meta.copyright_status = crate::CopyrightStatus::PublicDomain; - assert!(r.matches(&p, &Catalog::new()), "ids still match"); - } - - /// Yes/no fields read "Yes" / "No", and a value means the same to matching and to every - /// summary: `false`, `"false"`, `"no"` and `0` are no; `true`, `"yes"`, `1` and a missing value - /// are yes. Anything else is reported, not silently taken as yes. - #[test] - fn yes_no_values() { - for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(1.0), json!(null)] { - assert_eq!(bool_value(&v), Some(true), "{v}"); - } - for v in [json!(false), json!("false"), json!(" NO "), json!(0), json!(0.0)] { - assert_eq!(bool_value(&v), Some(false), "{v}"); - } - for v in [json!("maybe"), json!(2), json!(0.5), json!(-1), json!([true]), json!({"x": 1})] { - assert_eq!(bool_value(&v), None, "{v}"); - } - assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No")); - let cat = Catalog::new(); - let p = photo(1); // unedited - assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no"); - assert!( - !rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]})) - .matches(&p, &cat) - ); - assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing"); - let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]})); - assert_eq!(r.describe(), "has edits is yes and cropped is no"); - let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})); - let messages: Vec = bad.check(&cat).into_iter().map(|p| p.message).collect(); - assert_eq!(messages, vec!["`edited` is yes or no, not \"maybe\"".to_string()]); - } - - /// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero, - /// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means - /// "ever", so every dated photo is in it and none is outside it. - #[test] - fn in_the_last_survives_hostile_counts() { - set_now(Some("2026-09-01T00:00:00".into())); - let cat = Catalog::new(); - let p = photo(1); // captured 2026-08-14 - let m = |op: &str, n: serde_json::Value, unit: &str| { - rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat) - }; - for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] { - for op in ["inLast", "notInLast"] { - assert!(!m(op, n.clone(), "years"), "{op} {n}"); - } - } - for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] { - assert!(m("inLast", n.clone(), "years"), "{n} years is ever"); - assert!(!m("notInLast", n.clone(), "years"), "{n}"); - } - assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days")); - set_now(None); - } - - /// Every rule's value is checked against its field, so a rule that can't mean anything is - /// reported instead of quietly matching nothing (or everything). Each problem says which rule - /// it is about: "rule 2", or "rule 2.1" inside a group. - #[test] - fn values_are_checked_against_their_field() { - use crate::{Album, AlbumId, Op}; - let mut cat = Catalog::new(); - cat.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap(); - let smart = Album { smart: Some(Box::default()), ..Album::new(AlbumId(2), "Best") }; - cat.apply(Op::AddAlbum { album: smart }).unwrap(); - cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Client".into()) }).unwrap(); - let check = |rule: serde_json::Value| rs(json!({"rules": [rule]})).check(&cat); - let ok = |rule: serde_json::Value| assert!(check(rule.clone()).is_empty(), "{rule}: {:?}", check(rule.clone())); - let bad = |rule: serde_json::Value, want: &str| { - let p = check(rule.clone()); - assert!(p.iter().any(|p| p.message.contains(want)), "{rule}: wanted {want:?}, got {p:?}"); - }; - let r = |field: &str, op: &str, value: serde_json::Value| json!({"field": field, "op": op, "value": value}); - // numbers - ok(r("rating", "gte", json!(3))); - ok(r("rating", "gte", json!("4"))); - ok(r("rating", "between", json!([1, 5]))); - ok(r("aperture", "lte", json!("f/4"))); - ok(r("focalLength", "is", json!("50mm"))); - bad(r("rating", "gte", json!("abc")), "needs a number"); - bad(r("rating", "gte", json!(7)), "no rating 0–5 is ≥ 7"); - bad(r("iso", "between", json!([100])), "two"); - bad(r("iso", "between", json!("100")), "two"); - ok(r("shutterSpeed", "lte", json!("1/60"))); - bad(r("shutterSpeed", "lte", json!("fast")), "1/250"); - // dates - for d in ["2026", "2026-08", "2026-08-14", "2026-08-14T10:00:00"] { - ok(r("captureDate", "is", json!(d))); - } - ok(r("captureDate", "between", json!(["2026-01", "2026-12"]))); - for d in [json!("banana"), json!("2026-13"), json!("2026-02-30"), json!(""), json!(2026)] { - bad(r("captureDate", "after", d), "needs a date"); - } - bad(r("captureDate", "between", json!(["2026-12", "2026-01"])), "after the second"); - bad(r("captureDate", "between", json!(["", "2026"])), "needs a date"); - ok(r("captureDate", "inLast", json!({"n": 2, "unit": "weeks"}))); - ok(r("captureDate", "inLast", json!(7))); - bad(r("captureDate", "inLast", json!({"n": 0, "unit": "days"})), "more than 0"); - bad(r("captureDate", "notInLast", json!({"n": 3, "unit": "fortnights"})), "fortnights"); - ok(r("captureDate", "isEmpty", json!(null))); - // choices: the ids, the flag's aliases and a colour label's custom name - ok(r("kind", "is", json!("raw"))); - ok(r("flag", "is", json!("picked"))); - ok(r("label", "is", json!("client"))); - bad(r("label", "is", json!("pink")), "pink"); - bad(r("kind", "isNot", json!("photo")), "photo"); - // text needs something to look for - ok(r("title", "contains", json!("kite"))); - ok(r("title", "isEmpty", json!(null))); - bad(r("title", "contains", json!(" ")), "something to look for"); - bad(r("keywords", "startsWith", json!("")), "something to look for"); - // albums: one that exists, plain or smart - ok(r("album", "is", json!(1))); - bad(r("album", "is", json!(999)), "no album 999"); - ok(r("album", "is", json!(2))); - // groups: an empty one is a mistake; problems inside one say where they are - let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat); - assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 2: an empty group".to_string()]); - let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"rules": [r("iso", "is", json!(100)), r("rating", "is", json!(9))]}}]})) - .check(&cat); - assert_eq!(p.len(), 1); - assert_eq!(p[0].path, vec![1, 1]); - assert_eq!(p[0].to_string(), "rule 2.2: no rating 0–5 is 9"); - // an empty rule list at the top is "all photos" (or none, for any), not a mistake - assert!(rs(json!({"rules": []})).check(&cat).is_empty()); - } - - /// The check and the matcher agree. Units are any case, singular or plural, and missing means - /// days; an unknown unit is refused and matches nothing (it used to count as days, so "week" was - /// one day). A rating rule is refused only when no rating 0–5 could meet it. A colour label's - /// custom name matches in any case, accents included. Empty text hints at the right operator. - #[test] - fn check_and_matcher_agree() { - use crate::Op; - set_now(Some("2026-09-01T00:00:00".into())); - let mut cat = Catalog::new(); - cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Été".into()) }).unwrap(); - let p = photo(1); // captured 2026-08-14 (18 days before now), red label, rating 4 - let rule = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})); - let valid = |r: &RuleSet| r.check(&cat).is_empty(); - for unit in [json!("Days"), json!("DAY"), json!("days"), json!(null)] { - let r = rule("captureDate", "inLast", json!({"n": 30, "unit": unit})); - assert!(valid(&r) && r.matches(&p, &cat), "{unit}"); - } - let r = rule("captureDate", "inLast", json!({"n": 30})); - assert!(valid(&r) && r.matches(&p, &cat), "no unit is days"); - let week = rule("captureDate", "inLast", json!({"n": 1, "unit": "week"})); - assert!(valid(&week) && !week.matches(&p, &cat), "a week, not a day: 18 days ago is outside it"); - assert!(rule("captureDate", "inLast", json!({"n": 3, "unit": "Weeks"})).matches(&p, &cat)); - let odd = rule("captureDate", "inLast", json!({"n": 30, "unit": "fortnights"})); - assert!(!valid(&odd) && !odd.matches(&p, &cat), "an unknown unit is refused and matches nothing"); - // rating: only a rule no rating could meet - for (op, v) in [("lt", json!(6)), ("gt", json!(-1)), ("isNot", json!(9)), ("between", json!([3, 8]))] { - assert!(valid(&rule("rating", op, v.clone())), "{op} {v}"); - } - for (op, v) in [("is", json!(9)), ("gte", json!(7)), ("gt", json!(5)), ("lt", json!(0)), ("between", json!([6, 9]))] { - assert!(!valid(&rule("rating", op, v.clone())), "{op} {v}"); - } - // a custom label name, any case, accents too - let r = rule("label", "is", json!("été")); - assert!(valid(&r) && r.matches(&p, &cat)); - // "isn't" with nothing points at "isn't empty", the others at "is empty" - let hint = |op: &str| rule("title", op, json!("")).check(&cat).first().map(|p| p.message.clone()).unwrap_or_default(); - assert!(hint("isNot").contains("isn't empty"), "{}", hint("isNot")); - assert!(hint("contains").contains("“is empty”"), "{}", hint("contains")); - set_now(None); - } - - /// An album is in it or not: Album offers "is" and "isn't", never "≥" or "between". - #[test] - fn album_rules_are_is_or_isnt() { - assert_eq!(field_kind("album"), Some(Kind::Album)); - let ops: Vec<&str> = ops_for(Kind::Album).iter().map(|o| o.0).collect(); - assert_eq!(ops, ["is", "isNot"]); - let r = rs(json!({"rules": [{"field": "album", "op": "gte", "value": 1}]})); - assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`")); - } - - /// An Album rule names its album by id, written as a number or a numeric string, the same way - /// for the check, the matcher and the editor. A folder holds albums, not photos, so it can't be - /// tested. - #[test] - fn album_rule_ids() { - use crate::{Album, AlbumId, Op}; - assert_eq!(album_rule_id(&json!(3)), Some(AlbumId(3))); - assert_eq!(album_rule_id(&json!("3")), Some(AlbumId(3))); - assert_eq!(album_rule_id(&json!(3.0)), Some(AlbumId(3))); - for v in [json!(1.5), json!(-1), json!("x"), json!(null)] { - assert_eq!(album_rule_id(&v), None, "{v}"); - } - let mut cat = Catalog::new(); - cat.apply(Op::AddAlbum { album: Album { folder: true, ..Album::new(AlbumId(4), "Trips") } }).unwrap(); - let p = rs(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})).check(&cat); - assert!(p.first().is_some_and(|p| p.message.contains("folder")), "{p:?}"); - } - - /// Album rules saved before Album offered only "is" / "isn't" (≥, between… from when it was a - /// number field) always meant "isn't" to the matcher; upgrading says so, so they check again. - #[test] - fn old_album_operators_upgrade_to_isnt() { - let mut r = rs(json!({"rules": [ - {"field": "album", "op": "gte", "value": 1}, - {"field": "album", "op": "is", "value": 1}, - {"field": "rating", "op": "gte", "value": 3}, - {"group": {"rules": [{"field": "album", "op": "between", "value": [1, 2]}]}} - ]})); - r.upgrade(); - let ops = serde_json::to_value(&r).unwrap().to_string(); - assert!(ops.contains(r#""field":"album","op":"isNot","value":1"#), "{ops}"); - assert!(ops.contains(r#""field":"album","op":"is","value":1"#), "{ops}"); - assert!(ops.contains(r#""field":"rating","op":"gte""#), "other fields keep theirs: {ops}"); - assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}"); - } - - /// Besides its English message (for agents), a problem names its field and the kind of issue, - /// so the editor can show "Title: needs something to look for" in any language. Every issue - /// has its own short text. - #[test] - fn problems_name_their_field_and_issue() { - let cat = Catalog::new(); - let check = |rules: serde_json::Value| rs(json!({"rules": rules})).check(&cat); - let p = check(json!([{"field": "title", "op": "contains", "value": ""}])); - assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty)); - let p = check(json!([{"field": "title", "op": "isNot", "value": ""}])); - assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty); - // keywords and people say "are empty", and so does their hint - let p = check(json!([{"field": "keywords", "op": "contains", "value": ""}, {"field": "person", "op": "notContains", "value": " "}])); - assert_eq!((p[0].issue, p[1].issue), (Issue::NeedsNamesOrEmpty, Issue::NeedsNamesOrNotEmpty)); - assert!(p[0].issue.text().contains("“are empty”") && p[0].message.contains("“are empty”")); - let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}])); - assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches)); - assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup)); - let p = check(json!([{"field": "captureDate", "op": "between", "value": ["2026-12", "2026-01"]}])); - assert_eq!(p[0].issue, Issue::DatesReversed); - let p = check(json!([{"field": "album", "op": "is", "value": null}])); - assert_eq!(p[0].issue, Issue::ChooseAlbum); - let mut texts: Vec<&str> = Issue::ALL.iter().map(|i| i.text()).collect(); - assert!(texts.iter().all(|t| !t.is_empty())); - texts.sort_unstable(); - texts.dedup(); - assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently"); - } - - /// A rule date reads one way for the check and the matcher: a space works as the "T" - /// ("2026-10-01 10:00" matches a photo taken then). Imported capture times can carry fractions - /// of a second and a time zone (EXIF SubSecTime / OffsetTime: "…T10:00:00.250+02:00"), so a - /// rule may too, after the seconds; anywhere else they are no date. - #[test] - fn rule_dates_have_one_reading() { - let cat = Catalog::new(); - let mut p = photo(1); - p.captured = Some("2026-10-01T10:00:00".into()); - let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]})); - for (op, v) in [ - ("is", json!("2026-10-01 10:00")), - ("is", json!(" 2026-10-01T10 ")), - ("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])), - ("after", json!("2026-10-01 09:59")), - ] { - let r = rule(op, v.clone()); - assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}"); - } - // a capture time with a fraction and a zone, as imports write it - let mut zoned = photo(2); - zoned.captured = Some("2026-08-14T10:00:00.250+02:00".into()); - for v in ["2026-08-14T10:00:00.250", "2026-08-14T10:00:00.250+02:00", "2026-08-14T10:00:00"] { - let r = rule("is", json!(v)); - assert!(r.check(&cat).is_empty() && r.matches(&zoned, &cat), "{v}"); - } - assert!(rule("is", json!("2026-08-14T10:00:00Z")).check(&cat).is_empty()); - for v in [ - "2026-8-14", - "2026-08-14T1", - "2026-08-14X10", - "26-08-14", - "2026-08-14T25:00", - "2026-08-14Z", - "2026-08-14 10:00-05:00", - "2026-08-14T10:00:00.", - "2026-08-14T10:00:00+2", - ] { - let p = rule("is", json!(v)).check(&cat); - assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}"); - } - assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string())); - assert_eq!(rule_date("2026"), Ok("2026".to_string())); - } - - /// The field menu shows every rule field once: at the top level or in exactly one group, - /// and [`FIELDS`] lists them in menu order. - #[test] - fn every_field_is_in_the_menu_exactly_once() { - let menu: Vec<&str> = TOP_LEVEL_FIELDS.iter().chain(FIELD_GROUPS.iter().flat_map(|g| g.1.iter())).copied().collect(); - let fields: Vec<&str> = FIELDS.iter().map(|f| f.0).collect(); - assert_eq!(menu, fields, "the menu and FIELDS list the same fields in the same order"); - for (label, group) in FIELD_GROUPS { - assert!(!group.is_empty(), "group {label} is empty"); - } - let mut labels: Vec<&str> = FIELD_GROUPS.iter().map(|g| g.0).collect(); - labels.dedup(); - assert_eq!(labels.len(), FIELD_GROUPS.len(), "group labels are unique"); - } - - /// Related fields sit together: all the file fields, all the camera (EXIF) fields, all the - /// dates, all the keyword fields. - #[test] - fn related_fields_share_a_group() { - let same = |fields: &[&str], group: &str| { - for f in fields { - assert_eq!(field_group(f), Some(group), "{f}"); - } - }; - same(&["fileName", "extension", "filePath", "kind", "format", "duration"], "File"); - same(&["album", "virtualCopy", "copyName", "stacked"], "Source"); - same(&["longEdge", "shortEdge", "aspect", "megapixels"], "Size"); - same(&["edited", "cropped", "treatment"], "Develop"); - same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info"); - same(&["captureDate", "importDate", "editDate"], "Date"); - same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People"); - same(&["title", "caption", "altText", "creator", "copyright", "copyrightStatus"], "Description"); - same(&["location", "city", "state", "country", "hasGps"], "Location"); - for f in ["rating", "flag", "label", "text"] { - assert_eq!(field_group(f), None, "{f} stays at the top level"); - } - assert_eq!(field_group("nope"), None); - assert_eq!(field_label("filePath"), Some("File Path")); - assert_eq!(field_label("nope"), None); - } - - /// The `filePath` rule exactly as it was before the in-place ASCII path: new strings per photo. - fn file_path_old(op: &str, source: &Source, value: &serde_json::Value) -> bool { - let want = lower(value); - let path = match source { - Source::File { path } => path.replace('\\', "/"), - Source::Demo { .. } => String::new(), - }; - let want = want.replace('\\', "/"); - match op { - "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()), - "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()), - _ => text_op(op, &path, &want), - } - } - - #[test] - fn file_path_rules_match_the_old_strings_for_any_path() { - let paths = [ - "", - " ", - " /a/b.jpg \t", - "/Volumes/share/photos/Trip/IMG_0001.jpg", - "/Volumes/share/PHOTOS/trip/img_0001.JPG", - r"D:\Photos\Lake Como\IMG_0001.JPG", - r"d:/photos/lake como/img_0001.jpg", - r"\\nas\share\x.jpg", - "/photos/Straße/a.jpg", - "/photos/STRASSE/a.jpg", - "/photos/İstanbul/a.jpg", - "/photos/i̇stanbul/a.jpg", - "/photos/ΟΔΟΣ/a.jpg", - "/photos/οδος/a.jpg", - "/photos/ΟΔΌΣ/a.jpg", - "/photos/日本/東京.jpg", - "/p/\u{0B}x\u{0B}", - "/p/\u{85}x\u{85}", - "a", - ]; - let wants = [ - "", - " ", - "a", - "A", - "/", - r"\", - r"\photos\", - "/photos/", - "photos", - "PHOTOS", - "Lake Como", - " lake como ", - "lake como", - r"d:\photos\lake", - "IMG_0001.JPG", - ".jpg", - "straße", - "STRASSE", - "strasse", - "İstanbul", - "i̇stanbul", - "istanbul", - "ΟΔΟΣ", - "οδος", - "οδοσ", - "ΟΔΌΣ", - "東京", - "日本", - "x", - "\u{0B}x", - "/a/b.jpg", - "/a/b.jpg \t", - "\u{85}x", - ]; - let ops = ["contains", "notContains", "is", "isNot", "startsWith", "endsWith", "isEmpty", "isNotEmpty", "bogus"]; - let mut values: Vec = wants.iter().map(|w| json!(w)).collect(); - values.extend([json!(null), json!(5), json!(true), json!(["a", "b"])]); - let mut sources: Vec = paths.iter().map(|p| Source::File { path: (*p).into() }).collect(); - sources.push(Source::Demo { scene: 0 }); - for source in &sources { - for op in ops { - for value in &values { - assert_eq!(file_path_matches(op, source, value), file_path_old(op, source, value), "{source:?} {op} {value}"); - } - } - } - } - - #[test] - fn file_path_rule_through_a_rule_set_ignores_case_and_slash_direction() { - let mut p = photo(1); - p.source = Source::File { path: r"D:\Photos\Lake Como\IMG_0001.JPG".into() }; - let r = rs(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/LAKE como/"}]})); - assert!(r.matches(&p, &Catalog::default())); - } - - /// Every piece of a long path, in both cases and both slash directions, wherever it starts - /// (the search skips eight bytes at a time, so starts and ends at every offset matter). - #[test] - fn file_path_contains_finds_every_piece_of_a_long_path() { - let path = r"D:\Photos\Lake Como\Wedding-Day_2026\IMG_0001 (copy).JPG"; - let source = Source::File { path: path.into() }; - let shout = |s: &str| s.to_uppercase(); - let swap = |s: &str| s.replace('\\', "/"); - for from in 0..path.len() { - for to in from + 1..=path.len() { - let piece = &path[from..to]; - for want in [piece.to_string(), shout(piece), swap(piece), piece.replace('/', r"\"), format!("{piece}x"), format!("x{piece}")] { - let value = json!(want); - for op in ["contains", "notContains"] { - assert_eq!(file_path_matches(op, &source, &value), file_path_old(op, &source, &value), "{op} {want:?}"); - } - } - } - } - } -} +mod tests; diff --git a/crates/catalog/src/rules/tests.rs b/crates/catalog/src/rules/tests.rs new file mode 100644 index 000000000..4c4a9c8e8 --- /dev/null +++ b/crates/catalog/src/rules/tests.rs @@ -0,0 +1,784 @@ +use super::*; +use crate::{ColorLabel, Flag, PhotoId, Source}; +use serde_json::json; + +fn photo(id: u64) -> Photo { + let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00"); + p.rating = 4; + p.flag = Flag::Pick; + p.label = Some(ColorLabel::Red); + p.captured = Some("2026-08-14T18:30:00".into()); + p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()]; + p.meta.camera = "Model X2".into(); + p.meta.iso = Some(1600); + p.meta.aperture = Some(2.8); + p +} + +fn rs(v: serde_json::Value) -> RuleSet { + serde_json::from_value(v).unwrap() +} + +#[test] +fn f32_boundary_values_match_gte_lte() { + let cat = Catalog::new(); + let matches = + |p: &Photo, field: &str, op: &str, value: f64| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat); + for v in [1.4f32, 1.8, 2.8, 5.6, 0.95] { + let mut p = photo(1); + p.meta.aperture = Some(v); + p.meta.focal_mm = Some(v); + let w = v.to_string().parse::().unwrap(); + for field in ["aperture", "focalLength"] { + assert!(matches(&p, field, "gte", w), "{field} gte {w}"); + assert!(matches(&p, field, "lte", w), "{field} lte {w}"); + assert!(!matches(&p, field, "gt", w), "{field} gt {w}"); + assert!(!matches(&p, field, "lt", w), "{field} lt {w}"); + } + } +} + +#[test] +fn fields_ops_and_groups() { + let cat = Catalog::new(); + let p = photo(1); + let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}"); + let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}"); + yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]})); + no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]})); + yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]})); + no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]})); + yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]})); + yes( + json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}), + ); + no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]})); + yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]})); + // file path: the whole string (spaces included), either separator, case-insensitive + let mut fp = photo(3); + fp.source = Source::File { path: "D:\\Photos\\Lake Como Wedding-Day\\2026\\IMG_0042.CR2".into() }; + let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake Como Wedding-Day/"}]}))); + assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Como Lake/"}]}))); + assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Wedding Day"}]}))); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]}))); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]}))); + no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake/"}]})); // demo photo has no path + yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]})); + yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]})); + yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]})); + yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]})); + yes( + json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}), + ); + yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]})); + no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]})); + yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]})); + // copyright status (unknown until set) + yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]})); + no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]})); + let mut pd = photo(2); + pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain; + assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat)); + assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat)); + // nested: rating ≥ 4 and (label is blue or keywords contain food) + yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [ + {"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]})); + // in the last N days, relative to now + set_now(Some("2026-09-01T00:00:00".into())); + yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]})); + no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]})); + yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]})); + set_now(None); + // an empty rule list: all → everything, any → nothing + yes(json!({"rules": []})); + no(json!({"match": "any", "rules": []})); +} + +#[test] +fn problems_and_description() { + let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]})); + let messages: Vec = r.check(&Catalog::new()).into_iter().map(|p| p.message).collect(); + assert_eq!(messages, vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]); + let r = rs( + json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}), + ); + assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks"); + // every field has a kind with operators + for (f, _, k) in FIELDS { + assert!(!ops_for(*k).is_empty(), "{f}"); + } +} + +/// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path +/// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one. +#[test] +fn keyword_count_rules() { + let cat = Catalog::new(); + let mut p = photo(1); // travel|italy|rome, food + let m = |p: &Photo, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2))); + assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2]))); + p.meta.keywords.push("Food".into()); + assert!(m(&p, "is", json!(2)), "Food and food are one keyword"); + p.meta.keywords.clear(); + assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2))); + assert_eq!(field_group("keywordCount"), Some("Keywords & People")); +} + +/// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed +/// faces are not people; People Count counts each person once. +#[test] +fn people_rules() { + use lightcraft_meta::{Rect, Region, RegionKind}; + let cat = Catalog::new(); + let region = |name: Option<&str>, kind: RegionKind| Region { + rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind, + name: name.map(str::to_string), + description: None, + }; + let mut p = photo(1); + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0))); + p.meta.regions = vec![ + region(Some("Ana Lima"), RegionKind::Face), + region(Some(" ana lima "), RegionKind::Face), + region(Some("Rex"), RegionKind::Pet), + region(None, RegionKind::Face), + region(Some("Bo"), RegionKind::Face), + ]; + assert!(m(&p, "person", "contains", json!("ana"))); + assert!(m(&p, "person", "is", json!("ANA LIMA"))); + assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person"); + assert!(m(&p, "person", "notContains", json!("rex"))); + assert!(m(&p, "person", "isNotEmpty", json!(null))); + assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face"); + assert_eq!(field_group("person"), Some("Keywords & People")); +} + +/// Shutter Speed compares exposure times in seconds, written as the camera shows them: a +/// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125". +#[test] +fn shutter_speed_rules() { + let cat = Catalog::new(); + let mut p = photo(1); + let m = |p: &Photo, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded"); + p.meta.shutter = "1/250".into(); + assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60"))); + assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01))); + assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing"); + p.meta.shutter = "2\"".into(); + assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2))); + assert_eq!(field_group("shutterSpeed"), Some("Camera Info")); +} + +#[test] +fn shutter_seconds_parses_camera_notation() { + for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] { + assert_eq!(crate::parse_shutter_seconds(s), want, "{s}"); + } + for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] { + assert_eq!(crate::parse_shutter_seconds(s), None, "{s}"); + } +} + +/// File Extension ignores case and a leading dot; Copy Name, Alt Text, City, State / +/// Province and Country are text fields of their own (Location still matches all of them). +#[test] +fn file_source_description_and_location_rules() { + let cat = Catalog::new(); + let mut p = photo(1); // IMG_0042.CR2 + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "extension", "is", json!("cr2")) && m(&p, "extension", "is", json!(".CR2"))); + assert!(!m(&p, "extension", "is", json!("jpg"))); + p.file_name = "README".into(); + assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension"); + p.file_name = "archive.tar.gz".into(); + assert!(m(&p, "extension", "is", json!("gz"))); + p.file_name = ".hidden".into(); + assert!(m(&p, "extension", "isEmpty", json!(null)), "a dot file has no extension"); + assert!(m(&p, "copyName", "isEmpty", json!(null))); + p.copy_name = Some("Black and white".into()); + assert!(m(&p, "copyName", "contains", json!("black"))); + p.meta.alt_text = "A red kite over a hill".into(); + assert!(m(&p, "altText", "contains", json!("kite"))); + p.meta.city = "Porto".into(); + p.meta.state = "Porto District".into(); + p.meta.country = "Portugal".into(); + assert!(m(&p, "city", "is", json!("porto")) && m(&p, "state", "startsWith", json!("porto")) && m(&p, "country", "is", json!("portugal"))); + assert!(!m(&p, "city", "is", json!("portugal")), "City tests only the city"); + assert!(m(&p, "location", "contains", json!("portugal")), "Location still covers the country"); +} + +/// In a Stack, Video Duration (seconds; photos have none), Cropped and Treatment. +#[test] +fn source_file_and_develop_rules() { + use crate::{Op, Stack, StackId}; + let mut cat = Catalog::new(); + let a = photo(1); + let b = photo(2); + let c = photo(3); + for p in [&a, &b, &c] { + cat.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); + } + cat.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![a.id, b.id], collapsed: false } }).unwrap(); + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&a, "stacked", "is", json!(true)) && m(&b, "stacked", "is", json!(true))); + assert!(m(&c, "stacked", "is", json!(false))); + let mut v = photo(4); + assert!(!m(&v, "duration", "gte", json!(0)), "a photo has no duration"); + v.duration = Some(95.0); + assert!(m(&v, "duration", "gt", json!(60)) && m(&v, "duration", "between", json!([90, 120]))); + let mut d = photo(5); + assert!(m(&d, "cropped", "is", json!(false)) && m(&d, "treatment", "is", json!("color"))); + let mut s = (*d.develop).clone(); + s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9, 1.0); + s.treatment = lightcraft_develop::Treatment::Bw; + d.develop = std::sync::Arc::new(s); + assert!(m(&d, "cropped", "is", json!(true)) && m(&d, "treatment", "is", json!("monochrome"))); + assert!(m(&d, "treatment", "isNot", json!("color"))); + let mut s = (*d.develop).clone(); + s.crop = Default::default(); + s.crop.geometry.angle = 2.0; + d.develop = std::sync::Arc::new(s); + assert!(m(&d, "cropped", "is", json!(true)), "straightening counts as a crop"); +} + +/// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and +/// a crop changes its edges and its megapixels. Long / Short Edge are in pixels. +#[test] +fn size_rules_follow_orientation_and_crop() { + let cat = Catalog::new(); + let mut p = photo(1); // 6000 × 4000 + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000))); + assert!(m(&p, "megapixels", "is", json!(24))); + let mut s = (*p.develop).clone(); + s.orientation = lightcraft_geom::Orientation::Rotate90; + p.develop = std::sync::Arc::new(s.clone()); + assert!(m(&p, "aspect", "is", json!("portrait")) && m(&p, "longEdge", "is", json!(6000))); + // a square crop of the rotated frame: 4000 wide, 4000 of its 6000 tall + s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0); + p.develop = std::sync::Arc::new(s); + assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000))); + assert!(m(&p, "megapixels", "is", json!(16)), "a 4000 × 4000 crop is 16 MP, not the sensor's 24"); + assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24))); + assert!(m(&p, "aspect", "isNot", json!("portrait"))); + let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00"); + assert!( + !m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0)) + ); + assert!(m(&empty, "megapixels", "is", json!(0))); +} + +/// Any Searchable Text also finds the state / province, the alt text, a person on a face and +/// a virtual copy's name, as it already finds the city and country. +#[test] +fn any_searchable_text_covers_new_fields() { + let cat = Catalog::new(); + let mut p = photo(1); + let m = |p: &Photo, value: &str| rs(json!({"rules": [{"field": "text", "op": "contains", "value": value}]})).matches(p, &cat); + for word in ["oregon", "kite", "ana", "bluish"] { + assert!(!m(&p, word), "{word}"); + } + p.meta.state = "Oregon".into(); + p.meta.alt_text = "A kite".into(); + p.meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_meta::Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some("Ana".into()), + description: None, + }]; + p.copy_name = Some("Bluish".into()); + for word in ["oregon", "kite", "ana", "bluish"] { + assert!(m(&p, word), "{word}"); + } +} + +/// Choice values have readable labels: the editor and summaries show "Public Domain" and +/// "Black & White", never ids like `publicDomain`; rules still store and accept the ids. +#[test] +fn choices_have_readable_labels() { + assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain")); + assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White")); + // own labels where a shared word would translate wrongly: "Color" is the colour noun (the + // Color panel) and "None" agrees with other nouns in gendered languages + assert_eq!(choice_label("treatment", "color"), Some("In Color")); + assert_eq!(choice_label("label", "none"), Some("No Label")); + assert_eq!(choice_label("flag", "pick"), Some("Picked")); + assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)")); + assert_eq!(choice_label("flag", "nope"), None); + assert_eq!(choice_label("rating", "pick"), None, "not a choice field"); + for (field, _, kind) in FIELDS { + let Kind::Choice(choices) = kind else { continue }; + for (id, label) in *choices { + assert!(!label.is_empty() && label != id, "{field}: {id} has no label of its own"); + assert!(label.starts_with(|c: char| c.is_uppercase()), "{field}: {label}"); + } + } + let r = rs( + json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}, {"field": "flag", "op": "isNot", "value": "reject"}]}), + ); + assert_eq!(r.describe(), "copyright status is public domain and pick flag isn't rejected"); + let mut p = photo(1); + p.meta.copyright_status = crate::CopyrightStatus::PublicDomain; + assert!(r.matches(&p, &Catalog::new()), "ids still match"); +} + +/// Yes/no fields read "Yes" / "No", and a value means the same to matching and to every +/// summary: `false`, `"false"`, `"no"` and `0` are no; `true`, `"yes"`, `1` and a missing value +/// are yes. Anything else is reported, not silently taken as yes. +#[test] +fn yes_no_values() { + for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(1.0), json!(null)] { + assert_eq!(bool_value(&v), Some(true), "{v}"); + } + for v in [json!(false), json!("false"), json!(" NO "), json!(0), json!(0.0)] { + assert_eq!(bool_value(&v), Some(false), "{v}"); + } + for v in [json!("maybe"), json!(2), json!(0.5), json!(-1), json!([true]), json!({"x": 1})] { + assert_eq!(bool_value(&v), None, "{v}"); + } + assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No")); + let cat = Catalog::new(); + let p = photo(1); // unedited + assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no"); + assert!( + !rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]})) + .matches(&p, &cat) + ); + assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing"); + let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]})); + assert_eq!(r.describe(), "has edits is yes and cropped is no"); + let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})); + let messages: Vec = bad.check(&cat).into_iter().map(|p| p.message).collect(); + assert_eq!(messages, vec!["`edited` is yes or no, not \"maybe\"".to_string()]); +} + +/// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero, +/// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means +/// "ever", so every dated photo is in it and none is outside it. +#[test] +fn in_the_last_survives_hostile_counts() { + set_now(Some("2026-09-01T00:00:00".into())); + let cat = Catalog::new(); + let p = photo(1); // captured 2026-08-14 + let m = |op: &str, n: serde_json::Value, unit: &str| { + rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat) + }; + for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] { + for op in ["inLast", "notInLast"] { + assert!(!m(op, n.clone(), "years"), "{op} {n}"); + } + } + for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] { + assert!(m("inLast", n.clone(), "years"), "{n} years is ever"); + assert!(!m("notInLast", n.clone(), "years"), "{n}"); + } + assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days")); + set_now(None); +} + +/// Every rule's value is checked against its field, so a rule that can't mean anything is +/// reported instead of quietly matching nothing (or everything). Each problem says which rule +/// it is about: "rule 2", or "rule 2.1" inside a group. +#[test] +fn values_are_checked_against_their_field() { + use crate::{Album, AlbumId, Op}; + let mut cat = Catalog::new(); + cat.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap(); + let smart = Album { smart: Some(Box::default()), ..Album::new(AlbumId(2), "Best") }; + cat.apply(Op::AddAlbum { album: smart }).unwrap(); + cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Client".into()) }).unwrap(); + let check = |rule: serde_json::Value| rs(json!({"rules": [rule]})).check(&cat); + let ok = |rule: serde_json::Value| assert!(check(rule.clone()).is_empty(), "{rule}: {:?}", check(rule.clone())); + let bad = |rule: serde_json::Value, want: &str| { + let p = check(rule.clone()); + assert!(p.iter().any(|p| p.message.contains(want)), "{rule}: wanted {want:?}, got {p:?}"); + }; + let r = |field: &str, op: &str, value: serde_json::Value| json!({"field": field, "op": op, "value": value}); + // numbers + ok(r("rating", "gte", json!(3))); + ok(r("rating", "gte", json!("4"))); + ok(r("rating", "between", json!([1, 5]))); + ok(r("aperture", "lte", json!("f/4"))); + ok(r("focalLength", "is", json!("50mm"))); + bad(r("rating", "gte", json!("abc")), "needs a number"); + bad(r("rating", "gte", json!(7)), "no rating 0–5 is ≥ 7"); + bad(r("iso", "between", json!([100])), "two"); + bad(r("iso", "between", json!("100")), "two"); + ok(r("shutterSpeed", "lte", json!("1/60"))); + bad(r("shutterSpeed", "lte", json!("fast")), "1/250"); + // dates + for d in ["2026", "2026-08", "2026-08-14", "2026-08-14T10:00:00"] { + ok(r("captureDate", "is", json!(d))); + } + ok(r("captureDate", "between", json!(["2026-01", "2026-12"]))); + for d in [json!("banana"), json!("2026-13"), json!("2026-02-30"), json!(""), json!(2026)] { + bad(r("captureDate", "after", d), "needs a date"); + } + bad(r("captureDate", "between", json!(["2026-12", "2026-01"])), "after the second"); + bad(r("captureDate", "between", json!(["", "2026"])), "needs a date"); + ok(r("captureDate", "inLast", json!({"n": 2, "unit": "weeks"}))); + ok(r("captureDate", "inLast", json!(7))); + bad(r("captureDate", "inLast", json!({"n": 0, "unit": "days"})), "more than 0"); + bad(r("captureDate", "notInLast", json!({"n": 3, "unit": "fortnights"})), "fortnights"); + ok(r("captureDate", "isEmpty", json!(null))); + // choices: the ids, the flag's aliases and a colour label's custom name + ok(r("kind", "is", json!("raw"))); + ok(r("flag", "is", json!("picked"))); + ok(r("label", "is", json!("client"))); + bad(r("label", "is", json!("pink")), "pink"); + bad(r("kind", "isNot", json!("photo")), "photo"); + // text needs something to look for + ok(r("title", "contains", json!("kite"))); + ok(r("title", "isEmpty", json!(null))); + bad(r("title", "contains", json!(" ")), "something to look for"); + bad(r("keywords", "startsWith", json!("")), "something to look for"); + // albums: one that exists, plain or smart + ok(r("album", "is", json!(1))); + bad(r("album", "is", json!(999)), "no album 999"); + ok(r("album", "is", json!(2))); + // groups: an empty one is a mistake; problems inside one say where they are + let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat); + assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 2: an empty group".to_string()]); + let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"rules": [r("iso", "is", json!(100)), r("rating", "is", json!(9))]}}]})) + .check(&cat); + assert_eq!(p.len(), 1); + assert_eq!(p[0].path, vec![1, 1]); + assert_eq!(p[0].to_string(), "rule 2.2: no rating 0–5 is 9"); + // an empty rule list at the top is "all photos" (or none, for any), not a mistake + assert!(rs(json!({"rules": []})).check(&cat).is_empty()); +} + +/// The check and the matcher agree. Units are any case, singular or plural, and missing means +/// days; an unknown unit is refused and matches nothing (it used to count as days, so "week" was +/// one day). A rating rule is refused only when no rating 0–5 could meet it. A colour label's +/// custom name matches in any case, accents included. Empty text hints at the right operator. +#[test] +fn check_and_matcher_agree() { + use crate::Op; + set_now(Some("2026-09-01T00:00:00".into())); + let mut cat = Catalog::new(); + cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Été".into()) }).unwrap(); + let p = photo(1); // captured 2026-08-14 (18 days before now), red label, rating 4 + let rule = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})); + let valid = |r: &RuleSet| r.check(&cat).is_empty(); + for unit in [json!("Days"), json!("DAY"), json!("days"), json!(null)] { + let r = rule("captureDate", "inLast", json!({"n": 30, "unit": unit})); + assert!(valid(&r) && r.matches(&p, &cat), "{unit}"); + } + let r = rule("captureDate", "inLast", json!({"n": 30})); + assert!(valid(&r) && r.matches(&p, &cat), "no unit is days"); + let week = rule("captureDate", "inLast", json!({"n": 1, "unit": "week"})); + assert!(valid(&week) && !week.matches(&p, &cat), "a week, not a day: 18 days ago is outside it"); + assert!(rule("captureDate", "inLast", json!({"n": 3, "unit": "Weeks"})).matches(&p, &cat)); + let odd = rule("captureDate", "inLast", json!({"n": 30, "unit": "fortnights"})); + assert!(!valid(&odd) && !odd.matches(&p, &cat), "an unknown unit is refused and matches nothing"); + // rating: only a rule no rating could meet + for (op, v) in [("lt", json!(6)), ("gt", json!(-1)), ("isNot", json!(9)), ("between", json!([3, 8]))] { + assert!(valid(&rule("rating", op, v.clone())), "{op} {v}"); + } + for (op, v) in [("is", json!(9)), ("gte", json!(7)), ("gt", json!(5)), ("lt", json!(0)), ("between", json!([6, 9]))] { + assert!(!valid(&rule("rating", op, v.clone())), "{op} {v}"); + } + // a custom label name, any case, accents too + let r = rule("label", "is", json!("été")); + assert!(valid(&r) && r.matches(&p, &cat)); + // "isn't" with nothing points at "isn't empty", the others at "is empty" + let hint = |op: &str| rule("title", op, json!("")).check(&cat).first().map(|p| p.message.clone()).unwrap_or_default(); + assert!(hint("isNot").contains("isn't empty"), "{}", hint("isNot")); + assert!(hint("contains").contains("“is empty”"), "{}", hint("contains")); + set_now(None); +} + +/// An album is in it or not: Album offers "is" and "isn't", never "≥" or "between". +#[test] +fn album_rules_are_is_or_isnt() { + assert_eq!(field_kind("album"), Some(Kind::Album)); + let ops: Vec<&str> = ops_for(Kind::Album).iter().map(|o| o.0).collect(); + assert_eq!(ops, ["is", "isNot"]); + let r = rs(json!({"rules": [{"field": "album", "op": "gte", "value": 1}]})); + assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`")); +} + +/// An Album rule names its album by id, written as a number or a numeric string, the same way +/// for the check, the matcher and the editor. A folder holds albums, not photos, so it can't be +/// tested. +#[test] +fn album_rule_ids() { + use crate::{Album, AlbumId, Op}; + assert_eq!(album_rule_id(&json!(3)), Some(AlbumId(3))); + assert_eq!(album_rule_id(&json!("3")), Some(AlbumId(3))); + assert_eq!(album_rule_id(&json!(3.0)), Some(AlbumId(3))); + for v in [json!(1.5), json!(-1), json!("x"), json!(null)] { + assert_eq!(album_rule_id(&v), None, "{v}"); + } + let mut cat = Catalog::new(); + cat.apply(Op::AddAlbum { album: Album { folder: true, ..Album::new(AlbumId(4), "Trips") } }).unwrap(); + let p = rs(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})).check(&cat); + assert!(p.first().is_some_and(|p| p.message.contains("folder")), "{p:?}"); +} + +/// Album rules saved before Album offered only "is" / "isn't" (≥, between… from when it was a +/// number field) always meant "isn't" to the matcher; upgrading says so, so they check again. +#[test] +fn old_album_operators_upgrade_to_isnt() { + let mut r = rs(json!({"rules": [ + {"field": "album", "op": "gte", "value": 1}, + {"field": "album", "op": "is", "value": 1}, + {"field": "rating", "op": "gte", "value": 3}, + {"group": {"rules": [{"field": "album", "op": "between", "value": [1, 2]}]}} + ]})); + r.upgrade(); + let ops = serde_json::to_value(&r).unwrap().to_string(); + assert!(ops.contains(r#""field":"album","op":"isNot","value":1"#), "{ops}"); + assert!(ops.contains(r#""field":"album","op":"is","value":1"#), "{ops}"); + assert!(ops.contains(r#""field":"rating","op":"gte""#), "other fields keep theirs: {ops}"); + assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}"); +} + +/// Besides its English message (for agents), a problem names its field and the kind of issue, +/// so the editor can show "Title: needs something to look for" in any language. Every issue +/// has its own short text. +#[test] +fn problems_name_their_field_and_issue() { + let cat = Catalog::new(); + let check = |rules: serde_json::Value| rs(json!({"rules": rules})).check(&cat); + let p = check(json!([{"field": "title", "op": "contains", "value": ""}])); + assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty)); + let p = check(json!([{"field": "title", "op": "isNot", "value": ""}])); + assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty); + // keywords and people say "are empty", and so does their hint + let p = check(json!([{"field": "keywords", "op": "contains", "value": ""}, {"field": "person", "op": "notContains", "value": " "}])); + assert_eq!((p[0].issue, p[1].issue), (Issue::NeedsNamesOrEmpty, Issue::NeedsNamesOrNotEmpty)); + assert!(p[0].issue.text().contains("“are empty”") && p[0].message.contains("“are empty”")); + let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}])); + assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches)); + assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup)); + let p = check(json!([{"field": "captureDate", "op": "between", "value": ["2026-12", "2026-01"]}])); + assert_eq!(p[0].issue, Issue::DatesReversed); + let p = check(json!([{"field": "album", "op": "is", "value": null}])); + assert_eq!(p[0].issue, Issue::ChooseAlbum); + let mut texts: Vec<&str> = Issue::ALL.iter().map(|i| i.text()).collect(); + assert!(texts.iter().all(|t| !t.is_empty())); + texts.sort_unstable(); + texts.dedup(); + assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently"); +} + +/// A rule date reads one way for the check and the matcher: a space works as the "T" +/// ("2026-10-01 10:00" matches a photo taken then). Imported capture times can carry fractions +/// of a second and a time zone (EXIF SubSecTime / OffsetTime: "…T10:00:00.250+02:00"), so a +/// rule may too, after the seconds; anywhere else they are no date. +#[test] +fn rule_dates_have_one_reading() { + let cat = Catalog::new(); + let mut p = photo(1); + p.captured = Some("2026-10-01T10:00:00".into()); + let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]})); + for (op, v) in [ + ("is", json!("2026-10-01 10:00")), + ("is", json!(" 2026-10-01T10 ")), + ("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])), + ("after", json!("2026-10-01 09:59")), + ] { + let r = rule(op, v.clone()); + assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}"); + } + // a capture time with a fraction and a zone, as imports write it + let mut zoned = photo(2); + zoned.captured = Some("2026-08-14T10:00:00.250+02:00".into()); + for v in ["2026-08-14T10:00:00.250", "2026-08-14T10:00:00.250+02:00", "2026-08-14T10:00:00"] { + let r = rule("is", json!(v)); + assert!(r.check(&cat).is_empty() && r.matches(&zoned, &cat), "{v}"); + } + assert!(rule("is", json!("2026-08-14T10:00:00Z")).check(&cat).is_empty()); + for v in [ + "2026-8-14", + "2026-08-14T1", + "2026-08-14X10", + "26-08-14", + "2026-08-14T25:00", + "2026-08-14Z", + "2026-08-14 10:00-05:00", + "2026-08-14T10:00:00.", + "2026-08-14T10:00:00+2", + ] { + let p = rule("is", json!(v)).check(&cat); + assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}"); + } + assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string())); + assert_eq!(rule_date("2026"), Ok("2026".to_string())); +} + +/// The field menu shows every rule field once: at the top level or in exactly one group, +/// and [`FIELDS`] lists them in menu order. +#[test] +fn every_field_is_in_the_menu_exactly_once() { + let menu: Vec<&str> = TOP_LEVEL_FIELDS.iter().chain(FIELD_GROUPS.iter().flat_map(|g| g.1.iter())).copied().collect(); + let fields: Vec<&str> = FIELDS.iter().map(|f| f.0).collect(); + assert_eq!(menu, fields, "the menu and FIELDS list the same fields in the same order"); + for (label, group) in FIELD_GROUPS { + assert!(!group.is_empty(), "group {label} is empty"); + } + let mut labels: Vec<&str> = FIELD_GROUPS.iter().map(|g| g.0).collect(); + labels.dedup(); + assert_eq!(labels.len(), FIELD_GROUPS.len(), "group labels are unique"); +} + +/// Related fields sit together: all the file fields, all the camera (EXIF) fields, all the +/// dates, all the keyword fields. +#[test] +fn related_fields_share_a_group() { + let same = |fields: &[&str], group: &str| { + for f in fields { + assert_eq!(field_group(f), Some(group), "{f}"); + } + }; + same(&["fileName", "extension", "filePath", "kind", "format", "duration"], "File"); + same(&["album", "virtualCopy", "copyName", "stacked"], "Source"); + same(&["longEdge", "shortEdge", "aspect", "megapixels"], "Size"); + same(&["edited", "cropped", "treatment"], "Develop"); + same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info"); + same(&["captureDate", "importDate", "editDate"], "Date"); + same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People"); + same(&["title", "caption", "altText", "creator", "copyright", "copyrightStatus"], "Description"); + same(&["location", "city", "state", "country", "hasGps"], "Location"); + for f in ["rating", "flag", "label", "text"] { + assert_eq!(field_group(f), None, "{f} stays at the top level"); + } + assert_eq!(field_group("nope"), None); + assert_eq!(field_label("filePath"), Some("File Path")); + assert_eq!(field_label("nope"), None); +} + +/// The `filePath` rule exactly as it was before the in-place ASCII path: new strings per photo. +fn file_path_old(op: &str, source: &Source, value: &serde_json::Value) -> bool { + let want = lower(value); + let path = match source { + Source::File { path } => path.replace('\\', "/"), + Source::Demo { .. } => String::new(), + }; + let want = want.replace('\\', "/"); + match op { + "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()), + "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()), + _ => text_op(op, &path, &want), + } +} + +#[test] +fn file_path_rules_match_the_old_strings_for_any_path() { + let paths = [ + "", + " ", + " /a/b.jpg \t", + "/Volumes/share/photos/Trip/IMG_0001.jpg", + "/Volumes/share/PHOTOS/trip/img_0001.JPG", + r"D:\Photos\Lake Como\IMG_0001.JPG", + r"d:/photos/lake como/img_0001.jpg", + r"\\nas\share\x.jpg", + "/photos/Straße/a.jpg", + "/photos/STRASSE/a.jpg", + "/photos/İstanbul/a.jpg", + "/photos/i̇stanbul/a.jpg", + "/photos/ΟΔΟΣ/a.jpg", + "/photos/οδος/a.jpg", + "/photos/ΟΔΌΣ/a.jpg", + "/photos/日本/東京.jpg", + "/p/\u{0B}x\u{0B}", + "/p/\u{85}x\u{85}", + "a", + ]; + let wants = [ + "", + " ", + "a", + "A", + "/", + r"\", + r"\photos\", + "/photos/", + "photos", + "PHOTOS", + "Lake Como", + " lake como ", + "lake como", + r"d:\photos\lake", + "IMG_0001.JPG", + ".jpg", + "straße", + "STRASSE", + "strasse", + "İstanbul", + "i̇stanbul", + "istanbul", + "ΟΔΟΣ", + "οδος", + "οδοσ", + "ΟΔΌΣ", + "東京", + "日本", + "x", + "\u{0B}x", + "/a/b.jpg", + "/a/b.jpg \t", + "\u{85}x", + ]; + let ops = ["contains", "notContains", "is", "isNot", "startsWith", "endsWith", "isEmpty", "isNotEmpty", "bogus"]; + let mut values: Vec = wants.iter().map(|w| json!(w)).collect(); + values.extend([json!(null), json!(5), json!(true), json!(["a", "b"])]); + let mut sources: Vec = paths.iter().map(|p| Source::File { path: (*p).into() }).collect(); + sources.push(Source::Demo { scene: 0 }); + for source in &sources { + for op in ops { + for value in &values { + assert_eq!(file_path_matches(op, source, value), file_path_old(op, source, value), "{source:?} {op} {value}"); + } + } + } +} + +#[test] +fn file_path_rule_through_a_rule_set_ignores_case_and_slash_direction() { + let mut p = photo(1); + p.source = Source::File { path: r"D:\Photos\Lake Como\IMG_0001.JPG".into() }; + let r = rs(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/LAKE como/"}]})); + assert!(r.matches(&p, &Catalog::default())); +} + +/// Every piece of a long path, in both cases and both slash directions, wherever it starts +/// (the search skips eight bytes at a time, so starts and ends at every offset matter). +#[test] +fn file_path_contains_finds_every_piece_of_a_long_path() { + let path = r"D:\Photos\Lake Como\Wedding-Day_2026\IMG_0001 (copy).JPG"; + let source = Source::File { path: path.into() }; + let shout = |s: &str| s.to_uppercase(); + let swap = |s: &str| s.replace('\\', "/"); + for from in 0..path.len() { + for to in from + 1..=path.len() { + let piece = &path[from..to]; + for want in [piece.to_string(), shout(piece), swap(piece), piece.replace('/', r"\"), format!("{piece}x"), format!("x{piece}")] { + let value = json!(want); + for op in ["contains", "notContains"] { + assert_eq!(file_path_matches(op, &source, &value), file_path_old(op, &source, &value), "{op} {want:?}"); + } + } + } + } +} From 25768ea23a324b3448a5e61019c3188f8b7324bd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:07 +0000 Subject: [PATCH 066/191] refactor(lightcraft-raw): move ljpeg.rs tests to crates/raw/src/ljpeg/tests.rs --- crates/raw/src/ljpeg.rs | 314 +--------------------------------- crates/raw/src/ljpeg/tests.rs | 311 +++++++++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 313 deletions(-) create mode 100644 crates/raw/src/ljpeg/tests.rs diff --git a/crates/raw/src/ljpeg.rs b/crates/raw/src/ljpeg.rs index a3240eb20..922a9fb64 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -1004,316 +1004,4 @@ pub fn encode(data: &[u16], width: usize, height: usize, components: usize, prec } #[cfg(test)] -pub(crate) mod tests { - use super::*; - - /// Independent 4×4, 16-bit Sony 4:2:0 stream. Hand-specified differences in MCU order. - pub(crate) fn fixture_420() -> Vec { - fixture_subsampled(4, 4, 0x22, &[-31768, 1, 1000, 1, -16384, -16284, 1, 1, 1, 1, 1, 2, 100, 1, 1000, 1, 100, 100, 1, 1, 1, 1, 1, 2]) - } - - /// A 16-bit three-component frame of `width × height` pixels with the given luma `sampling` (`0x21` / `0x22`) - /// and one shared 5-bit Huffman table, whose differences are written as given (MCU order). - pub(crate) fn fixture_subsampled(width: u8, height: u8, sampling: u8, differences: &[i32]) -> Vec { - let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 17, 16, 0, height, 0, width, 3, 1, sampling, 0, 2, 0x11, 0, 3, 0x11, 0]; - out.extend_from_slice(&[0xff, 0xc4, 0, 36, 0]); - let mut counts = [0; 16]; - counts[4] = 17; - out.extend_from_slice(&counts); - out.extend(0..=16); - out.extend_from_slice(&[0xff, 0xda, 0, 12, 3, 1, 0, 2, 0, 3, 0, 1, 0, 0]); - let mut bw = BitWriter { out, acc: 0, n: 0 }; - for &d in differences { - let s = ssss_of(d); - bw.put(s as u32, 5); - if s > 0 && s < 16 { - let v = if d < 0 { d - 1 } else { d }; - bw.put(v as u32 & ((1 << s) - 1), s as u32); - } - } - bw.flush(); - bw.out.extend_from_slice(&[0xff, 0xd9]); - bw.out - } - - #[test] - fn horizontal_only_subsampling_preserves_rows_and_chroma() { - // Independent 4×2, 16-bit 4:2:2 stream: two luma samples, Cb, Cr per MCU. - let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); - let frame = decode_subsampled(&enc, 16, Prediction::Geometric).unwrap(); - assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 2, 1)); - assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 1100, 1101, 1102, 1103]); - assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); - assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); - assert!(decode(&enc, 24).is_err()); - assert!(matches!(decode_subsampled(&enc, 15, Prediction::Geometric), Err(RawError::Limit(_)))); - assert!(decode_subsampled(&enc[..enc.len() - 6], 16, Prediction::Geometric).is_err()); - } - - #[test] - fn subsampled_mcu_order_preserves_each_plane() { - let enc = fixture_420(); - let frame = decode_subsampled(&enc, 24, Prediction::Geometric).unwrap(); - assert_eq!((frame.width, frame.height), (4, 4)); - assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 2000, 2001, 2002, 2003, 1100, 1101, 1102, 1103, 2100, 2101, 2102, 2103]); - assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); - assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); - assert!(decode(&enc, 48).is_err()); // CFA API must keep rejecting subsampling. - assert!(matches!(decode_subsampled(&enc, 23, Prediction::Geometric), Err(RawError::Limit(_)))); - assert!(decode_subsampled(&enc[..enc.len() - 6], 24, Prediction::Geometric).is_err()); - let mut bad = enc.clone(); - bad[10] = 3; // odd frame width - assert!(frame_info_subsampled(&bad).is_err()); - let sos = bad.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); - bad = enc.clone(); - bad[sos + 11] = 2; // unsupported predictor - assert!(frame_info_subsampled(&bad).is_err()); - } - - #[test] - fn sequential_prediction_chains_each_component_in_decoding_order() { - // Canon sRAW / mRAW: the same stream as `subsampled_mcu_order_preserves_each_plane`, but each component - // continues from its previous sample in decoding order (the second luma row of an MCU follows the first), - // and a new MCU row starts from the first sample of the previous one. Worked out by hand from init 32768. - let enc = fixture_420(); - let frame = decode_subsampled(&enc, 24, Prediction::Sequential).unwrap(); - assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 4, 2)); - assert_eq!(frame.planes[0], [1000, 1001, 2003, 2004, 2001, 2002, 2005, 2006, 1100, 1101, 2103, 2104, 2101, 2102, 2105, 2106]); - assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); - assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); - // 4:2:2 has one luma row per MCU row, so both predictions agree - let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); - assert_eq!( - decode_subsampled(&enc, 16, Prediction::Sequential).unwrap().planes, - decode_subsampled(&enc, 16, Prediction::Geometric).unwrap().planes - ); - } - - fn noise(n: usize, bits: u32, seed: u64) -> Vec { - let mut s = seed; - (0..n) - .map(|i| { - s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - let smooth = ((i % 97) as u32 * 37) % (1 << bits); - let jitter = ((s >> 33) as u32) % 64; - ((smooth + jitter) % (1 << bits)) as u16 - }) - .collect() - } - - #[test] - fn roundtrip_all_predictors_components_precisions() { - for pred in 1..=7u8 { - for comps in 1..=4usize { - for bits in [8u8, 12, 14, 16] { - let (w, h) = (13, 7); - let data = noise(w * h * comps, bits as u32, (pred as u64) * 31 + comps as u64); - let enc = encode(&data, w, h, comps, bits, pred, 0); - let f = decode(&enc, usize::MAX).unwrap(); - assert_eq!((f.width, f.height, f.components, f.precision, f.predictor), (w, h, comps, bits, pred)); - assert_eq!(f.data, data, "pred {pred} comps {comps} bits {bits}"); - } - } - } - } - - #[test] - fn roundtrip_extremes_and_restarts() { - // alternating 0 / 65535 forces SSSS = 16 differences - let data: Vec = (0..64).map(|i| if i % 2 == 0 { 0 } else { 65535 }).collect(); - for pred in 1..=7 { - let enc = encode(&data, 8, 8, 1, 16, pred, 0); - assert_eq!(decode(&enc, usize::MAX).unwrap().data, data); - } - let data = noise(20 * 9 * 2, 12, 5); - for rst in [1usize, 7, 20, 40] { - let enc = encode(&data, 20, 9, 2, 12, 6, rst); - assert_eq!(decode(&enc, usize::MAX).unwrap().data, data, "restart {rst}"); - } - } - - #[test] - fn constant_image_single_symbol_table() { - let data = vec![1234u16; 16 * 4]; - let enc = encode(&data, 16, 4, 1, 14, 1, 0); - assert_eq!(decode(&enc, usize::MAX).unwrap().data, data); - } - - #[test] - fn rejects_bad_streams() { - assert!(decode(&[], 100).is_err()); - assert!(decode(&[0xff, 0xd8, 0xff, 0xd9], 100).is_err()); - let data = noise(64, 12, 1); - let enc = encode(&data, 8, 8, 1, 12, 1, 0); - assert!(matches!(decode(&enc, 10), Err(RawError::Limit(_)))); - for n in 0..enc.len() { - let _ = decode(&enc[..n], usize::MAX); - } - let mut bad = enc.clone(); - for i in (20..bad.len()).step_by(3) { - bad[i] ^= 0x5a; - } - let _ = decode(&bad, usize::MAX); - } - - // --- selection value 8 (Hasselblad 3FR / FFF) --- - - /// SOI, SOF3 (16 bits, `height` x `width`, one component), a DHT and an SOS with selection value 8, then - /// `entropy` and EOI. - fn hasselblad_stream(width: u16, height: u16, counts: &[u8; 16], values: &[u8], entropy: &[u8]) -> Vec { - let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 11, 16]; - out.extend_from_slice(&height.to_be_bytes()); - out.extend_from_slice(&width.to_be_bytes()); - out.extend_from_slice(&[1, 0, 0x11, 0]); - out.extend_from_slice(&[0xff, 0xc4]); - out.extend_from_slice(&((2 + 17 + values.len()) as u16).to_be_bytes()); - out.push(0); - out.extend_from_slice(counts); - out.extend_from_slice(values); - out.extend_from_slice(&[0xff, 0xda, 0, 8, 1, 0, 0, 8, 0, 0]); - out.extend_from_slice(entropy); - out.extend_from_slice(&[0xff, 0xd9]); - out - } - - /// Codes: SSSS 0 = `00`, 1 = `01`, 2 = `100`, 15 = `101`, 16 = `110`. - const HAND_COUNTS: [u8; 16] = [0, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - const HAND_VALUES: [u8; 5] = [0, 1, 2, 15, 16]; - - /// A 4 x 2 frame written out bit by bit: - /// - row 0, pair 1: codes `110` `110` (SSSS 16 twice), then 16 bits each (`FFFF`, `7FFF`) that do not count: - /// both samples are 32768 - 32768 = 0; - /// - row 0, pair 2: codes `01` `100`, bits `1` (+1) and `01` (-2): 0 + 1 = 1 and 0 - 2 = 65534 (modulo 2^16); - /// - row 1, pair 1: codes `00` `01`, bit `0` (-1): both predictors start again at 32768, so 32768 and 32767; - /// - row 1, pair 2: codes `100` `00`, bits `11` (+3): 32771 and 32767. - /// - /// 58 bits, padded with zeros to two 32-bit words `DBFFFDFF` `FD9450C0`, stored little-endian. The `FF FD` - /// they start with would be a marker to a T.81 reader: this coding has no byte stuffing. - const HAND_WORDS: [u8; 8] = [0xff, 0xfd, 0xff, 0xdb, 0xc0, 0x50, 0x94, 0xfd]; - const HAND_SAMPLES: [u16; 8] = [0, 0, 1, 65534, 32768, 32767, 32771, 32767]; - - #[test] - fn selection_value_8_known_answer() { - let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); - assert_eq!(frame_info(&enc).unwrap(), (4, 2, 1, 16)); - let f = decode(&enc, 8).unwrap(); - assert_eq!((f.width, f.height, f.components, f.predictor), (4, 2, 1, 8)); - assert_eq!(f.data, HAND_SAMPLES); - assert_eq!(decode_hasselblad(&enc, 8).unwrap().data, HAND_SAMPLES); - assert!(matches!(decode(&enc, 7), Err(RawError::Limit(_)))); - } - - /// The same bits in plain byte order (not 32-bit little-endian words) are a different stream. - #[test] - fn selection_value_8_reads_little_endian_words() { - let mut plain = HAND_WORDS; - plain[..4].reverse(); - plain[4..].reverse(); - let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &plain); - assert_ne!(decode(&enc, 8).map(|f| f.data).ok(), Some(HAND_SAMPLES.to_vec())); - } - - #[test] - fn selection_value_8_rejects_bad_streams() { - // one word is not enough for the second row - let short = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS[..4]); - assert!(decode(&short, 8).is_err()); - // a category above 16 - let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &[0, 1, 2, 15, 17], &HAND_WORDS); - assert!(decode(&enc, 8).is_err()); - // layouts never seen with selection value 8 are not guessed at - let mut odd = hasselblad_stream(3, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); - assert!(matches!(frame_info(&odd), Err(RawError::Unsupported(_)))); - assert!(matches!(decode(&odd, 8), Err(RawError::Unsupported(_)))); - odd = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); - odd[6] = 14; // precision - assert!(matches!(decode(&odd, 8), Err(RawError::Unsupported(_)))); - // selection values above 8 stay unknown - let mut nine = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); - let sos = nine.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); - nine[sos + 7] = 9; - assert!(matches!(decode(&nine, 8), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); - // every prefix fails cleanly - let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); - for n in 0..enc.len() { - let _ = decode(&enc[..n], 8); - } - } - - /// Test-only writer of the selection-value-8 coding (the inverse of the rule in [`decode_hasselblad`]), with - /// a 5-bit code for every category. Category-16 differences carry `FFFF` as their ignored bits. - pub(crate) fn encode_hasselblad(data: &[u16], width: usize, height: usize) -> Vec { - assert!(width.is_multiple_of(2) && data.len() == width * height); - let mut bits: Vec = vec![]; - let put = |bits: &mut Vec, v: u32, n: u32| { - for i in (0..n).rev() { - bits.push((v >> i) & 1 == 1); - } - }; - for row in data.chunks_exact(width) { - let mut last = [32768i32, 32768]; - for pair in row.as_chunks::<2>().0 { - let mut d = [0i32; 2]; - for k in 0..2 { - let mut v = (pair[k] as i32 - last[k]) & 0xffff; - if v >= 32768 { - v -= 65536; - } - d[k] = v; - last[k] = pair[k] as i32; - } - let s = [ssss_of(d[0]), ssss_of(d[1])]; - put(&mut bits, s[0] as u32, 5); - put(&mut bits, s[1] as u32, 5); - for k in 0..2 { - match s[k] { - 0 => {} - 16 => put(&mut bits, 0xffff, 16), - n => { - let v = if d[k] < 0 { d[k] - 1 } else { d[k] }; - put(&mut bits, v as u32 & ((1 << n) - 1), n as u32); - } - } - } - } - } - let mut entropy = vec![]; - for word in bits.chunks(32) { - let w = word.iter().enumerate().fold(0u32, |a, (i, &b)| a | ((b as u32) << (31 - i))); - entropy.extend_from_slice(&w.to_le_bytes()); - } - let mut counts = [0u8; 16]; - counts[4] = 17; - let values: Vec = (0..=16).collect(); - hasselblad_stream(width as u16, height as u16, &counts, &values, &entropy) - } - - #[test] - fn selection_value_8_round_trips_through_the_test_writer() { - // extremes force SSSS 16 and wrap-around; noise exercises every other category - let mut data = noise(10 * 7, 16, 9); - data[0] = 0; - data[1] = 65535; - data[2] = 65535; - data[3] = 0; - let enc = encode_hasselblad(&data, 10, 7); - assert_eq!(decode(&enc, 70).unwrap().data, data); - } - - #[test] - fn code_lengths_are_limited_to_16() { - // Fibonacci-like frequencies produce very long codes before adjustment - let mut f = [0u32; 17]; - let (mut a, mut b) = (1u32, 1u32); - for x in f.iter_mut() { - *x = a; - let c = a.saturating_add(b); - a = b; - b = c; - } - let (counts, values) = code_lengths(&f); - assert_eq!(counts.iter().map(|&c| c as usize).sum::(), 17); - assert_eq!(values.len(), 17); - assert!(Huffman::new(&counts, &values).is_ok()); - } -} +pub(crate) mod tests; diff --git a/crates/raw/src/ljpeg/tests.rs b/crates/raw/src/ljpeg/tests.rs new file mode 100644 index 000000000..c5c6ccf25 --- /dev/null +++ b/crates/raw/src/ljpeg/tests.rs @@ -0,0 +1,311 @@ +use super::*; + +/// Independent 4×4, 16-bit Sony 4:2:0 stream. Hand-specified differences in MCU order. +pub(crate) fn fixture_420() -> Vec { + fixture_subsampled(4, 4, 0x22, &[-31768, 1, 1000, 1, -16384, -16284, 1, 1, 1, 1, 1, 2, 100, 1, 1000, 1, 100, 100, 1, 1, 1, 1, 1, 2]) +} + +/// A 16-bit three-component frame of `width × height` pixels with the given luma `sampling` (`0x21` / `0x22`) +/// and one shared 5-bit Huffman table, whose differences are written as given (MCU order). +pub(crate) fn fixture_subsampled(width: u8, height: u8, sampling: u8, differences: &[i32]) -> Vec { + let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 17, 16, 0, height, 0, width, 3, 1, sampling, 0, 2, 0x11, 0, 3, 0x11, 0]; + out.extend_from_slice(&[0xff, 0xc4, 0, 36, 0]); + let mut counts = [0; 16]; + counts[4] = 17; + out.extend_from_slice(&counts); + out.extend(0..=16); + out.extend_from_slice(&[0xff, 0xda, 0, 12, 3, 1, 0, 2, 0, 3, 0, 1, 0, 0]); + let mut bw = BitWriter { out, acc: 0, n: 0 }; + for &d in differences { + let s = ssss_of(d); + bw.put(s as u32, 5); + if s > 0 && s < 16 { + let v = if d < 0 { d - 1 } else { d }; + bw.put(v as u32 & ((1 << s) - 1), s as u32); + } + } + bw.flush(); + bw.out.extend_from_slice(&[0xff, 0xd9]); + bw.out +} + +#[test] +fn horizontal_only_subsampling_preserves_rows_and_chroma() { + // Independent 4×2, 16-bit 4:2:2 stream: two luma samples, Cb, Cr per MCU. + let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); + let frame = decode_subsampled(&enc, 16, Prediction::Geometric).unwrap(); + assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 2, 1)); + assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 1100, 1101, 1102, 1103]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + assert!(decode(&enc, 24).is_err()); + assert!(matches!(decode_subsampled(&enc, 15, Prediction::Geometric), Err(RawError::Limit(_)))); + assert!(decode_subsampled(&enc[..enc.len() - 6], 16, Prediction::Geometric).is_err()); +} + +#[test] +fn subsampled_mcu_order_preserves_each_plane() { + let enc = fixture_420(); + let frame = decode_subsampled(&enc, 24, Prediction::Geometric).unwrap(); + assert_eq!((frame.width, frame.height), (4, 4)); + assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 2000, 2001, 2002, 2003, 1100, 1101, 1102, 1103, 2100, 2101, 2102, 2103]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + assert!(decode(&enc, 48).is_err()); // CFA API must keep rejecting subsampling. + assert!(matches!(decode_subsampled(&enc, 23, Prediction::Geometric), Err(RawError::Limit(_)))); + assert!(decode_subsampled(&enc[..enc.len() - 6], 24, Prediction::Geometric).is_err()); + let mut bad = enc.clone(); + bad[10] = 3; // odd frame width + assert!(frame_info_subsampled(&bad).is_err()); + let sos = bad.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); + bad = enc.clone(); + bad[sos + 11] = 2; // unsupported predictor + assert!(frame_info_subsampled(&bad).is_err()); +} + +#[test] +fn sequential_prediction_chains_each_component_in_decoding_order() { + // Canon sRAW / mRAW: the same stream as `subsampled_mcu_order_preserves_each_plane`, but each component + // continues from its previous sample in decoding order (the second luma row of an MCU follows the first), + // and a new MCU row starts from the first sample of the previous one. Worked out by hand from init 32768. + let enc = fixture_420(); + let frame = decode_subsampled(&enc, 24, Prediction::Sequential).unwrap(); + assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 4, 2)); + assert_eq!(frame.planes[0], [1000, 1001, 2003, 2004, 2001, 2002, 2005, 2006, 1100, 1101, 2103, 2104, 2101, 2102, 2105, 2106]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + // 4:2:2 has one luma row per MCU row, so both predictions agree + let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); + assert_eq!( + decode_subsampled(&enc, 16, Prediction::Sequential).unwrap().planes, + decode_subsampled(&enc, 16, Prediction::Geometric).unwrap().planes + ); +} + +fn noise(n: usize, bits: u32, seed: u64) -> Vec { + let mut s = seed; + (0..n) + .map(|i| { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let smooth = ((i % 97) as u32 * 37) % (1 << bits); + let jitter = ((s >> 33) as u32) % 64; + ((smooth + jitter) % (1 << bits)) as u16 + }) + .collect() +} + +#[test] +fn roundtrip_all_predictors_components_precisions() { + for pred in 1..=7u8 { + for comps in 1..=4usize { + for bits in [8u8, 12, 14, 16] { + let (w, h) = (13, 7); + let data = noise(w * h * comps, bits as u32, (pred as u64) * 31 + comps as u64); + let enc = encode(&data, w, h, comps, bits, pred, 0); + let f = decode(&enc, usize::MAX).unwrap(); + assert_eq!((f.width, f.height, f.components, f.precision, f.predictor), (w, h, comps, bits, pred)); + assert_eq!(f.data, data, "pred {pred} comps {comps} bits {bits}"); + } + } + } +} + +#[test] +fn roundtrip_extremes_and_restarts() { + // alternating 0 / 65535 forces SSSS = 16 differences + let data: Vec = (0..64).map(|i| if i % 2 == 0 { 0 } else { 65535 }).collect(); + for pred in 1..=7 { + let enc = encode(&data, 8, 8, 1, 16, pred, 0); + assert_eq!(decode(&enc, usize::MAX).unwrap().data, data); + } + let data = noise(20 * 9 * 2, 12, 5); + for rst in [1usize, 7, 20, 40] { + let enc = encode(&data, 20, 9, 2, 12, 6, rst); + assert_eq!(decode(&enc, usize::MAX).unwrap().data, data, "restart {rst}"); + } +} + +#[test] +fn constant_image_single_symbol_table() { + let data = vec![1234u16; 16 * 4]; + let enc = encode(&data, 16, 4, 1, 14, 1, 0); + assert_eq!(decode(&enc, usize::MAX).unwrap().data, data); +} + +#[test] +fn rejects_bad_streams() { + assert!(decode(&[], 100).is_err()); + assert!(decode(&[0xff, 0xd8, 0xff, 0xd9], 100).is_err()); + let data = noise(64, 12, 1); + let enc = encode(&data, 8, 8, 1, 12, 1, 0); + assert!(matches!(decode(&enc, 10), Err(RawError::Limit(_)))); + for n in 0..enc.len() { + let _ = decode(&enc[..n], usize::MAX); + } + let mut bad = enc.clone(); + for i in (20..bad.len()).step_by(3) { + bad[i] ^= 0x5a; + } + let _ = decode(&bad, usize::MAX); +} + +// --- selection value 8 (Hasselblad 3FR / FFF) --- + +/// SOI, SOF3 (16 bits, `height` x `width`, one component), a DHT and an SOS with selection value 8, then +/// `entropy` and EOI. +fn hasselblad_stream(width: u16, height: u16, counts: &[u8; 16], values: &[u8], entropy: &[u8]) -> Vec { + let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 11, 16]; + out.extend_from_slice(&height.to_be_bytes()); + out.extend_from_slice(&width.to_be_bytes()); + out.extend_from_slice(&[1, 0, 0x11, 0]); + out.extend_from_slice(&[0xff, 0xc4]); + out.extend_from_slice(&((2 + 17 + values.len()) as u16).to_be_bytes()); + out.push(0); + out.extend_from_slice(counts); + out.extend_from_slice(values); + out.extend_from_slice(&[0xff, 0xda, 0, 8, 1, 0, 0, 8, 0, 0]); + out.extend_from_slice(entropy); + out.extend_from_slice(&[0xff, 0xd9]); + out +} + +/// Codes: SSSS 0 = `00`, 1 = `01`, 2 = `100`, 15 = `101`, 16 = `110`. +const HAND_COUNTS: [u8; 16] = [0, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; +const HAND_VALUES: [u8; 5] = [0, 1, 2, 15, 16]; + +/// A 4 x 2 frame written out bit by bit: +/// - row 0, pair 1: codes `110` `110` (SSSS 16 twice), then 16 bits each (`FFFF`, `7FFF`) that do not count: +/// both samples are 32768 - 32768 = 0; +/// - row 0, pair 2: codes `01` `100`, bits `1` (+1) and `01` (-2): 0 + 1 = 1 and 0 - 2 = 65534 (modulo 2^16); +/// - row 1, pair 1: codes `00` `01`, bit `0` (-1): both predictors start again at 32768, so 32768 and 32767; +/// - row 1, pair 2: codes `100` `00`, bits `11` (+3): 32771 and 32767. +/// +/// 58 bits, padded with zeros to two 32-bit words `DBFFFDFF` `FD9450C0`, stored little-endian. The `FF FD` +/// they start with would be a marker to a T.81 reader: this coding has no byte stuffing. +const HAND_WORDS: [u8; 8] = [0xff, 0xfd, 0xff, 0xdb, 0xc0, 0x50, 0x94, 0xfd]; +const HAND_SAMPLES: [u16; 8] = [0, 0, 1, 65534, 32768, 32767, 32771, 32767]; + +#[test] +fn selection_value_8_known_answer() { + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + assert_eq!(frame_info(&enc).unwrap(), (4, 2, 1, 16)); + let f = decode(&enc, 8).unwrap(); + assert_eq!((f.width, f.height, f.components, f.predictor), (4, 2, 1, 8)); + assert_eq!(f.data, HAND_SAMPLES); + assert_eq!(decode_hasselblad(&enc, 8).unwrap().data, HAND_SAMPLES); + assert!(matches!(decode(&enc, 7), Err(RawError::Limit(_)))); +} + +/// The same bits in plain byte order (not 32-bit little-endian words) are a different stream. +#[test] +fn selection_value_8_reads_little_endian_words() { + let mut plain = HAND_WORDS; + plain[..4].reverse(); + plain[4..].reverse(); + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &plain); + assert_ne!(decode(&enc, 8).map(|f| f.data).ok(), Some(HAND_SAMPLES.to_vec())); +} + +#[test] +fn selection_value_8_rejects_bad_streams() { + // one word is not enough for the second row + let short = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS[..4]); + assert!(decode(&short, 8).is_err()); + // a category above 16 + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &[0, 1, 2, 15, 17], &HAND_WORDS); + assert!(decode(&enc, 8).is_err()); + // layouts never seen with selection value 8 are not guessed at + let mut odd = hasselblad_stream(3, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + assert!(matches!(frame_info(&odd), Err(RawError::Unsupported(_)))); + assert!(matches!(decode(&odd, 8), Err(RawError::Unsupported(_)))); + odd = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + odd[6] = 14; // precision + assert!(matches!(decode(&odd, 8), Err(RawError::Unsupported(_)))); + // selection values above 8 stay unknown + let mut nine = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + let sos = nine.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); + nine[sos + 7] = 9; + assert!(matches!(decode(&nine, 8), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); + // every prefix fails cleanly + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + for n in 0..enc.len() { + let _ = decode(&enc[..n], 8); + } +} + +/// Test-only writer of the selection-value-8 coding (the inverse of the rule in [`decode_hasselblad`]), with +/// a 5-bit code for every category. Category-16 differences carry `FFFF` as their ignored bits. +pub(crate) fn encode_hasselblad(data: &[u16], width: usize, height: usize) -> Vec { + assert!(width.is_multiple_of(2) && data.len() == width * height); + let mut bits: Vec = vec![]; + let put = |bits: &mut Vec, v: u32, n: u32| { + for i in (0..n).rev() { + bits.push((v >> i) & 1 == 1); + } + }; + for row in data.chunks_exact(width) { + let mut last = [32768i32, 32768]; + for pair in row.as_chunks::<2>().0 { + let mut d = [0i32; 2]; + for k in 0..2 { + let mut v = (pair[k] as i32 - last[k]) & 0xffff; + if v >= 32768 { + v -= 65536; + } + d[k] = v; + last[k] = pair[k] as i32; + } + let s = [ssss_of(d[0]), ssss_of(d[1])]; + put(&mut bits, s[0] as u32, 5); + put(&mut bits, s[1] as u32, 5); + for k in 0..2 { + match s[k] { + 0 => {} + 16 => put(&mut bits, 0xffff, 16), + n => { + let v = if d[k] < 0 { d[k] - 1 } else { d[k] }; + put(&mut bits, v as u32 & ((1 << n) - 1), n as u32); + } + } + } + } + } + let mut entropy = vec![]; + for word in bits.chunks(32) { + let w = word.iter().enumerate().fold(0u32, |a, (i, &b)| a | ((b as u32) << (31 - i))); + entropy.extend_from_slice(&w.to_le_bytes()); + } + let mut counts = [0u8; 16]; + counts[4] = 17; + let values: Vec = (0..=16).collect(); + hasselblad_stream(width as u16, height as u16, &counts, &values, &entropy) +} + +#[test] +fn selection_value_8_round_trips_through_the_test_writer() { + // extremes force SSSS 16 and wrap-around; noise exercises every other category + let mut data = noise(10 * 7, 16, 9); + data[0] = 0; + data[1] = 65535; + data[2] = 65535; + data[3] = 0; + let enc = encode_hasselblad(&data, 10, 7); + assert_eq!(decode(&enc, 70).unwrap().data, data); +} + +#[test] +fn code_lengths_are_limited_to_16() { + // Fibonacci-like frequencies produce very long codes before adjustment + let mut f = [0u32; 17]; + let (mut a, mut b) = (1u32, 1u32); + for x in f.iter_mut() { + *x = a; + let c = a.saturating_add(b); + a = b; + b = c; + } + let (counts, values) = code_lengths(&f); + assert_eq!(counts.iter().map(|&c| c as usize).sum::(), 17); + assert_eq!(values.len(), 17); + assert!(Huffman::new(&counts, &values).is_ok()); +} From b61a981f732f638e6bff08ccd29f8de27e03bf7f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:08 +0000 Subject: [PATCH 067/191] refactor(lightcraft-raw): move lib.rs tests to crates/raw/src/tests.rs --- crates/raw/src/lib.rs | 615 +--------------------------------------- crates/raw/src/tests.rs | 612 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 613 insertions(+), 614 deletions(-) create mode 100644 crates/raw/src/tests.rs diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 011142b09..6626f10cb 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -894,620 +894,7 @@ impl RawImage { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cfa_helpers() { - let c = Cfa::bayer("RGGB").unwrap(); - assert!(c.is_bayer()); - assert_eq!(c.color_at(0, 0), 0); - assert_eq!(c.color_at(3, 3), 2); - assert_eq!(c.shifted(1, 0).name(), "GRBG"); - assert_eq!(c.shifted(1, 1).name(), "BGGR"); - assert!(Cfa::bayer("RGGX").is_none()); - for name in ["RGGB", "BGGR", "GRBG", "GBRG"] { - assert_eq!(Cfa::bayer_static(name).name(), name); - } - let x = Cfa::xtrans(); - assert!(!x.is_bayer()); - let greens = x.pattern.iter().filter(|&&p| p == 1).count(); - assert_eq!(greens, 20); - // every 3×3 block of the X-Trans layout has 5 greens - for by in 0..2 { - for bx in 0..2 { - let g = (0..9).filter(|i| x.color_at(bx * 3 + i % 3, by * 3 + i / 3) == 1).count(); - assert_eq!(g, 5); - } - } - } - - #[test] - fn a_picture_made_elsewhere_is_finished_like_develop_does() { - let (w, h) = (12usize, 10usize); - let raw = RawImage { - format: RawFormat::Dng, - width: w, - height: h, - cpp: 1, - data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()), - cfa: Cfa::bayer("RGGB"), - bits: 12, - black: BlackLevel::uniform(64.0), - white: vec![1023.0], - active_area: Rect::new(2, 2, 8, 6), - crop: Rect::new(1, 1, 5, 4), - orientation: Orientation::Normal, - color: ColorData::default(), - wb_multipliers: None, - linearized: false, - pixel_aspect: 1.0, - opcodes: OpcodeLists::default(), - metadata: Metadata::default(), - lens_metadata: Default::default(), - }; - let direct = raw.develop(Method::Bilinear).unwrap(); - let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear)); - assert_eq!((direct.width, direct.height), (5, 4)); - assert_eq!(direct, by_hand); - // the crop is what develop keeps; no usable crop keeps everything - assert_eq!(raw.develop_crop(8, 6), Rect::new(1, 1, 5, 4)); - let mut whole = raw.clone(); - whole.crop = Rect::default(); - assert_eq!(whole.develop_crop(8, 6), Rect::new(0, 0, 8, 6)); - assert_eq!(whole.develop(Method::Bilinear).unwrap().width, 8); - // a crop that pokes out is clipped to the picture - whole.crop = Rect::new(6, 4, 10, 10); - assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); - - // tall pixels (D1X: 2.0) are stretched to square ones: the rows double, `developed_size` says so - let mut tall = raw.clone(); - tall.pixel_aspect = 2.0; - let d = tall.develop(Method::Bilinear).unwrap(); - assert_eq!((d.width, d.height), (5, 8)); - assert_eq!(tall.info().developed_size(), (5, 8)); - assert_eq!(raw.info().developed_size(), (5, 4)); - assert!(!tall.can_bin(2)); - assert_eq!(stretched_height(1324, 2.0), 2648); - assert_eq!(stretched_height(1324, 1.0), 1324); - } - - /// A maker-note crop (`PEF` AOC / `ORF` ImageProcessing) with an origin of `u64::MAX` used to - /// pass the bounds check (`x + width` wraps to 0) and then index out of bounds in - /// `normalized`. It must be a `Corrupt` error at validate time instead. - #[test] - fn an_active_area_that_wraps_past_the_image_is_corrupt() { - let (w, h) = (12usize, 10usize); - let mut raw = RawImage { - format: RawFormat::Dng, - width: w, - height: h, - cpp: 1, - data: RawData::U16(vec![100; w * h]), - cfa: Cfa::bayer("RGGB"), - bits: 12, - black: BlackLevel::uniform(64.0), - white: vec![1023.0], - active_area: Rect::new(usize::MAX, 0, 1, 1), - crop: Rect::default(), - orientation: Orientation::Normal, - color: ColorData::default(), - wb_multipliers: None, - linearized: false, - opcodes: OpcodeLists::default(), - metadata: Metadata::default(), - lens_metadata: Default::default(), - pixel_aspect: 1.0, - }; - assert!(raw.validate().is_err(), "`x + width` wrapped: the area is outside the image"); - assert!(raw.normalized().is_err()); - raw.active_area = Rect::new(0, 0, w + 1, h); - assert!(raw.validate().is_err(), "one past the last column is outside too"); - } - - #[test] - fn black_levels() { - let b = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![1.0, 2.0, 3.0, 4.0], delta_h: vec![0.5, 0.0], delta_v: vec![0.0, 10.0] }; - assert_eq!(b.at(0, 0, 0, 1), 1.5); - assert_eq!(b.at(1, 1, 0, 1), 14.0); - assert_eq!(b.at(3, 2, 0, 1), 2.0); - assert_eq!(b.mean(), 2.5); - assert_eq!(BlackLevel::uniform(7.0).at(9, 9, 0, 3), 7.0); - } - - // --- TIFF shells around a private raw block (thumbnail-only IFD0) --- - - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - - /// An uncompressed 8-bit RGB image IFD. - fn rgb_ifd(w: u32, h: u32) -> IfdBuilder { - let mut ifd = IfdBuilder::new(); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![90u8; (w * h * 3) as usize]] }); - ifd - } - - fn exif_size(w: u32, h: u32) -> IfdBuilder { - IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h])) - } - - fn write(chain: &[IfdBuilder]) -> Vec { - TiffWriter::default().write(chain).unwrap() - } - - const SHELL_REASON: &str = "raw image in a private block"; - - /// A bigger image in a `SubIFDs` child of a small IFD0 (the layout of an 8-bit-mosaic TIFF raw). - #[test] - fn thumbnail_with_a_bigger_sub_ifd_is_an_undecodable_raw() { - let mut ifd0 = rgb_ifd(16, 12); - ifd0.add_sub_ifd(rgb_ifd(64, 48)); - let bytes = write(&[ifd0]); - assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); - let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; - assert!(why.contains("64x48") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); - // a sub-IFD that is not much bigger (a pyramid level) does not count - let mut ifd0 = rgb_ifd(16, 12); - ifd0.add_sub_ifd(rgb_ifd(32, 24)); - assert_eq!(probe(&write(&[ifd0])), None); - } - - /// Exif says the picture is far bigger than IFD0 and the file holds a block that could carry it. - #[test] - fn thumbnail_with_a_private_block_behind_the_exif_size_is_an_undecodable_raw() { - let mut ifd0 = rgb_ifd(16, 12); - ifd0.set_child(t::EXIF_IFD, exif_size(400, 300)); - let mut bytes = write(&[ifd0.clone()]); - // no private block: a downsized copy that kept its old Exif size is an ordinary image - assert_eq!(probe(&bytes), None); - // 400 x 300 pixels at one bit each is 15000 bytes: 14000 is not enough, 16000 is - bytes.extend(std::iter::repeat_n(7u8, 14_000)); - assert_eq!(probe(&bytes), None); - bytes.extend(std::iter::repeat_n(7u8, 2_000)); - assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); - let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; - assert!(why.contains("400x300") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); - // truncating it never panics - for n in (0..bytes.len()).step_by(37) { - let _ = probe(&bytes[..n]); - let _ = decode(&bytes[..n]); - } - } - - /// Ordinary TIFFs stay images: the full picture first, equal Exif size, or later pages that are bigger. - #[test] - fn ordinary_tiffs_are_not_shells() { - let padded = |chain: &[IfdBuilder]| { - let mut b = write(chain); - b.extend(std::iter::repeat_n(0u8, 100_000)); - b - }; - let mut full = rgb_ifd(400, 300); - full.set_child(t::EXIF_IFD, exif_size(400, 300)); - assert_eq!(probe(&padded(&[full])), None); - // the Exif size is much bigger than IFD0, with spare bytes, but IFD0 is not tiny next to it - let mut near = rgb_ifd(120, 90); - near.set_child(t::EXIF_IFD, exif_size(400, 300)); - assert_eq!(probe(&padded(&[near])), None); - // a multi-page TIFF whose first page is small - assert_eq!(probe(&padded(&[rgb_ifd(16, 12), rgb_ifd(400, 300)])), None); - // no dimensions at all - assert_eq!(probe(&padded(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("X".into()))])), None); - // raw evidence without a CFA photometric: Pentax's Exif `CFAPattern` (uncompressed / PackBits PEFs) and - // Samsung's private compressions (here 32773, PackBits elsewhere: NX1, NX500) - let mut pef = rgb_ifd(400, 300); - pef.set(t::MAKE, Value::Ascii("PENTAX Corporation".into())); - let mut exif = exif_size(400, 300); - exif.set(vendor::EXIF_CFA_PATTERN, Value::Undefined(vec![0, 2, 0, 2, 0, 1, 1, 2])); - pef.set_child(t::EXIF_IFD, exif); - assert_eq!(probe(&padded(&[pef])), Some(RawFormat::Pef)); - let mut srw = rgb_ifd(400, 300); - srw.set(t::MAKE, Value::Ascii("SAMSUNG".into())); - srw.set(t::COMPRESSION, Value::Short(vec![32773])); - assert_eq!(probe(&padded(&[srw])), Some(RawFormat::Srw)); - // camera-authored or exported TIFFs preserving camera Make tags remain ordinary images - for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { - let mut cam = rgb_ifd(400, 300); - cam.set(t::MAKE, Value::Ascii(make.into())); - cam.set_child(t::EXIF_IFD, exif_size(400, 300)); - assert_eq!(probe(&padded(&[cam])), None, "{make} TIFF should not be probed as raw"); - } - } - - /// #702: two Sony layouts carry no CFA IFD, so the raw evidence #281 asks of Sony files has to - /// include them: SRF (an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data addressed - /// by `SubIFDs`). An 8-bit TIFF from the same bodies, or a SubIFDs TIFF from another Sony model, - /// stays an ordinary image. - #[test] - fn sony_raws_without_a_cfa_ifd_are_still_raws() { - let sony = |model: &str, bits: u16, compression: u16, sub_ifd: Option| { - let mut ifd = rgb_ifd(400, 300); - ifd.set(t::MAKE, Value::Ascii("SONY".into())); - ifd.set(t::MODEL, Value::Ascii(model.into())); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3])); - ifd.set(t::COMPRESSION, Value::Short(vec![compression])); - if let Some(child) = sub_ifd { - ifd.set_child(t::SUB_IFDS, child); - } - probe(&write(&[ifd])) - }; - let raw_block = || Some(rgb_ifd(64, 48)); // stands in for the A100's raw-data pointer - assert_eq!(sony("DSC-F828", 14, 1, None), Some(RawFormat::Arw), "SRF"); - assert_eq!(sony("DSC-F828", 12, 1, None), Some(RawFormat::Arw), "12-bit samples count too"); - assert_eq!(sony("DSLR-A100", 8, 6, raw_block()), Some(RawFormat::Arw), "A100: old-JPEG IFD0 and SubIFDs"); - assert_eq!(sony("DSC-F828", 8, 1, None), None, "an 8-bit TIFF"); - assert_eq!(sony("DSC-F828", 16, 1, None), None, "a 16-bit TIFF"); - // TIFFs exported from A100 photos, with a thumbnail in SubIFDs (Codex review of #702) - assert_eq!(sony("DSLR-A100", 8, 1, Some(rgb_ifd(64, 48))), None, "an 8-bit A100 TIFF with a SubIFDs thumbnail"); - assert_eq!(sony("DSLR-A100", 16, 8, Some(rgb_ifd(64, 48))), None, "a 16-bit A100 TIFF with a SubIFDs thumbnail"); - assert_eq!(sony("ILCE-7M4", 8, 6, raw_block()), None, "the A100's layout under another model"); - } - - // --- containers that are recognised but not decoded, with a preview --- - - #[test] - fn private_containers_are_recognised_by_their_magic() { - assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw)); - assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw)); - assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f)); - for f in [RawFormat::Crw, RawFormat::X3f] { - assert!(!f.is_supported()); - } - assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f"))); - // too short for the CIFF signature - assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None); - } - - /// An IFD0 with a JPEG pointer but no `ImageWidth` / `ImageLength` (the Kodak KDC layout) is a raw - /// container with a preview; a TIFF reader would call it malformed. - #[test] - fn ifd0_without_dimensions_but_with_a_jpeg_is_a_preview_container() { - let jpeg: Vec = [&[0xffu8, 0xd8, 0xff, 0xc0, 0, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0][..], &[0x55; 40], &[0xff, 0xd9]].concat(); - let off = 8 + 2 + 24 + 4; - let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); - for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] { - f.extend_from_slice(&tag.to_le_bytes()); - f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); - f.extend_from_slice(&v.to_le_bytes()); - } - f.extend_from_slice(&[0, 0, 0, 0]); - f.extend_from_slice(&jpeg); - assert_eq!(probe(&f), Some(RawFormat::OtherTiff)); - assert!(matches!(decode(&f), Err(RawError::Unsupported(_)))); - assert_eq!(embedded_preview(&f), Some(jpeg)); - // no pointer and no sub-IFDs: just a broken TIFF - let g = write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("KODAK".into()))]); - assert_eq!(probe(&g), None); - } - - /// A GoPro GPR (a DNG with VC-5 coded raw data, Compression 9) is refused by the full decode with - /// a reason that names the format, whatever its tile bytes hold; the header probe still - /// describes it (so it imports). - #[test] - fn gopro_vc5_dng_is_unsupported_with_a_clear_reason() { - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); - ifd.set(t::COMPRESSION, Value::Short(vec![9])); - ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); - ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); - ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); - ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); - let bytes = write(&[ifd]); - assert_eq!(probe(&bytes), Some(RawFormat::Dng)); - let want = "DNG compression 9 (GoPro VC-5) is not decoded yet"; - let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported from decode") }; - assert_eq!(why, want); - let info = probe_info(&bytes).expect("header probe describes the file"); - assert_eq!((info.width, info.height), (32, 16)); - } - - /// A LinearRaw DNG in the layout of Lightroom's merges: a `w` × `h` main image (samples 1000) with a default - /// crop, and reduced copies (samples 2000 + their width) of the given sizes beside it. - fn linear_dng_with_copies(w: u32, h: u32, copies: &[(u32, u32)], main_tags: &[(u16, Value)]) -> Vec { - let linear = |subfile: u32, w: u32, h: u32, sample: u16| { - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::LINEAR_RAW])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); - let samples: Vec = std::iter::repeat_n(sample.to_le_bytes(), (w * h * 3) as usize).flatten().collect(); - ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![samples] }); - ifd - }; - let mut main = linear(0, w, h, 1000); - main.set(t::BLACK_LEVEL, Value::Short(vec![100])); - main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); - main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); - for (tag, v) in main_tags { - main.set(*tag, v.clone()); - } - let mut ifd0 = rgb_ifd(4, 2); - ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); - ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); - ifd0.set(t::MAKE, Value::Ascii("DJI".into())); - ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); - ifd0.add_sub_ifd(main); - for &(cw, ch) in copies { - ifd0.add_sub_ifd(linear(1, cw, ch, 2000 + cw as u16)); - } - write(&[ifd0]) - } - - /// A thumbnail of a Lightroom merge decodes the smallest reduced copy of its raw image that covers it, with the - /// main image's levels and its crop scaled; nothing covers it, or the main image has full-size opcodes: `None`. - #[test] - fn reduced_copies_of_a_linear_raw_dng_decode_at_their_size() { - let file = linear_dng_with_copies(64, 32, &[(32, 16), (16, 8)], &[]); - let full = decode(&file).unwrap(); - assert_eq!((full.width, full.height, full.crop), (64, 32, Rect::new(4, 2, 56, 28))); - // a 12 px picture: the 16 px copy (its crop is 14 px long) - let small = decode_reduced(&file, 12).unwrap().expect("a copy covers 12 px"); - assert_eq!((small.width, small.height), (16, 8)); - assert_eq!(small.crop, Rect::new(1, 1, 14, 7)); - let RawData::U16(v) = &small.data else { panic!("16-bit samples") }; - assert!(v.iter().all(|&s| s == 2016), "the 16 px copy's samples"); - assert_eq!((small.white.clone(), small.black.values.clone()), (vec![32768.0], vec![100.0]), "its own white, the main black"); - assert_eq!((small.metadata.width, small.metadata.height), (Some(56), Some(28)), "the developed size is the photo's"); - // 20 px: the 32 px copy (28 px); 40 px: none covers it - assert_eq!(decode_reduced(&file, 20).unwrap().map(|r| r.width), Some(32)); - assert!(decode_reduced(&file, 40).unwrap().is_none()); - // a different aspect ratio is not a copy of the image - assert!(decode_reduced(&linear_dng_with_copies(64, 32, &[(16, 16)], &[]), 8).unwrap().is_none()); - // opcodes are in full-size pixels - let opcodes = linear_dng_with_copies(64, 32, &[(16, 8)], &[(t::OPCODE_LIST_3, Value::Undefined(vec![0, 0, 0, 0]))]); - assert!(decode_reduced(&opcodes, 8).unwrap().is_none()); - // not a DNG - assert!(decode_reduced(&write(&[rgb_ifd(16, 8)]), 8).unwrap().is_none()); - } - - /// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag. - #[test] - fn private_compression_99_is_a_raw() { - let mut ifd = rgb_ifd(16, 12); - ifd.set(t::COMPRESSION, Value::Short(vec![99])); - assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff)); - } - - /// Issue #1275: an uncompressed Leaf MOS (Aptus 22) is a full-size 16-bit grey IFD0 with no Make tag; only Leaf's - /// data block (`0x8606`) says it is a raw. It must not import as a greyscale image of its mosaic. The same IFD - /// without the block, or an RGB image carrying it, stays an ordinary image. - #[test] - fn uncompressed_leaf_mos_is_a_raw() { - let mosaic = |leaf_data: bool| { - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![12])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::BLACK_IS_ZERO])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x40; 16 * 12 * 2]] }); - if leaf_data { - ifd.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); - } - ifd - }; - let bytes = TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[mosaic(true)]).unwrap(); - assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); - assert!(matches!(decode(&bytes), Err(RawError::Unsupported(_)))); - assert!(matches!(probe_info(&bytes), Err(RawError::Unsupported(_)))); - assert_eq!(probe(&write(&[mosaic(false)])), None, "a 16-bit grey TIFF"); - let mut rgb = rgb_ifd(16, 12); - rgb.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); - assert_eq!(probe(&write(&[rgb])), None, "a rendered RGB image that kept the block"); - } - - /// Issue #1275: a Hasselblad Lunar is a Sony NEX-7 ARW under Make HASSELBLAD. Sony's raw coding (compression 32767 - /// or the tone-curve tag `0x7010`) sends it to the ARW reader; a Hasselblad file without it does not go there. - #[test] - fn hasselblad_make_with_sony_raw_coding_is_arw() { - let file = |make: &str, compression: u16, tone_curve: bool| { - let mut raw = IfdBuilder::new(); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![64])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![2])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); - raw.set(t::COMPRESSION, Value::Short(vec![compression])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - if tone_curve { - raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); - } - raw.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![0; 128]] }); - let mut ifd0 = rgb_ifd(4, 3); - ifd0.set(t::MAKE, Value::Ascii(make.into())); - ifd0.set(t::MODEL, Value::Ascii("Lunar".into())); - ifd0.add_sub_ifd(raw); - write(&[ifd0]) - }; - assert_eq!(probe(&file("HASSELBLAD", 32767, false)), Some(RawFormat::Arw)); - assert_eq!(probe(&file("Hasselblad", 1, true)), Some(RawFormat::Arw)); - let lunar = decode(&file("HASSELBLAD", 32767, true)).unwrap(); - assert_eq!((lunar.format, lunar.width, lunar.height, lunar.bits), (RawFormat::Arw, 64, 2, 14)); - // a Hasselblad CFA image in another coding is not an ARW - assert_ne!(probe(&file("HASSELBLAD", 1, false)), Some(RawFormat::Arw)); - assert_ne!(probe(&file("HASSELBLAD", 7, false)), Some(RawFormat::Arw)); - } - - // --- TIFF raws that describe their raw IFD the way DNG does but carry no DNG version tag --- - - /// 8 x 6 samples, 16 bits, value `1000 + 100 y + x`. - fn cfa_samples() -> Vec { - (0..48u16).map(|i| 1000 + 100 * (i / 8) + i % 8).collect() - } - - /// A small raw in the layout of the Hasselblad 3FR/FFF family: IFD0 is a reduced RGB image with the - /// colour tags, a SubIFD holds the full-resolution CFA image (no `CFAPattern`, DNG-style levels and - /// crop). `strip` is the CFA image's data and `compression` its coding. - fn plain_cfa_tiff(compression: u16, strip: Vec, pattern: Option<[u8; 4]>) -> Vec { - let mut raw = IfdBuilder::new(); - raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - raw.set(t::COMPRESSION, Value::Short(vec![compression])); - raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); - raw.set(t::WHITE_LEVEL, Value::Long(vec![60000])); - raw.set(t::DEFAULT_CROP_ORIGIN, Value::Short(vec![2, 2])); - raw.set(t::DEFAULT_CROP_SIZE, Value::Short(vec![4, 2])); - if let Some(p) = pattern { - raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); - raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec())); - } - raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![strip] }); - let mut ifd0 = rgb_ifd(4, 3); - ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); - ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(5, 10), (-1, 10), (0, 10), (-5, 10), (12, 10), (3, 10), (-1, 10), (2, 10), (6, 10)])); - ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (3, 5)])); - ifd0.add_sub_ifd(raw); - write(&[ifd0]) - } - - fn le_words(v: &[u16]) -> Vec { - v.iter().flat_map(|x| x.to_le_bytes()).collect() - } - - /// Known answer: samples, black and white level, crop and colour come from the file's own tags; the - /// layout it does not state is RGGB. - #[test] - fn uncompressed_cfa_tiff_without_a_dng_version_decodes() { - let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), None); - assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); - let r = decode(&bytes).unwrap(); - assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::CfaTiff, 8, 6, 16)); - assert_eq!(r.data, RawData::U16(cfa_samples())); - assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); - assert_eq!((r.black.mean(), r.white.clone()), (256.0, vec![60000.0])); - assert_eq!(r.crop, Rect::new(2, 2, 4, 2)); - assert!(color::has_matrix(&r.color)); - assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.6])); - // headers only: the same description without samples - let i = probe_info(&bytes).unwrap(); - assert_eq!((i.format, i.cfa.as_ref().map(Cfa::name), i.crop), (RawFormat::CfaTiff, Some("RGGB".to_string()), Rect::new(2, 2, 4, 2))); - } - - /// Without DNG's white-balance tag the file is left alone (a Kodak or Sinar TIFF with a CFA IFD, say). - #[test] - fn cfa_tiff_without_a_white_balance_tag_is_not_claimed() { - let mut raw = IfdBuilder::new(); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - raw.set(t::COMPRESSION, Value::Short(vec![1])); - raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![le_words(&cfa_samples())] }); - let mut ifd0 = rgb_ifd(4, 3); - ifd0.add_sub_ifd(raw); - assert_eq!(probe(&write(&[ifd0])), Some(RawFormat::OtherTiff)); - } - - /// A stated `CFAPattern` wins over the RGGB default. - #[test] - fn cfa_tiff_keeps_a_stated_pattern() { - let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), Some([2, 1, 1, 0])); - assert_eq!(decode(&bytes).unwrap().cfa.unwrap().name(), "BGGR"); - } - - /// The lossless-JPEG coding of the same layout goes through the lossless decoder (one strip for the - /// whole image, as the 3FR/FFF files have it). - #[test] - fn lossless_jpeg_cfa_tiff_decodes() { - let strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); - let bytes = plain_cfa_tiff(7, strip, None); - assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); - let r = decode(&bytes).unwrap(); - assert_eq!(r.data, RawData::U16(cfa_samples())); - assert_eq!(r.cfa.unwrap().name(), "RGGB"); - } - - /// Hasselblad's own lossless-JPEG coding (selection value 8, as in FFF and most compressed 3FR files) of the - /// same layout decodes: the file is read like the uncompressed one. - #[test] - fn selection_value_8_cfa_tiff_decodes() { - let strip = ljpeg::tests::encode_hasselblad(&cfa_samples(), 8, 6); - let bytes = plain_cfa_tiff(7, strip, None); - assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); - let r = decode(&bytes).unwrap(); - assert_eq!((r.format, r.width, r.height), (RawFormat::CfaTiff, 8, 6)); - assert_eq!(r.data, RawData::U16(cfa_samples())); - assert_eq!(r.cfa.unwrap().name(), "RGGB"); - assert_eq!(probe_info(&bytes).unwrap().format, RawFormat::CfaTiff); - } - - /// A lossless-JPEG scan whose predictor selection value is neither 0 to 7 (T.81) nor 8 (Hasselblad) is not a - /// coding the lossless decoder knows: the file stays an undecodable raw, with that as the reason, and keeps the - /// stand-in preview an unsupported container gets. - #[test] - fn cfa_tiff_with_an_unknown_predictor_stays_undecodable() { - let mut strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); - let sos = strip.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); - let ss = sos + 2 + 2 + 1 + 2; - assert_eq!(strip[ss], 1, "selection value byte"); - strip[ss] = 9; - assert!(matches!(ljpeg::decode(&strip, 1 << 20), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); - let bytes = plain_cfa_tiff(7, strip, None); - assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); - assert!(!RawFormat::OtherTiff.is_supported()); - let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; - assert!(why.contains("lossless JPEG") && why.contains("selection value 9"), "{why}"); - } - - /// Other codings of a CFA IFD (here Deflate) are not claimed by the plain reader. - #[test] - fn cfa_tiff_with_another_coding_is_not_claimed() { - let bytes = plain_cfa_tiff(8, vec![0; 96], None); - assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); - } - - /// A Samsung-branded body whose maker note has a Pentax layout is read by the Pentax reader; another - /// Samsung file stays with the Samsung format. - #[test] - fn samsung_make_with_a_pentax_maker_note_is_pef() { - let with_note = |note: &[u8], cfa: bool| { - let mut ifd = rgb_ifd(16, 12); - if cfa { - ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - } - ifd.set(t::MAKE, Value::Ascii("SAMSUNG TECHWIN".into())); - let mut exif = IfdBuilder::new(); - exif.set(t::MAKER_NOTE, Value::Undefined(note.to_vec())); - ifd.set_child(t::EXIF_IFD, exif); - write(&[ifd]) - }; - assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0", true)), Some(RawFormat::Pef)); - assert_eq!(probe(&with_note(b"PENTAX \0MM\0\0\0\0\0\0\0", true)), Some(RawFormat::Pef)); - assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", true)), Some(RawFormat::Srw)); - // without raw evidence (an RGB image from such a body) neither reader claims it (#281) - assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0", false)), None); - assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", false)), None); - } - - #[test] - fn probe_rejects_junk() { - assert_eq!(probe(b""), None); - assert_eq!(probe(b"hello world, not a raw"), None); - assert_eq!(probe(b"\0\0\0\x18ftypcrx \0\0\0\x01"), Some(RawFormat::Cr3)); - assert_eq!(probe(b"FUJIFILMCCD-RAW 0201"), Some(RawFormat::Raf)); - assert!(decode(b"FUJIFILMCCD-RAW 0201").is_err()); - assert_eq!(decode(b"junk"), Err(RawError::NotRaw)); - } -} +mod tests; #[cfg(test)] mod checked_arithmetic_tests { diff --git a/crates/raw/src/tests.rs b/crates/raw/src/tests.rs new file mode 100644 index 000000000..865099cc9 --- /dev/null +++ b/crates/raw/src/tests.rs @@ -0,0 +1,612 @@ +use super::*; + +#[test] +fn cfa_helpers() { + let c = Cfa::bayer("RGGB").unwrap(); + assert!(c.is_bayer()); + assert_eq!(c.color_at(0, 0), 0); + assert_eq!(c.color_at(3, 3), 2); + assert_eq!(c.shifted(1, 0).name(), "GRBG"); + assert_eq!(c.shifted(1, 1).name(), "BGGR"); + assert!(Cfa::bayer("RGGX").is_none()); + for name in ["RGGB", "BGGR", "GRBG", "GBRG"] { + assert_eq!(Cfa::bayer_static(name).name(), name); + } + let x = Cfa::xtrans(); + assert!(!x.is_bayer()); + let greens = x.pattern.iter().filter(|&&p| p == 1).count(); + assert_eq!(greens, 20); + // every 3×3 block of the X-Trans layout has 5 greens + for by in 0..2 { + for bx in 0..2 { + let g = (0..9).filter(|i| x.color_at(bx * 3 + i % 3, by * 3 + i / 3) == 1).count(); + assert_eq!(g, 5); + } + } +} + +#[test] +fn a_picture_made_elsewhere_is_finished_like_develop_does() { + let (w, h) = (12usize, 10usize); + let raw = RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(2, 2, 8, 6), + crop: Rect::new(1, 1, 5, 4), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers: None, + linearized: false, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + lens_metadata: Default::default(), + }; + let direct = raw.develop(Method::Bilinear).unwrap(); + let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear)); + assert_eq!((direct.width, direct.height), (5, 4)); + assert_eq!(direct, by_hand); + // the crop is what develop keeps; no usable crop keeps everything + assert_eq!(raw.develop_crop(8, 6), Rect::new(1, 1, 5, 4)); + let mut whole = raw.clone(); + whole.crop = Rect::default(); + assert_eq!(whole.develop_crop(8, 6), Rect::new(0, 0, 8, 6)); + assert_eq!(whole.develop(Method::Bilinear).unwrap().width, 8); + // a crop that pokes out is clipped to the picture + whole.crop = Rect::new(6, 4, 10, 10); + assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); + + // tall pixels (D1X: 2.0) are stretched to square ones: the rows double, `developed_size` says so + let mut tall = raw.clone(); + tall.pixel_aspect = 2.0; + let d = tall.develop(Method::Bilinear).unwrap(); + assert_eq!((d.width, d.height), (5, 8)); + assert_eq!(tall.info().developed_size(), (5, 8)); + assert_eq!(raw.info().developed_size(), (5, 4)); + assert!(!tall.can_bin(2)); + assert_eq!(stretched_height(1324, 2.0), 2648); + assert_eq!(stretched_height(1324, 1.0), 1324); +} + +/// A maker-note crop (`PEF` AOC / `ORF` ImageProcessing) with an origin of `u64::MAX` used to +/// pass the bounds check (`x + width` wraps to 0) and then index out of bounds in +/// `normalized`. It must be a `Corrupt` error at validate time instead. +#[test] +fn an_active_area_that_wraps_past_the_image_is_corrupt() { + let (w, h) = (12usize, 10usize); + let mut raw = RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16(vec![100; w * h]), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(usize::MAX, 0, 1, 1), + crop: Rect::default(), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers: None, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, + }; + assert!(raw.validate().is_err(), "`x + width` wrapped: the area is outside the image"); + assert!(raw.normalized().is_err()); + raw.active_area = Rect::new(0, 0, w + 1, h); + assert!(raw.validate().is_err(), "one past the last column is outside too"); +} + +#[test] +fn black_levels() { + let b = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![1.0, 2.0, 3.0, 4.0], delta_h: vec![0.5, 0.0], delta_v: vec![0.0, 10.0] }; + assert_eq!(b.at(0, 0, 0, 1), 1.5); + assert_eq!(b.at(1, 1, 0, 1), 14.0); + assert_eq!(b.at(3, 2, 0, 1), 2.0); + assert_eq!(b.mean(), 2.5); + assert_eq!(BlackLevel::uniform(7.0).at(9, 9, 0, 3), 7.0); +} + +// --- TIFF shells around a private raw block (thumbnail-only IFD0) --- + +use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + +/// An uncompressed 8-bit RGB image IFD. +fn rgb_ifd(w: u32, h: u32) -> IfdBuilder { + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![90u8; (w * h * 3) as usize]] }); + ifd +} + +fn exif_size(w: u32, h: u32) -> IfdBuilder { + IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h])) +} + +fn write(chain: &[IfdBuilder]) -> Vec { + TiffWriter::default().write(chain).unwrap() +} + +const SHELL_REASON: &str = "raw image in a private block"; + +/// A bigger image in a `SubIFDs` child of a small IFD0 (the layout of an 8-bit-mosaic TIFF raw). +#[test] +fn thumbnail_with_a_bigger_sub_ifd_is_an_undecodable_raw() { + let mut ifd0 = rgb_ifd(16, 12); + ifd0.add_sub_ifd(rgb_ifd(64, 48)); + let bytes = write(&[ifd0]); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("64x48") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); + // a sub-IFD that is not much bigger (a pyramid level) does not count + let mut ifd0 = rgb_ifd(16, 12); + ifd0.add_sub_ifd(rgb_ifd(32, 24)); + assert_eq!(probe(&write(&[ifd0])), None); +} + +/// Exif says the picture is far bigger than IFD0 and the file holds a block that could carry it. +#[test] +fn thumbnail_with_a_private_block_behind_the_exif_size_is_an_undecodable_raw() { + let mut ifd0 = rgb_ifd(16, 12); + ifd0.set_child(t::EXIF_IFD, exif_size(400, 300)); + let mut bytes = write(&[ifd0.clone()]); + // no private block: a downsized copy that kept its old Exif size is an ordinary image + assert_eq!(probe(&bytes), None); + // 400 x 300 pixels at one bit each is 15000 bytes: 14000 is not enough, 16000 is + bytes.extend(std::iter::repeat_n(7u8, 14_000)); + assert_eq!(probe(&bytes), None); + bytes.extend(std::iter::repeat_n(7u8, 2_000)); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("400x300") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); + // truncating it never panics + for n in (0..bytes.len()).step_by(37) { + let _ = probe(&bytes[..n]); + let _ = decode(&bytes[..n]); + } +} + +/// Ordinary TIFFs stay images: the full picture first, equal Exif size, or later pages that are bigger. +#[test] +fn ordinary_tiffs_are_not_shells() { + let padded = |chain: &[IfdBuilder]| { + let mut b = write(chain); + b.extend(std::iter::repeat_n(0u8, 100_000)); + b + }; + let mut full = rgb_ifd(400, 300); + full.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[full])), None); + // the Exif size is much bigger than IFD0, with spare bytes, but IFD0 is not tiny next to it + let mut near = rgb_ifd(120, 90); + near.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[near])), None); + // a multi-page TIFF whose first page is small + assert_eq!(probe(&padded(&[rgb_ifd(16, 12), rgb_ifd(400, 300)])), None); + // no dimensions at all + assert_eq!(probe(&padded(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("X".into()))])), None); + // raw evidence without a CFA photometric: Pentax's Exif `CFAPattern` (uncompressed / PackBits PEFs) and + // Samsung's private compressions (here 32773, PackBits elsewhere: NX1, NX500) + let mut pef = rgb_ifd(400, 300); + pef.set(t::MAKE, Value::Ascii("PENTAX Corporation".into())); + let mut exif = exif_size(400, 300); + exif.set(vendor::EXIF_CFA_PATTERN, Value::Undefined(vec![0, 2, 0, 2, 0, 1, 1, 2])); + pef.set_child(t::EXIF_IFD, exif); + assert_eq!(probe(&padded(&[pef])), Some(RawFormat::Pef)); + let mut srw = rgb_ifd(400, 300); + srw.set(t::MAKE, Value::Ascii("SAMSUNG".into())); + srw.set(t::COMPRESSION, Value::Short(vec![32773])); + assert_eq!(probe(&padded(&[srw])), Some(RawFormat::Srw)); + // camera-authored or exported TIFFs preserving camera Make tags remain ordinary images + for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { + let mut cam = rgb_ifd(400, 300); + cam.set(t::MAKE, Value::Ascii(make.into())); + cam.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[cam])), None, "{make} TIFF should not be probed as raw"); + } +} + +/// #702: two Sony layouts carry no CFA IFD, so the raw evidence #281 asks of Sony files has to +/// include them: SRF (an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data addressed +/// by `SubIFDs`). An 8-bit TIFF from the same bodies, or a SubIFDs TIFF from another Sony model, +/// stays an ordinary image. +#[test] +fn sony_raws_without_a_cfa_ifd_are_still_raws() { + let sony = |model: &str, bits: u16, compression: u16, sub_ifd: Option| { + let mut ifd = rgb_ifd(400, 300); + ifd.set(t::MAKE, Value::Ascii("SONY".into())); + ifd.set(t::MODEL, Value::Ascii(model.into())); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression])); + if let Some(child) = sub_ifd { + ifd.set_child(t::SUB_IFDS, child); + } + probe(&write(&[ifd])) + }; + let raw_block = || Some(rgb_ifd(64, 48)); // stands in for the A100's raw-data pointer + assert_eq!(sony("DSC-F828", 14, 1, None), Some(RawFormat::Arw), "SRF"); + assert_eq!(sony("DSC-F828", 12, 1, None), Some(RawFormat::Arw), "12-bit samples count too"); + assert_eq!(sony("DSLR-A100", 8, 6, raw_block()), Some(RawFormat::Arw), "A100: old-JPEG IFD0 and SubIFDs"); + assert_eq!(sony("DSC-F828", 8, 1, None), None, "an 8-bit TIFF"); + assert_eq!(sony("DSC-F828", 16, 1, None), None, "a 16-bit TIFF"); + // TIFFs exported from A100 photos, with a thumbnail in SubIFDs (Codex review of #702) + assert_eq!(sony("DSLR-A100", 8, 1, Some(rgb_ifd(64, 48))), None, "an 8-bit A100 TIFF with a SubIFDs thumbnail"); + assert_eq!(sony("DSLR-A100", 16, 8, Some(rgb_ifd(64, 48))), None, "a 16-bit A100 TIFF with a SubIFDs thumbnail"); + assert_eq!(sony("ILCE-7M4", 8, 6, raw_block()), None, "the A100's layout under another model"); +} + +// --- containers that are recognised but not decoded, with a preview --- + +#[test] +fn private_containers_are_recognised_by_their_magic() { + assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw)); + assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw)); + assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f)); + for f in [RawFormat::Crw, RawFormat::X3f] { + assert!(!f.is_supported()); + } + assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f"))); + // too short for the CIFF signature + assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None); +} + +/// An IFD0 with a JPEG pointer but no `ImageWidth` / `ImageLength` (the Kodak KDC layout) is a raw +/// container with a preview; a TIFF reader would call it malformed. +#[test] +fn ifd0_without_dimensions_but_with_a_jpeg_is_a_preview_container() { + let jpeg: Vec = [&[0xffu8, 0xd8, 0xff, 0xc0, 0, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0][..], &[0x55; 40], &[0xff, 0xd9]].concat(); + let off = 8 + 2 + 24 + 4; + let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&jpeg); + assert_eq!(probe(&f), Some(RawFormat::OtherTiff)); + assert!(matches!(decode(&f), Err(RawError::Unsupported(_)))); + assert_eq!(embedded_preview(&f), Some(jpeg)); + // no pointer and no sub-IFDs: just a broken TIFF + let g = write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("KODAK".into()))]); + assert_eq!(probe(&g), None); +} + +/// A GoPro GPR (a DNG with VC-5 coded raw data, Compression 9) is refused by the full decode with +/// a reason that names the format, whatever its tile bytes hold; the header probe still +/// describes it (so it imports). +#[test] +fn gopro_vc5_dng_is_unsupported_with_a_clear_reason() { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); + ifd.set(t::COMPRESSION, Value::Short(vec![9])); + ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); + let bytes = write(&[ifd]); + assert_eq!(probe(&bytes), Some(RawFormat::Dng)); + let want = "DNG compression 9 (GoPro VC-5) is not decoded yet"; + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported from decode") }; + assert_eq!(why, want); + let info = probe_info(&bytes).expect("header probe describes the file"); + assert_eq!((info.width, info.height), (32, 16)); +} + +/// A LinearRaw DNG in the layout of Lightroom's merges: a `w` × `h` main image (samples 1000) with a default +/// crop, and reduced copies (samples 2000 + their width) of the given sizes beside it. +fn linear_dng_with_copies(w: u32, h: u32, copies: &[(u32, u32)], main_tags: &[(u16, Value)]) -> Vec { + let linear = |subfile: u32, w: u32, h: u32, sample: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::LINEAR_RAW])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); + let samples: Vec = std::iter::repeat_n(sample.to_le_bytes(), (w * h * 3) as usize).flatten().collect(); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![samples] }); + ifd + }; + let mut main = linear(0, w, h, 1000); + main.set(t::BLACK_LEVEL, Value::Short(vec![100])); + main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); + main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); + for (tag, v) in main_tags { + main.set(*tag, v.clone()); + } + let mut ifd0 = rgb_ifd(4, 2); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("DJI".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.add_sub_ifd(main); + for &(cw, ch) in copies { + ifd0.add_sub_ifd(linear(1, cw, ch, 2000 + cw as u16)); + } + write(&[ifd0]) +} + +/// A thumbnail of a Lightroom merge decodes the smallest reduced copy of its raw image that covers it, with the +/// main image's levels and its crop scaled; nothing covers it, or the main image has full-size opcodes: `None`. +#[test] +fn reduced_copies_of_a_linear_raw_dng_decode_at_their_size() { + let file = linear_dng_with_copies(64, 32, &[(32, 16), (16, 8)], &[]); + let full = decode(&file).unwrap(); + assert_eq!((full.width, full.height, full.crop), (64, 32, Rect::new(4, 2, 56, 28))); + // a 12 px picture: the 16 px copy (its crop is 14 px long) + let small = decode_reduced(&file, 12).unwrap().expect("a copy covers 12 px"); + assert_eq!((small.width, small.height), (16, 8)); + assert_eq!(small.crop, Rect::new(1, 1, 14, 7)); + let RawData::U16(v) = &small.data else { panic!("16-bit samples") }; + assert!(v.iter().all(|&s| s == 2016), "the 16 px copy's samples"); + assert_eq!((small.white.clone(), small.black.values.clone()), (vec![32768.0], vec![100.0]), "its own white, the main black"); + assert_eq!((small.metadata.width, small.metadata.height), (Some(56), Some(28)), "the developed size is the photo's"); + // 20 px: the 32 px copy (28 px); 40 px: none covers it + assert_eq!(decode_reduced(&file, 20).unwrap().map(|r| r.width), Some(32)); + assert!(decode_reduced(&file, 40).unwrap().is_none()); + // a different aspect ratio is not a copy of the image + assert!(decode_reduced(&linear_dng_with_copies(64, 32, &[(16, 16)], &[]), 8).unwrap().is_none()); + // opcodes are in full-size pixels + let opcodes = linear_dng_with_copies(64, 32, &[(16, 8)], &[(t::OPCODE_LIST_3, Value::Undefined(vec![0, 0, 0, 0]))]); + assert!(decode_reduced(&opcodes, 8).unwrap().is_none()); + // not a DNG + assert!(decode_reduced(&write(&[rgb_ifd(16, 8)]), 8).unwrap().is_none()); +} + +/// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag. +#[test] +fn private_compression_99_is_a_raw() { + let mut ifd = rgb_ifd(16, 12); + ifd.set(t::COMPRESSION, Value::Short(vec![99])); + assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff)); +} + +/// Issue #1275: an uncompressed Leaf MOS (Aptus 22) is a full-size 16-bit grey IFD0 with no Make tag; only Leaf's +/// data block (`0x8606`) says it is a raw. It must not import as a greyscale image of its mosaic. The same IFD +/// without the block, or an RGB image carrying it, stays an ordinary image. +#[test] +fn uncompressed_leaf_mos_is_a_raw() { + let mosaic = |leaf_data: bool| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![12])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::BLACK_IS_ZERO])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x40; 16 * 12 * 2]] }); + if leaf_data { + ifd.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); + } + ifd + }; + let bytes = TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[mosaic(true)]).unwrap(); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + assert!(matches!(decode(&bytes), Err(RawError::Unsupported(_)))); + assert!(matches!(probe_info(&bytes), Err(RawError::Unsupported(_)))); + assert_eq!(probe(&write(&[mosaic(false)])), None, "a 16-bit grey TIFF"); + let mut rgb = rgb_ifd(16, 12); + rgb.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); + assert_eq!(probe(&write(&[rgb])), None, "a rendered RGB image that kept the block"); +} + +/// Issue #1275: a Hasselblad Lunar is a Sony NEX-7 ARW under Make HASSELBLAD. Sony's raw coding (compression 32767 +/// or the tone-curve tag `0x7010`) sends it to the ARW reader; a Hasselblad file without it does not go there. +#[test] +fn hasselblad_make_with_sony_raw_coding_is_arw() { + let file = |make: &str, compression: u16, tone_curve: bool| { + let mut raw = IfdBuilder::new(); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![64])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![compression])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + if tone_curve { + raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); + } + raw.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![0; 128]] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii("Lunar".into())); + ifd0.add_sub_ifd(raw); + write(&[ifd0]) + }; + assert_eq!(probe(&file("HASSELBLAD", 32767, false)), Some(RawFormat::Arw)); + assert_eq!(probe(&file("Hasselblad", 1, true)), Some(RawFormat::Arw)); + let lunar = decode(&file("HASSELBLAD", 32767, true)).unwrap(); + assert_eq!((lunar.format, lunar.width, lunar.height, lunar.bits), (RawFormat::Arw, 64, 2, 14)); + // a Hasselblad CFA image in another coding is not an ARW + assert_ne!(probe(&file("HASSELBLAD", 1, false)), Some(RawFormat::Arw)); + assert_ne!(probe(&file("HASSELBLAD", 7, false)), Some(RawFormat::Arw)); +} + +// --- TIFF raws that describe their raw IFD the way DNG does but carry no DNG version tag --- + +/// 8 x 6 samples, 16 bits, value `1000 + 100 y + x`. +fn cfa_samples() -> Vec { + (0..48u16).map(|i| 1000 + 100 * (i / 8) + i % 8).collect() +} + +/// A small raw in the layout of the Hasselblad 3FR/FFF family: IFD0 is a reduced RGB image with the +/// colour tags, a SubIFD holds the full-resolution CFA image (no `CFAPattern`, DNG-style levels and +/// crop). `strip` is the CFA image's data and `compression` its coding. +fn plain_cfa_tiff(compression: u16, strip: Vec, pattern: Option<[u8; 4]>) -> Vec { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![compression])); + raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![60000])); + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Short(vec![2, 2])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Short(vec![4, 2])); + if let Some(p) = pattern { + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec())); + } + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![strip] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(5, 10), (-1, 10), (0, 10), (-5, 10), (12, 10), (3, 10), (-1, 10), (2, 10), (6, 10)])); + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (3, 5)])); + ifd0.add_sub_ifd(raw); + write(&[ifd0]) +} + +fn le_words(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// Known answer: samples, black and white level, crop and colour come from the file's own tags; the +/// layout it does not state is RGGB. +#[test] +fn uncompressed_cfa_tiff_without_a_dng_version_decodes() { + let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), None); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode(&bytes).unwrap(); + assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::CfaTiff, 8, 6, 16)); + assert_eq!(r.data, RawData::U16(cfa_samples())); + assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); + assert_eq!((r.black.mean(), r.white.clone()), (256.0, vec![60000.0])); + assert_eq!(r.crop, Rect::new(2, 2, 4, 2)); + assert!(color::has_matrix(&r.color)); + assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.6])); + // headers only: the same description without samples + let i = probe_info(&bytes).unwrap(); + assert_eq!((i.format, i.cfa.as_ref().map(Cfa::name), i.crop), (RawFormat::CfaTiff, Some("RGGB".to_string()), Rect::new(2, 2, 4, 2))); +} + +/// Without DNG's white-balance tag the file is left alone (a Kodak or Sinar TIFF with a CFA IFD, say). +#[test] +fn cfa_tiff_without_a_white_balance_tag_is_not_claimed() { + let mut raw = IfdBuilder::new(); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![le_words(&cfa_samples())] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.add_sub_ifd(raw); + assert_eq!(probe(&write(&[ifd0])), Some(RawFormat::OtherTiff)); +} + +/// A stated `CFAPattern` wins over the RGGB default. +#[test] +fn cfa_tiff_keeps_a_stated_pattern() { + let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), Some([2, 1, 1, 0])); + assert_eq!(decode(&bytes).unwrap().cfa.unwrap().name(), "BGGR"); +} + +/// The lossless-JPEG coding of the same layout goes through the lossless decoder (one strip for the +/// whole image, as the 3FR/FFF files have it). +#[test] +fn lossless_jpeg_cfa_tiff_decodes() { + let strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); + let bytes = plain_cfa_tiff(7, strip, None); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16(cfa_samples())); + assert_eq!(r.cfa.unwrap().name(), "RGGB"); +} + +/// Hasselblad's own lossless-JPEG coding (selection value 8, as in FFF and most compressed 3FR files) of the +/// same layout decodes: the file is read like the uncompressed one. +#[test] +fn selection_value_8_cfa_tiff_decodes() { + let strip = ljpeg::tests::encode_hasselblad(&cfa_samples(), 8, 6); + let bytes = plain_cfa_tiff(7, strip, None); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode(&bytes).unwrap(); + assert_eq!((r.format, r.width, r.height), (RawFormat::CfaTiff, 8, 6)); + assert_eq!(r.data, RawData::U16(cfa_samples())); + assert_eq!(r.cfa.unwrap().name(), "RGGB"); + assert_eq!(probe_info(&bytes).unwrap().format, RawFormat::CfaTiff); +} + +/// A lossless-JPEG scan whose predictor selection value is neither 0 to 7 (T.81) nor 8 (Hasselblad) is not a +/// coding the lossless decoder knows: the file stays an undecodable raw, with that as the reason, and keeps the +/// stand-in preview an unsupported container gets. +#[test] +fn cfa_tiff_with_an_unknown_predictor_stays_undecodable() { + let mut strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); + let sos = strip.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); + let ss = sos + 2 + 2 + 1 + 2; + assert_eq!(strip[ss], 1, "selection value byte"); + strip[ss] = 9; + assert!(matches!(ljpeg::decode(&strip, 1 << 20), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); + let bytes = plain_cfa_tiff(7, strip, None); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + assert!(!RawFormat::OtherTiff.is_supported()); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("lossless JPEG") && why.contains("selection value 9"), "{why}"); +} + +/// Other codings of a CFA IFD (here Deflate) are not claimed by the plain reader. +#[test] +fn cfa_tiff_with_another_coding_is_not_claimed() { + let bytes = plain_cfa_tiff(8, vec![0; 96], None); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); +} + +/// A Samsung-branded body whose maker note has a Pentax layout is read by the Pentax reader; another +/// Samsung file stays with the Samsung format. +#[test] +fn samsung_make_with_a_pentax_maker_note_is_pef() { + let with_note = |note: &[u8], cfa: bool| { + let mut ifd = rgb_ifd(16, 12); + if cfa { + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + } + ifd.set(t::MAKE, Value::Ascii("SAMSUNG TECHWIN".into())); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note.to_vec())); + ifd.set_child(t::EXIF_IFD, exif); + write(&[ifd]) + }; + assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0", true)), Some(RawFormat::Pef)); + assert_eq!(probe(&with_note(b"PENTAX \0MM\0\0\0\0\0\0\0", true)), Some(RawFormat::Pef)); + assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", true)), Some(RawFormat::Srw)); + // without raw evidence (an RGB image from such a body) neither reader claims it (#281) + assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0", false)), None); + assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", false)), None); +} + +#[test] +fn probe_rejects_junk() { + assert_eq!(probe(b""), None); + assert_eq!(probe(b"hello world, not a raw"), None); + assert_eq!(probe(b"\0\0\0\x18ftypcrx \0\0\0\x01"), Some(RawFormat::Cr3)); + assert_eq!(probe(b"FUJIFILMCCD-RAW 0201"), Some(RawFormat::Raf)); + assert!(decode(b"FUJIFILMCCD-RAW 0201").is_err()); + assert_eq!(decode(b"junk"), Err(RawError::NotRaw)); +} From 64e46830459752a0631a273e7fad25207ad3f2a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:08 +0000 Subject: [PATCH 068/191] refactor(lightcraft-raw): move opcodes.rs tests to crates/raw/src/opcodes/tests.rs --- crates/raw/src/opcodes.rs | 447 +------------------------------- crates/raw/src/opcodes/tests.rs | 444 +++++++++++++++++++++++++++++++ 2 files changed, 445 insertions(+), 446 deletions(-) create mode 100644 crates/raw/src/opcodes/tests.rs diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index d5a3ffd1e..df85e2703 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -754,449 +754,4 @@ pub fn warp_rectilinear2(img: &Rgb32f, planes: &[[f64; WR2_LEN]], center: [f64; } #[cfg(test)] -pub(crate) mod tests { - use super::*; - - pub(crate) struct ListWriter(pub Vec, pub u32); - - impl ListWriter { - pub fn new() -> Self { - ListWriter(Vec::new(), 0) - } - pub fn op(&mut self, id: u32, params: &[u8]) -> &mut Self { - for v in [id, 0x01030000, 0, params.len() as u32] { - self.0.extend_from_slice(&v.to_be_bytes()); - } - self.0.extend_from_slice(params); - self.1 += 1; - self - } - pub fn finish(&self) -> Vec { - let mut v = self.1.to_be_bytes().to_vec(); - v.extend_from_slice(&self.0); - v - } - } - - pub(crate) fn be(vals: &[f64]) -> Vec { - vals.iter().flat_map(|v| v.to_be_bytes()).collect() - } - pub(crate) fn area(a: [u32; 8]) -> Vec { - a.iter().flat_map(|v| v.to_be_bytes()).collect() - } - - /// Warp and bad-pixel records each cost a full-image pass, and a hostile `OpcodeList3` repeats them - /// thousands of times ([`MAX_LIST3_PASSES`] bounds the multiplier); identity warps below the cap must - /// still apply, exactly, so the same image comes out. - #[test] - fn list3_pass_cap_skips_repeated_records() { - let mut img = Rgb32f::new(8, 6); - for (i, px) in img.data.iter_mut().enumerate() { - *px = [i as f32 / 2.0, (i % 8) as f32, (i / 8) as f32]; - } - let orig: Vec<[f32; 3]> = img.data.clone(); - let list: Vec = - (0..MAX_LIST3_PASSES * 5).map(|_| Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }).collect(); - apply_list3(&list, &mut img); - assert_eq!(img.data, orig, "identity warps change nothing, and the cap skips the repeated rest"); - - // a real warp below the cap is still applied - let one = [Opcode::WarpRectilinear { planes: vec![[2.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }]; - apply_list3(&one, &mut img); - assert_ne!(img.data, orig, "a genuine warp record is not skipped"); - } - - #[test] - fn parse_and_apply_area_ops() { - let (w, h) = (4usize, 3usize); - let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]); - for v in [2u32, 2] { - gm.extend_from_slice(&v.to_be_bytes()); - } - gm.extend(be(&[1.0, 1.0, 0.0, 0.0])); - gm.extend_from_slice(&1u32.to_be_bytes()); - for g in [1.0f32, 2.0, 1.0, 2.0] { - gm.extend_from_slice(&g.to_be_bytes()); - } - let mut dpr = area([0, 0, 3, 4, 0, 1, 1, 1]); - dpr.extend_from_slice(&3u32.to_be_bytes()); - for d in [0.0f32, 0.1, 0.2] { - dpr.extend_from_slice(&d.to_be_bytes()); - } - let mut poly = area([0, 0, 3, 4, 0, 1, 1, 2]); - poly.extend_from_slice(&1u32.to_be_bytes()); - poly.extend(be(&[0.0, 2.0])); - let blob = ListWriter::new().op(9, &gm).op(10, &dpr).op(8, &poly).op(99, &[1, 2, 3]).finish(); - let list = parse_list(&blob); - assert_eq!(list.len(), 4); - assert!(matches!(list[3], Opcode::Unknown { id: 99, .. })); - assert!(list[..3].iter().all(|o| o.is_applied())); - let mut buf = vec![0.25f32; w * h]; - apply_list(&list, &mut buf, w, h, 1, None, 1.0); - // gain map: horizontal ramp 1 → 2 across relative x 0..1 (x / w) - let expect = |x: usize, y: usize| { - let g = 1.0 + x as f32 / w as f32; - let v = 0.25 * g + [0.0, 0.1, 0.2][y]; - if x.is_multiple_of(2) { v * 2.0 } else { v } - }; - for y in 0..h { - for x in 0..w { - assert!((buf[y * w + x] - expect(x, y)).abs() < 1e-6, "{x},{y}: {} vs {}", buf[y * w + x], expect(x, y)); - } - } - } - - #[test] - fn map_table_raw_scale_and_bad_pixels() { - let mut t = area([0, 0, 2, 2, 0, 1, 1, 1]); - t.extend_from_slice(&3u32.to_be_bytes()); - for v in [0u16, 30000, 65535] { - t.extend_from_slice(&v.to_be_bytes()); - } - let list = parse_list(&ListWriter::new().op(7, &t).finish()); - let mut buf = vec![0.0, 1.0 / 65535.0, 2.0 / 65535.0, 0.5]; - apply_list(&list, &mut buf, 2, 2, 1, None, 1.0); - assert_eq!(buf[0], 0.0); - assert!((buf[1] - 30000.0 / 65535.0).abs() < 1e-7); - assert_eq!(buf[3], 1.0); - - let mut p = vec![]; - for v in [7u32, 0] { - p.extend_from_slice(&v.to_be_bytes()); - } - let list = parse_list(&ListWriter::new().op(4, &p).finish()); - let cfa = Cfa::bayer("RGGB").unwrap(); - let mut img: Vec = (0..36).map(|i| 100.0 + (i % 6) as f32).collect(); - img[2 * 6 + 2] = 7.0; - apply_list(&list, &mut img, 6, 6, 1, Some(&cfa), 65535.0); - assert_eq!(img[2 * 6 + 2], 102.0); - } - - /// A mosaic equal to the bad-pixel constant, or one rectangle over the whole image, used to list every pixel as - /// bad: w × h entries (plus a set of them), tens of GB at the decoder's size limit. Here the rows between a good - /// first row (500) and a good last row (900) are bad: 2052 rows of 2048 pixels, four rows more than the 1 << 22 - /// cap. Row 1, within the cap, is repaired from the first row; the second-last row, past the cap, must be left - /// as it is (uncapped, it was rewritten from the last row). - fn assert_bad_pixels_capped(op: Opcode) { - let (w, h) = (2048, 2054); - let mut buf = vec![0.0f32; w * h]; - buf[..w].fill(500.0); - buf[(h - 1) * w..].fill(900.0); - apply_list(&[op], &mut buf, w, h, 1, None, 65535.0); - assert!(buf[w..2 * w].iter().all(|&v| v == 500.0), "row 1 (within the cap) not repaired: {:?}", &buf[w..w + 4]); - let past = &buf[(h - 2) * w..(h - 1) * w]; - assert!(past.iter().all(|&v| v == 0.0), "a row past the cap was rewritten: {:?}", &past[..4]); - } - - #[test] - fn bad_pixels_constant_is_capped() { - assert_bad_pixels_capped(Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 0 }); - } - - #[test] - fn bad_pixels_list_is_capped_inside_a_rectangle() { - // top, left, bottom, right: every row but the first and the last - assert_bad_pixels_capped(Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![], rects: vec![[1, 0, 2053, 2048]] }); - } - - #[test] - fn vignette_and_warp_identity() { - let mut img = Rgb32f::from_fn(9, 7, |x, y| [x as f32 / 9.0, y as f32 / 7.0, 0.5]); - let orig = img.clone(); - let mut p = be(&[0.0; 5]); - p.extend(be(&[0.5, 0.5])); - let mut warp = 1u32.to_be_bytes().to_vec(); - warp.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5])); - let list = parse_list(&ListWriter::new().op(3, &p).op(1, &warp).finish()); - assert_eq!(list.len(), 2); - apply_list3(&list, &mut img); - for (a, b) in img.data.iter().zip(&orig.data) { - for c in 0..3 { - assert!((a[c] - b[c]).abs() < 1e-5); - } - } - // positive k0 brightens the corners more than the centre - let mut p = be(&[1.0, 0.0, 0.0, 0.0, 0.0]); - p.extend(be(&[0.5, 0.5])); - let list = parse_list(&ListWriter::new().op(3, &p).finish()); - let mut flat = Rgb32f::filled(9, 9, [0.5; 3]); - apply_list3(&list, &mut flat); - assert!(flat.get(0, 0)[0] > 0.85 && (flat.get(4, 4)[0] - 0.5).abs() < 0.01, "{:?}", flat.get(0, 0)); - } - - #[test] - fn warp_barrel_moves_pixels_outward() { - let img = Rgb32f::from_fn(31, 31, |x, y| if x == 24 && y == 15 { [1.0; 3] } else { [0.0; 3] }); - // source radius = r (1 + k r²) with k < 0 → content moves outward - let out = warp_rectilinear(&img, &[[1.0, -0.6, 0.0, 0.0, 0.0, 0.0]], [0.5, 0.5]); - let (bx, _) = (0..31).map(|x| (x, out.get(x, 15)[0])).fold((0, 0.0), |a, b| if b.1 > a.1 { b } else { a }); - assert!(bx >= 25, "{bx}"); - } - - #[test] - fn write_parse_roundtrip() { - let a = Area { top: 1, left: 2, bottom: 30, right: 40, plane: 0, planes: 3, row_pitch: 1, col_pitch: 2 }; - let list = vec![ - Opcode::WarpRectilinear { planes: vec![[1.0, 0.01, -0.002, 0.0, 1e-4, -2e-4]; 3], center: [0.5, 0.49] }, - Opcode::WarpFisheye { planes: vec![[1.0, 0.1, 0.0, 0.0]], center: [0.5, 0.5] }, - Opcode::FixVignetteRadial { k: [0.1, 0.2, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, - Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 1 }, - Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![(3, 4)], rects: vec![[1, 1, 2, 2]] }, - Opcode::TrimBounds { top: 1, left: 1, bottom: 9, right: 9 }, - Opcode::MapTable { area: a, table: vec![0, 100, 65535] }, - Opcode::MapPolynomial { area: a, coefficients: vec![0.0, 1.0, -0.1] }, - Opcode::GainMap { area: a, points_v: 2, points_h: 1, spacing: [1.0, 1.0], origin: [0.0, 0.0], map_planes: 1, gains: vec![1.0, 1.5] }, - Opcode::DeltaPerRow { area: a, deltas: vec![0.1, 0.2] }, - Opcode::DeltaPerColumn { area: a, deltas: vec![0.3] }, - Opcode::ScalePerRow { area: a, scales: vec![1.1] }, - Opcode::ScalePerColumn { area: a, scales: vec![0.9, 1.0] }, - Opcode::WarpRectilinear2 { - planes: vec![wr2_set(&[(0, 0.5), (3, 0.1)]), wr2_set(&[(0, 0.25)])], - center: [0.5, 0.49], - reciprocal: true, - optional: true, - }, - Opcode::Unknown { id: 77, flags: 1, params: vec![1, 2, 3] }, - ]; - assert_eq!(parse_list(&write_list(&list)), list); - } - - #[test] - fn truncated_lists_do_not_panic() { - let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]); - gm.extend_from_slice(&[0xff; 64]); - let blob = ListWriter::new().op(9, &gm).op(5, &[0xff; 12]).op(1, &[0, 0, 0, 9]).finish(); - for n in 0..blob.len() { - let l = parse_list(&blob[..n]); - let mut buf = vec![0.5f32; 12]; - apply_list(&l, &mut buf, 4, 3, 1, None, 1.0); - } - } - - /// `WarpRectilinear2` parameter bytes (spec 1.6.0.0: N, then per set kr0..kr14, kt0, kt1, min/max valid radius, - /// then the centre and `reciprocalRadial`). - fn wr2_params(sets: &[[f64; WR2_LEN]], center: [f64; 2], reciprocal: u32) -> Vec { - let mut p = (sets.len() as u32).to_be_bytes().to_vec(); - for s in sets { - p.extend(be(s)); - } - p.extend(be(¢er)); - p.extend_from_slice(&reciprocal.to_be_bytes()); - p - } - - fn wr2_set(kr: &[(usize, f64)]) -> [f64; WR2_LEN] { - let mut k = [0.0; WR2_LEN]; - for &(i, v) in kr { - k[i] = v; - } - k[18] = 1.0; - k - } - - #[test] - fn warp_rectilinear2_parses_the_spec_layout() { - let mut k = wr2_set(&[(0, 1.0), (1, 0.25), (14, -2.0)]); - k[15] = 0.001; - k[16] = -0.002; - k[17] = 0.1; - k[18] = 0.9; - let blob = ListWriter::new().op(14, &wr2_params(&[k, wr2_set(&[(0, 1.0)])], [0.5, 0.25], 1)).finish(); - let list = parse_list(&blob); - assert_eq!( - list, - vec![Opcode::WarpRectilinear2 { planes: vec![k, wr2_set(&[(0, 1.0)])], center: [0.5, 0.25], reciprocal: true, optional: false }] - ); - assert!(list[0].is_applied() && list[0].is_lens_correction()); - // written back as opcode 14, version 1.6.0.0, optional flag kept - let again = vec![Opcode::WarpRectilinear2 { planes: vec![k], center: [0.5, 0.5], reciprocal: false, optional: true }]; - let bytes = write_list(&again); - assert_eq!(bytes.get(4..8), Some(&14u32.to_be_bytes()[..])); - assert_eq!(bytes.get(8..12), Some(&0x0106_0000u32.to_be_bytes()[..])); - assert_eq!(parse_list(&bytes), again); - } - - #[test] - fn warp_rectilinear2_identity_leaves_the_image() { - let img = Rgb32f::from_fn(21, 15, |x, y| [x as f32 / 21.0, y as f32 / 15.0, 0.25]); - for reciprocal in [false, true] { - let mut i = img.clone(); - let p = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], reciprocal as u32); - apply_list3(&parse_list(&ListWriter::new().op(14, &p).finish()), &mut i); - for (a, b) in i.data.iter().zip(&img.data) { - for c in 0..3 { - assert!((a[c] - b[c]).abs() < 1e-5, "{a:?} {b:?}"); - } - } - } - } - - #[test] - fn warp_rectilinear2_matches_warp_rectilinear_on_even_terms() { - // kr0 + kr2 r^2 + kr4 r^4 is WarpRectilinear's kr0 + kr1 r^2 + kr2 r^4 - let img = Rgb32f::from_fn(31, 31, |x, y| [(x * 7 % 13) as f32 / 13.0, (y * 5 % 11) as f32 / 11.0, ((x + y) % 4) as f32 / 4.0]); - let a = warp_rectilinear(&img, &[[1.0, -0.3, 0.1, 0.0, 0.01, -0.02]], [0.5, 0.5]); - let mut k = wr2_set(&[(0, 1.0), (2, -0.3), (4, 0.1)]); - k[15] = 0.01; - k[16] = -0.02; - let b = warp_rectilinear2(&img, &[k], [0.5, 0.5], false); - for (p, q) in a.data.iter().zip(&b.data) { - for c in 0..3 { - assert!((p[c] - q[c]).abs() < 1e-5); - } - } - // odd terms, the reciprocal and the valid-radius clamp give the factor the spec defines - let k = wr2_set(&[(0, 1.0), (1, 0.5)]); - assert_eq!(wr2_factor(&k, false, 0.5), Some(1.25)); - assert_eq!(wr2_factor(&k, true, 0.5), Some(0.8)); - let mut k = k; - k[18] = 0.5; // max_valid_radius - assert_eq!(wr2_factor(&k, false, 0.9), Some(1.25)); - k[17] = 0.25; // min_valid_radius - assert_eq!(wr2_factor(&k, false, 0.0), Some(1.125)); - assert_eq!(wr2_factor(&wr2_set(&[]), true, 0.3), None, "1 / 0"); - } - - #[test] - fn malformed_warp_rectilinear2_is_unknown_not_a_panic() { - let good = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); - let is_wr2 = |p: &[u8]| matches!(parse_list(&ListWriter::new().op(14, p).finish()).as_slice(), [Opcode::WarpRectilinear2 { .. }]); - assert!(is_wr2(&good)); - // every truncation and any trailing byte - for n in 0..good.len() { - assert!(!is_wr2(&good[..n]), "{n}"); - } - assert!(!is_wr2(&[&good[..], &[0u8][..]].concat())); - // N = 0, N too big for the data, N beyond the plane limit - for n in [0u32, 2, 5, u32::MAX] { - let mut p = good.clone(); - p[..4].copy_from_slice(&n.to_be_bytes()); - assert!(!is_wr2(&p), "N = {n}"); - } - // non-finite or absurd values, in a coefficient, the valid range and the centre - let patch = |at: usize, v: f64| { - let mut p = good.clone(); - p[at..at + 8].copy_from_slice(&v.to_be_bytes()); - p - }; - for (at, v) in [ - (4, f64::NAN), - (4 + 8, f64::INFINITY), - (4 + 8 * 3, 1e300), - (4 + 8 * 17, -0.5), - (4 + 8 * 18, 1.5), - (4 + 8 * 17, 1.0), - (4 + 19 * 8, f64::NAN), - (4 + 19 * 8 + 8, 1e30), - ] { - assert!(!is_wr2(&patch(at, v)), "{at} {v}"); - } - // refused ones stay Unknown and are not applied - let list = parse_list(&ListWriter::new().op(14, &good[..10]).finish()); - assert!(matches!(list.as_slice(), [Opcode::Unknown { id: 14, .. }])); - let mut img = Rgb32f::filled(5, 5, [0.5; 3]); - apply_list3(&list, &mut img); - assert_eq!(img.get(2, 2), [0.5; 3]); - // a hostile but accepted set (huge radial terms) still gives a finite image - let k = wr2_set(&[(0, 1.0), (14, 1e9), (13, -1e9)]); - let img = Rgb32f::from_fn(9, 9, |x, y| [x as f32 / 9.0, y as f32 / 9.0, 0.5]); - for reciprocal in [false, true] { - let out = warp_rectilinear2(&img, &[k], [0.5, 0.5], reciprocal); - assert!(out.data.iter().all(|p| p.iter().all(|v| v.is_finite()))); - } - } - - #[test] - fn warp_list_order_and_the_optional_skip_rule() { - let wr2 = |optional| Opcode::WarpRectilinear2 { planes: vec![wr2_set(&[(0, 1.0)])], center: [0.5, 0.5], reciprocal: false, optional }; - let wr = Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; - let fish = Opcode::WarpFisheye { planes: vec![[1.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; - let vig = Opcode::FixVignetteRadial { k: [0.0; 5], center: [0.5, 0.5] }; - let d = std::mem::discriminant::; - let ids = |l: &[Opcode]| applied_ops(l).map(d).collect::>(); - // WarpRectilinear2 (optional) then WarpRectilinear / WarpFisheye: the second is skipped - assert_eq!(ids(&[wr2(true), wr.clone()]), vec![d(&wr2(true))]); - assert_eq!(ids(&[wr2(true), fish.clone()]), vec![d(&wr2(true))]); - // not optional: both run, in list order - assert_eq!(ids(&[wr2(false), wr.clone()]), vec![d(&wr2(false)), d(&wr)]); - // only the opcode immediately after is skipped - assert_eq!(ids(&[wr2(true), vig.clone(), wr.clone()]), vec![d(&wr2(true)), d(&vig), d(&wr)]); - // two in a row: both run, and the WarpRectilinear after the optional second one is skipped - assert_eq!(ids(&[wr2(true), wr2(true), wr.clone()]), vec![d(&wr2(true)); 2]); - // a WarpRectilinear before the WarpRectilinear2 runs, in list order - assert_eq!(ids(&[wr.clone(), wr2(true)]), vec![d(&wr), d(&wr2(true))]); - // the same from bytes: an optional (flag 1) opcode 14 followed by opcode 1 - let w2 = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); - let mut one = 1u32.to_be_bytes().to_vec(); - one.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5])); - let mut blob = 2u32.to_be_bytes().to_vec(); - for (id, flags, p) in [(14u32, 1u32, &w2), (1, 1, &one)] { - for v in [id, 0x0106_0000, flags, p.len() as u32] { - blob.extend_from_slice(&v.to_be_bytes()); - } - blob.extend_from_slice(p); - } - let list = parse_list(&blob); - assert_eq!(list.len(), 2); - assert_eq!(applied_ops(&list).count(), 1); - // a refused (Unknown) opcode 14 triggers no skip - let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] }; - assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. }))); - } - - /// `cropped` re-expresses a lens correction for a crop: the corrected crop is the crop of the corrected whole. A - /// linear ramp makes bilinear sampling exact, so only the borders (where the warp reaches past the crop) differ. - #[test] - fn cropped_lens_corrections_correct_the_crop_like_the_whole() { - let (w, h) = (90usize, 64usize); - let crop = crate::Rect::new(7, 5, 70, 50); - let ramp = Rgb32f::from_fn(w, h, |x, y| [0.002 * x as f32 + 0.001 * y as f32 + 0.1, 0.3 + 0.0015 * y as f32, 0.2]); - let mut wr2 = [0.0; WR2_LEN]; - wr2[..5].copy_from_slice(&[1.0, 0.01, -0.03, 0.0, 0.01]); - wr2[15] = 0.002; - wr2[16] = -0.001; - wr2[17] = 0.05; - wr2[18] = 0.9; - let warps = [ - Opcode::WarpRectilinear { planes: vec![[0.97, 0.04, -0.01, 0.002, 0.003, -0.002]], center: [0.47, 0.53] }, - Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: false, optional: true }, - Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: true, optional: false }, - ]; - for op in &warps { - let mut whole = ramp.clone(); - apply_list3(std::slice::from_ref(op), &mut whole); - let re = op.cropped(w, h, crop).unwrap(); - let mut part = ramp.clone().into_crop(crop.x, crop.y, crop.width, crop.height); - apply_list3(std::slice::from_ref(&re), &mut part); - let mut worst = 0f32; - for y in 6..crop.height - 6 { - for x in 6..crop.width - 6 { - let (a, b) = (whole.data[(y + crop.y) * w + x + crop.x], part.data[y * crop.width + x]); - worst = (0..3).map(|c| (a[c] - b[c]).abs()).fold(worst, f32::max); - } - } - assert!(worst < 1e-4, "{op:?} → {re:?}: {worst}"); - } - // the vignette, in the warps' centre convention the develop pipeline reads it with - let gain = |k: &[f64; 5], c: [f64; 2], w: usize, h: usize, x: f64, y: f64| { - let (aw, ah) = (w as f64 - 1.0, h as f64 - 1.0); - let (px, py) = (c[0] * aw, c[1] * ah); - let r2 = ((x - px).powi(2) + (y - py).powi(2)) / max_radius(px, py, aw, ah).powi(2); - 1.0 + (0..5).map(|i| k[i] * r2.powi(i as i32 + 1)).sum::() - }; - let (k, center) = ([-0.3, 0.1, -0.05, 0.02, -0.01], [0.45, 0.55]); - let Some(Opcode::FixVignetteRadial { k: k2, center: c2 }) = (Opcode::FixVignetteRadial { k, center }).cropped(w, h, crop) else { - panic!("vignette") - }; - for (x, y) in [(0.0, 0.0), (69.0, 49.0), (33.5, 20.0), (69.0, 0.0), (12.0, 40.0)] { - let (a, b) = (gain(&k, center, w, h, x + crop.x as f64, y + crop.y as f64), gain(&k2, c2, crop.width, crop.height, x, y)); - assert!((a - b).abs() < 1e-12, "({x}, {y}): {a} vs {b}"); - } - // no change for a full-frame "crop"; nothing for other opcodes, tiny images or a crop past the edge - assert_eq!(warps[0].cropped(w, h, crate::Rect::new(0, 0, w, h)).unwrap(), warps[0]); - assert!(Opcode::TrimBounds { top: 0, left: 0, bottom: 2, right: 2 }.cropped(w, h, crop).is_none()); - assert!(warps[0].cropped(1, h, crate::Rect::new(0, 0, 1, h)).is_none()); - assert!(warps[0].cropped(w, h, crate::Rect::new(30, 0, 70, 50)).is_none()); - assert!(warps[0].cropped(w, h, crate::Rect::new(usize::MAX, 0, 70, 50)).is_none()); - } -} +pub(crate) mod tests; diff --git a/crates/raw/src/opcodes/tests.rs b/crates/raw/src/opcodes/tests.rs new file mode 100644 index 000000000..e63a0bf56 --- /dev/null +++ b/crates/raw/src/opcodes/tests.rs @@ -0,0 +1,444 @@ +use super::*; + +pub(crate) struct ListWriter(pub Vec, pub u32); + +impl ListWriter { + pub fn new() -> Self { + ListWriter(Vec::new(), 0) + } + pub fn op(&mut self, id: u32, params: &[u8]) -> &mut Self { + for v in [id, 0x01030000, 0, params.len() as u32] { + self.0.extend_from_slice(&v.to_be_bytes()); + } + self.0.extend_from_slice(params); + self.1 += 1; + self + } + pub fn finish(&self) -> Vec { + let mut v = self.1.to_be_bytes().to_vec(); + v.extend_from_slice(&self.0); + v + } +} + +pub(crate) fn be(vals: &[f64]) -> Vec { + vals.iter().flat_map(|v| v.to_be_bytes()).collect() +} +pub(crate) fn area(a: [u32; 8]) -> Vec { + a.iter().flat_map(|v| v.to_be_bytes()).collect() +} + +/// Warp and bad-pixel records each cost a full-image pass, and a hostile `OpcodeList3` repeats them +/// thousands of times ([`MAX_LIST3_PASSES`] bounds the multiplier); identity warps below the cap must +/// still apply, exactly, so the same image comes out. +#[test] +fn list3_pass_cap_skips_repeated_records() { + let mut img = Rgb32f::new(8, 6); + for (i, px) in img.data.iter_mut().enumerate() { + *px = [i as f32 / 2.0, (i % 8) as f32, (i / 8) as f32]; + } + let orig: Vec<[f32; 3]> = img.data.clone(); + let list: Vec = + (0..MAX_LIST3_PASSES * 5).map(|_| Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }).collect(); + apply_list3(&list, &mut img); + assert_eq!(img.data, orig, "identity warps change nothing, and the cap skips the repeated rest"); + + // a real warp below the cap is still applied + let one = [Opcode::WarpRectilinear { planes: vec![[2.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }]; + apply_list3(&one, &mut img); + assert_ne!(img.data, orig, "a genuine warp record is not skipped"); +} + +#[test] +fn parse_and_apply_area_ops() { + let (w, h) = (4usize, 3usize); + let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]); + for v in [2u32, 2] { + gm.extend_from_slice(&v.to_be_bytes()); + } + gm.extend(be(&[1.0, 1.0, 0.0, 0.0])); + gm.extend_from_slice(&1u32.to_be_bytes()); + for g in [1.0f32, 2.0, 1.0, 2.0] { + gm.extend_from_slice(&g.to_be_bytes()); + } + let mut dpr = area([0, 0, 3, 4, 0, 1, 1, 1]); + dpr.extend_from_slice(&3u32.to_be_bytes()); + for d in [0.0f32, 0.1, 0.2] { + dpr.extend_from_slice(&d.to_be_bytes()); + } + let mut poly = area([0, 0, 3, 4, 0, 1, 1, 2]); + poly.extend_from_slice(&1u32.to_be_bytes()); + poly.extend(be(&[0.0, 2.0])); + let blob = ListWriter::new().op(9, &gm).op(10, &dpr).op(8, &poly).op(99, &[1, 2, 3]).finish(); + let list = parse_list(&blob); + assert_eq!(list.len(), 4); + assert!(matches!(list[3], Opcode::Unknown { id: 99, .. })); + assert!(list[..3].iter().all(|o| o.is_applied())); + let mut buf = vec![0.25f32; w * h]; + apply_list(&list, &mut buf, w, h, 1, None, 1.0); + // gain map: horizontal ramp 1 → 2 across relative x 0..1 (x / w) + let expect = |x: usize, y: usize| { + let g = 1.0 + x as f32 / w as f32; + let v = 0.25 * g + [0.0, 0.1, 0.2][y]; + if x.is_multiple_of(2) { v * 2.0 } else { v } + }; + for y in 0..h { + for x in 0..w { + assert!((buf[y * w + x] - expect(x, y)).abs() < 1e-6, "{x},{y}: {} vs {}", buf[y * w + x], expect(x, y)); + } + } +} + +#[test] +fn map_table_raw_scale_and_bad_pixels() { + let mut t = area([0, 0, 2, 2, 0, 1, 1, 1]); + t.extend_from_slice(&3u32.to_be_bytes()); + for v in [0u16, 30000, 65535] { + t.extend_from_slice(&v.to_be_bytes()); + } + let list = parse_list(&ListWriter::new().op(7, &t).finish()); + let mut buf = vec![0.0, 1.0 / 65535.0, 2.0 / 65535.0, 0.5]; + apply_list(&list, &mut buf, 2, 2, 1, None, 1.0); + assert_eq!(buf[0], 0.0); + assert!((buf[1] - 30000.0 / 65535.0).abs() < 1e-7); + assert_eq!(buf[3], 1.0); + + let mut p = vec![]; + for v in [7u32, 0] { + p.extend_from_slice(&v.to_be_bytes()); + } + let list = parse_list(&ListWriter::new().op(4, &p).finish()); + let cfa = Cfa::bayer("RGGB").unwrap(); + let mut img: Vec = (0..36).map(|i| 100.0 + (i % 6) as f32).collect(); + img[2 * 6 + 2] = 7.0; + apply_list(&list, &mut img, 6, 6, 1, Some(&cfa), 65535.0); + assert_eq!(img[2 * 6 + 2], 102.0); +} + +/// A mosaic equal to the bad-pixel constant, or one rectangle over the whole image, used to list every pixel as +/// bad: w × h entries (plus a set of them), tens of GB at the decoder's size limit. Here the rows between a good +/// first row (500) and a good last row (900) are bad: 2052 rows of 2048 pixels, four rows more than the 1 << 22 +/// cap. Row 1, within the cap, is repaired from the first row; the second-last row, past the cap, must be left +/// as it is (uncapped, it was rewritten from the last row). +fn assert_bad_pixels_capped(op: Opcode) { + let (w, h) = (2048, 2054); + let mut buf = vec![0.0f32; w * h]; + buf[..w].fill(500.0); + buf[(h - 1) * w..].fill(900.0); + apply_list(&[op], &mut buf, w, h, 1, None, 65535.0); + assert!(buf[w..2 * w].iter().all(|&v| v == 500.0), "row 1 (within the cap) not repaired: {:?}", &buf[w..w + 4]); + let past = &buf[(h - 2) * w..(h - 1) * w]; + assert!(past.iter().all(|&v| v == 0.0), "a row past the cap was rewritten: {:?}", &past[..4]); +} + +#[test] +fn bad_pixels_constant_is_capped() { + assert_bad_pixels_capped(Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 0 }); +} + +#[test] +fn bad_pixels_list_is_capped_inside_a_rectangle() { + // top, left, bottom, right: every row but the first and the last + assert_bad_pixels_capped(Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![], rects: vec![[1, 0, 2053, 2048]] }); +} + +#[test] +fn vignette_and_warp_identity() { + let mut img = Rgb32f::from_fn(9, 7, |x, y| [x as f32 / 9.0, y as f32 / 7.0, 0.5]); + let orig = img.clone(); + let mut p = be(&[0.0; 5]); + p.extend(be(&[0.5, 0.5])); + let mut warp = 1u32.to_be_bytes().to_vec(); + warp.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5])); + let list = parse_list(&ListWriter::new().op(3, &p).op(1, &warp).finish()); + assert_eq!(list.len(), 2); + apply_list3(&list, &mut img); + for (a, b) in img.data.iter().zip(&orig.data) { + for c in 0..3 { + assert!((a[c] - b[c]).abs() < 1e-5); + } + } + // positive k0 brightens the corners more than the centre + let mut p = be(&[1.0, 0.0, 0.0, 0.0, 0.0]); + p.extend(be(&[0.5, 0.5])); + let list = parse_list(&ListWriter::new().op(3, &p).finish()); + let mut flat = Rgb32f::filled(9, 9, [0.5; 3]); + apply_list3(&list, &mut flat); + assert!(flat.get(0, 0)[0] > 0.85 && (flat.get(4, 4)[0] - 0.5).abs() < 0.01, "{:?}", flat.get(0, 0)); +} + +#[test] +fn warp_barrel_moves_pixels_outward() { + let img = Rgb32f::from_fn(31, 31, |x, y| if x == 24 && y == 15 { [1.0; 3] } else { [0.0; 3] }); + // source radius = r (1 + k r²) with k < 0 → content moves outward + let out = warp_rectilinear(&img, &[[1.0, -0.6, 0.0, 0.0, 0.0, 0.0]], [0.5, 0.5]); + let (bx, _) = (0..31).map(|x| (x, out.get(x, 15)[0])).fold((0, 0.0), |a, b| if b.1 > a.1 { b } else { a }); + assert!(bx >= 25, "{bx}"); +} + +#[test] +fn write_parse_roundtrip() { + let a = Area { top: 1, left: 2, bottom: 30, right: 40, plane: 0, planes: 3, row_pitch: 1, col_pitch: 2 }; + let list = vec![ + Opcode::WarpRectilinear { planes: vec![[1.0, 0.01, -0.002, 0.0, 1e-4, -2e-4]; 3], center: [0.5, 0.49] }, + Opcode::WarpFisheye { planes: vec![[1.0, 0.1, 0.0, 0.0]], center: [0.5, 0.5] }, + Opcode::FixVignetteRadial { k: [0.1, 0.2, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, + Opcode::FixBadPixelsConstant { constant: 0, bayer_phase: 1 }, + Opcode::FixBadPixelsList { bayer_phase: 0, points: vec![(3, 4)], rects: vec![[1, 1, 2, 2]] }, + Opcode::TrimBounds { top: 1, left: 1, bottom: 9, right: 9 }, + Opcode::MapTable { area: a, table: vec![0, 100, 65535] }, + Opcode::MapPolynomial { area: a, coefficients: vec![0.0, 1.0, -0.1] }, + Opcode::GainMap { area: a, points_v: 2, points_h: 1, spacing: [1.0, 1.0], origin: [0.0, 0.0], map_planes: 1, gains: vec![1.0, 1.5] }, + Opcode::DeltaPerRow { area: a, deltas: vec![0.1, 0.2] }, + Opcode::DeltaPerColumn { area: a, deltas: vec![0.3] }, + Opcode::ScalePerRow { area: a, scales: vec![1.1] }, + Opcode::ScalePerColumn { area: a, scales: vec![0.9, 1.0] }, + Opcode::WarpRectilinear2 { + planes: vec![wr2_set(&[(0, 0.5), (3, 0.1)]), wr2_set(&[(0, 0.25)])], + center: [0.5, 0.49], + reciprocal: true, + optional: true, + }, + Opcode::Unknown { id: 77, flags: 1, params: vec![1, 2, 3] }, + ]; + assert_eq!(parse_list(&write_list(&list)), list); +} + +#[test] +fn truncated_lists_do_not_panic() { + let mut gm = area([0, 0, 3, 4, 0, 1, 1, 1]); + gm.extend_from_slice(&[0xff; 64]); + let blob = ListWriter::new().op(9, &gm).op(5, &[0xff; 12]).op(1, &[0, 0, 0, 9]).finish(); + for n in 0..blob.len() { + let l = parse_list(&blob[..n]); + let mut buf = vec![0.5f32; 12]; + apply_list(&l, &mut buf, 4, 3, 1, None, 1.0); + } +} + +/// `WarpRectilinear2` parameter bytes (spec 1.6.0.0: N, then per set kr0..kr14, kt0, kt1, min/max valid radius, +/// then the centre and `reciprocalRadial`). +fn wr2_params(sets: &[[f64; WR2_LEN]], center: [f64; 2], reciprocal: u32) -> Vec { + let mut p = (sets.len() as u32).to_be_bytes().to_vec(); + for s in sets { + p.extend(be(s)); + } + p.extend(be(¢er)); + p.extend_from_slice(&reciprocal.to_be_bytes()); + p +} + +fn wr2_set(kr: &[(usize, f64)]) -> [f64; WR2_LEN] { + let mut k = [0.0; WR2_LEN]; + for &(i, v) in kr { + k[i] = v; + } + k[18] = 1.0; + k +} + +#[test] +fn warp_rectilinear2_parses_the_spec_layout() { + let mut k = wr2_set(&[(0, 1.0), (1, 0.25), (14, -2.0)]); + k[15] = 0.001; + k[16] = -0.002; + k[17] = 0.1; + k[18] = 0.9; + let blob = ListWriter::new().op(14, &wr2_params(&[k, wr2_set(&[(0, 1.0)])], [0.5, 0.25], 1)).finish(); + let list = parse_list(&blob); + assert_eq!( + list, + vec![Opcode::WarpRectilinear2 { planes: vec![k, wr2_set(&[(0, 1.0)])], center: [0.5, 0.25], reciprocal: true, optional: false }] + ); + assert!(list[0].is_applied() && list[0].is_lens_correction()); + // written back as opcode 14, version 1.6.0.0, optional flag kept + let again = vec![Opcode::WarpRectilinear2 { planes: vec![k], center: [0.5, 0.5], reciprocal: false, optional: true }]; + let bytes = write_list(&again); + assert_eq!(bytes.get(4..8), Some(&14u32.to_be_bytes()[..])); + assert_eq!(bytes.get(8..12), Some(&0x0106_0000u32.to_be_bytes()[..])); + assert_eq!(parse_list(&bytes), again); +} + +#[test] +fn warp_rectilinear2_identity_leaves_the_image() { + let img = Rgb32f::from_fn(21, 15, |x, y| [x as f32 / 21.0, y as f32 / 15.0, 0.25]); + for reciprocal in [false, true] { + let mut i = img.clone(); + let p = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], reciprocal as u32); + apply_list3(&parse_list(&ListWriter::new().op(14, &p).finish()), &mut i); + for (a, b) in i.data.iter().zip(&img.data) { + for c in 0..3 { + assert!((a[c] - b[c]).abs() < 1e-5, "{a:?} {b:?}"); + } + } + } +} + +#[test] +fn warp_rectilinear2_matches_warp_rectilinear_on_even_terms() { + // kr0 + kr2 r^2 + kr4 r^4 is WarpRectilinear's kr0 + kr1 r^2 + kr2 r^4 + let img = Rgb32f::from_fn(31, 31, |x, y| [(x * 7 % 13) as f32 / 13.0, (y * 5 % 11) as f32 / 11.0, ((x + y) % 4) as f32 / 4.0]); + let a = warp_rectilinear(&img, &[[1.0, -0.3, 0.1, 0.0, 0.01, -0.02]], [0.5, 0.5]); + let mut k = wr2_set(&[(0, 1.0), (2, -0.3), (4, 0.1)]); + k[15] = 0.01; + k[16] = -0.02; + let b = warp_rectilinear2(&img, &[k], [0.5, 0.5], false); + for (p, q) in a.data.iter().zip(&b.data) { + for c in 0..3 { + assert!((p[c] - q[c]).abs() < 1e-5); + } + } + // odd terms, the reciprocal and the valid-radius clamp give the factor the spec defines + let k = wr2_set(&[(0, 1.0), (1, 0.5)]); + assert_eq!(wr2_factor(&k, false, 0.5), Some(1.25)); + assert_eq!(wr2_factor(&k, true, 0.5), Some(0.8)); + let mut k = k; + k[18] = 0.5; // max_valid_radius + assert_eq!(wr2_factor(&k, false, 0.9), Some(1.25)); + k[17] = 0.25; // min_valid_radius + assert_eq!(wr2_factor(&k, false, 0.0), Some(1.125)); + assert_eq!(wr2_factor(&wr2_set(&[]), true, 0.3), None, "1 / 0"); +} + +#[test] +fn malformed_warp_rectilinear2_is_unknown_not_a_panic() { + let good = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); + let is_wr2 = |p: &[u8]| matches!(parse_list(&ListWriter::new().op(14, p).finish()).as_slice(), [Opcode::WarpRectilinear2 { .. }]); + assert!(is_wr2(&good)); + // every truncation and any trailing byte + for n in 0..good.len() { + assert!(!is_wr2(&good[..n]), "{n}"); + } + assert!(!is_wr2(&[&good[..], &[0u8][..]].concat())); + // N = 0, N too big for the data, N beyond the plane limit + for n in [0u32, 2, 5, u32::MAX] { + let mut p = good.clone(); + p[..4].copy_from_slice(&n.to_be_bytes()); + assert!(!is_wr2(&p), "N = {n}"); + } + // non-finite or absurd values, in a coefficient, the valid range and the centre + let patch = |at: usize, v: f64| { + let mut p = good.clone(); + p[at..at + 8].copy_from_slice(&v.to_be_bytes()); + p + }; + for (at, v) in [ + (4, f64::NAN), + (4 + 8, f64::INFINITY), + (4 + 8 * 3, 1e300), + (4 + 8 * 17, -0.5), + (4 + 8 * 18, 1.5), + (4 + 8 * 17, 1.0), + (4 + 19 * 8, f64::NAN), + (4 + 19 * 8 + 8, 1e30), + ] { + assert!(!is_wr2(&patch(at, v)), "{at} {v}"); + } + // refused ones stay Unknown and are not applied + let list = parse_list(&ListWriter::new().op(14, &good[..10]).finish()); + assert!(matches!(list.as_slice(), [Opcode::Unknown { id: 14, .. }])); + let mut img = Rgb32f::filled(5, 5, [0.5; 3]); + apply_list3(&list, &mut img); + assert_eq!(img.get(2, 2), [0.5; 3]); + // a hostile but accepted set (huge radial terms) still gives a finite image + let k = wr2_set(&[(0, 1.0), (14, 1e9), (13, -1e9)]); + let img = Rgb32f::from_fn(9, 9, |x, y| [x as f32 / 9.0, y as f32 / 9.0, 0.5]); + for reciprocal in [false, true] { + let out = warp_rectilinear2(&img, &[k], [0.5, 0.5], reciprocal); + assert!(out.data.iter().all(|p| p.iter().all(|v| v.is_finite()))); + } +} + +#[test] +fn warp_list_order_and_the_optional_skip_rule() { + let wr2 = |optional| Opcode::WarpRectilinear2 { planes: vec![wr2_set(&[(0, 1.0)])], center: [0.5, 0.5], reciprocal: false, optional }; + let wr = Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + let fish = Opcode::WarpFisheye { planes: vec![[1.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + let vig = Opcode::FixVignetteRadial { k: [0.0; 5], center: [0.5, 0.5] }; + let d = std::mem::discriminant::; + let ids = |l: &[Opcode]| applied_ops(l).map(d).collect::>(); + // WarpRectilinear2 (optional) then WarpRectilinear / WarpFisheye: the second is skipped + assert_eq!(ids(&[wr2(true), wr.clone()]), vec![d(&wr2(true))]); + assert_eq!(ids(&[wr2(true), fish.clone()]), vec![d(&wr2(true))]); + // not optional: both run, in list order + assert_eq!(ids(&[wr2(false), wr.clone()]), vec![d(&wr2(false)), d(&wr)]); + // only the opcode immediately after is skipped + assert_eq!(ids(&[wr2(true), vig.clone(), wr.clone()]), vec![d(&wr2(true)), d(&vig), d(&wr)]); + // two in a row: both run, and the WarpRectilinear after the optional second one is skipped + assert_eq!(ids(&[wr2(true), wr2(true), wr.clone()]), vec![d(&wr2(true)); 2]); + // a WarpRectilinear before the WarpRectilinear2 runs, in list order + assert_eq!(ids(&[wr.clone(), wr2(true)]), vec![d(&wr), d(&wr2(true))]); + // the same from bytes: an optional (flag 1) opcode 14 followed by opcode 1 + let w2 = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); + let mut one = 1u32.to_be_bytes().to_vec(); + one.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5])); + let mut blob = 2u32.to_be_bytes().to_vec(); + for (id, flags, p) in [(14u32, 1u32, &w2), (1, 1, &one)] { + for v in [id, 0x0106_0000, flags, p.len() as u32] { + blob.extend_from_slice(&v.to_be_bytes()); + } + blob.extend_from_slice(p); + } + let list = parse_list(&blob); + assert_eq!(list.len(), 2); + assert_eq!(applied_ops(&list).count(), 1); + // a refused (Unknown) opcode 14 triggers no skip + let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] }; + assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. }))); +} + +/// `cropped` re-expresses a lens correction for a crop: the corrected crop is the crop of the corrected whole. A +/// linear ramp makes bilinear sampling exact, so only the borders (where the warp reaches past the crop) differ. +#[test] +fn cropped_lens_corrections_correct_the_crop_like_the_whole() { + let (w, h) = (90usize, 64usize); + let crop = crate::Rect::new(7, 5, 70, 50); + let ramp = Rgb32f::from_fn(w, h, |x, y| [0.002 * x as f32 + 0.001 * y as f32 + 0.1, 0.3 + 0.0015 * y as f32, 0.2]); + let mut wr2 = [0.0; WR2_LEN]; + wr2[..5].copy_from_slice(&[1.0, 0.01, -0.03, 0.0, 0.01]); + wr2[15] = 0.002; + wr2[16] = -0.001; + wr2[17] = 0.05; + wr2[18] = 0.9; + let warps = [ + Opcode::WarpRectilinear { planes: vec![[0.97, 0.04, -0.01, 0.002, 0.003, -0.002]], center: [0.47, 0.53] }, + Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: false, optional: true }, + Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: true, optional: false }, + ]; + for op in &warps { + let mut whole = ramp.clone(); + apply_list3(std::slice::from_ref(op), &mut whole); + let re = op.cropped(w, h, crop).unwrap(); + let mut part = ramp.clone().into_crop(crop.x, crop.y, crop.width, crop.height); + apply_list3(std::slice::from_ref(&re), &mut part); + let mut worst = 0f32; + for y in 6..crop.height - 6 { + for x in 6..crop.width - 6 { + let (a, b) = (whole.data[(y + crop.y) * w + x + crop.x], part.data[y * crop.width + x]); + worst = (0..3).map(|c| (a[c] - b[c]).abs()).fold(worst, f32::max); + } + } + assert!(worst < 1e-4, "{op:?} → {re:?}: {worst}"); + } + // the vignette, in the warps' centre convention the develop pipeline reads it with + let gain = |k: &[f64; 5], c: [f64; 2], w: usize, h: usize, x: f64, y: f64| { + let (aw, ah) = (w as f64 - 1.0, h as f64 - 1.0); + let (px, py) = (c[0] * aw, c[1] * ah); + let r2 = ((x - px).powi(2) + (y - py).powi(2)) / max_radius(px, py, aw, ah).powi(2); + 1.0 + (0..5).map(|i| k[i] * r2.powi(i as i32 + 1)).sum::() + }; + let (k, center) = ([-0.3, 0.1, -0.05, 0.02, -0.01], [0.45, 0.55]); + let Some(Opcode::FixVignetteRadial { k: k2, center: c2 }) = (Opcode::FixVignetteRadial { k, center }).cropped(w, h, crop) else { + panic!("vignette") + }; + for (x, y) in [(0.0, 0.0), (69.0, 49.0), (33.5, 20.0), (69.0, 0.0), (12.0, 40.0)] { + let (a, b) = (gain(&k, center, w, h, x + crop.x as f64, y + crop.y as f64), gain(&k2, c2, crop.width, crop.height, x, y)); + assert!((a - b).abs() < 1e-12, "({x}, {y}): {a} vs {b}"); + } + // no change for a full-frame "crop"; nothing for other opcodes, tiny images or a crop past the edge + assert_eq!(warps[0].cropped(w, h, crate::Rect::new(0, 0, w, h)).unwrap(), warps[0]); + assert!(Opcode::TrimBounds { top: 0, left: 0, bottom: 2, right: 2 }.cropped(w, h, crop).is_none()); + assert!(warps[0].cropped(1, h, crate::Rect::new(0, 0, 1, h)).is_none()); + assert!(warps[0].cropped(w, h, crate::Rect::new(30, 0, 70, 50)).is_none()); + assert!(warps[0].cropped(w, h, crate::Rect::new(usize::MAX, 0, 70, 50)).is_none()); +} From 5809ed4e51a6fb7feba7e453dc66a90429016040 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:09 +0000 Subject: [PATCH 069/191] refactor(lightcraft-raw): move preview.rs tests to crates/raw/src/preview/tests.rs --- crates/raw/src/preview.rs | 349 +------------------------------- crates/raw/src/preview/tests.rs | 346 +++++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+), 348 deletions(-) create mode 100644 crates/raw/src/preview/tests.rs diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 4bcf6963e..245b24f75 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -435,351 +435,4 @@ fn trim_eoi(s: &[u8]) -> &[u8] { } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; - - fn fake_jpeg(n: usize) -> Vec { - let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0]; - j.extend(std::iter::repeat_n(0x55u8, n)); - j.extend_from_slice(&[0xff, 0xd9]); - j - } - - /// Preview extraction only serves single-chunk JPEG-compressed IFDs and asks the `single_chunk` first: - /// a TIFF whose strip array declares 100 000 one-row strips and no StripByteCounts must not build their - /// descriptor vector, and of course finds no preview in it. - #[test] - fn a_multi_strip_jpeg_ifd_costs_no_descriptor_vector() { - let mut ifd = IfdBuilder::new(); - ifd.set(t::COMPRESSION, Value::Short(vec![7])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![100_000])); - ifd.set(t::ROWS_PER_STRIP, Value::Short(vec![1])); - ifd.set(t::STRIP_OFFSETS, Value::Byte((0..100_000).map(|i| (i % 255 + 1) as u8).collect())); - let file = TiffWriter::default().write(&[ifd]).unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || tx.send(embedded_preview(&file))); - assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None)); - } - - /// CR3 candidates claim overlapping extents: each block's first bytes double as a 40-byte jump marker so - /// a walk jogs past the next header and reaches the end of the file, making every one of the 9000 - /// candidates walk its whole suffix before the marker-walk budget — quadratic without it. Nothing here - /// is a JPEG (no frame header), so the answer is `None` either way. - #[test] - fn cr3_preview_boxes_stop_walking_when_the_budget_is_spent() { - // one 256-byte block per candidate, all marker-aligned so walks chain through later blocks: - // 0..4, size (itself a jump marker of 40 bytes); 4..8, "PRVW"; 20..24, the claimed length - // (to the file end); 24..28, an SOI plus a marker jumping to the fill at 40; 64..256, a fill of - // 4-byte markers (`ff 01 00 02`). - let blocks = 9000usize; - let total = blocks * 256; - let mut f = Vec::with_capacity(total); - for k in 0..blocks { - let base = k * 256; - f.extend_from_slice(&[0xff, 0x01, 0x00, 0x26, b'P', b'R', b'V', b'W', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); - f.extend_from_slice(&u32::try_from(total - base - 24).unwrap().to_be_bytes()); - f.extend_from_slice(&[0xff, 0xd8, 0x00, 0x24, 0xff, 0x01, 0x00, 0x26, 0, 0, 0, 0, 0, 0, 0, 0]); - f.resize(base + 64, 0); // 40..64 filler: walks jump over it, so its bytes are free - for _ in 0..48 { - f.extend_from_slice(&[0xff, 0x01, 0x00, 0x02]); - } - } - assert_eq!(f.len(), total); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || tx.send(cr3_preview_boxes(&f).map(|s| s.len()))); - assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None), "no PRVW candidate is a JPEG"); - } - - /// A minimal RAF: the fixed header (JPEG offset/length at 84/88), the JPEG, then a raw tail the - /// caller must not have to read. `jpeg_len` is written as declared, so a test can claim more than - /// the JPEG holds the way a padded container does. - fn raf_fixture(jpeg: &[u8], gap: usize, preamble: usize, declared_len: Option) -> Vec { - let head = 160 + preamble; - let jpeg_off = head; - let mut f = vec![0u8; head]; - f[..16].copy_from_slice(b"FUJIFILMCCD-RAW "); - f[84..88].copy_from_slice(&(jpeg_off as u32).to_be_bytes()); - f[88..92].copy_from_slice(&(declared_len.unwrap_or(jpeg.len()) as u32).to_be_bytes()); - // records directory and raw block are absent (offset 0), which `header` reads as "none" - f.extend_from_slice(jpeg); - f.extend(std::iter::repeat_n(0x33u8, gap)); - f - } - - /// A RAF's preview pointer sits at a fixed offset, so a header-sized prefix locates it and the - /// caller reads just that range instead of the whole file — the whole point on a network share. - #[test] - fn bounded_preview_locates_a_rafs_jpeg_from_the_header_alone() { - let jpeg = fake_jpeg(5000); - let file = raf_fixture(&jpeg, 40_000, 0, None); - // A prefix that covers the pointer's range: header + JPEG, and nothing of the tail. - let prefix_len = 8192.min(file.len()); - let prefix = &file[..prefix_len]; - - let range = bounded_preview(prefix).expect("a RAF's preview is found from its header"); - assert_eq!(range.offset, 160); - assert_eq!(range.window_len, jpeg.len() as u64); - assert!(range.offset + range.window_len <= prefix_len as u64, "the prefix covers the whole range: {range:?} of {prefix_len}"); - assert!((range.offset + range.window_len) < file.len() as u64, "the range stops short of the tail: {range:?} of {}", file.len()); - - // What the caller reads, and what it holds afterwards: identical to the whole-file answer. - let window = prefix.get(range.offset as usize..(range.offset + range.window_len) as usize).expect("the range lies inside the prefix"); - let bounded = embedded_preview_from_range(window, range).expect("the range holds the preview"); - assert_eq!(Some(bounded.clone()), embedded_preview(&file)); - assert!(bounded.ends_with(&[0xff, 0xd9]), "the preview ends at its EOI marker"); - - // A prefix that stops inside the declared extent is clamped by the container reader. The - // caller then reads a short window; what comes back is the whole JPEG it holds (the EOI - // marker is inside), never bytes past the window. - let short = &file[..2000]; - let clipped = bounded_preview(short).expect("a pointer before the cut is still found"); - assert!(clipped.window_len < jpeg.len() as u64, "clamped: {clipped:?}"); - let window = short.get(clipped.offset as usize..).expect("inside"); - let preview = embedded_preview_from_range(window, clipped).expect("the bytes are a JPEG start"); - // No end-of-image marker inside the window: the bytes come back as they are, exactly as the - // whole-file path returns an unclosed JPEG. The decoder refuses it later; nothing is invented. - assert_eq!(preview, window, "an unclosed JPEG is handed on unchanged, never padded"); - assert!(preview.len() <= window.len(), "never past the window"); - } - - /// The preview pointer may sit past the prefix (a long maker-note record before it), and a - /// container may declare padding after the JPEG: both stay correct, and the padding is dropped. - #[test] - fn bounded_preview_handles_a_late_pointer_and_padding() { - let jpeg = fake_jpeg(300); - let late = raf_fixture(&jpeg, 0, 9000, None); - let range = bounded_preview(&late).expect("the whole file is always a valid prefix"); - assert_eq!(range.offset, (160 + 9000) as u64); - let padded = raf_fixture(&jpeg, 500, 0, Some(jpeg.len() + 500)); - let range = bounded_preview(&padded).expect("a padded extent is still located"); - assert_eq!(range.window_len, (jpeg.len() + 500) as u64); - let window = padded.get(range.offset as usize..(range.offset + range.window_len) as usize).expect("the window is inside the file"); - let preview = embedded_preview_from_range(window, range).expect("the padding is dropped"); - assert_eq!(preview, embedded_preview(&padded).expect("the whole-file answer agrees")); - assert!(preview.ends_with(&[0xff, 0xd9])); - } - - /// Without a usable header or pointer the caller must read the file whole: the answer is `None`, - /// never a wrong range. - #[test] - fn bounded_preview_declines_what_the_header_cannot_answer() { - let jpeg = fake_jpeg(64); - let file = raf_fixture(&jpeg, 0, 0, None); - assert_eq!(bounded_preview(&jpeg), None, "not a raw at all"); - assert_eq!(bounded_preview(b""), None); - if let Some(short) = file.get(..64) { - assert_eq!(bounded_preview(short), None, "a header cut short has not shown the pointer yet"); - } - // a pointer that claims more than the file holds: `header` clamps the slice to the buffer, - // so the range never runs past the end (no out-of-bounds read can follow from it) - let mut lying = raf_fixture(&jpeg, 4096, 0, None); - lying[88..92].copy_from_slice(&(u32::MAX / 2).to_be_bytes()); - let clamped = bounded_preview(&lying).expect("a pointer inside the file is still usable"); - assert!(clamped.offset + clamped.window_len <= lying.len() as u64, "clamped to the file: {clamped:?}"); - // a range the window cannot satisfy yields nothing - assert_eq!(embedded_preview_from_range(&jpeg, PreviewRange { offset: 0, window_len: u64::MAX }), None); - // a TIFF-based raw: its pointer tag lives in an IFD this prefix may cut short - let mut tiff = b"II*\0\x08\0\0\0".to_vec(); - tiff.extend_from_slice(&[0u8; 64]); - assert_eq!(bounded_preview(&tiff), None); - } - - /// A range that does not hold a JPEG yields nothing, rather than a truncated image handed on. - #[test] - fn a_range_that_does_not_hold_a_jpeg_yields_nothing() { - let jpeg = fake_jpeg(200); - let range = PreviewRange { offset: 0, window_len: jpeg.len() as u64 }; - assert_eq!(embedded_preview_from_range(&jpeg, range).as_deref(), Some(&jpeg[..])); - assert_eq!(embedded_preview_from_range(&jpeg[..10], range), None, "window shorter than the range claims"); - let not_jpeg = vec![0u8; jpeg.len()]; - assert_eq!(embedded_preview_from_range(¬_jpeg, range), None, "not a JPEG"); - assert_eq!(embedded_preview_from_range(&[], PreviewRange { offset: 0, window_len: 0 }), None, "empty window"); - } - - #[test] - fn picks_largest_dct_jpeg() { - let small = fake_jpeg(10); - let big = fake_jpeg(500); - let lossless = crate::ljpeg::encode(&[1u16; 64], 8, 8, 1, 12, 1, 0); - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::COMPRESSION, Value::Short(vec![6])); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![1])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![1])); - ifd0.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![small.clone()] }); - let mut raw = IfdBuilder::new(); - raw.set(t::COMPRESSION, Value::Short(vec![7])); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![8])); - raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![lossless] }); - ifd0.add_sub_ifd(raw); - let mut sub = IfdBuilder::new(); - sub.set(t::COMPRESSION, Value::Short(vec![7])); - sub.set(t::IMAGE_WIDTH, Value::Long(vec![2])); - sub.set(t::IMAGE_LENGTH, Value::Long(vec![2])); - sub.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); - sub.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![big.clone()] }); - ifd0.add_sub_ifd(sub); - let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); - assert_eq!(embedded_preview(&bytes).unwrap(), big); - assert!(embedded_preview(b"nope").is_none()); - // CR3: a PRVW box after the ftyp - let mut cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); - let j = fake_jpeg(300); - cr3.extend_from_slice(&((24 + j.len()) as u32).to_be_bytes()); - cr3.extend_from_slice(b"PRVW\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01"); - cr3.extend_from_slice(&(j.len() as u32).to_be_bytes()); - cr3.extend_from_slice(&j); - assert_eq!(embedded_preview(&cr3).unwrap(), j); - for n in 0..cr3.len() { - let _ = embedded_preview(&cr3[..n]); - } - } - - /// A JPEG like [`fake_jpeg`] whose frame header says `w` x `h` with `nc` components. - fn sized_jpeg(w: u16, h: u16, nc: u8, n: usize) -> Vec { - let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 8 + 3 * nc, 8]; - j.extend_from_slice(&h.to_be_bytes()); - j.extend_from_slice(&w.to_be_bytes()); - j.push(nc); - for c in 0..nc { - j.extend_from_slice(&[c + 1, 0x11, 0]); - } - j.extend(std::iter::repeat_n(0x55u8, n)); - j.extend_from_slice(&[0xff, 0xd9]); - j - } - - /// A TIFF raw holding `jpegs` as JPEG-compressed strips: the first in IFD0, the others in sub-IFDs. - fn tiff_with_jpegs(jpegs: &[&[u8]]) -> Vec { - let ifd = |j: &[u8]| { - let mut ifd = IfdBuilder::new(); - ifd.set(t::COMPRESSION, Value::Short(vec![6])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![1])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![1])); - ifd.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![j.to_vec()] }); - ifd - }; - let mut ifd0 = ifd(jpegs[0]); - for j in &jpegs[1..] { - ifd0.add_sub_ifd(ifd(j)); - } - TiffWriter::default().write(&[ifd0]).unwrap() - } - - /// A stand-in decodes the smallest JPEG that covers its box and shows the same picture as the largest: not a - /// thumbnail of another shape (a letterboxed one) or a grey mask, and the largest when no smaller one is big enough. - #[test] - fn a_stand_in_takes_the_smallest_covering_jpeg_of_the_same_shape() { - let (big, small, boxed) = (sized_jpeg(9984, 6656, 3, 500), sized_jpeg(1616, 1080, 3, 50), sized_jpeg(160, 120, 3, 10)); - // a ProRAW DNG keeps grey semantic masks of the picture's shape as JPEGs too - let mask = sized_jpeg(768, 512, 1, 20); - let file = tiff_with_jpegs(&[&small, &big, &boxed, &mask]); - assert_eq!(embedded_preview(&file).unwrap(), big, "unchanged: the largest"); - assert_eq!(embedded_preview_covering(&file, 1600).unwrap(), small, "1616 px covers a 1600 px box"); - assert_eq!(embedded_preview_covering(&file, 1616).unwrap(), small); - assert_eq!(embedded_preview_covering(&file, 1617).unwrap(), big, "too small: the largest"); - assert_eq!(embedded_preview_covering(&file, u32::MAX).unwrap(), big); - assert_eq!(embedded_preview_covering(&file, 100).unwrap(), small, "the 4:3 thumbnail and the grey mask are not this picture"); - // a small copy of the same shape (within 1 %) is taken - let thumb = sized_jpeg(160, 107, 3, 10); - let file = tiff_with_jpegs(&[&small, &big, &thumb]); - assert_eq!(embedded_preview_covering(&file, 100).unwrap(), thumb); - assert_eq!(embedded_preview_covering(&tiff_with_jpegs(&[&big]), 100).unwrap(), big, "one preview: that one"); - for n in (0..file.len()).step_by(7) { - let _ = embedded_preview_covering(&file[..n], 100); - } - } - - /// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box. - #[test] - fn cr3_prefers_the_full_size_jpeg_track() { - let bx = |kind: &[u8; 4], body: &[u8]| -> Vec { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() }; - let full = |kind: &[u8; 4], body: &[u8]| bx(kind, &[&[0u8; 4][..], body].concat()); - let (small, big) = (fake_jpeg(300), fake_jpeg(3000)); - let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); - let mut prvw = b"\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01".to_vec(); - prvw.extend_from_slice(&(small.len() as u32).to_be_bytes()); - prvw.extend_from_slice(&small); - file.extend(bx(b"PRVW", &prvw)); - // CRAW sample entry: 82 bytes, then a JPEG child box; one sample at `at` - let mut craw = vec![0u8; 82]; - craw.extend(bx(b"JPEG", &[0; 4])); - let at = 2048u64; - let stsd = full(b"stsd", &[&1u32.to_be_bytes()[..], &bx(b"CRAW", &craw)].concat()); - let stsz = full(b"stsz", &[0u32.to_be_bytes(), 1u32.to_be_bytes(), (big.len() as u32).to_be_bytes()].concat()); - let co64 = full(b"co64", &[&1u32.to_be_bytes()[..], &at.to_be_bytes()].concat()); - let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &[stsd, stsz, co64].concat())))); - file.extend(bx(b"moov", &trak)); - file.resize(at as usize, 0); - file.extend_from_slice(&big); - assert_eq!(embedded_preview(&file).unwrap(), big); - // a track pointing past the end falls back to PRVW - file.truncate(at as usize + 100); - assert_eq!(embedded_preview(&file).unwrap(), small); - } - - /// A structurally complete DCT JPEG (no real image data): `nc` components, `n` entropy bytes. - fn scan_jpeg(nc: u8, n: usize) -> Vec { - let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00]; - j.extend(std::iter::repeat_n(8u8, 64)); - j.extend_from_slice(&[0xff, 0xc0, 0x00, 8 + 3 * nc, 8, 0, 16, 0, 24, nc]); - for c in 0..nc { - j.extend_from_slice(&[c + 1, 0x11, 0]); - } - j.extend_from_slice(&[0xff, 0xda, 0x00, 6 + 2 * nc, nc]); - for c in 0..nc { - j.extend_from_slice(&[c + 1, 0]); - } - j.extend_from_slice(&[0, 63, 0]); - j.extend(std::iter::repeat_n(0x55u8, n)); - j.extend_from_slice(&[0xff, 0x00, 0x12, 0xff, 0xd0, 0x34, 0xff, 0xd9]); - j - } - - /// A crafted container full of JPEG-like starts (each walk running on through the ones after it) - /// is scanned in linear time: the walks share a budget (without it this 40 MB file takes about a minute). - #[test] - fn a_scan_through_many_fake_jpegs_stays_linear() { - let mut unit = vec![0u8, 0xd8, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xda, 0x00, 0x02]; - unit.extend(std::iter::repeat_n(0x55u8, 190)); - let mut f = b"\0MRM\0\x01\0\0".to_vec(); - for _ in 0..200_000 { - f.extend_from_slice(&unit); - } - let t = std::time::Instant::now(); - assert_eq!(scan_for_jpeg(&f), None); - assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); - } - - /// Containers whose structure we do not walk give up their largest colour JPEG, found by scanning. - #[test] - fn scanned_containers_give_the_largest_colour_jpeg() { - let (small, big, mosaic) = (scan_jpeg(3, 100), scan_jpeg(3, 900), scan_jpeg(1, 5000)); - let heads: [&[u8]; 3] = [b"II\x1a\0\0\0HEAPCCDR", b"\0MRM\0\x01\0\0", b"FOVb\x02\0\x02\0"]; - for head in heads { - let mut f = head.to_vec(); - for part in [&small, &mosaic, &big] { - f.extend_from_slice(&[0xff, 0xd8, 0xff, 0x00, 0x00]); // a stray SOI-like run: not a JPEG - f.extend_from_slice(part); - f.extend_from_slice(&[0u8; 7]); - } - // MRW decodes; its preview is still found by the scan - assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Mrw), "{head:?}"); - assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}"); - for n in (0..f.len()).step_by(11) { - let _ = embedded_preview(&f[..n]); - } - } - // a preview whose first byte was overwritten (Minolta and Epson files store 00, 02 or ee there) is restored - let mut f = b"\0MRM\0\x01\0\0".to_vec(); - f.push(0x02); - f.extend_from_slice(&big[1..]); - assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..])); - // only a one-component JPEG, or none: nothing - let mut f = b"FOVb\x02\0\x02\0".to_vec(); - f.extend_from_slice(&mosaic); - assert_eq!(embedded_preview(&f), None); - } -} +mod tests; diff --git a/crates/raw/src/preview/tests.rs b/crates/raw/src/preview/tests.rs new file mode 100644 index 000000000..286ad1d77 --- /dev/null +++ b/crates/raw/src/preview/tests.rs @@ -0,0 +1,346 @@ +use super::*; +use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + +fn fake_jpeg(n: usize) -> Vec { + let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0]; + j.extend(std::iter::repeat_n(0x55u8, n)); + j.extend_from_slice(&[0xff, 0xd9]); + j +} + +/// Preview extraction only serves single-chunk JPEG-compressed IFDs and asks the `single_chunk` first: +/// a TIFF whose strip array declares 100 000 one-row strips and no StripByteCounts must not build their +/// descriptor vector, and of course finds no preview in it. +#[test] +fn a_multi_strip_jpeg_ifd_costs_no_descriptor_vector() { + let mut ifd = IfdBuilder::new(); + ifd.set(t::COMPRESSION, Value::Short(vec![7])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![100_000])); + ifd.set(t::ROWS_PER_STRIP, Value::Short(vec![1])); + ifd.set(t::STRIP_OFFSETS, Value::Byte((0..100_000).map(|i| (i % 255 + 1) as u8).collect())); + let file = TiffWriter::default().write(&[ifd]).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(embedded_preview(&file))); + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None)); +} + +/// CR3 candidates claim overlapping extents: each block's first bytes double as a 40-byte jump marker so +/// a walk jogs past the next header and reaches the end of the file, making every one of the 9000 +/// candidates walk its whole suffix before the marker-walk budget — quadratic without it. Nothing here +/// is a JPEG (no frame header), so the answer is `None` either way. +#[test] +fn cr3_preview_boxes_stop_walking_when_the_budget_is_spent() { + // one 256-byte block per candidate, all marker-aligned so walks chain through later blocks: + // 0..4, size (itself a jump marker of 40 bytes); 4..8, "PRVW"; 20..24, the claimed length + // (to the file end); 24..28, an SOI plus a marker jumping to the fill at 40; 64..256, a fill of + // 4-byte markers (`ff 01 00 02`). + let blocks = 9000usize; + let total = blocks * 256; + let mut f = Vec::with_capacity(total); + for k in 0..blocks { + let base = k * 256; + f.extend_from_slice(&[0xff, 0x01, 0x00, 0x26, b'P', b'R', b'V', b'W', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + f.extend_from_slice(&u32::try_from(total - base - 24).unwrap().to_be_bytes()); + f.extend_from_slice(&[0xff, 0xd8, 0x00, 0x24, 0xff, 0x01, 0x00, 0x26, 0, 0, 0, 0, 0, 0, 0, 0]); + f.resize(base + 64, 0); // 40..64 filler: walks jump over it, so its bytes are free + for _ in 0..48 { + f.extend_from_slice(&[0xff, 0x01, 0x00, 0x02]); + } + } + assert_eq!(f.len(), total); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(cr3_preview_boxes(&f).map(|s| s.len()))); + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None), "no PRVW candidate is a JPEG"); +} + +/// A minimal RAF: the fixed header (JPEG offset/length at 84/88), the JPEG, then a raw tail the +/// caller must not have to read. `jpeg_len` is written as declared, so a test can claim more than +/// the JPEG holds the way a padded container does. +fn raf_fixture(jpeg: &[u8], gap: usize, preamble: usize, declared_len: Option) -> Vec { + let head = 160 + preamble; + let jpeg_off = head; + let mut f = vec![0u8; head]; + f[..16].copy_from_slice(b"FUJIFILMCCD-RAW "); + f[84..88].copy_from_slice(&(jpeg_off as u32).to_be_bytes()); + f[88..92].copy_from_slice(&(declared_len.unwrap_or(jpeg.len()) as u32).to_be_bytes()); + // records directory and raw block are absent (offset 0), which `header` reads as "none" + f.extend_from_slice(jpeg); + f.extend(std::iter::repeat_n(0x33u8, gap)); + f +} + +/// A RAF's preview pointer sits at a fixed offset, so a header-sized prefix locates it and the +/// caller reads just that range instead of the whole file — the whole point on a network share. +#[test] +fn bounded_preview_locates_a_rafs_jpeg_from_the_header_alone() { + let jpeg = fake_jpeg(5000); + let file = raf_fixture(&jpeg, 40_000, 0, None); + // A prefix that covers the pointer's range: header + JPEG, and nothing of the tail. + let prefix_len = 8192.min(file.len()); + let prefix = &file[..prefix_len]; + + let range = bounded_preview(prefix).expect("a RAF's preview is found from its header"); + assert_eq!(range.offset, 160); + assert_eq!(range.window_len, jpeg.len() as u64); + assert!(range.offset + range.window_len <= prefix_len as u64, "the prefix covers the whole range: {range:?} of {prefix_len}"); + assert!((range.offset + range.window_len) < file.len() as u64, "the range stops short of the tail: {range:?} of {}", file.len()); + + // What the caller reads, and what it holds afterwards: identical to the whole-file answer. + let window = prefix.get(range.offset as usize..(range.offset + range.window_len) as usize).expect("the range lies inside the prefix"); + let bounded = embedded_preview_from_range(window, range).expect("the range holds the preview"); + assert_eq!(Some(bounded.clone()), embedded_preview(&file)); + assert!(bounded.ends_with(&[0xff, 0xd9]), "the preview ends at its EOI marker"); + + // A prefix that stops inside the declared extent is clamped by the container reader. The + // caller then reads a short window; what comes back is the whole JPEG it holds (the EOI + // marker is inside), never bytes past the window. + let short = &file[..2000]; + let clipped = bounded_preview(short).expect("a pointer before the cut is still found"); + assert!(clipped.window_len < jpeg.len() as u64, "clamped: {clipped:?}"); + let window = short.get(clipped.offset as usize..).expect("inside"); + let preview = embedded_preview_from_range(window, clipped).expect("the bytes are a JPEG start"); + // No end-of-image marker inside the window: the bytes come back as they are, exactly as the + // whole-file path returns an unclosed JPEG. The decoder refuses it later; nothing is invented. + assert_eq!(preview, window, "an unclosed JPEG is handed on unchanged, never padded"); + assert!(preview.len() <= window.len(), "never past the window"); +} + +/// The preview pointer may sit past the prefix (a long maker-note record before it), and a +/// container may declare padding after the JPEG: both stay correct, and the padding is dropped. +#[test] +fn bounded_preview_handles_a_late_pointer_and_padding() { + let jpeg = fake_jpeg(300); + let late = raf_fixture(&jpeg, 0, 9000, None); + let range = bounded_preview(&late).expect("the whole file is always a valid prefix"); + assert_eq!(range.offset, (160 + 9000) as u64); + let padded = raf_fixture(&jpeg, 500, 0, Some(jpeg.len() + 500)); + let range = bounded_preview(&padded).expect("a padded extent is still located"); + assert_eq!(range.window_len, (jpeg.len() + 500) as u64); + let window = padded.get(range.offset as usize..(range.offset + range.window_len) as usize).expect("the window is inside the file"); + let preview = embedded_preview_from_range(window, range).expect("the padding is dropped"); + assert_eq!(preview, embedded_preview(&padded).expect("the whole-file answer agrees")); + assert!(preview.ends_with(&[0xff, 0xd9])); +} + +/// Without a usable header or pointer the caller must read the file whole: the answer is `None`, +/// never a wrong range. +#[test] +fn bounded_preview_declines_what_the_header_cannot_answer() { + let jpeg = fake_jpeg(64); + let file = raf_fixture(&jpeg, 0, 0, None); + assert_eq!(bounded_preview(&jpeg), None, "not a raw at all"); + assert_eq!(bounded_preview(b""), None); + if let Some(short) = file.get(..64) { + assert_eq!(bounded_preview(short), None, "a header cut short has not shown the pointer yet"); + } + // a pointer that claims more than the file holds: `header` clamps the slice to the buffer, + // so the range never runs past the end (no out-of-bounds read can follow from it) + let mut lying = raf_fixture(&jpeg, 4096, 0, None); + lying[88..92].copy_from_slice(&(u32::MAX / 2).to_be_bytes()); + let clamped = bounded_preview(&lying).expect("a pointer inside the file is still usable"); + assert!(clamped.offset + clamped.window_len <= lying.len() as u64, "clamped to the file: {clamped:?}"); + // a range the window cannot satisfy yields nothing + assert_eq!(embedded_preview_from_range(&jpeg, PreviewRange { offset: 0, window_len: u64::MAX }), None); + // a TIFF-based raw: its pointer tag lives in an IFD this prefix may cut short + let mut tiff = b"II*\0\x08\0\0\0".to_vec(); + tiff.extend_from_slice(&[0u8; 64]); + assert_eq!(bounded_preview(&tiff), None); +} + +/// A range that does not hold a JPEG yields nothing, rather than a truncated image handed on. +#[test] +fn a_range_that_does_not_hold_a_jpeg_yields_nothing() { + let jpeg = fake_jpeg(200); + let range = PreviewRange { offset: 0, window_len: jpeg.len() as u64 }; + assert_eq!(embedded_preview_from_range(&jpeg, range).as_deref(), Some(&jpeg[..])); + assert_eq!(embedded_preview_from_range(&jpeg[..10], range), None, "window shorter than the range claims"); + let not_jpeg = vec![0u8; jpeg.len()]; + assert_eq!(embedded_preview_from_range(¬_jpeg, range), None, "not a JPEG"); + assert_eq!(embedded_preview_from_range(&[], PreviewRange { offset: 0, window_len: 0 }), None, "empty window"); +} + +#[test] +fn picks_largest_dct_jpeg() { + let small = fake_jpeg(10); + let big = fake_jpeg(500); + let lossless = crate::ljpeg::encode(&[1u16; 64], 8, 8, 1, 12, 1, 0); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::COMPRESSION, Value::Short(vec![6])); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![1])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![1])); + ifd0.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![small.clone()] }); + let mut raw = IfdBuilder::new(); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![8])); + raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![lossless] }); + ifd0.add_sub_ifd(raw); + let mut sub = IfdBuilder::new(); + sub.set(t::COMPRESSION, Value::Short(vec![7])); + sub.set(t::IMAGE_WIDTH, Value::Long(vec![2])); + sub.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + sub.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + sub.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![big.clone()] }); + ifd0.add_sub_ifd(sub); + let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); + assert_eq!(embedded_preview(&bytes).unwrap(), big); + assert!(embedded_preview(b"nope").is_none()); + // CR3: a PRVW box after the ftyp + let mut cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); + let j = fake_jpeg(300); + cr3.extend_from_slice(&((24 + j.len()) as u32).to_be_bytes()); + cr3.extend_from_slice(b"PRVW\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01"); + cr3.extend_from_slice(&(j.len() as u32).to_be_bytes()); + cr3.extend_from_slice(&j); + assert_eq!(embedded_preview(&cr3).unwrap(), j); + for n in 0..cr3.len() { + let _ = embedded_preview(&cr3[..n]); + } +} + +/// A JPEG like [`fake_jpeg`] whose frame header says `w` x `h` with `nc` components. +fn sized_jpeg(w: u16, h: u16, nc: u8, n: usize) -> Vec { + let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 8 + 3 * nc, 8]; + j.extend_from_slice(&h.to_be_bytes()); + j.extend_from_slice(&w.to_be_bytes()); + j.push(nc); + for c in 0..nc { + j.extend_from_slice(&[c + 1, 0x11, 0]); + } + j.extend(std::iter::repeat_n(0x55u8, n)); + j.extend_from_slice(&[0xff, 0xd9]); + j +} + +/// A TIFF raw holding `jpegs` as JPEG-compressed strips: the first in IFD0, the others in sub-IFDs. +fn tiff_with_jpegs(jpegs: &[&[u8]]) -> Vec { + let ifd = |j: &[u8]| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::COMPRESSION, Value::Short(vec![6])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![1])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![j.to_vec()] }); + ifd + }; + let mut ifd0 = ifd(jpegs[0]); + for j in &jpegs[1..] { + ifd0.add_sub_ifd(ifd(j)); + } + TiffWriter::default().write(&[ifd0]).unwrap() +} + +/// A stand-in decodes the smallest JPEG that covers its box and shows the same picture as the largest: not a +/// thumbnail of another shape (a letterboxed one) or a grey mask, and the largest when no smaller one is big enough. +#[test] +fn a_stand_in_takes_the_smallest_covering_jpeg_of_the_same_shape() { + let (big, small, boxed) = (sized_jpeg(9984, 6656, 3, 500), sized_jpeg(1616, 1080, 3, 50), sized_jpeg(160, 120, 3, 10)); + // a ProRAW DNG keeps grey semantic masks of the picture's shape as JPEGs too + let mask = sized_jpeg(768, 512, 1, 20); + let file = tiff_with_jpegs(&[&small, &big, &boxed, &mask]); + assert_eq!(embedded_preview(&file).unwrap(), big, "unchanged: the largest"); + assert_eq!(embedded_preview_covering(&file, 1600).unwrap(), small, "1616 px covers a 1600 px box"); + assert_eq!(embedded_preview_covering(&file, 1616).unwrap(), small); + assert_eq!(embedded_preview_covering(&file, 1617).unwrap(), big, "too small: the largest"); + assert_eq!(embedded_preview_covering(&file, u32::MAX).unwrap(), big); + assert_eq!(embedded_preview_covering(&file, 100).unwrap(), small, "the 4:3 thumbnail and the grey mask are not this picture"); + // a small copy of the same shape (within 1 %) is taken + let thumb = sized_jpeg(160, 107, 3, 10); + let file = tiff_with_jpegs(&[&small, &big, &thumb]); + assert_eq!(embedded_preview_covering(&file, 100).unwrap(), thumb); + assert_eq!(embedded_preview_covering(&tiff_with_jpegs(&[&big]), 100).unwrap(), big, "one preview: that one"); + for n in (0..file.len()).step_by(7) { + let _ = embedded_preview_covering(&file[..n], 100); + } +} + +/// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box. +#[test] +fn cr3_prefers_the_full_size_jpeg_track() { + let bx = |kind: &[u8; 4], body: &[u8]| -> Vec { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() }; + let full = |kind: &[u8; 4], body: &[u8]| bx(kind, &[&[0u8; 4][..], body].concat()); + let (small, big) = (fake_jpeg(300), fake_jpeg(3000)); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let mut prvw = b"\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01".to_vec(); + prvw.extend_from_slice(&(small.len() as u32).to_be_bytes()); + prvw.extend_from_slice(&small); + file.extend(bx(b"PRVW", &prvw)); + // CRAW sample entry: 82 bytes, then a JPEG child box; one sample at `at` + let mut craw = vec![0u8; 82]; + craw.extend(bx(b"JPEG", &[0; 4])); + let at = 2048u64; + let stsd = full(b"stsd", &[&1u32.to_be_bytes()[..], &bx(b"CRAW", &craw)].concat()); + let stsz = full(b"stsz", &[0u32.to_be_bytes(), 1u32.to_be_bytes(), (big.len() as u32).to_be_bytes()].concat()); + let co64 = full(b"co64", &[&1u32.to_be_bytes()[..], &at.to_be_bytes()].concat()); + let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &[stsd, stsz, co64].concat())))); + file.extend(bx(b"moov", &trak)); + file.resize(at as usize, 0); + file.extend_from_slice(&big); + assert_eq!(embedded_preview(&file).unwrap(), big); + // a track pointing past the end falls back to PRVW + file.truncate(at as usize + 100); + assert_eq!(embedded_preview(&file).unwrap(), small); +} + +/// A structurally complete DCT JPEG (no real image data): `nc` components, `n` entropy bytes. +fn scan_jpeg(nc: u8, n: usize) -> Vec { + let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00]; + j.extend(std::iter::repeat_n(8u8, 64)); + j.extend_from_slice(&[0xff, 0xc0, 0x00, 8 + 3 * nc, 8, 0, 16, 0, 24, nc]); + for c in 0..nc { + j.extend_from_slice(&[c + 1, 0x11, 0]); + } + j.extend_from_slice(&[0xff, 0xda, 0x00, 6 + 2 * nc, nc]); + for c in 0..nc { + j.extend_from_slice(&[c + 1, 0]); + } + j.extend_from_slice(&[0, 63, 0]); + j.extend(std::iter::repeat_n(0x55u8, n)); + j.extend_from_slice(&[0xff, 0x00, 0x12, 0xff, 0xd0, 0x34, 0xff, 0xd9]); + j +} + +/// A crafted container full of JPEG-like starts (each walk running on through the ones after it) +/// is scanned in linear time: the walks share a budget (without it this 40 MB file takes about a minute). +#[test] +fn a_scan_through_many_fake_jpegs_stays_linear() { + let mut unit = vec![0u8, 0xd8, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xda, 0x00, 0x02]; + unit.extend(std::iter::repeat_n(0x55u8, 190)); + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + for _ in 0..200_000 { + f.extend_from_slice(&unit); + } + let t = std::time::Instant::now(); + assert_eq!(scan_for_jpeg(&f), None); + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); +} + +/// Containers whose structure we do not walk give up their largest colour JPEG, found by scanning. +#[test] +fn scanned_containers_give_the_largest_colour_jpeg() { + let (small, big, mosaic) = (scan_jpeg(3, 100), scan_jpeg(3, 900), scan_jpeg(1, 5000)); + let heads: [&[u8]; 3] = [b"II\x1a\0\0\0HEAPCCDR", b"\0MRM\0\x01\0\0", b"FOVb\x02\0\x02\0"]; + for head in heads { + let mut f = head.to_vec(); + for part in [&small, &mosaic, &big] { + f.extend_from_slice(&[0xff, 0xd8, 0xff, 0x00, 0x00]); // a stray SOI-like run: not a JPEG + f.extend_from_slice(part); + f.extend_from_slice(&[0u8; 7]); + } + // MRW decodes; its preview is still found by the scan + assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Mrw), "{head:?}"); + assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}"); + for n in (0..f.len()).step_by(11) { + let _ = embedded_preview(&f[..n]); + } + } + // a preview whose first byte was overwritten (Minolta and Epson files store 00, 02 or ee there) is restored + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + f.push(0x02); + f.extend_from_slice(&big[1..]); + assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..])); + // only a one-component JPEG, or none: nothing + let mut f = b"FOVb\x02\0\x02\0".to_vec(); + f.extend_from_slice(&mosaic); + assert_eq!(embedded_preview(&f), None); +} From f030eb4c71bda079d5322d415b732fe7096f557d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:09 +0000 Subject: [PATCH 070/191] refactor(lightcraft-raw): move highlight.rs tests to crates/raw/src/highlight/tests.rs --- crates/raw/src/highlight.rs | 349 +----------------------------- crates/raw/src/highlight/tests.rs | 346 +++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+), 348 deletions(-) create mode 100644 crates/raw/src/highlight/tests.rs diff --git a/crates/raw/src/highlight.rs b/crates/raw/src/highlight.rs index bfebfe814..40f35dc20 100644 --- a/crates/raw/src/highlight.rs +++ b/crates/raw/src/highlight.rs @@ -531,351 +531,4 @@ fn rebuild_clipped(img: &mut Rgb32f, wb: [f32; 3], clip: f32, white: [f32; 3], c } #[cfg(test)] -mod tests { - use super::*; - - /// Daylight-like multipliers: green clips first, red and blue keep rising. - const WB: [f32; 3] = [2.5, 1.0, 1.6]; - - fn balanced(p: [f32; 3]) -> [f32; 3] { - [p[0] * WB[0], p[1] * WB[1], p[2] * WB[2]] - } - - fn sensor(q: [f32; 3]) -> [f32; 3] { - [q[0] / WB[0], q[1] / WB[1], q[2] / WB[2]].map(|v| v.min(1.0)) - } - - fn chroma(q: [f32; 3]) -> [f32; 3] { - let s = q[0] + q[1] + q[2]; - q.map(|v| v / s) - } - - /// A pale blue sky whose green channel clipped, an unclipped strip of the same sky along the top, and a dark - /// branch with purple fringes on both sides standing in the clipped part (white-balanced sky colour returned). - fn branch_scene() -> (Rgb32f, [f32; 3]) { - let sky = [1.10, 1.25, 1.45]; - let img = Rgb32f::from_fn(192, 128, |x, y| { - sensor(if y < 24 { - sky.map(|v| v * 0.7) - } else if (90..100).contains(&x) && y >= 40 { - [0.06, 0.05, 0.03] - } else if (x == 89 || x == 100) && y >= 40 { - [0.75, 0.25, 0.96] - } else { - sky - }) - }); - (img, sky) - } - - /// How far chromaticity `got` lies off the way from `from` to neutral (largest channel difference to the - /// nearest point of that way). - fn off_the_way_to_neutral(got: [f32; 3], from: [f32; 3]) -> f32 { - let d: [f32; 3] = std::array::from_fn(|c| 1.0 / 3.0 - from[c]); - let len = d.iter().map(|v| v * v).sum::(); - let t = if len > 0.0 { ((0..3).map(|c| (got[c] - from[c]) * d[c]).sum::() / len).clamp(0.0, 1.0) } else { 0.0 }; - (0..3).map(|c| (got[c] - (from[c] + d[c] * t)).abs()).fold(0.0, f32::max) - } - - /// Issue #523: the clipped sky beside a branch took the colour of the branch's fringes and of the branch - /// itself (magenta, green), which any darkening of the highlights then showed. Beside the branch it is now the - /// clipped sky away from it: the sky's colour, on its way to neutral. - #[test] - fn fringes_and_dark_branches_do_not_tint_a_clipped_sky() { - let (mut img, sky) = branch_scene(); - assert!(reconstruct(&mut img, WB, 0.99) > 0); - let mut worst = 0f32; - for y in 40..128 { - let away = chroma(balanced(img.get(30, y))); - assert!(away == chroma(balanced(img.get(170, y))), "row {y}"); - let off = off_the_way_to_neutral(away, chroma(sky)); - assert!(off < 0.005, "row {y}: the clipped sky {away:?} is not the sky's colour on its way to neutral ({off})"); - for x in [84, 86, 88, 101, 103, 105] { - let got = chroma(balanced(img.get(x, y))); - worst = worst.max((0..3).map(|c| (got[c] - away[c]).abs()).fold(0.0, f32::max)); - } - } - assert!(worst < 0.01, "clipped sky beside the branch is off the sky away from it by {worst}"); - } - - /// A warm light with red and green clipped: left at their clip levels the pair reads red-magenta (the white - /// balance multiplies red's clip level by 2.5, green's by 1). - #[test] - fn two_clipped_channels_are_not_left_at_their_clip_levels() { - let light = [3.5, 2.6, 0.8]; - let mut img = Rgb32f::from_fn(64, 64, |x, y| { - let d = ((x as f32 - 32.0).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); - if d < 20.0 { sensor(light) } else { [0.02; 3] } - }); - reconstruct(&mut img, WB, 0.99); - let q = balanced(img.get(32, 32)); - assert!((q[2] - 0.8).abs() < 0.1, "the unclipped channel is about kept: {q:?}"); - let ratio = q[1] / q[0]; - assert!((0.6..=1.05).contains(&ratio), "green / red {ratio} ({q:?}): not a warm white"); - } - - #[test] - fn fully_clipped_pixels_follow_the_colour_model_white() { - let white = [1.03, 0.98, 1.06]; - let mut img = Rgb32f::filled(8, 8, [1.0; 3]); - img.data[0] = [0.3, 0.6, 0.4]; - reconstruct_with(&mut img, WB, 0.99, white); - let q = balanced(img.get(4, 4)); - let k = q[1] / white[1]; - assert!((0..3).all(|c| (q[c] - white[c] * k).abs() < 1e-4 * k), "{q:?} is not along {white:?}"); - assert!(k >= 2.5 * 0.99 - 1e-4, "clipped neutral at least as bright as the highest clip level: {k}"); - assert_eq!(img.data[0], [0.3, 0.6, 0.4]); - // a white that cannot be right falls back to camera neutral, and nothing overflows - for bad in [[f32::NAN, 1.0, 1.0], [2.5e38, 0.74, 0.74], [1.0, 0.0, 1.0], [40.0, 1.0, 1.0]] { - let mut img = Rgb32f::filled(4, 4, [1.0; 3]); - img.data[0] = [1.0, 1.0, 0.5]; - reconstruct_with(&mut img, WB, 0.99, bad); - let q = balanced(img.get(1, 1)); - assert!((q[0] - q[1]).abs() < 1e-4 && (q[2] - q[1]).abs() < 1e-4, "{bad:?}: {q:?}"); - assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{bad:?}"); - } - } - - /// Red rises through its clip level across a sky whose green and blue clipped: the rebuilt colour changes - /// smoothly (no edge where red clips too) and arrives at the clipped neutral. - #[test] - fn rebuilt_colour_is_continuous_as_more_channels_clip() { - let blue = [1.0, 1.3, 1.9]; - let img0 = Rgb32f::from_fn(240, 40, |x, y| { - if y < 8 { - return sensor(blue.map(|v| v * 0.5)); // unclipped sky: the source of colour - } - let level = 0.6 + 2.2 * x as f32 / 239.0; // red at 0.24 to 1.12 of its clip level - sensor([level * WB[0] * 0.4, 1.6, 2.0]) - }); - let mut img = img0.clone(); - reconstruct(&mut img, WB, 0.99); - let row: Vec<[f32; 3]> = (0..240).map(|x| chroma(balanced(img.get(x, 30)))).collect(); - let step = row.windows(2).map(|w| (0..3).map(|c| (w[1][c] - w[0][c]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); - assert!(step < 0.01, "largest chromaticity step between neighbours {step}"); - let end = row[239]; - assert!(end.iter().all(|v| (v - 1.0 / 3.0).abs() < 1e-3), "fully clipped end {end:?}"); - } - - /// A smooth pink ramp (`[0.9k, k, 0.8k]`, k from 0.8 to 1.35) whose green clips first, then red, then blue, - /// in `rows` of a `h`-row picture that is otherwise near black: the rebuilt middle row, and where green first - /// clips on it. - fn rebuilt_ramp(rows: std::ops::Range, h: usize) -> (Vec<[f32; 3]>, usize) { - let (w, k0, k1) = (1600usize, 0.8f32, 1.35f32); - let k = |x: usize| k0 + (k1 - k0) * x as f32 / (w - 1) as f32; - let img0 = Rgb32f::from_fn(w, h, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); - let mut img = img0.clone(); - reconstruct(&mut img, WB, 0.99); - let y = (rows.start + rows.end) / 2; - let row: Vec<[f32; 3]> = (0..w).map(|x| balanced(img.get(x, y))).collect(); - (row, (0..w).find(|&x| img0.get(x, y)[1] >= 0.99).unwrap_or(0)) - } - - /// No channel changes by more than `at_clip` (relative) where green first clips, nor by 3 % anywhere. - fn assert_continuous(row: &[[f32; 3]], first: usize, at_clip: f32, case: &str) { - // across the first clip the colour carries on as it was (the sensor's own colour, not a fade) - let (a, b) = (row[first - 1], row[first]); - assert!((0..3).all(|c| (b[c] / a[c] - 1.0).abs() < at_clip), "{case}, first clip: {a:?} then {b:?}"); - let worst = row.windows(2).map(|p| (0..3).map(|c| (p[1][c] / p[0][c] - 1.0).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); - // (a smooth ramp moves 0.04 % per pixel here; the rebuilt colour turns faster where it leaves the colour of - // the unclipped part for neutral, but never by a step) - assert!(worst < 0.03, "{case}: largest relative step between neighbours {worst}"); - } - - /// The rebuilt colour has no step anywhere along the ramp: not where green first clips (the neutral fade starts - /// from nothing there), nor where red does, nor where the last channel clips (the fade ends at the fully - /// clipped neutral). - #[test] - fn rebuilt_ramp_is_continuous_from_the_first_clip_to_the_last() { - let (row, first) = rebuilt_ramp(0..32, 32); - assert_continuous(&row, first, 0.002, "full ramp"); - } - - /// The same ramp as a thin line on near black has no reliable colour around (every pixel of it is on a strong - /// edge), so its clipped part falls back to the neutral guess. That guess only applies as far as the line runs - /// past its own unclipped part: no step where it first clips, however thin the line and however much dark - /// background surrounds it. - #[test] - fn thin_line_without_reliable_colour_is_continuous_where_it_clips() { - for height in [32usize, 512, 1024] { - for width in [1usize, 4, 16] { - let top = (height - width) / 2; - let (row, first) = rebuilt_ramp(top..top + width, height); - assert_continuous(&row, first, 0.002, &format!("{width} rows of {height}")); - // and well past the first clip the neutral guess has taken over (green as high as red and blue imply) - let far = row[1500]; - assert!(far[1] > 0.95 * far[2], "{width} rows of {height}: {far:?}"); - } - } - } - - /// Issue #523 (a river photo darkened by Highlights -75): a pale cyan-blue sky (`[1, 1.07, 1.2]` white-balanced) - /// brightening from left to right through the point where green clips (and later blue), above bright - /// yellow-green foliage, with thin dark branches standing in its lower half. Its rebuilt colour used to lean - /// green to teal: the foliage lent its colour to the sky (green rebuilt from it), with a step where green first - /// clips, and the sky kept its own pale colour where Lightroom shows white. - #[test] - fn pale_sky_by_dark_branches_and_foliage_is_not_tinted() { - let sky = [1.0, 1.07, 1.2]; - let (w, h) = (320usize, 96usize); - let k = |x: usize| 0.7 * (1.2 * x as f32 / (w - 1) as f32).exp2(); - let branches = [150usize, 200, 250]; - let on_branch = |x: usize| branches.iter().any(|&b| (b.saturating_sub(1)..=b + 2).contains(&x)); - let img0 = Rgb32f::from_fn(w, h, |x, y| { - sensor(if y >= 72 { - [0.5, 0.6, 0.22] - } else if y >= 40 && branches.iter().any(|&b| x == b || x == b + 1) { - [0.1, 0.09, 0.04] - } else { - sky.map(|v| v * k(x)) - }) - }); - let mut img = img0.clone(); - reconstruct(&mut img, WB, 0.99); - let at = |x: usize, y: usize| chroma(balanced(img.get(x, y))); - let first = (0..w).find(|&x| img0.get(x, 20)[1] >= 0.99).unwrap_or(0); - assert!(first > 0 && first < 150); - let diff = |a: [f32; 3], b: [f32; 3]| (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max); - for y in [20, 50] { - // no step where green clips - let step = diff(at(first - 1, y), at(first, y)); - assert!(step < 0.001, "row {y}: step of {step} where green clips"); - for x in first..w { - // nothing but the sky's colour on its way to neutral (no green from the foliage)... - let off = off_the_way_to_neutral(at(x, y), chroma(sky)); - assert!(on_branch(x) || off < 0.005, "row {y}, x {x}: {:?} is off the sky's way to neutral by {off}", at(x, y)); - // ... the same beside the branches as above them ... - let beside = diff(at(x, y), at(x, 20)); - assert!(on_branch(x) || beside < 0.005, "row {y}, x {x}: {:?} beside a branch, {:?} above", at(x, y), at(x, 20)); - } - // ... and neutral well before the last channel clips (the sky is 0.7 stops past green's clip there) - let end = at(w - 1, y); - assert!(end.iter().all(|v| (v - 1.0 / 3.0).abs() < 0.002), "row {y}: {end:?} at the end"); - } - } - - #[test] - fn unclipped_pixels_are_left_exactly() { - let mut seed = 0x2545_f491_4f6c_dd1du64; - let mut rnd = move || { - seed ^= seed << 13; - seed ^= seed >> 7; - seed ^= seed << 17; - (seed >> 40) as f32 / (1u64 << 24) as f32 - }; - for (w, h) in [(1, 1), (3, 1), (7, 5), (64, 48), (129, 77)] { - let noise: Vec = (0..w * h).map(|_| rnd()).collect(); - let img = Rgb32f::from_fn(w, h, |x, y| { - let t = ((x as f32 * 0.21).sin() + (y as f32 * 0.17).cos()) * 0.5; - let base = [0.5 + 0.4 * t, 0.45 - 0.2 * t, 0.3 + 0.1 * t]; - match noise[y * w + x] { - r if r < 0.1 => [1.0; 3], - r if r < 0.4 => [1.0, base[1] * 1.6, base[2]], - r if r > 0.995 => [0.0, 0.0, 0.00001], - _ => base, - } - }); - let mut out = img.clone(); - let n = reconstruct_with(&mut out, [2.1, 1.0, 1.6], 0.99, [1.02, 0.99, 1.05]); - let clipped = img.data.iter().filter(|p| p.iter().any(|&v| v >= 0.99)).count(); - assert_eq!(n, clipped); - for (a, b) in img.data.iter().zip(&out.data) { - if a.iter().all(|&v| v < 0.99) { - assert_eq!(a, b, "{w}×{h}"); - } else { - assert!(b.iter().all(|v| v.is_finite() && *v >= 0.0), "{w}×{h}: {b:?}"); - } - } - } - } - - #[test] - fn clip_neutral_makes_clipped_white_neutral() { - let wb = [2.0, 1.0, 1.5]; - let mut img = Rgb32f::filled(2, 1, [1.0, 1.0, 1.0]); - img.data[1] = [0.2, 0.3, 0.4]; - clip_neutral(&mut img, wb, 1.0); - let p = img.data[0]; - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - assert!((q[0] - q[1]).abs() < 1e-6 && (q[1] - q[2]).abs() < 1e-6); - assert_eq!(img.data[1], [0.2, 0.3, 0.4]); - } - - #[test] - fn reconstruct_restores_clipped_channel() { - // a warm gradient whose red channel clips at its bright end - let truth = Rgb32f::from_fn(256, 16, |x, _| { - let v = 0.3 + 1.0 * x as f32 / 255.0; - [v * 1.0, v * 0.6, v * 0.35] - }); - let mut img = truth.map(|p| p.map(|v| v.min(1.0))); - let first = (0..256).find(|&x| img.get(x, 8)[0] >= 0.999).unwrap(); - // near the unclipped gradient (within the reach of its colour) red is rebuilt from it - let err = |img: &Rgb32f| -> f32 { (first..first + 12).map(|x| (img.get(x, 8)[0] - truth.get(x, 8)[0]).abs()).sum() }; - let before = err(&img); - let n = reconstruct(&mut img, [1.0, 1.0, 1.0], 0.999); - assert!(n > 0); - let after = err(&img); - assert!(after < before * 0.2, "before {before} after {after}"); - // unclipped pixels untouched - assert_eq!(img.get(0, 0), truth.get(0, 0)); - assert_eq!(img.get(first - 1, 8), truth.get(first - 1, 8)); - } - - #[test] - fn fully_clipped_and_no_clipping() { - let mut img = Rgb32f::filled(4, 4, [1.0; 3]); - assert_eq!(reconstruct(&mut img, [2.0, 1.0, 1.5], 0.99), 16); - let p = img.data[0]; - assert!((p[0] * 2.0 - p[1]).abs() < 1e-5 && (p[2] * 1.5 - p[1]).abs() < 1e-5); - let mut img = Rgb32f::filled(4, 4, [0.5; 3]); - assert_eq!(reconstruct(&mut img, [2.0, 1.0, 1.5], 0.99), 0); - let mut empty = Rgb32f::new(0, 0); - assert_eq!(reconstruct(&mut empty, [1.0; 3], 0.99), 0); - } - - /// `cargo test --release -p lightcraft-raw --lib -- --ignored bench_reconstruct --nocapture` - #[test] - #[ignore] - fn bench_reconstruct() { - let img = Rgb32f::from_fn(6000, 4000, |x, y| { - let v = 0.4 + 0.8 * ((x as f32 * 0.003).sin() * (y as f32 * 0.002).cos()).abs(); - [v.min(1.0), (v * 0.7).min(1.0), (v * 0.5).min(1.0)] - }); - let ms = (0..3) - .map(|_| { - let mut i = img.clone(); - let t = std::time::Instant::now(); - reconstruct(&mut i, [2.0, 1.0, 1.5], 0.99); - t.elapsed().as_secs_f64() * 1e3 - }) - .fold(f64::MAX, f64::min); - eprintln!("reconstruct 24 MP: {ms:.0} ms"); - } - - /// A mask decides which channels are clipped (their values are lower bounds), whatever their values; the - /// unmarked pixels are left as they were; a mask of another size leaves clipping to the values. - #[test] - fn a_mask_marks_the_clipped_channels() { - let base = Rgb32f::from_fn(32, 32, |x, _| if x >= 16 { [0.2, 0.5, 0.25] } else { [0.5, 0.5, 0.5] }); - let mask: Vec = (0..32 * 32).map(|i| if i % 32 >= 16 { CLIPPED_R } else { 0 }).collect(); - let mut img = base.clone(); - assert_eq!(reconstruct_masked(&mut img, [1.0; 3], 0.99, [1.0; 3], &mask), 512); - for (p, b) in img.data.iter().zip(&base.data) { - assert!(p.iter().all(|v| v.is_finite()), "{p:?}"); - if b[0] == 0.5 { - assert_eq!(p, b); - } else { - // red rebuilt from at least its mean, the others kept - assert!(p[0] >= 0.2 && p[1] == 0.5 && p[2] == 0.25, "{p:?}"); - } - } - // red is rebuilt above its mean, towards the neighbours' neutral (as far as the pixel's own blue agrees) - assert!(img.get(24, 16)[0] > 0.3, "{:?}", img.get(24, 16)); - // the values alone see nothing clipped here, nor does a mask of another size - let mut plain = base.clone(); - assert_eq!(reconstruct(&mut plain, [1.0; 3], 0.99), 0); - assert_eq!(reconstruct_masked(&mut plain, [1.0; 3], 0.99, [1.0; 3], &mask[1..]), 0); - assert!(plain.data == base.data); - } -} +mod tests; diff --git a/crates/raw/src/highlight/tests.rs b/crates/raw/src/highlight/tests.rs new file mode 100644 index 000000000..3386e5d73 --- /dev/null +++ b/crates/raw/src/highlight/tests.rs @@ -0,0 +1,346 @@ +use super::*; + +/// Daylight-like multipliers: green clips first, red and blue keep rising. +const WB: [f32; 3] = [2.5, 1.0, 1.6]; + +fn balanced(p: [f32; 3]) -> [f32; 3] { + [p[0] * WB[0], p[1] * WB[1], p[2] * WB[2]] +} + +fn sensor(q: [f32; 3]) -> [f32; 3] { + [q[0] / WB[0], q[1] / WB[1], q[2] / WB[2]].map(|v| v.min(1.0)) +} + +fn chroma(q: [f32; 3]) -> [f32; 3] { + let s = q[0] + q[1] + q[2]; + q.map(|v| v / s) +} + +/// A pale blue sky whose green channel clipped, an unclipped strip of the same sky along the top, and a dark +/// branch with purple fringes on both sides standing in the clipped part (white-balanced sky colour returned). +fn branch_scene() -> (Rgb32f, [f32; 3]) { + let sky = [1.10, 1.25, 1.45]; + let img = Rgb32f::from_fn(192, 128, |x, y| { + sensor(if y < 24 { + sky.map(|v| v * 0.7) + } else if (90..100).contains(&x) && y >= 40 { + [0.06, 0.05, 0.03] + } else if (x == 89 || x == 100) && y >= 40 { + [0.75, 0.25, 0.96] + } else { + sky + }) + }); + (img, sky) +} + +/// How far chromaticity `got` lies off the way from `from` to neutral (largest channel difference to the +/// nearest point of that way). +fn off_the_way_to_neutral(got: [f32; 3], from: [f32; 3]) -> f32 { + let d: [f32; 3] = std::array::from_fn(|c| 1.0 / 3.0 - from[c]); + let len = d.iter().map(|v| v * v).sum::(); + let t = if len > 0.0 { ((0..3).map(|c| (got[c] - from[c]) * d[c]).sum::() / len).clamp(0.0, 1.0) } else { 0.0 }; + (0..3).map(|c| (got[c] - (from[c] + d[c] * t)).abs()).fold(0.0, f32::max) +} + +/// Issue #523: the clipped sky beside a branch took the colour of the branch's fringes and of the branch +/// itself (magenta, green), which any darkening of the highlights then showed. Beside the branch it is now the +/// clipped sky away from it: the sky's colour, on its way to neutral. +#[test] +fn fringes_and_dark_branches_do_not_tint_a_clipped_sky() { + let (mut img, sky) = branch_scene(); + assert!(reconstruct(&mut img, WB, 0.99) > 0); + let mut worst = 0f32; + for y in 40..128 { + let away = chroma(balanced(img.get(30, y))); + assert!(away == chroma(balanced(img.get(170, y))), "row {y}"); + let off = off_the_way_to_neutral(away, chroma(sky)); + assert!(off < 0.005, "row {y}: the clipped sky {away:?} is not the sky's colour on its way to neutral ({off})"); + for x in [84, 86, 88, 101, 103, 105] { + let got = chroma(balanced(img.get(x, y))); + worst = worst.max((0..3).map(|c| (got[c] - away[c]).abs()).fold(0.0, f32::max)); + } + } + assert!(worst < 0.01, "clipped sky beside the branch is off the sky away from it by {worst}"); +} + +/// A warm light with red and green clipped: left at their clip levels the pair reads red-magenta (the white +/// balance multiplies red's clip level by 2.5, green's by 1). +#[test] +fn two_clipped_channels_are_not_left_at_their_clip_levels() { + let light = [3.5, 2.6, 0.8]; + let mut img = Rgb32f::from_fn(64, 64, |x, y| { + let d = ((x as f32 - 32.0).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); + if d < 20.0 { sensor(light) } else { [0.02; 3] } + }); + reconstruct(&mut img, WB, 0.99); + let q = balanced(img.get(32, 32)); + assert!((q[2] - 0.8).abs() < 0.1, "the unclipped channel is about kept: {q:?}"); + let ratio = q[1] / q[0]; + assert!((0.6..=1.05).contains(&ratio), "green / red {ratio} ({q:?}): not a warm white"); +} + +#[test] +fn fully_clipped_pixels_follow_the_colour_model_white() { + let white = [1.03, 0.98, 1.06]; + let mut img = Rgb32f::filled(8, 8, [1.0; 3]); + img.data[0] = [0.3, 0.6, 0.4]; + reconstruct_with(&mut img, WB, 0.99, white); + let q = balanced(img.get(4, 4)); + let k = q[1] / white[1]; + assert!((0..3).all(|c| (q[c] - white[c] * k).abs() < 1e-4 * k), "{q:?} is not along {white:?}"); + assert!(k >= 2.5 * 0.99 - 1e-4, "clipped neutral at least as bright as the highest clip level: {k}"); + assert_eq!(img.data[0], [0.3, 0.6, 0.4]); + // a white that cannot be right falls back to camera neutral, and nothing overflows + for bad in [[f32::NAN, 1.0, 1.0], [2.5e38, 0.74, 0.74], [1.0, 0.0, 1.0], [40.0, 1.0, 1.0]] { + let mut img = Rgb32f::filled(4, 4, [1.0; 3]); + img.data[0] = [1.0, 1.0, 0.5]; + reconstruct_with(&mut img, WB, 0.99, bad); + let q = balanced(img.get(1, 1)); + assert!((q[0] - q[1]).abs() < 1e-4 && (q[2] - q[1]).abs() < 1e-4, "{bad:?}: {q:?}"); + assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{bad:?}"); + } +} + +/// Red rises through its clip level across a sky whose green and blue clipped: the rebuilt colour changes +/// smoothly (no edge where red clips too) and arrives at the clipped neutral. +#[test] +fn rebuilt_colour_is_continuous_as_more_channels_clip() { + let blue = [1.0, 1.3, 1.9]; + let img0 = Rgb32f::from_fn(240, 40, |x, y| { + if y < 8 { + return sensor(blue.map(|v| v * 0.5)); // unclipped sky: the source of colour + } + let level = 0.6 + 2.2 * x as f32 / 239.0; // red at 0.24 to 1.12 of its clip level + sensor([level * WB[0] * 0.4, 1.6, 2.0]) + }); + let mut img = img0.clone(); + reconstruct(&mut img, WB, 0.99); + let row: Vec<[f32; 3]> = (0..240).map(|x| chroma(balanced(img.get(x, 30)))).collect(); + let step = row.windows(2).map(|w| (0..3).map(|c| (w[1][c] - w[0][c]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); + assert!(step < 0.01, "largest chromaticity step between neighbours {step}"); + let end = row[239]; + assert!(end.iter().all(|v| (v - 1.0 / 3.0).abs() < 1e-3), "fully clipped end {end:?}"); +} + +/// A smooth pink ramp (`[0.9k, k, 0.8k]`, k from 0.8 to 1.35) whose green clips first, then red, then blue, +/// in `rows` of a `h`-row picture that is otherwise near black: the rebuilt middle row, and where green first +/// clips on it. +fn rebuilt_ramp(rows: std::ops::Range, h: usize) -> (Vec<[f32; 3]>, usize) { + let (w, k0, k1) = (1600usize, 0.8f32, 1.35f32); + let k = |x: usize| k0 + (k1 - k0) * x as f32 / (w - 1) as f32; + let img0 = Rgb32f::from_fn(w, h, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); + let mut img = img0.clone(); + reconstruct(&mut img, WB, 0.99); + let y = (rows.start + rows.end) / 2; + let row: Vec<[f32; 3]> = (0..w).map(|x| balanced(img.get(x, y))).collect(); + (row, (0..w).find(|&x| img0.get(x, y)[1] >= 0.99).unwrap_or(0)) +} + +/// No channel changes by more than `at_clip` (relative) where green first clips, nor by 3 % anywhere. +fn assert_continuous(row: &[[f32; 3]], first: usize, at_clip: f32, case: &str) { + // across the first clip the colour carries on as it was (the sensor's own colour, not a fade) + let (a, b) = (row[first - 1], row[first]); + assert!((0..3).all(|c| (b[c] / a[c] - 1.0).abs() < at_clip), "{case}, first clip: {a:?} then {b:?}"); + let worst = row.windows(2).map(|p| (0..3).map(|c| (p[1][c] / p[0][c] - 1.0).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); + // (a smooth ramp moves 0.04 % per pixel here; the rebuilt colour turns faster where it leaves the colour of + // the unclipped part for neutral, but never by a step) + assert!(worst < 0.03, "{case}: largest relative step between neighbours {worst}"); +} + +/// The rebuilt colour has no step anywhere along the ramp: not where green first clips (the neutral fade starts +/// from nothing there), nor where red does, nor where the last channel clips (the fade ends at the fully +/// clipped neutral). +#[test] +fn rebuilt_ramp_is_continuous_from_the_first_clip_to_the_last() { + let (row, first) = rebuilt_ramp(0..32, 32); + assert_continuous(&row, first, 0.002, "full ramp"); +} + +/// The same ramp as a thin line on near black has no reliable colour around (every pixel of it is on a strong +/// edge), so its clipped part falls back to the neutral guess. That guess only applies as far as the line runs +/// past its own unclipped part: no step where it first clips, however thin the line and however much dark +/// background surrounds it. +#[test] +fn thin_line_without_reliable_colour_is_continuous_where_it_clips() { + for height in [32usize, 512, 1024] { + for width in [1usize, 4, 16] { + let top = (height - width) / 2; + let (row, first) = rebuilt_ramp(top..top + width, height); + assert_continuous(&row, first, 0.002, &format!("{width} rows of {height}")); + // and well past the first clip the neutral guess has taken over (green as high as red and blue imply) + let far = row[1500]; + assert!(far[1] > 0.95 * far[2], "{width} rows of {height}: {far:?}"); + } + } +} + +/// Issue #523 (a river photo darkened by Highlights -75): a pale cyan-blue sky (`[1, 1.07, 1.2]` white-balanced) +/// brightening from left to right through the point where green clips (and later blue), above bright +/// yellow-green foliage, with thin dark branches standing in its lower half. Its rebuilt colour used to lean +/// green to teal: the foliage lent its colour to the sky (green rebuilt from it), with a step where green first +/// clips, and the sky kept its own pale colour where Lightroom shows white. +#[test] +fn pale_sky_by_dark_branches_and_foliage_is_not_tinted() { + let sky = [1.0, 1.07, 1.2]; + let (w, h) = (320usize, 96usize); + let k = |x: usize| 0.7 * (1.2 * x as f32 / (w - 1) as f32).exp2(); + let branches = [150usize, 200, 250]; + let on_branch = |x: usize| branches.iter().any(|&b| (b.saturating_sub(1)..=b + 2).contains(&x)); + let img0 = Rgb32f::from_fn(w, h, |x, y| { + sensor(if y >= 72 { + [0.5, 0.6, 0.22] + } else if y >= 40 && branches.iter().any(|&b| x == b || x == b + 1) { + [0.1, 0.09, 0.04] + } else { + sky.map(|v| v * k(x)) + }) + }); + let mut img = img0.clone(); + reconstruct(&mut img, WB, 0.99); + let at = |x: usize, y: usize| chroma(balanced(img.get(x, y))); + let first = (0..w).find(|&x| img0.get(x, 20)[1] >= 0.99).unwrap_or(0); + assert!(first > 0 && first < 150); + let diff = |a: [f32; 3], b: [f32; 3]| (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max); + for y in [20, 50] { + // no step where green clips + let step = diff(at(first - 1, y), at(first, y)); + assert!(step < 0.001, "row {y}: step of {step} where green clips"); + for x in first..w { + // nothing but the sky's colour on its way to neutral (no green from the foliage)... + let off = off_the_way_to_neutral(at(x, y), chroma(sky)); + assert!(on_branch(x) || off < 0.005, "row {y}, x {x}: {:?} is off the sky's way to neutral by {off}", at(x, y)); + // ... the same beside the branches as above them ... + let beside = diff(at(x, y), at(x, 20)); + assert!(on_branch(x) || beside < 0.005, "row {y}, x {x}: {:?} beside a branch, {:?} above", at(x, y), at(x, 20)); + } + // ... and neutral well before the last channel clips (the sky is 0.7 stops past green's clip there) + let end = at(w - 1, y); + assert!(end.iter().all(|v| (v - 1.0 / 3.0).abs() < 0.002), "row {y}: {end:?} at the end"); + } +} + +#[test] +fn unclipped_pixels_are_left_exactly() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut rnd = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 40) as f32 / (1u64 << 24) as f32 + }; + for (w, h) in [(1, 1), (3, 1), (7, 5), (64, 48), (129, 77)] { + let noise: Vec = (0..w * h).map(|_| rnd()).collect(); + let img = Rgb32f::from_fn(w, h, |x, y| { + let t = ((x as f32 * 0.21).sin() + (y as f32 * 0.17).cos()) * 0.5; + let base = [0.5 + 0.4 * t, 0.45 - 0.2 * t, 0.3 + 0.1 * t]; + match noise[y * w + x] { + r if r < 0.1 => [1.0; 3], + r if r < 0.4 => [1.0, base[1] * 1.6, base[2]], + r if r > 0.995 => [0.0, 0.0, 0.00001], + _ => base, + } + }); + let mut out = img.clone(); + let n = reconstruct_with(&mut out, [2.1, 1.0, 1.6], 0.99, [1.02, 0.99, 1.05]); + let clipped = img.data.iter().filter(|p| p.iter().any(|&v| v >= 0.99)).count(); + assert_eq!(n, clipped); + for (a, b) in img.data.iter().zip(&out.data) { + if a.iter().all(|&v| v < 0.99) { + assert_eq!(a, b, "{w}×{h}"); + } else { + assert!(b.iter().all(|v| v.is_finite() && *v >= 0.0), "{w}×{h}: {b:?}"); + } + } + } +} + +#[test] +fn clip_neutral_makes_clipped_white_neutral() { + let wb = [2.0, 1.0, 1.5]; + let mut img = Rgb32f::filled(2, 1, [1.0, 1.0, 1.0]); + img.data[1] = [0.2, 0.3, 0.4]; + clip_neutral(&mut img, wb, 1.0); + let p = img.data[0]; + let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + assert!((q[0] - q[1]).abs() < 1e-6 && (q[1] - q[2]).abs() < 1e-6); + assert_eq!(img.data[1], [0.2, 0.3, 0.4]); +} + +#[test] +fn reconstruct_restores_clipped_channel() { + // a warm gradient whose red channel clips at its bright end + let truth = Rgb32f::from_fn(256, 16, |x, _| { + let v = 0.3 + 1.0 * x as f32 / 255.0; + [v * 1.0, v * 0.6, v * 0.35] + }); + let mut img = truth.map(|p| p.map(|v| v.min(1.0))); + let first = (0..256).find(|&x| img.get(x, 8)[0] >= 0.999).unwrap(); + // near the unclipped gradient (within the reach of its colour) red is rebuilt from it + let err = |img: &Rgb32f| -> f32 { (first..first + 12).map(|x| (img.get(x, 8)[0] - truth.get(x, 8)[0]).abs()).sum() }; + let before = err(&img); + let n = reconstruct(&mut img, [1.0, 1.0, 1.0], 0.999); + assert!(n > 0); + let after = err(&img); + assert!(after < before * 0.2, "before {before} after {after}"); + // unclipped pixels untouched + assert_eq!(img.get(0, 0), truth.get(0, 0)); + assert_eq!(img.get(first - 1, 8), truth.get(first - 1, 8)); +} + +#[test] +fn fully_clipped_and_no_clipping() { + let mut img = Rgb32f::filled(4, 4, [1.0; 3]); + assert_eq!(reconstruct(&mut img, [2.0, 1.0, 1.5], 0.99), 16); + let p = img.data[0]; + assert!((p[0] * 2.0 - p[1]).abs() < 1e-5 && (p[2] * 1.5 - p[1]).abs() < 1e-5); + let mut img = Rgb32f::filled(4, 4, [0.5; 3]); + assert_eq!(reconstruct(&mut img, [2.0, 1.0, 1.5], 0.99), 0); + let mut empty = Rgb32f::new(0, 0); + assert_eq!(reconstruct(&mut empty, [1.0; 3], 0.99), 0); +} + +/// `cargo test --release -p lightcraft-raw --lib -- --ignored bench_reconstruct --nocapture` +#[test] +#[ignore] +fn bench_reconstruct() { + let img = Rgb32f::from_fn(6000, 4000, |x, y| { + let v = 0.4 + 0.8 * ((x as f32 * 0.003).sin() * (y as f32 * 0.002).cos()).abs(); + [v.min(1.0), (v * 0.7).min(1.0), (v * 0.5).min(1.0)] + }); + let ms = (0..3) + .map(|_| { + let mut i = img.clone(); + let t = std::time::Instant::now(); + reconstruct(&mut i, [2.0, 1.0, 1.5], 0.99); + t.elapsed().as_secs_f64() * 1e3 + }) + .fold(f64::MAX, f64::min); + eprintln!("reconstruct 24 MP: {ms:.0} ms"); +} + +/// A mask decides which channels are clipped (their values are lower bounds), whatever their values; the +/// unmarked pixels are left as they were; a mask of another size leaves clipping to the values. +#[test] +fn a_mask_marks_the_clipped_channels() { + let base = Rgb32f::from_fn(32, 32, |x, _| if x >= 16 { [0.2, 0.5, 0.25] } else { [0.5, 0.5, 0.5] }); + let mask: Vec = (0..32 * 32).map(|i| if i % 32 >= 16 { CLIPPED_R } else { 0 }).collect(); + let mut img = base.clone(); + assert_eq!(reconstruct_masked(&mut img, [1.0; 3], 0.99, [1.0; 3], &mask), 512); + for (p, b) in img.data.iter().zip(&base.data) { + assert!(p.iter().all(|v| v.is_finite()), "{p:?}"); + if b[0] == 0.5 { + assert_eq!(p, b); + } else { + // red rebuilt from at least its mean, the others kept + assert!(p[0] >= 0.2 && p[1] == 0.5 && p[2] == 0.25, "{p:?}"); + } + } + // red is rebuilt above its mean, towards the neighbours' neutral (as far as the pixel's own blue agrees) + assert!(img.get(24, 16)[0] > 0.3, "{:?}", img.get(24, 16)); + // the values alone see nothing clipped here, nor does a mask of another size + let mut plain = base.clone(); + assert_eq!(reconstruct(&mut plain, [1.0; 3], 0.99), 0); + assert_eq!(reconstruct_masked(&mut plain, [1.0; 3], 0.99, [1.0; 3], &mask[1..]), 0); + assert!(plain.data == base.data); +} From 4e57c3b99ae0b7febd33ff1d3d2d97fd0f04a449 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:10 +0000 Subject: [PATCH 071/191] refactor(lightcraft): move main.rs tests to apps/lightcraft/src/tests.rs --- apps/lightcraft/src/main.rs | 468 +---------------------------------- apps/lightcraft/src/tests.rs | 465 ++++++++++++++++++++++++++++++++++ 2 files changed, 466 insertions(+), 467 deletions(-) create mode 100644 apps/lightcraft/src/tests.rs diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 868112a97..2d11a3375 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -1166,473 +1166,7 @@ fn startup_failed(error: &str, log_file: Option<&std::path::Path>) { #[cfg(test)] #[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - fn dir(tag: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// Issue #374: launch arguments that would walk a whole drive or home folder are reported, - /// not imported; empty ones are ignored; the rest are imported as absolute paths. - #[test] - fn launch_arguments_never_walk_a_drive_or_the_home_folder() { - let home = dir("launch"); - std::fs::create_dir_all(home.join("Trip")).unwrap(); - let args: Vec = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect(); - let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home)); - assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]); - assert_eq!(notices.len(), 2, "{notices:?}"); - assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}"); - // the working folder is a drive root (a launcher's choice): `.` is never the whole drive - let root = home.ancestors().last().unwrap().to_path_buf(); - let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None); - assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}"); - let _ = std::fs::remove_dir_all(&home); - } - - /// Issue #236: the window answers control requests on its UI thread, so the desktop session - /// adds to Apple Photos on a worker (headless sessions wait instead). - #[test] - fn the_desktop_session_adds_to_apple_photos_in_the_background() { - let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false }, false); - assert!(problem.is_none()); - assert!(s.apple_photos_background); - assert!(!lightcraft_engine::Session::new().apple_photos_background); - } - - /// Issue #1290: with Settings ▸ At launch ▸ Ask me nothing is opened (nor seeded with demo - /// photos) before the user chooses, and the settings are still saved while the list shows (a - /// removed entry is kept), unlike the failed-library window's temporary session. - #[test] - fn asking_at_launch_opens_nothing_and_still_saves_the_settings() { - let (s, problem) = open_session(false, &Launch::Ask, true); - assert!(problem.is_none() && s.library.is_none() && s.catalog.is_empty()); - assert!(s.apple_photos_background, "the same desktop session as an opened one"); - - let d = dir("writer-ask"); - let path = d.join("ui.json"); - let mut app = LightcraftApp::new(s, Services::default()); - app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()]; - app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); - let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; - app.ui.settings.recent_libraries.remove(0); - w.save(&app).unwrap(); - let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); - // the failed-library window still writes nothing - app.library_problem = Some(LibraryProblem::new("/gone/b", "not there")); - app.ui.settings.recent_libraries.clear(); - w.save(&app).unwrap(); - let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #103: a damaged ui.json (which holds the library location) is kept aside and - /// reported, not silently replaced by the defaults. - #[test] - fn damaged_ui_json_is_kept_and_reported() { - let d = dir("damaged"); - let path = d.join("ui.json"); - std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap(); - let (ui, warning, keep) = load_prefs_at(&path, true); - assert!(ui.is_none() && !keep); - let warning = warning.unwrap(); - assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}"); - assert!(!path.exists()); - let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); - assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}"); - // a missing file is just "no settings yet" - assert_eq!(load_prefs_at(&path, true).1, None); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is - /// reported and left where it is, not set aside. - #[test] - fn memory_session_leaves_a_damaged_ui_json_alone() { - let d = dir("damaged-memory"); - let path = d.join("ui.json"); - let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#; - std::fs::write(&path, bytes).unwrap(); - let (ui, warning, keep) = load_prefs_at(&path, false); - assert!(ui.is_none() && keep); - let warning = warning.unwrap(); - assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}"); - assert_eq!(std::fs::read(&path).unwrap(), bytes); - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn docking_survives_native_settings_file_reload() { - use lightcraft_ui_egui::docking::Panel; - let d = dir("docking-reload"); - let path = d.join("ui.json"); - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); - app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap(); - app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap(); - let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; - writer.save_ui(&app.ui, false).unwrap(); - let (restored, warning, keep) = load_prefs_at(&path, false); - assert!(warning.is_none()); - assert!(!keep); - let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); - restored_app.ui = restored.unwrap(); - restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap(); - assert_eq!( - restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect, - [70.0, 80.0, 310.0, 500.0] - ); - std::fs::remove_dir_all(d).unwrap(); - } - - #[test] - fn future_docking_preserves_library_settings_and_the_original_preferences_file() { - let d = dir("future-docking"); - let path = d.join("ui.json"); - let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() }; - ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into(); - for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] { - let mut saved = serde_json::to_value(&ui).unwrap(); - saved["docking"] = docking; - assert!(serde_json::from_value::(saved.clone()).is_err(), "runtime state parsing stays strict"); - let bytes = serde_json::to_vec(&saved).unwrap(); - for set_aside in [false, true] { - std::fs::write(&path, &bytes).unwrap(); - let (loaded, warning, keep) = load_prefs_at(&path, set_aside); - let loaded = loaded.unwrap(); - assert_eq!(loaded.settings.library_path, ui.settings.library_path); - assert_eq!(loaded.thumb_size, ui.thumb_size); - assert!(!loaded.filmstrip); - assert!(loaded.docking.is_none()); - assert!(warning.unwrap().contains("Other settings were loaded")); - assert!(keep); - let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() }; - writer.save_ui(&loaded, false).unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), bytes); - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file"); - } - } - std::fs::remove_dir_all(d).unwrap(); - } - - /// Issue #164: the writer saves changed settings to its file — and nothing at all without one - /// (`--memory`) or while the session is the temporary one offered when the library can't open. - #[test] - fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() { - let d = dir("writer"); - let path = d.join("sub").join("ui.json"); - let mut ui = UiState::default(); - // an ordinary session: a change is written, the folder created on the way - let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; - ui.thumb_size += 50.0; - w.save_ui(&ui, false).unwrap(); - let saved = std::fs::read(&path).unwrap(); - assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved)); - // the temporary session (library problem): a further change is not written… - ui.thumb_size += 50.0; - w.save_ui(&ui, true).unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), saved); - // …until a library opens - w.save_ui(&ui, false).unwrap(); - assert_ne!(std::fs::read(&path).unwrap(), saved); - // --memory: no path, so no file and no folder, whatever changes - let d2 = dir("writer-memory"); - let _ = std::fs::remove_dir_all(&d2); - let mut w = PrefsWriter { path: None, ..Default::default() }; - ui.thumb_size += 50.0; - w.save_ui(&ui, false).unwrap(); - assert!(w.written.is_empty() && !d2.exists()); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create - /// the settings folder) nor removes a marker a crashed launch left; it still reports it, and - /// the next ordinary launch finds it, reports it and clears it. - #[test] - fn memory_session_leaves_the_gpu_marker_and_folder_alone() { - let d = dir("gpu-marker-memory"); - let _ = std::fs::remove_dir_all(&d); - let m = d.join("gpu-init.marker"); - // nothing left behind: no notice, and the folder is not created - assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None); - assert!(!d.exists(), "a --memory session must not create the settings folder"); - // a marker left by a crashed launch: reported, kept - std::fs::create_dir_all(&d).unwrap(); - std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap(); - let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap(); - assert!(notice.contains("GPU rendering is now off"), "{notice}"); - assert!(m.exists(), "the marker stays for the next ordinary launch"); - // the next ordinary launch reports it once and removes it - assert!(gpu_crash_check_at(Some(m.clone()), false).is_some()); - lightcraft_engine::gpu::backend::set_init_marker(None); - assert!(!m.exists()); - assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None); - lightcraft_engine::gpu::backend::set_init_marker(None); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #1337: the window stopped drawing after the graphics driver reset the GPU during an - /// export, and the dialog said the window "could not open". The stop is recorded; the next - /// launch says why once and renders on the CPU (a `--memory` session leaves the record). - #[test] - fn a_gpu_lost_while_running_turns_the_gpu_off_next_time() { - let d = dir("gpu-lost"); - let m = d.join("gpu-lost.marker"); - assert_eq!(gpu_lost_check_at(Some(&m), false), None, "nothing recorded"); - assert_eq!(gpu_lost_check_at(None, false), None, "no settings folder"); - record_gpu_lost(Some(&m), "device lost (Unknown): Device is lost"); - let notice = gpu_lost_check_at(Some(&m), true).unwrap(); - assert!( - notice.contains("(device lost (Unknown): Device is lost)") - && notice.contains("GPU rendering is now off") - && notice.contains("Use the GPU for rendering"), - "{notice}" - ); - assert!(m.exists(), "a --memory session leaves it"); - assert!(gpu_lost_check_at(Some(&m), false).is_some()); - assert!(!m.exists()); - assert_eq!(gpu_lost_check_at(Some(&m), false), None, "said once"); - // the message box: LightCraft stopped (not "could not open its window"), and why - let text = stopped_text("its window stopped drawing (the renderer panicked)", Some("device lost (Unknown): Device is lost")); - assert!(text.contains("reset the GPU (device lost (Unknown)") && text.contains("renders on the CPU from its next start"), "{text}"); - assert_eq!(stopped_text("its window stopped drawing", None), "its window stopped drawing"); - let (title, body) = lightcraft_ui_egui::i18n::stopped_message(&text, Some("/logs/lightcraft.log")); - assert_eq!(title, "LightCraft stopped"); - assert!(body.starts_with("LightCraft stopped unexpectedly: its window stopped drawing") && body.ends_with("/logs/lightcraft.log"), "{body}"); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #136: a GPU init marker left by a crashed launch is reported once and removed. - #[test] - fn gpu_crash_marker_is_reported_once() { - let d = dir("gpu-marker"); - let m = d.join("gpu-init.marker"); - assert_eq!(lightcraft_engine::gpu::backend::take_init_marker(&m), None); - std::fs::write(&m, "GPU device creation started (backends VULKAN | DX12)").unwrap(); - let what = lightcraft_engine::gpu::backend::take_init_marker(&m).unwrap(); - let notice = gpu_crash_notice(&what); - assert!(notice.contains("GPU rendering is now off") && notice.contains("Use the GPU for rendering"), "{notice}"); - assert!(!m.exists()); - let _ = std::fs::remove_dir_all(&d); - } - - /// Issue #136: Windows windows render with DX12 alone (no Vulkan driver loaded) by default. - #[test] - fn window_backends_follow_the_platform_default() { - let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; - let b = n.instance_descriptor.backends; - if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() { - if cfg!(windows) { - assert_eq!(b, eframe::wgpu::Backends::DX12); - } else if cfg!(target_os = "macos") { - assert_eq!(b, eframe::wgpu::Backends::METAL); - } - } - assert!(!b.is_empty()); - } - - /// Issue #471: the window's DX12 shaders compile with FXC — wgpu's default loaded any - /// `dxcompiler.dll` on the search path, and one without `dxil.dll` kept the window from opening. - #[test] - fn window_compiles_dx12_shaders_with_fxc() { - let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; - if std::env::var_os("WGPU_DX12_COMPILER").is_none() { - assert!(matches!(n.instance_descriptor.backend_options.dx12.shader_compiler, eframe::wgpu::Dx12Compiler::Fxc)); - } - } - - /// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline, - /// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened). - #[test] - fn window_has_no_indirect_call_validation_pipeline() { - let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; - if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { - assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); - } - } - - /// Issue #234: Windows opens links with the URL protocol handler (the default browser), not - /// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument. - #[test] - fn windows_links_open_in_the_browser() { - let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200"; - let c = windows_open_url_command(url); - assert_eq!(c.get_program(), "rundll32"); - let args: Vec<&std::ffi::OsStr> = c.get_args().collect(); - assert_eq!(args, ["url.dll,FileProtocolHandler", url]); - } - - /// Issue #272: Explorer gets the file to select quoted after `/select,`, with backslashes and - /// without the verbatim prefix, even when the path has spaces (else it opens Documents). - #[test] - fn explorer_select_quotes_the_path() { - assert_eq!(explorer_select_arg(r"C:\Users\Me\My Photos\IMG 1.jpg"), r#"/select,"C:\Users\Me\My Photos\IMG 1.jpg""#); - assert_eq!(explorer_select_arg("D:/Example/Photos/IMG_0001.CR3"), r#"/select,"D:\Example\Photos\IMG_0001.CR3""#); - assert_eq!(explorer_select_arg(r"\\server\share\My Photos\a.jpg"), r#"/select,"\\server\share\My Photos\a.jpg""#); - assert_eq!(explorer_select_arg(r"\\?\D:\My Photos\a.jpg"), r#"/select,"D:\My Photos\a.jpg""#); - assert_eq!(explorer_select_arg(r"\\?\UNC\server\share\a.jpg"), r#"/select,"\\server\share\a.jpg""#); - // a `"` can't end the quoted path and add arguments to the raw command line - assert_eq!(explorer_select_arg(r#"C:\a" "C:\b.jpg"#), r#"/select,"C:\a C:\b.jpg""#); - let c = explorer_select_command(r"C:\My Photos\a.jpg"); - assert_eq!(c.get_program(), "explorer"); - let args: Vec<&std::ffi::OsStr> = c.get_args().collect(); - assert_eq!(args, [r#"/select,"C:\My Photos\a.jpg""#]); - } - - #[test] - fn a_control_port_that_is_not_a_number_is_ignored() { - assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980)); - assert_eq!(control_port_from("--control", Some(" 18001 ".into())), Some(18001)); - assert_eq!(control_port_from("--control", Some("nope".into())), None); - assert_eq!(control_port_from("--control", Some(String::new())), None); - assert_eq!(control_port_from("--control", Some("70000".into())), None); - assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None); - } - - #[test] - fn ui_json_is_written_atomically() { - let d = dir("atomic"); - let path = d.join("ui.json"); - std::fs::write(&path, b"old").unwrap(); - write_atomic(&path, b"{\"new\": true}").unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}"); - assert!(!d.join("ui.json.tmp").exists()); - // a failed write leaves the old file - let missing = d.join("gone").join("ui.json"); - assert!(write_atomic(&missing, b"x").is_err()); - assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}"); - let ui = UiState::default(); - write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap(); - let (loaded, warning, _) = load_prefs_at(&path, true); - assert!(loaded.is_some() && warning.is_none()); - let _ = std::fs::remove_dir_all(&d); - } - - /// Given what the settings remember of the window, when the app's window is built, then it - /// opens at that size, zoomed if it was, where the system puts it; with nothing remembered it - /// opens at the usual size. - #[test] - fn the_window_opens_as_it_was() { - use lightcraft_ui_egui::window_place::{DEFAULT_SIZE, WindowPlace}; - let fresh = as_remembered(egui::ViewportBuilder::default(), None); - assert_eq!((fresh.inner_size, fresh.position, fresh.maximized), (Some(egui::Vec2::from(DEFAULT_SIZE)), None, None)); - let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: true })); - assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, Some(true))); - // (not asked to un-zoom: macOS would then resize a window that fills its screen) - let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); - assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, None)); - } - - /// Given a window that rests at a size, when the settings are written and read again, then - /// they hold how it was. A window in the file that is not one (a hand edit, another version's) - /// reads as nothing remembered and costs no other setting; and a window resized just before - /// the app quits is remembered at that size. - #[test] - fn the_settings_keep_how_the_window_was() { - use lightcraft_ui_egui::window_place::{Seen, WindowPlace}; - let d = dir("window"); - let path = d.join("ui.json"); - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); - app.ui.settings.library_path = "/pictures/library".into(); - let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; - writer.save(&app).unwrap(); - assert!(!std::fs::read_to_string(&path).unwrap().contains("\"window\""), "nothing to remember yet"); - let window = WindowPlace { size: [1300.0, 800.0], zoomed: true }; - app.ui.window = Some(window); - writer.save(&app).unwrap(); - let (restored, warning, _) = load_prefs_at(&path, false); - assert_eq!((restored.unwrap().window, warning), (Some(window), None)); - - // a window that is no window: the file is read all the same, with its other settings - let text = std::fs::read_to_string(&path).unwrap(); - for odd in [r#""window": {}"#, r#""window": "wide""#, r#""window": {"size": [1, 2, 3]}"#, r#""window": {"size": [null, 800.0]}"#] { - let start = text.find("\"window\"").unwrap(); - let end = start + text[start..].find('}').unwrap() + 1; - std::fs::write(&path, format!("{}{odd}{}", &text[..start], &text[end..])).unwrap(); - let (restored, warning, keep) = load_prefs_at(&path, true); - let restored = restored.unwrap_or_else(|| panic!("{odd}: {warning:?}")); - assert_eq!((restored.window, restored.settings.library_path.as_str(), warning, keep), (None, "/pictures/library", None, false), "{odd}"); - } - - // resized, and the app quits a moment later: the new size is what is written - let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; - let rest = Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }); - writer.window.note(rest, &Seen { size: Some([1500.0, 900.0]), ..Default::default() }, 0.0); - writer.window_since = std::time::Instant::now().checked_sub(std::time::Duration::from_millis(400)); - assert!(writer.window_since.is_some()); - assert_eq!(writer.window_at_exit(rest), Some(WindowPlace { size: [1500.0, 900.0], zoomed: false })); - assert_eq!(PrefsWriter::default().window_at_exit(rest), rest, "a window that was never seen"); - let _ = std::fs::remove_dir_all(&d); - } - - /// Given the app's window at a size, when the settings writer looks at it frame after frame, - /// then it asks to be woken while the size has still to rest, remembers the size once it has, - /// and asks for nothing more. A window in the settings that is not one (a script set it) is - /// put right before it is gone by. - #[test] - fn the_writer_follows_the_window_from_frame_to_frame() { - use lightcraft_ui_egui::window_place::WindowPlace; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); - let mut writer = PrefsWriter::default(); - let ctx = egui::Context::default(); - let pass = |look: &mut dyn FnMut(&egui::Context)| { - let raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| look(ui.ctx())); - out.textures_delta.clear(); - out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay) - }; - // (egui's own first frames ask for another: let them pass) - for _ in 0..3 { - pass(&mut |_| {}); - } - app.ui.window = Some(WindowPlace { size: [f32::NAN, 800.0], zoomed: true }); - let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); - assert_eq!(app.ui.window, None, "no window, and not yet at rest"); - assert!(asked.is_some_and(|wait| wait <= std::time::Duration::from_secs(1)), "{asked:?}"); - // a second later - writer.window_since = writer.window_since.and_then(|since| since.checked_sub(std::time::Duration::from_secs(1))); - pass(&mut |ctx| writer.tick(&mut app, ctx)); - assert_eq!(app.ui.window, Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); - let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); - assert!(asked.is_none_or(|wait| wait > std::time::Duration::from_secs(3600)), "at rest and remembered: {asked:?}"); - } - - /// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down. - /// The check runs in a child (this test binary again), since on macOS it ends the process. - #[test] - fn on_exit_ends_the_process_on_macos() { - if std::env::var_os("LIGHTCRAFT_TEST_ON_EXIT").is_some() { - let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); - let mut app = App( - app, - PrefsWriter::default(), - #[cfg(target_os = "macos")] - None, - ); - println!("calling on_exit"); - eframe::App::on_exit(&mut app, None); - println!("on_exit returned"); - return; - } - let out = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"]) - .env("LIGHTCRAFT_TEST_ON_EXIT", "1") - .env("LIGHTCRAFT_NO_PREFS", "1") - .output() - .unwrap(); - let stdout = String::from_utf8_lossy(&out.stdout); - assert!(out.status.success(), "{stdout}"); - assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}"); - assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}"); - } -} +mod tests; #[cfg(test)] mod config_path_tests { diff --git a/apps/lightcraft/src/tests.rs b/apps/lightcraft/src/tests.rs new file mode 100644 index 000000000..adbcadc20 --- /dev/null +++ b/apps/lightcraft/src/tests.rs @@ -0,0 +1,465 @@ +use super::*; + +fn dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// Issue #374: launch arguments that would walk a whole drive or home folder are reported, +/// not imported; empty ones are ignored; the rest are imported as absolute paths. +#[test] +fn launch_arguments_never_walk_a_drive_or_the_home_folder() { + let home = dir("launch"); + std::fs::create_dir_all(home.join("Trip")).unwrap(); + let args: Vec = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect(); + let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home)); + assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]); + assert_eq!(notices.len(), 2, "{notices:?}"); + assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}"); + // the working folder is a drive root (a launcher's choice): `.` is never the whole drive + let root = home.ancestors().last().unwrap().to_path_buf(); + let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None); + assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}"); + let _ = std::fs::remove_dir_all(&home); +} + +/// Issue #236: the window answers control requests on its UI thread, so the desktop session +/// adds to Apple Photos on a worker (headless sessions wait instead). +#[test] +fn the_desktop_session_adds_to_apple_photos_in_the_background() { + let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false }, false); + assert!(problem.is_none()); + assert!(s.apple_photos_background); + assert!(!lightcraft_engine::Session::new().apple_photos_background); +} + +/// Issue #1290: with Settings ▸ At launch ▸ Ask me nothing is opened (nor seeded with demo +/// photos) before the user chooses, and the settings are still saved while the list shows (a +/// removed entry is kept), unlike the failed-library window's temporary session. +#[test] +fn asking_at_launch_opens_nothing_and_still_saves_the_settings() { + let (s, problem) = open_session(false, &Launch::Ask, true); + assert!(problem.is_none() && s.library.is_none() && s.catalog.is_empty()); + assert!(s.apple_photos_background, "the same desktop session as an opened one"); + + let d = dir("writer-ask"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(s, Services::default()); + app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + app.ui.settings.recent_libraries.remove(0); + w.save(&app).unwrap(); + let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); + // the failed-library window still writes nothing + app.library_problem = Some(LibraryProblem::new("/gone/b", "not there")); + app.ui.settings.recent_libraries.clear(); + w.save(&app).unwrap(); + let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and +/// reported, not silently replaced by the defaults. +#[test] +fn damaged_ui_json_is_kept_and_reported() { + let d = dir("damaged"); + let path = d.join("ui.json"); + std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap(); + let (ui, warning, keep) = load_prefs_at(&path, true); + assert!(ui.is_none() && !keep); + let warning = warning.unwrap(); + assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}"); + assert!(!path.exists()); + let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}"); + // a missing file is just "no settings yet" + assert_eq!(load_prefs_at(&path, true).1, None); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is +/// reported and left where it is, not set aside. +#[test] +fn memory_session_leaves_a_damaged_ui_json_alone() { + let d = dir("damaged-memory"); + let path = d.join("ui.json"); + let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#; + std::fs::write(&path, bytes).unwrap(); + let (ui, warning, keep) = load_prefs_at(&path, false); + assert!(ui.is_none() && keep); + let warning = warning.unwrap(); + assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn docking_survives_native_settings_file_reload() { + use lightcraft_ui_egui::docking::Panel; + let d = dir("docking-reload"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap(); + app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap(); + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + writer.save_ui(&app.ui, false).unwrap(); + let (restored, warning, keep) = load_prefs_at(&path, false); + assert!(warning.is_none()); + assert!(!keep); + let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + restored_app.ui = restored.unwrap(); + restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap(); + assert_eq!( + restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect, + [70.0, 80.0, 310.0, 500.0] + ); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn future_docking_preserves_library_settings_and_the_original_preferences_file() { + let d = dir("future-docking"); + let path = d.join("ui.json"); + let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() }; + ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into(); + for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] { + let mut saved = serde_json::to_value(&ui).unwrap(); + saved["docking"] = docking; + assert!(serde_json::from_value::(saved.clone()).is_err(), "runtime state parsing stays strict"); + let bytes = serde_json::to_vec(&saved).unwrap(); + for set_aside in [false, true] { + std::fs::write(&path, &bytes).unwrap(); + let (loaded, warning, keep) = load_prefs_at(&path, set_aside); + let loaded = loaded.unwrap(); + assert_eq!(loaded.settings.library_path, ui.settings.library_path); + assert_eq!(loaded.thumb_size, ui.thumb_size); + assert!(!loaded.filmstrip); + assert!(loaded.docking.is_none()); + assert!(warning.unwrap().contains("Other settings were loaded")); + assert!(keep); + let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() }; + writer.save_ui(&loaded, false).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file"); + } + } + std::fs::remove_dir_all(d).unwrap(); +} + +/// Issue #164: the writer saves changed settings to its file — and nothing at all without one +/// (`--memory`) or while the session is the temporary one offered when the library can't open. +#[test] +fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() { + let d = dir("writer"); + let path = d.join("sub").join("ui.json"); + let mut ui = UiState::default(); + // an ordinary session: a change is written, the folder created on the way + let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + ui.thumb_size += 50.0; + w.save_ui(&ui, false).unwrap(); + let saved = std::fs::read(&path).unwrap(); + assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved)); + // the temporary session (library problem): a further change is not written… + ui.thumb_size += 50.0; + w.save_ui(&ui, true).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), saved); + // …until a library opens + w.save_ui(&ui, false).unwrap(); + assert_ne!(std::fs::read(&path).unwrap(), saved); + // --memory: no path, so no file and no folder, whatever changes + let d2 = dir("writer-memory"); + let _ = std::fs::remove_dir_all(&d2); + let mut w = PrefsWriter { path: None, ..Default::default() }; + ui.thumb_size += 50.0; + w.save_ui(&ui, false).unwrap(); + assert!(w.written.is_empty() && !d2.exists()); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create +/// the settings folder) nor removes a marker a crashed launch left; it still reports it, and +/// the next ordinary launch finds it, reports it and clears it. +#[test] +fn memory_session_leaves_the_gpu_marker_and_folder_alone() { + let d = dir("gpu-marker-memory"); + let _ = std::fs::remove_dir_all(&d); + let m = d.join("gpu-init.marker"); + // nothing left behind: no notice, and the folder is not created + assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None); + assert!(!d.exists(), "a --memory session must not create the settings folder"); + // a marker left by a crashed launch: reported, kept + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap(); + let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap(); + assert!(notice.contains("GPU rendering is now off"), "{notice}"); + assert!(m.exists(), "the marker stays for the next ordinary launch"); + // the next ordinary launch reports it once and removes it + assert!(gpu_crash_check_at(Some(m.clone()), false).is_some()); + lightcraft_engine::gpu::backend::set_init_marker(None); + assert!(!m.exists()); + assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None); + lightcraft_engine::gpu::backend::set_init_marker(None); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #1337: the window stopped drawing after the graphics driver reset the GPU during an +/// export, and the dialog said the window "could not open". The stop is recorded; the next +/// launch says why once and renders on the CPU (a `--memory` session leaves the record). +#[test] +fn a_gpu_lost_while_running_turns_the_gpu_off_next_time() { + let d = dir("gpu-lost"); + let m = d.join("gpu-lost.marker"); + assert_eq!(gpu_lost_check_at(Some(&m), false), None, "nothing recorded"); + assert_eq!(gpu_lost_check_at(None, false), None, "no settings folder"); + record_gpu_lost(Some(&m), "device lost (Unknown): Device is lost"); + let notice = gpu_lost_check_at(Some(&m), true).unwrap(); + assert!( + notice.contains("(device lost (Unknown): Device is lost)") + && notice.contains("GPU rendering is now off") + && notice.contains("Use the GPU for rendering"), + "{notice}" + ); + assert!(m.exists(), "a --memory session leaves it"); + assert!(gpu_lost_check_at(Some(&m), false).is_some()); + assert!(!m.exists()); + assert_eq!(gpu_lost_check_at(Some(&m), false), None, "said once"); + // the message box: LightCraft stopped (not "could not open its window"), and why + let text = stopped_text("its window stopped drawing (the renderer panicked)", Some("device lost (Unknown): Device is lost")); + assert!(text.contains("reset the GPU (device lost (Unknown)") && text.contains("renders on the CPU from its next start"), "{text}"); + assert_eq!(stopped_text("its window stopped drawing", None), "its window stopped drawing"); + let (title, body) = lightcraft_ui_egui::i18n::stopped_message(&text, Some("/logs/lightcraft.log")); + assert_eq!(title, "LightCraft stopped"); + assert!(body.starts_with("LightCraft stopped unexpectedly: its window stopped drawing") && body.ends_with("/logs/lightcraft.log"), "{body}"); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #136: a GPU init marker left by a crashed launch is reported once and removed. +#[test] +fn gpu_crash_marker_is_reported_once() { + let d = dir("gpu-marker"); + let m = d.join("gpu-init.marker"); + assert_eq!(lightcraft_engine::gpu::backend::take_init_marker(&m), None); + std::fs::write(&m, "GPU device creation started (backends VULKAN | DX12)").unwrap(); + let what = lightcraft_engine::gpu::backend::take_init_marker(&m).unwrap(); + let notice = gpu_crash_notice(&what); + assert!(notice.contains("GPU rendering is now off") && notice.contains("Use the GPU for rendering"), "{notice}"); + assert!(!m.exists()); + let _ = std::fs::remove_dir_all(&d); +} + +/// Issue #136: Windows windows render with DX12 alone (no Vulkan driver loaded) by default. +#[test] +fn window_backends_follow_the_platform_default() { + let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; + let b = n.instance_descriptor.backends; + if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() { + if cfg!(windows) { + assert_eq!(b, eframe::wgpu::Backends::DX12); + } else if cfg!(target_os = "macos") { + assert_eq!(b, eframe::wgpu::Backends::METAL); + } + } + assert!(!b.is_empty()); +} + +/// Issue #471: the window's DX12 shaders compile with FXC — wgpu's default loaded any +/// `dxcompiler.dll` on the search path, and one without `dxil.dll` kept the window from opening. +#[test] +fn window_compiles_dx12_shaders_with_fxc() { + let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; + if std::env::var_os("WGPU_DX12_COMPILER").is_none() { + assert!(matches!(n.instance_descriptor.backend_options.dx12.shader_compiler, eframe::wgpu::Dx12Compiler::Fxc)); + } +} + +/// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline, +/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened). +#[test] +fn window_has_no_indirect_call_validation_pipeline() { + let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); + } +} + +/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not +/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument. +#[test] +fn windows_links_open_in_the_browser() { + let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200"; + let c = windows_open_url_command(url); + assert_eq!(c.get_program(), "rundll32"); + let args: Vec<&std::ffi::OsStr> = c.get_args().collect(); + assert_eq!(args, ["url.dll,FileProtocolHandler", url]); +} + +/// Issue #272: Explorer gets the file to select quoted after `/select,`, with backslashes and +/// without the verbatim prefix, even when the path has spaces (else it opens Documents). +#[test] +fn explorer_select_quotes_the_path() { + assert_eq!(explorer_select_arg(r"C:\Users\Me\My Photos\IMG 1.jpg"), r#"/select,"C:\Users\Me\My Photos\IMG 1.jpg""#); + assert_eq!(explorer_select_arg("D:/Example/Photos/IMG_0001.CR3"), r#"/select,"D:\Example\Photos\IMG_0001.CR3""#); + assert_eq!(explorer_select_arg(r"\\server\share\My Photos\a.jpg"), r#"/select,"\\server\share\My Photos\a.jpg""#); + assert_eq!(explorer_select_arg(r"\\?\D:\My Photos\a.jpg"), r#"/select,"D:\My Photos\a.jpg""#); + assert_eq!(explorer_select_arg(r"\\?\UNC\server\share\a.jpg"), r#"/select,"\\server\share\a.jpg""#); + // a `"` can't end the quoted path and add arguments to the raw command line + assert_eq!(explorer_select_arg(r#"C:\a" "C:\b.jpg"#), r#"/select,"C:\a C:\b.jpg""#); + let c = explorer_select_command(r"C:\My Photos\a.jpg"); + assert_eq!(c.get_program(), "explorer"); + let args: Vec<&std::ffi::OsStr> = c.get_args().collect(); + assert_eq!(args, [r#"/select,"C:\My Photos\a.jpg""#]); +} + +#[test] +fn a_control_port_that_is_not_a_number_is_ignored() { + assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980)); + assert_eq!(control_port_from("--control", Some(" 18001 ".into())), Some(18001)); + assert_eq!(control_port_from("--control", Some("nope".into())), None); + assert_eq!(control_port_from("--control", Some(String::new())), None); + assert_eq!(control_port_from("--control", Some("70000".into())), None); + assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None); +} + +#[test] +fn ui_json_is_written_atomically() { + let d = dir("atomic"); + let path = d.join("ui.json"); + std::fs::write(&path, b"old").unwrap(); + write_atomic(&path, b"{\"new\": true}").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}"); + assert!(!d.join("ui.json.tmp").exists()); + // a failed write leaves the old file + let missing = d.join("gone").join("ui.json"); + assert!(write_atomic(&missing, b"x").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}"); + let ui = UiState::default(); + write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap(); + let (loaded, warning, _) = load_prefs_at(&path, true); + assert!(loaded.is_some() && warning.is_none()); + let _ = std::fs::remove_dir_all(&d); +} + +/// Given what the settings remember of the window, when the app's window is built, then it +/// opens at that size, zoomed if it was, where the system puts it; with nothing remembered it +/// opens at the usual size. +#[test] +fn the_window_opens_as_it_was() { + use lightcraft_ui_egui::window_place::{DEFAULT_SIZE, WindowPlace}; + let fresh = as_remembered(egui::ViewportBuilder::default(), None); + assert_eq!((fresh.inner_size, fresh.position, fresh.maximized), (Some(egui::Vec2::from(DEFAULT_SIZE)), None, None)); + let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: true })); + assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, Some(true))); + // (not asked to un-zoom: macOS would then resize a window that fills its screen) + let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); + assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, None)); +} + +/// Given a window that rests at a size, when the settings are written and read again, then +/// they hold how it was. A window in the file that is not one (a hand edit, another version's) +/// reads as nothing remembered and costs no other setting; and a window resized just before +/// the app quits is remembered at that size. +#[test] +fn the_settings_keep_how_the_window_was() { + use lightcraft_ui_egui::window_place::{Seen, WindowPlace}; + let d = dir("window"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.ui.settings.library_path = "/pictures/library".into(); + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + writer.save(&app).unwrap(); + assert!(!std::fs::read_to_string(&path).unwrap().contains("\"window\""), "nothing to remember yet"); + let window = WindowPlace { size: [1300.0, 800.0], zoomed: true }; + app.ui.window = Some(window); + writer.save(&app).unwrap(); + let (restored, warning, _) = load_prefs_at(&path, false); + assert_eq!((restored.unwrap().window, warning), (Some(window), None)); + + // a window that is no window: the file is read all the same, with its other settings + let text = std::fs::read_to_string(&path).unwrap(); + for odd in [r#""window": {}"#, r#""window": "wide""#, r#""window": {"size": [1, 2, 3]}"#, r#""window": {"size": [null, 800.0]}"#] { + let start = text.find("\"window\"").unwrap(); + let end = start + text[start..].find('}').unwrap() + 1; + std::fs::write(&path, format!("{}{odd}{}", &text[..start], &text[end..])).unwrap(); + let (restored, warning, keep) = load_prefs_at(&path, true); + let restored = restored.unwrap_or_else(|| panic!("{odd}: {warning:?}")); + assert_eq!((restored.window, restored.settings.library_path.as_str(), warning, keep), (None, "/pictures/library", None, false), "{odd}"); + } + + // resized, and the app quits a moment later: the new size is what is written + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + let rest = Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }); + writer.window.note(rest, &Seen { size: Some([1500.0, 900.0]), ..Default::default() }, 0.0); + writer.window_since = std::time::Instant::now().checked_sub(std::time::Duration::from_millis(400)); + assert!(writer.window_since.is_some()); + assert_eq!(writer.window_at_exit(rest), Some(WindowPlace { size: [1500.0, 900.0], zoomed: false })); + assert_eq!(PrefsWriter::default().window_at_exit(rest), rest, "a window that was never seen"); + let _ = std::fs::remove_dir_all(&d); +} + +/// Given the app's window at a size, when the settings writer looks at it frame after frame, +/// then it asks to be woken while the size has still to rest, remembers the size once it has, +/// and asks for nothing more. A window in the settings that is not one (a script set it) is +/// put right before it is gone by. +#[test] +fn the_writer_follows_the_window_from_frame_to_frame() { + use lightcraft_ui_egui::window_place::WindowPlace; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut writer = PrefsWriter::default(); + let ctx = egui::Context::default(); + let pass = |look: &mut dyn FnMut(&egui::Context)| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| look(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay) + }; + // (egui's own first frames ask for another: let them pass) + for _ in 0..3 { + pass(&mut |_| {}); + } + app.ui.window = Some(WindowPlace { size: [f32::NAN, 800.0], zoomed: true }); + let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert_eq!(app.ui.window, None, "no window, and not yet at rest"); + assert!(asked.is_some_and(|wait| wait <= std::time::Duration::from_secs(1)), "{asked:?}"); + // a second later + writer.window_since = writer.window_since.and_then(|since| since.checked_sub(std::time::Duration::from_secs(1))); + pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert_eq!(app.ui.window, Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); + let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert!(asked.is_none_or(|wait| wait > std::time::Duration::from_secs(3600)), "at rest and remembered: {asked:?}"); +} + +/// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down. +/// The check runs in a child (this test binary again), since on macOS it ends the process. +#[test] +fn on_exit_ends_the_process_on_macos() { + if std::env::var_os("LIGHTCRAFT_TEST_ON_EXIT").is_some() { + let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut app = App( + app, + PrefsWriter::default(), + #[cfg(target_os = "macos")] + None, + ); + println!("calling on_exit"); + eframe::App::on_exit(&mut app, None); + println!("on_exit returned"); + return; + } + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"]) + .env("LIGHTCRAFT_TEST_ON_EXIT", "1") + .env("LIGHTCRAFT_NO_PREFS", "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{stdout}"); + assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}"); + assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}"); +} From ebff40bbd0716f1c286198d44b43d4b79b96f7a7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:10 +0000 Subject: [PATCH 072/191] refactor(lightcraft): move logging.rs tests to apps/lightcraft/src/logging/tests.rs --- apps/lightcraft/src/logging.rs | 367 +-------------------------- apps/lightcraft/src/logging/tests.rs | 364 ++++++++++++++++++++++++++ 2 files changed, 365 insertions(+), 366 deletions(-) create mode 100644 apps/lightcraft/src/logging/tests.rs diff --git a/apps/lightcraft/src/logging.rs b/apps/lightcraft/src/logging.rs index 6239aded6..dd174972d 100644 --- a/apps/lightcraft/src/logging.rs +++ b/apps/lightcraft/src/logging.rs @@ -343,369 +343,4 @@ pub fn record_panics(logger: &'static AppLogger) { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{Duration, UNIX_EPOCH}; - - fn temp_dir(tag: &str) -> PathBuf { - static N: AtomicUsize = AtomicUsize::new(0); - let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); - let _ = std::fs::remove_dir_all(&d); - d - } - - fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read log") - } - - fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { - use log::Log; - logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); - } - - /// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and - /// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them. - fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool { - let max = match lightcraft_log { - "debug" => LevelFilter::Debug, - "info" => LevelFilter::Info, - _ => LevelFilter::Warn, - }; - level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft")) - } - - #[test] - fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() { - let f = Filter::parse(DEFAULT_FILTER); - assert_eq!(f.level_for("lightcraft"), LevelFilter::Info); - assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info); - assert_eq!(f.level_for("lightcraft_engine::guard"), LevelFilter::Info); - assert_eq!(f.level_for("lightcraft_ui_egui::render"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); - assert_eq!(f.level_for("naga"), LevelFilter::Warn); - assert_eq!(f.max(), LevelFilter::Info); - assert_eq!(filter_spec(None, None), DEFAULT_FILTER); - // An empty variable is the same as an unset one. - assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER); - } - - /// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger. - #[test] - fn lightcraft_log_keeps_its_meaning() { - let targets = - ["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"]; - let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace]; - for value in ["info", "debug", "warn", "error", "verbose", "INFO"] { - let f = Filter::parse(&filter_spec(Some(value), None)); - for target in targets { - for level in levels { - assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}"); - } - } - } - assert_eq!(Filter::parse(&filter_spec(Some("debug"), None)).max(), LevelFilter::Debug); - assert_eq!(Filter::parse(&filter_spec(Some("warn"), None)).max(), LevelFilter::Warn); - } - - #[test] - fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() { - assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info"); - let f = Filter::parse(&filter_spec(None, Some("debug"))); - assert_eq!(f.level_for("eframe"), LevelFilter::Debug); - // The app's own variable is the more specific choice. - let f = Filter::parse(&filter_spec(Some("info"), Some("trace"))); - assert_eq!(f.level_for("eframe"), LevelFilter::Warn); - assert_eq!(f.level_for("lightcraft_engine"), LevelFilter::Info); - } - - #[test] - fn directives_follow_env_logger_and_the_most_specific_one_wins() { - let f = Filter::parse("info,wgpu_core=error,lightcraft_pipeline=trace,lightcraft_pipeline::tiles=off"); - assert_eq!(f.level_for("eframe"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); - assert_eq!(f.level_for("lightcraft_pipeline::stage"), LevelFilter::Trace); - assert_eq!(f.level_for("lightcraft_pipeline::tiles"), LevelFilter::Off); - assert_eq!(f.max(), LevelFilter::Trace); - // A module name is matched at `::` boundaries, not as a bare prefix. - assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); - // A trailing `*` is a prefix. - assert_eq!(Filter::parse("lightcraft*=debug").level_for("lightcraft_raw::cr2"), LevelFilter::Debug); - assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error); - // A bare target name sets that target to the most verbose level, as env_logger does. - assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); - assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); - // Levels are case-insensitive and surrounding blanks are ignored. - assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug); - assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn); - } - - #[test] - fn hostile_specs_never_panic_and_fall_back_sensibly() { - let long = "x".repeat(100_000); - let many = "a=info,".repeat(10_000); - for spec in [ - "", - ",,,", - "=", - "==", - "=debug", - "lightcraft=", - "nonsense=loud", - "🦀=info", - "*", - "*=", - "=*", - "a=b=c", - "warn,,lightcraft=DEBUG", - "\0", - "é::ü=trace", - &long, - &many, - ] { - let f = Filter::parse(spec); - for target in ["", "lightcraft", "🦀", "a::b", "*"] { - let _ = f.level_for(target); - } - let _ = f.max(); - let _ = filter_spec(Some(spec), Some(spec)); - } - assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug); - // An unknown level is skipped: that target keeps the default. - assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); - assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error); - assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); - assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); - } - - #[test] - fn timestamps_are_utc_with_milliseconds() { - assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); - // Leap day, and the day after it. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); - // 2000 is a leap year (divisible by 400), 2100 is not. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); - // Before the epoch (a clock set wrong) clamps instead of panicking. - assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); - // Past chrono's year range still converts via the 400-year cycle (the old code did too, - // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' - // SystemTime can't hold this instant, so it is only checked where it exists. - // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { - assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); - } - // Years past 9999 keep the plain zero-padded year (no `+`). - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); - // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. - // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { - assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); - } - } - - #[test] - fn a_line_carries_time_level_thread_target_and_message() { - assert_eq!( - format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"), - "2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n" - ); - // A multi-line message keeps its lines; the record still ends in one newline. - assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); - } - - #[test] - fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { - let dir = temp_dir("rotate"); - for run in 1..=4 { - let (path, _file) = rotate(&dir).expect("rotate"); - assert_eq!(path, dir.join(LOG_FILE)); - assert_eq!(read(&path), ""); - std::fs::write(&path, format!("run {run}")).expect("write"); - } - assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); - assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3"); - assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2"); - assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { - let dir = temp_dir("blocked"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "a file where the directory should be").expect("block"); - assert!(rotate(&dir).is_err()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { - let dir = temp_dir("pending"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early 1\n"); - sink.write("early 2\n"); - sink.attach(file); - sink.write("late\n"); - assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { - let dir = temp_dir("bounded"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING + 5 { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); - assert!(text.contains("5 earlier log lines were dropped"), "{text}"); - assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn exactly_a_full_pending_buffer_drops_nothing() { - let dir = temp_dir("full"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().count(), MAX_PENDING); - assert!(!text.contains("dropped"), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn without_a_file_nothing_is_kept_in_memory() { - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early\n"); - sink.no_file(); - sink.write("late\n"); - assert!(sink.pending.is_empty() && sink.dropped == 0); - } - - #[test] - fn the_file_stops_at_its_size_cap_with_one_note() { - let dir = temp_dir("cap"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(100); - sink.attach(file); - for i in 0..50 { - sink.write(&format!("line {i:02} with some padding\n")); - } - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); - assert_eq!(text.matches("log file reached").count(), 1, "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_logger_filters_records_and_writes_them_to_the_attached_file() { - use log::Log; - let dir = temp_dir("logger"); - let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); - record(&logger, log::Level::Info, "lightcraft", "before the settings directory"); - record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); - let path = logger.attach_dir(&dir).expect("attach"); - assert_eq!(path, dir.join(LOG_FILE)); - record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); - record(&logger, log::Level::Error, "lightcraft_engine::guard", "`develop.reset` failed unexpectedly: boom"); - record(&logger, log::Level::Debug, "lightcraft", "below info"); - let text = read(&path); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 3, "{text}"); - assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}"); - assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); - assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("lightcraft_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}"); - assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("lightcraft_ui_egui").build())); - assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); - // Attaching again rotates: the first log becomes `.1`. - logger.attach_dir(&dir).expect("attach again"); - assert_eq!(read(&dir.join("lightcraft.1.log")), text); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn records_from_many_threads_are_whole_lines() { - let dir = temp_dir("threads"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let path = logger.attach_dir(&dir).expect("attach"); - let workers: Vec<_> = (0..8) - .map(|t| { - let l = std::sync::Arc::clone(&logger); - std::thread::spawn(move || { - for i in 0..100 { - record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}")); - } - }) - }) - .collect(); - for w in workers { - w.join().expect("worker"); - } - let text = read(&path); - assert_eq!(text.lines().count(), 800); - assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { - let dir = temp_dir("unusable"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "not a directory").expect("block"); - let logger = AppLogger::new(Filter::parse("info"), false); - record(&logger, log::Level::Info, "lightcraft", "early"); - assert!(logger.attach_dir(&dir).is_err()); - record(&logger, log::Level::Error, "x", "still fine"); - // Nothing waits in memory for a file that won't come. - assert!(logger.sink.lock().expect("sink").pending.is_empty()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn a_poisoned_sink_still_logs() { - let dir = temp_dir("poison"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let l = std::sync::Arc::clone(&logger); - let _ = std::thread::spawn(move || { - let _guard = l.sink.lock().expect("lock"); - panic!("poison the sink"); - }) - .join(); - assert!(logger.sink.is_poisoned()); - let path = logger.attach_dir(&dir).expect("attach"); - record(&logger, log::Level::Warn, "lightcraft", "after the poison"); - assert!(read(&path).contains("lightcraft: after the poison")); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The panic hook's report lands in the file, and never deadlocks when the panic happened - /// while this thread held the sink. - #[test] - fn a_panic_report_goes_to_the_file_and_never_deadlocks() { - let dir = temp_dir("panic"); - let logger = AppLogger::new(Filter::parse("off"), false); - let path = logger.attach_dir(&dir).expect("attach"); - logger.record_panic("render", "panicked at crates/x/src/lib.rs:3:5:\nboom"); - { - let _held = logger.sink.lock().expect("lock"); - logger.record_panic("main", "while the sink is held"); - } - let text = read(&path); - assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}"); - assert!(!text.contains("while the sink is held"), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/apps/lightcraft/src/logging/tests.rs b/apps/lightcraft/src/logging/tests.rs new file mode 100644 index 000000000..e4d663191 --- /dev/null +++ b/apps/lightcraft/src/logging/tests.rs @@ -0,0 +1,364 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, UNIX_EPOCH}; + +fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") +} + +fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { + use log::Log; + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); +} + +/// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and +/// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them. +fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool { + let max = match lightcraft_log { + "debug" => LevelFilter::Debug, + "info" => LevelFilter::Info, + _ => LevelFilter::Warn, + }; + level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft")) +} + +#[test] +fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("lightcraft"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft_engine::guard"), LevelFilter::Info); + assert_eq!(f.level_for("lightcraft_ui_egui::render"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + assert_eq!(filter_spec(None, None), DEFAULT_FILTER); + // An empty variable is the same as an unset one. + assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER); +} + +/// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger. +#[test] +fn lightcraft_log_keeps_its_meaning() { + let targets = + ["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"]; + let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace]; + for value in ["info", "debug", "warn", "error", "verbose", "INFO"] { + let f = Filter::parse(&filter_spec(Some(value), None)); + for target in targets { + for level in levels { + assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}"); + } + } + } + assert_eq!(Filter::parse(&filter_spec(Some("debug"), None)).max(), LevelFilter::Debug); + assert_eq!(Filter::parse(&filter_spec(Some("warn"), None)).max(), LevelFilter::Warn); +} + +#[test] +fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() { + assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info"); + let f = Filter::parse(&filter_spec(None, Some("debug"))); + assert_eq!(f.level_for("eframe"), LevelFilter::Debug); + // The app's own variable is the more specific choice. + let f = Filter::parse(&filter_spec(Some("info"), Some("trace"))); + assert_eq!(f.level_for("eframe"), LevelFilter::Warn); + assert_eq!(f.level_for("lightcraft_engine"), LevelFilter::Info); +} + +#[test] +fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,lightcraft_pipeline=trace,lightcraft_pipeline::tiles=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("lightcraft_pipeline::stage"), LevelFilter::Trace); + assert_eq!(f.level_for("lightcraft_pipeline::tiles"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("lightcraft*=debug").level_for("lightcraft_raw::cr2"), LevelFilter::Debug); + assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn); +} + +#[test] +fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "lightcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,lightcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "lightcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + let _ = filter_spec(Some(spec), Some(spec)); + } + assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); +} + +#[test] +fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } +} + +#[test] +fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"), + "2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); +} + +#[test] +fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2"); + assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); +} + +#[test] +fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "lightcraft", "before the settings directory"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Error, "lightcraft_engine::guard", "`develop.reset` failed unexpectedly: boom"); + record(&logger, log::Level::Debug, "lightcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("lightcraft_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("lightcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("lightcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}")); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + record(&logger, log::Level::Info, "lightcraft", "early"); + assert!(logger.attach_dir(&dir).is_err()); + record(&logger, log::Level::Error, "x", "still fine"); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn a_poisoned_sink_still_logs() { + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + record(&logger, log::Level::Warn, "lightcraft", "after the poison"); + assert!(read(&path).contains("lightcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The panic hook's report lands in the file, and never deadlocks when the panic happened +/// while this thread held the sink. +#[test] +fn a_panic_report_goes_to_the_file_and_never_deadlocks() { + let dir = temp_dir("panic"); + let logger = AppLogger::new(Filter::parse("off"), false); + let path = logger.attach_dir(&dir).expect("attach"); + logger.record_panic("render", "panicked at crates/x/src/lib.rs:3:5:\nboom"); + { + let _held = logger.sink.lock().expect("lock"); + logger.record_panic("main", "while the sink is held"); + } + let text = read(&path); + assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}"); + assert!(!text.contains("while the sink is held"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} From 41ec36ac831047f35dc4f213cd3f9a0cb53c8bbb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:24:54 +0000 Subject: [PATCH 073/191] refactor(lightcraft-ui-egui): move tests_*.rs to tests/ --- crates/ui-egui/src/lib.rs | 170 +----------------- crates/ui-egui/src/tests.rs | 84 +++++++++ .../{tests_activity.rs => tests/activity.rs} | 0 .../album_picker.rs} | 0 .../album_remove.rs} | 0 .../appearance.rs} | 0 .../before_after.rs} | 0 .../browse_active.rs} | 0 .../src/{tests_button.rs => tests/button.rs} | 0 .../canvas_keys.rs} | 0 .../canvas_pan.rs} | 0 .../capture_time.rs} | 0 .../{tests_compare.rs => tests/compare.rs} | 0 .../copy_paste_dialogs.rs} | 0 .../crop_rotate.rs} | 0 .../crop_tool.rs} | 0 .../src/{tests_curve.rs => tests/curve.rs} | 0 .../date_picker.rs} | 0 .../dialog_fit.rs} | 0 .../dialog_primary.rs} | 0 .../dialog_settings.rs} | 0 .../dialog_tab.rs} | 0 .../{tests_dialogs.rs => tests/dialogs.rs} | 0 .../docking_acceptance.rs} | 0 .../drop_import.rs} | 0 .../edit_layout.rs} | 0 .../empty_library.rs} | 0 .../error_kinds.rs} | 0 .../filmstrip.rs} | 0 .../focus_point.rs} | 0 .../folder_drop.rs} | 0 .../{tests_grading.rs => tests/grading.rs} | 0 .../src/{tests_grid.rs => tests/grid.rs} | 0 .../guided_guides.rs} | 0 .../histogram_drag.rs} | 0 .../info_dialogs.rs} | 0 .../src/{tests_keymap.rs => tests/keymap.rs} | 0 .../keyword_files.rs} | 0 .../keyword_list.rs} | 0 .../keyword_set.rs} | 0 .../keyword_sidebar.rs} | 0 .../keywording.rs} | 0 .../src/{tests_labels.rs => tests/labels.rs} | 0 .../launch_library.rs} | 0 .../layout_image.rs} | 0 .../library_problem.rs} | 0 .../lifecycle_037.rs} | 0 .../lightroom_destination.rs} | 0 .../lights_out.rs} | 0 .../mask_effects.rs} | 0 .../{tests_masking.rs => tests/masking.rs} | 0 .../masking_layout.rs} | 0 .../{tests_menubar.rs => tests/menubar.rs} | 0 .../merge_preview.rs} | 0 .../model_setup.rs} | 0 .../{tests_offline.rs => tests/offline.rs} | 0 .../open_lightroom.rs} | 0 .../src/{tests_panels.rs => tests/panels.rs} | 0 .../preset_favorites.rs} | 0 .../preset_import.rs} | 0 .../preview_limit.rs} | 0 .../src/{tests_print.rs => tests/print.rs} | 0 .../profile_menu.rs} | 0 .../profiles_layout.rs} | 0 .../{tests_progress.rs => tests/progress.rs} | 0 .../quit_unsaved.rs} | 0 .../src/{tests_rating.rs => tests/rating.rs} | 0 .../{tests_recheck.rs => tests/recheck.rs} | 0 .../rename_person.rs} | 0 .../rules_editor.rs} | 0 .../sam_setup.rs} | 0 .../src/{tests_scroll.rs => tests/scroll.rs} | 0 .../slider_reset.rs} | 0 .../slideshow.rs} | 0 .../solo_sections.rs} | 0 .../switch_library.rs} | 0 .../src/{tests_sync.rs => tests/sync.rs} | 0 .../{tests_tallies.rs => tests/tallies.rs} | 0 .../temp_slider.rs} | 0 .../{tests_titlebar.rs => tests/titlebar.rs} | 0 .../tool_overlay.rs} | 0 .../{tests_unsaved.rs => tests/unsaved.rs} | 0 .../view_cost.rs} | 0 .../wb_selector.rs} | 0 .../src/{tests_zoom.rs => tests/zoom.rs} | 0 .../zoom_keys.rs} | 0 86 files changed, 86 insertions(+), 168 deletions(-) create mode 100644 crates/ui-egui/src/tests.rs rename crates/ui-egui/src/{tests_activity.rs => tests/activity.rs} (100%) rename crates/ui-egui/src/{tests_album_picker.rs => tests/album_picker.rs} (100%) rename crates/ui-egui/src/{tests_album_remove.rs => tests/album_remove.rs} (100%) rename crates/ui-egui/src/{tests_appearance.rs => tests/appearance.rs} (100%) rename crates/ui-egui/src/{tests_before_after.rs => tests/before_after.rs} (100%) rename crates/ui-egui/src/{tests_browse_active.rs => tests/browse_active.rs} (100%) rename crates/ui-egui/src/{tests_button.rs => tests/button.rs} (100%) rename crates/ui-egui/src/{tests_canvas_keys.rs => tests/canvas_keys.rs} (100%) rename crates/ui-egui/src/{tests_canvas_pan.rs => tests/canvas_pan.rs} (100%) rename crates/ui-egui/src/{tests_capture_time.rs => tests/capture_time.rs} (100%) rename crates/ui-egui/src/{tests_compare.rs => tests/compare.rs} (100%) rename crates/ui-egui/src/{tests_copy_paste_dialogs.rs => tests/copy_paste_dialogs.rs} (100%) rename crates/ui-egui/src/{tests_crop_rotate.rs => tests/crop_rotate.rs} (100%) rename crates/ui-egui/src/{tests_crop_tool.rs => tests/crop_tool.rs} (100%) rename crates/ui-egui/src/{tests_curve.rs => tests/curve.rs} (100%) rename crates/ui-egui/src/{tests_date_picker.rs => tests/date_picker.rs} (100%) rename crates/ui-egui/src/{tests_dialog_fit.rs => tests/dialog_fit.rs} (100%) rename crates/ui-egui/src/{tests_dialog_primary.rs => tests/dialog_primary.rs} (100%) rename crates/ui-egui/src/{tests_dialog_settings.rs => tests/dialog_settings.rs} (100%) rename crates/ui-egui/src/{tests_dialog_tab.rs => tests/dialog_tab.rs} (100%) rename crates/ui-egui/src/{tests_dialogs.rs => tests/dialogs.rs} (100%) rename crates/ui-egui/src/{tests_docking_acceptance.rs => tests/docking_acceptance.rs} (100%) rename crates/ui-egui/src/{tests_drop_import.rs => tests/drop_import.rs} (100%) rename crates/ui-egui/src/{tests_edit_layout.rs => tests/edit_layout.rs} (100%) rename crates/ui-egui/src/{tests_empty_library.rs => tests/empty_library.rs} (100%) rename crates/ui-egui/src/{tests_error_kinds.rs => tests/error_kinds.rs} (100%) rename crates/ui-egui/src/{tests_filmstrip.rs => tests/filmstrip.rs} (100%) rename crates/ui-egui/src/{tests_focus_point.rs => tests/focus_point.rs} (100%) rename crates/ui-egui/src/{tests_folder_drop.rs => tests/folder_drop.rs} (100%) rename crates/ui-egui/src/{tests_grading.rs => tests/grading.rs} (100%) rename crates/ui-egui/src/{tests_grid.rs => tests/grid.rs} (100%) rename crates/ui-egui/src/{tests_guided_guides.rs => tests/guided_guides.rs} (100%) rename crates/ui-egui/src/{tests_histogram_drag.rs => tests/histogram_drag.rs} (100%) rename crates/ui-egui/src/{tests_info_dialogs.rs => tests/info_dialogs.rs} (100%) rename crates/ui-egui/src/{tests_keymap.rs => tests/keymap.rs} (100%) rename crates/ui-egui/src/{tests_keyword_files.rs => tests/keyword_files.rs} (100%) rename crates/ui-egui/src/{tests_keyword_list.rs => tests/keyword_list.rs} (100%) rename crates/ui-egui/src/{tests_keyword_set.rs => tests/keyword_set.rs} (100%) rename crates/ui-egui/src/{tests_keyword_sidebar.rs => tests/keyword_sidebar.rs} (100%) rename crates/ui-egui/src/{tests_keywording.rs => tests/keywording.rs} (100%) rename crates/ui-egui/src/{tests_labels.rs => tests/labels.rs} (100%) rename crates/ui-egui/src/{tests_launch_library.rs => tests/launch_library.rs} (100%) rename crates/ui-egui/src/{tests_layout_image.rs => tests/layout_image.rs} (100%) rename crates/ui-egui/src/{tests_library_problem.rs => tests/library_problem.rs} (100%) rename crates/ui-egui/src/{tests_lifecycle_037.rs => tests/lifecycle_037.rs} (100%) rename crates/ui-egui/src/{tests_lightroom_destination.rs => tests/lightroom_destination.rs} (100%) rename crates/ui-egui/src/{tests_lights_out.rs => tests/lights_out.rs} (100%) rename crates/ui-egui/src/{tests_mask_effects.rs => tests/mask_effects.rs} (100%) rename crates/ui-egui/src/{tests_masking.rs => tests/masking.rs} (100%) rename crates/ui-egui/src/{tests_masking_layout.rs => tests/masking_layout.rs} (100%) rename crates/ui-egui/src/{tests_menubar.rs => tests/menubar.rs} (100%) rename crates/ui-egui/src/{tests_merge_preview.rs => tests/merge_preview.rs} (100%) rename crates/ui-egui/src/{tests_model_setup.rs => tests/model_setup.rs} (100%) rename crates/ui-egui/src/{tests_offline.rs => tests/offline.rs} (100%) rename crates/ui-egui/src/{tests_open_lightroom.rs => tests/open_lightroom.rs} (100%) rename crates/ui-egui/src/{tests_panels.rs => tests/panels.rs} (100%) rename crates/ui-egui/src/{tests_preset_favorites.rs => tests/preset_favorites.rs} (100%) rename crates/ui-egui/src/{tests_preset_import.rs => tests/preset_import.rs} (100%) rename crates/ui-egui/src/{tests_preview_limit.rs => tests/preview_limit.rs} (100%) rename crates/ui-egui/src/{tests_print.rs => tests/print.rs} (100%) rename crates/ui-egui/src/{tests_profile_menu.rs => tests/profile_menu.rs} (100%) rename crates/ui-egui/src/{tests_profiles_layout.rs => tests/profiles_layout.rs} (100%) rename crates/ui-egui/src/{tests_progress.rs => tests/progress.rs} (100%) rename crates/ui-egui/src/{tests_quit_unsaved.rs => tests/quit_unsaved.rs} (100%) rename crates/ui-egui/src/{tests_rating.rs => tests/rating.rs} (100%) rename crates/ui-egui/src/{tests_recheck.rs => tests/recheck.rs} (100%) rename crates/ui-egui/src/{tests_rename_person.rs => tests/rename_person.rs} (100%) rename crates/ui-egui/src/{tests_rules_editor.rs => tests/rules_editor.rs} (100%) rename crates/ui-egui/src/{tests_sam_setup.rs => tests/sam_setup.rs} (100%) rename crates/ui-egui/src/{tests_scroll.rs => tests/scroll.rs} (100%) rename crates/ui-egui/src/{tests_slider_reset.rs => tests/slider_reset.rs} (100%) rename crates/ui-egui/src/{tests_slideshow.rs => tests/slideshow.rs} (100%) rename crates/ui-egui/src/{tests_solo_sections.rs => tests/solo_sections.rs} (100%) rename crates/ui-egui/src/{tests_switch_library.rs => tests/switch_library.rs} (100%) rename crates/ui-egui/src/{tests_sync.rs => tests/sync.rs} (100%) rename crates/ui-egui/src/{tests_tallies.rs => tests/tallies.rs} (100%) rename crates/ui-egui/src/{tests_temp_slider.rs => tests/temp_slider.rs} (100%) rename crates/ui-egui/src/{tests_titlebar.rs => tests/titlebar.rs} (100%) rename crates/ui-egui/src/{tests_tool_overlay.rs => tests/tool_overlay.rs} (100%) rename crates/ui-egui/src/{tests_unsaved.rs => tests/unsaved.rs} (100%) rename crates/ui-egui/src/{tests_view_cost.rs => tests/view_cost.rs} (100%) rename crates/ui-egui/src/{tests_wb_selector.rs => tests/wb_selector.rs} (100%) rename crates/ui-egui/src/{tests_zoom.rs => tests/zoom.rs} (100%) rename crates/ui-egui/src/{tests_zoom_keys.rs => tests/zoom_keys.rs} (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e53989fca..2dcc84189 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,169 +54,9 @@ pub mod widgets; pub mod window_place; #[cfg(test)] -mod tests_activity; +mod tests; #[cfg(test)] -mod tests_album_picker; -#[cfg(test)] -mod tests_album_remove; -#[cfg(test)] -mod tests_appearance; -#[cfg(test)] -mod tests_before_after; -#[cfg(test)] -mod tests_browse_active; -#[cfg(test)] -mod tests_button; -#[cfg(test)] -mod tests_canvas_keys; -#[cfg(test)] -mod tests_canvas_pan; -#[cfg(test)] -mod tests_capture_time; -#[cfg(test)] -mod tests_compare; -#[cfg(test)] -mod tests_copy_paste_dialogs; -#[cfg(test)] -mod tests_crop_rotate; -#[cfg(test)] -mod tests_crop_tool; -#[cfg(test)] -mod tests_curve; -#[cfg(test)] -mod tests_date_picker; -#[cfg(test)] -mod tests_dialog_fit; -#[cfg(test)] -mod tests_dialog_primary; -#[cfg(test)] -mod tests_dialog_settings; -#[cfg(test)] -mod tests_dialog_tab; -#[cfg(test)] -mod tests_dialogs; -#[cfg(test)] -mod tests_drop_import; -#[cfg(test)] -mod tests_edit_layout; -#[cfg(test)] -mod tests_empty_library; -#[cfg(test)] -mod tests_error_kinds; -#[cfg(test)] -mod tests_filmstrip; -#[cfg(test)] -mod tests_focus_point; -#[cfg(test)] -mod tests_folder_drop; -#[cfg(test)] -mod tests_grading; -#[cfg(test)] -mod tests_grid; -#[cfg(test)] -mod tests_guided_guides; -#[cfg(test)] -mod tests_histogram_drag; -#[cfg(test)] -mod tests_info_dialogs; -#[cfg(test)] -mod tests_keymap; -#[cfg(test)] -mod tests_keyword_files; -#[cfg(test)] -mod tests_keyword_list; -#[cfg(test)] -mod tests_keyword_set; -#[cfg(test)] -mod tests_keyword_sidebar; -#[cfg(test)] -mod tests_keywording; -#[cfg(test)] -mod tests_labels; -#[cfg(test)] -mod tests_launch_library; -#[cfg(test)] -mod tests_layout_image; -#[cfg(test)] -mod tests_library_problem; -#[cfg(test)] -mod tests_lifecycle_037; -#[cfg(test)] -mod tests_lightroom_destination; -#[cfg(test)] -mod tests_lights_out; -#[cfg(test)] -mod tests_mask_effects; -#[cfg(test)] -mod tests_masking; -#[cfg(test)] -mod tests_masking_layout; -#[cfg(test)] -mod tests_menubar; -#[cfg(test)] -mod tests_merge_preview; -#[cfg(test)] -mod tests_offline; -#[cfg(test)] -mod tests_open_lightroom; -#[cfg(test)] -mod tests_panels; -#[cfg(test)] -mod tests_preset_favorites; -#[cfg(test)] -mod tests_preset_import; -#[cfg(test)] -mod tests_preview_limit; -#[cfg(test)] -mod tests_print; -#[cfg(test)] -mod tests_profile_menu; -#[cfg(test)] -mod tests_profiles_layout; -#[cfg(test)] -mod tests_progress; -#[cfg(test)] -mod tests_quit_unsaved; -#[cfg(test)] -mod tests_rating; -#[cfg(test)] -mod tests_recheck; -#[cfg(test)] -mod tests_rename_person; -#[cfg(test)] -mod tests_rules_editor; -#[cfg(test)] -mod tests_sam_setup; -#[cfg(test)] -mod tests_scroll; -#[cfg(test)] -mod tests_slider_reset; -#[cfg(test)] -mod tests_slideshow; -#[cfg(test)] -mod tests_solo_sections; -#[cfg(test)] -mod tests_switch_library; -#[cfg(test)] -mod tests_sync; -#[cfg(test)] -mod tests_tallies; -#[cfg(test)] -mod tests_temp_slider; -#[cfg(test)] -mod tests_titlebar; -#[cfg(test)] -mod tests_tool_overlay; -#[cfg(test)] -mod tests_unsaved; -#[cfg(test)] -mod tests_view_cost; -#[cfg(test)] -mod tests_wb_selector; -#[cfg(test)] -mod tests_zoom; -#[cfg(test)] -mod tests_zoom_keys; +use tests::panels as tests_panels; use std::sync::mpsc::{Receiver, Sender}; @@ -3589,9 +3429,3 @@ mod cache_tests { assert_eq!(c.album_count_scans, 1); } } - -#[cfg(test)] -mod tests_model_setup; - -#[cfg(test)] -mod tests_docking_acceptance; diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs new file mode 100644 index 000000000..9487734b2 --- /dev/null +++ b/crates/ui-egui/src/tests.rs @@ -0,0 +1,84 @@ +mod activity; +mod album_picker; +mod album_remove; +mod appearance; +mod before_after; +mod browse_active; +mod button; +mod canvas_keys; +mod canvas_pan; +mod capture_time; +mod compare; +mod copy_paste_dialogs; +mod crop_rotate; +mod crop_tool; +mod curve; +mod date_picker; +mod dialog_fit; +mod dialog_primary; +mod dialog_settings; +mod dialog_tab; +mod dialogs; +mod drop_import; +mod edit_layout; +mod empty_library; +mod error_kinds; +mod filmstrip; +mod focus_point; +mod folder_drop; +mod grading; +mod grid; +mod guided_guides; +mod histogram_drag; +mod info_dialogs; +mod keymap; +mod keyword_files; +mod keyword_list; +mod keyword_set; +mod keyword_sidebar; +mod keywording; +mod labels; +mod launch_library; +mod layout_image; +mod library_problem; +mod lifecycle_037; +mod lightroom_destination; +mod lights_out; +mod mask_effects; +mod masking; +mod masking_layout; +mod menubar; +mod merge_preview; +mod offline; +mod open_lightroom; +pub(super) mod panels; +mod preset_favorites; +mod preset_import; +mod preview_limit; +mod print; +mod profile_menu; +mod profiles_layout; +mod progress; +mod quit_unsaved; +mod rating; +mod recheck; +mod rename_person; +mod rules_editor; +mod sam_setup; +mod scroll; +mod slider_reset; +mod slideshow; +mod solo_sections; +mod switch_library; +mod sync; +mod tallies; +mod temp_slider; +mod titlebar; +mod tool_overlay; +mod unsaved; +mod view_cost; +mod wb_selector; +mod zoom; +mod zoom_keys; +mod model_setup; +mod docking_acceptance; diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests/activity.rs similarity index 100% rename from crates/ui-egui/src/tests_activity.rs rename to crates/ui-egui/src/tests/activity.rs diff --git a/crates/ui-egui/src/tests_album_picker.rs b/crates/ui-egui/src/tests/album_picker.rs similarity index 100% rename from crates/ui-egui/src/tests_album_picker.rs rename to crates/ui-egui/src/tests/album_picker.rs diff --git a/crates/ui-egui/src/tests_album_remove.rs b/crates/ui-egui/src/tests/album_remove.rs similarity index 100% rename from crates/ui-egui/src/tests_album_remove.rs rename to crates/ui-egui/src/tests/album_remove.rs diff --git a/crates/ui-egui/src/tests_appearance.rs b/crates/ui-egui/src/tests/appearance.rs similarity index 100% rename from crates/ui-egui/src/tests_appearance.rs rename to crates/ui-egui/src/tests/appearance.rs diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests/before_after.rs similarity index 100% rename from crates/ui-egui/src/tests_before_after.rs rename to crates/ui-egui/src/tests/before_after.rs diff --git a/crates/ui-egui/src/tests_browse_active.rs b/crates/ui-egui/src/tests/browse_active.rs similarity index 100% rename from crates/ui-egui/src/tests_browse_active.rs rename to crates/ui-egui/src/tests/browse_active.rs diff --git a/crates/ui-egui/src/tests_button.rs b/crates/ui-egui/src/tests/button.rs similarity index 100% rename from crates/ui-egui/src/tests_button.rs rename to crates/ui-egui/src/tests/button.rs diff --git a/crates/ui-egui/src/tests_canvas_keys.rs b/crates/ui-egui/src/tests/canvas_keys.rs similarity index 100% rename from crates/ui-egui/src/tests_canvas_keys.rs rename to crates/ui-egui/src/tests/canvas_keys.rs diff --git a/crates/ui-egui/src/tests_canvas_pan.rs b/crates/ui-egui/src/tests/canvas_pan.rs similarity index 100% rename from crates/ui-egui/src/tests_canvas_pan.rs rename to crates/ui-egui/src/tests/canvas_pan.rs diff --git a/crates/ui-egui/src/tests_capture_time.rs b/crates/ui-egui/src/tests/capture_time.rs similarity index 100% rename from crates/ui-egui/src/tests_capture_time.rs rename to crates/ui-egui/src/tests/capture_time.rs diff --git a/crates/ui-egui/src/tests_compare.rs b/crates/ui-egui/src/tests/compare.rs similarity index 100% rename from crates/ui-egui/src/tests_compare.rs rename to crates/ui-egui/src/tests/compare.rs diff --git a/crates/ui-egui/src/tests_copy_paste_dialogs.rs b/crates/ui-egui/src/tests/copy_paste_dialogs.rs similarity index 100% rename from crates/ui-egui/src/tests_copy_paste_dialogs.rs rename to crates/ui-egui/src/tests/copy_paste_dialogs.rs diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests/crop_rotate.rs similarity index 100% rename from crates/ui-egui/src/tests_crop_rotate.rs rename to crates/ui-egui/src/tests/crop_rotate.rs diff --git a/crates/ui-egui/src/tests_crop_tool.rs b/crates/ui-egui/src/tests/crop_tool.rs similarity index 100% rename from crates/ui-egui/src/tests_crop_tool.rs rename to crates/ui-egui/src/tests/crop_tool.rs diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests/curve.rs similarity index 100% rename from crates/ui-egui/src/tests_curve.rs rename to crates/ui-egui/src/tests/curve.rs diff --git a/crates/ui-egui/src/tests_date_picker.rs b/crates/ui-egui/src/tests/date_picker.rs similarity index 100% rename from crates/ui-egui/src/tests_date_picker.rs rename to crates/ui-egui/src/tests/date_picker.rs diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests/dialog_fit.rs similarity index 100% rename from crates/ui-egui/src/tests_dialog_fit.rs rename to crates/ui-egui/src/tests/dialog_fit.rs diff --git a/crates/ui-egui/src/tests_dialog_primary.rs b/crates/ui-egui/src/tests/dialog_primary.rs similarity index 100% rename from crates/ui-egui/src/tests_dialog_primary.rs rename to crates/ui-egui/src/tests/dialog_primary.rs diff --git a/crates/ui-egui/src/tests_dialog_settings.rs b/crates/ui-egui/src/tests/dialog_settings.rs similarity index 100% rename from crates/ui-egui/src/tests_dialog_settings.rs rename to crates/ui-egui/src/tests/dialog_settings.rs diff --git a/crates/ui-egui/src/tests_dialog_tab.rs b/crates/ui-egui/src/tests/dialog_tab.rs similarity index 100% rename from crates/ui-egui/src/tests_dialog_tab.rs rename to crates/ui-egui/src/tests/dialog_tab.rs diff --git a/crates/ui-egui/src/tests_dialogs.rs b/crates/ui-egui/src/tests/dialogs.rs similarity index 100% rename from crates/ui-egui/src/tests_dialogs.rs rename to crates/ui-egui/src/tests/dialogs.rs diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests/docking_acceptance.rs similarity index 100% rename from crates/ui-egui/src/tests_docking_acceptance.rs rename to crates/ui-egui/src/tests/docking_acceptance.rs diff --git a/crates/ui-egui/src/tests_drop_import.rs b/crates/ui-egui/src/tests/drop_import.rs similarity index 100% rename from crates/ui-egui/src/tests_drop_import.rs rename to crates/ui-egui/src/tests/drop_import.rs diff --git a/crates/ui-egui/src/tests_edit_layout.rs b/crates/ui-egui/src/tests/edit_layout.rs similarity index 100% rename from crates/ui-egui/src/tests_edit_layout.rs rename to crates/ui-egui/src/tests/edit_layout.rs diff --git a/crates/ui-egui/src/tests_empty_library.rs b/crates/ui-egui/src/tests/empty_library.rs similarity index 100% rename from crates/ui-egui/src/tests_empty_library.rs rename to crates/ui-egui/src/tests/empty_library.rs diff --git a/crates/ui-egui/src/tests_error_kinds.rs b/crates/ui-egui/src/tests/error_kinds.rs similarity index 100% rename from crates/ui-egui/src/tests_error_kinds.rs rename to crates/ui-egui/src/tests/error_kinds.rs diff --git a/crates/ui-egui/src/tests_filmstrip.rs b/crates/ui-egui/src/tests/filmstrip.rs similarity index 100% rename from crates/ui-egui/src/tests_filmstrip.rs rename to crates/ui-egui/src/tests/filmstrip.rs diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests/focus_point.rs similarity index 100% rename from crates/ui-egui/src/tests_focus_point.rs rename to crates/ui-egui/src/tests/focus_point.rs diff --git a/crates/ui-egui/src/tests_folder_drop.rs b/crates/ui-egui/src/tests/folder_drop.rs similarity index 100% rename from crates/ui-egui/src/tests_folder_drop.rs rename to crates/ui-egui/src/tests/folder_drop.rs diff --git a/crates/ui-egui/src/tests_grading.rs b/crates/ui-egui/src/tests/grading.rs similarity index 100% rename from crates/ui-egui/src/tests_grading.rs rename to crates/ui-egui/src/tests/grading.rs diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests/grid.rs similarity index 100% rename from crates/ui-egui/src/tests_grid.rs rename to crates/ui-egui/src/tests/grid.rs diff --git a/crates/ui-egui/src/tests_guided_guides.rs b/crates/ui-egui/src/tests/guided_guides.rs similarity index 100% rename from crates/ui-egui/src/tests_guided_guides.rs rename to crates/ui-egui/src/tests/guided_guides.rs diff --git a/crates/ui-egui/src/tests_histogram_drag.rs b/crates/ui-egui/src/tests/histogram_drag.rs similarity index 100% rename from crates/ui-egui/src/tests_histogram_drag.rs rename to crates/ui-egui/src/tests/histogram_drag.rs diff --git a/crates/ui-egui/src/tests_info_dialogs.rs b/crates/ui-egui/src/tests/info_dialogs.rs similarity index 100% rename from crates/ui-egui/src/tests_info_dialogs.rs rename to crates/ui-egui/src/tests/info_dialogs.rs diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests/keymap.rs similarity index 100% rename from crates/ui-egui/src/tests_keymap.rs rename to crates/ui-egui/src/tests/keymap.rs diff --git a/crates/ui-egui/src/tests_keyword_files.rs b/crates/ui-egui/src/tests/keyword_files.rs similarity index 100% rename from crates/ui-egui/src/tests_keyword_files.rs rename to crates/ui-egui/src/tests/keyword_files.rs diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests/keyword_list.rs similarity index 100% rename from crates/ui-egui/src/tests_keyword_list.rs rename to crates/ui-egui/src/tests/keyword_list.rs diff --git a/crates/ui-egui/src/tests_keyword_set.rs b/crates/ui-egui/src/tests/keyword_set.rs similarity index 100% rename from crates/ui-egui/src/tests_keyword_set.rs rename to crates/ui-egui/src/tests/keyword_set.rs diff --git a/crates/ui-egui/src/tests_keyword_sidebar.rs b/crates/ui-egui/src/tests/keyword_sidebar.rs similarity index 100% rename from crates/ui-egui/src/tests_keyword_sidebar.rs rename to crates/ui-egui/src/tests/keyword_sidebar.rs diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests/keywording.rs similarity index 100% rename from crates/ui-egui/src/tests_keywording.rs rename to crates/ui-egui/src/tests/keywording.rs diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests/labels.rs similarity index 100% rename from crates/ui-egui/src/tests_labels.rs rename to crates/ui-egui/src/tests/labels.rs diff --git a/crates/ui-egui/src/tests_launch_library.rs b/crates/ui-egui/src/tests/launch_library.rs similarity index 100% rename from crates/ui-egui/src/tests_launch_library.rs rename to crates/ui-egui/src/tests/launch_library.rs diff --git a/crates/ui-egui/src/tests_layout_image.rs b/crates/ui-egui/src/tests/layout_image.rs similarity index 100% rename from crates/ui-egui/src/tests_layout_image.rs rename to crates/ui-egui/src/tests/layout_image.rs diff --git a/crates/ui-egui/src/tests_library_problem.rs b/crates/ui-egui/src/tests/library_problem.rs similarity index 100% rename from crates/ui-egui/src/tests_library_problem.rs rename to crates/ui-egui/src/tests/library_problem.rs diff --git a/crates/ui-egui/src/tests_lifecycle_037.rs b/crates/ui-egui/src/tests/lifecycle_037.rs similarity index 100% rename from crates/ui-egui/src/tests_lifecycle_037.rs rename to crates/ui-egui/src/tests/lifecycle_037.rs diff --git a/crates/ui-egui/src/tests_lightroom_destination.rs b/crates/ui-egui/src/tests/lightroom_destination.rs similarity index 100% rename from crates/ui-egui/src/tests_lightroom_destination.rs rename to crates/ui-egui/src/tests/lightroom_destination.rs diff --git a/crates/ui-egui/src/tests_lights_out.rs b/crates/ui-egui/src/tests/lights_out.rs similarity index 100% rename from crates/ui-egui/src/tests_lights_out.rs rename to crates/ui-egui/src/tests/lights_out.rs diff --git a/crates/ui-egui/src/tests_mask_effects.rs b/crates/ui-egui/src/tests/mask_effects.rs similarity index 100% rename from crates/ui-egui/src/tests_mask_effects.rs rename to crates/ui-egui/src/tests/mask_effects.rs diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests/masking.rs similarity index 100% rename from crates/ui-egui/src/tests_masking.rs rename to crates/ui-egui/src/tests/masking.rs diff --git a/crates/ui-egui/src/tests_masking_layout.rs b/crates/ui-egui/src/tests/masking_layout.rs similarity index 100% rename from crates/ui-egui/src/tests_masking_layout.rs rename to crates/ui-egui/src/tests/masking_layout.rs diff --git a/crates/ui-egui/src/tests_menubar.rs b/crates/ui-egui/src/tests/menubar.rs similarity index 100% rename from crates/ui-egui/src/tests_menubar.rs rename to crates/ui-egui/src/tests/menubar.rs diff --git a/crates/ui-egui/src/tests_merge_preview.rs b/crates/ui-egui/src/tests/merge_preview.rs similarity index 100% rename from crates/ui-egui/src/tests_merge_preview.rs rename to crates/ui-egui/src/tests/merge_preview.rs diff --git a/crates/ui-egui/src/tests_model_setup.rs b/crates/ui-egui/src/tests/model_setup.rs similarity index 100% rename from crates/ui-egui/src/tests_model_setup.rs rename to crates/ui-egui/src/tests/model_setup.rs diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests/offline.rs similarity index 100% rename from crates/ui-egui/src/tests_offline.rs rename to crates/ui-egui/src/tests/offline.rs diff --git a/crates/ui-egui/src/tests_open_lightroom.rs b/crates/ui-egui/src/tests/open_lightroom.rs similarity index 100% rename from crates/ui-egui/src/tests_open_lightroom.rs rename to crates/ui-egui/src/tests/open_lightroom.rs diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests/panels.rs similarity index 100% rename from crates/ui-egui/src/tests_panels.rs rename to crates/ui-egui/src/tests/panels.rs diff --git a/crates/ui-egui/src/tests_preset_favorites.rs b/crates/ui-egui/src/tests/preset_favorites.rs similarity index 100% rename from crates/ui-egui/src/tests_preset_favorites.rs rename to crates/ui-egui/src/tests/preset_favorites.rs diff --git a/crates/ui-egui/src/tests_preset_import.rs b/crates/ui-egui/src/tests/preset_import.rs similarity index 100% rename from crates/ui-egui/src/tests_preset_import.rs rename to crates/ui-egui/src/tests/preset_import.rs diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests/preview_limit.rs similarity index 100% rename from crates/ui-egui/src/tests_preview_limit.rs rename to crates/ui-egui/src/tests/preview_limit.rs diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests/print.rs similarity index 100% rename from crates/ui-egui/src/tests_print.rs rename to crates/ui-egui/src/tests/print.rs diff --git a/crates/ui-egui/src/tests_profile_menu.rs b/crates/ui-egui/src/tests/profile_menu.rs similarity index 100% rename from crates/ui-egui/src/tests_profile_menu.rs rename to crates/ui-egui/src/tests/profile_menu.rs diff --git a/crates/ui-egui/src/tests_profiles_layout.rs b/crates/ui-egui/src/tests/profiles_layout.rs similarity index 100% rename from crates/ui-egui/src/tests_profiles_layout.rs rename to crates/ui-egui/src/tests/profiles_layout.rs diff --git a/crates/ui-egui/src/tests_progress.rs b/crates/ui-egui/src/tests/progress.rs similarity index 100% rename from crates/ui-egui/src/tests_progress.rs rename to crates/ui-egui/src/tests/progress.rs diff --git a/crates/ui-egui/src/tests_quit_unsaved.rs b/crates/ui-egui/src/tests/quit_unsaved.rs similarity index 100% rename from crates/ui-egui/src/tests_quit_unsaved.rs rename to crates/ui-egui/src/tests/quit_unsaved.rs diff --git a/crates/ui-egui/src/tests_rating.rs b/crates/ui-egui/src/tests/rating.rs similarity index 100% rename from crates/ui-egui/src/tests_rating.rs rename to crates/ui-egui/src/tests/rating.rs diff --git a/crates/ui-egui/src/tests_recheck.rs b/crates/ui-egui/src/tests/recheck.rs similarity index 100% rename from crates/ui-egui/src/tests_recheck.rs rename to crates/ui-egui/src/tests/recheck.rs diff --git a/crates/ui-egui/src/tests_rename_person.rs b/crates/ui-egui/src/tests/rename_person.rs similarity index 100% rename from crates/ui-egui/src/tests_rename_person.rs rename to crates/ui-egui/src/tests/rename_person.rs diff --git a/crates/ui-egui/src/tests_rules_editor.rs b/crates/ui-egui/src/tests/rules_editor.rs similarity index 100% rename from crates/ui-egui/src/tests_rules_editor.rs rename to crates/ui-egui/src/tests/rules_editor.rs diff --git a/crates/ui-egui/src/tests_sam_setup.rs b/crates/ui-egui/src/tests/sam_setup.rs similarity index 100% rename from crates/ui-egui/src/tests_sam_setup.rs rename to crates/ui-egui/src/tests/sam_setup.rs diff --git a/crates/ui-egui/src/tests_scroll.rs b/crates/ui-egui/src/tests/scroll.rs similarity index 100% rename from crates/ui-egui/src/tests_scroll.rs rename to crates/ui-egui/src/tests/scroll.rs diff --git a/crates/ui-egui/src/tests_slider_reset.rs b/crates/ui-egui/src/tests/slider_reset.rs similarity index 100% rename from crates/ui-egui/src/tests_slider_reset.rs rename to crates/ui-egui/src/tests/slider_reset.rs diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests/slideshow.rs similarity index 100% rename from crates/ui-egui/src/tests_slideshow.rs rename to crates/ui-egui/src/tests/slideshow.rs diff --git a/crates/ui-egui/src/tests_solo_sections.rs b/crates/ui-egui/src/tests/solo_sections.rs similarity index 100% rename from crates/ui-egui/src/tests_solo_sections.rs rename to crates/ui-egui/src/tests/solo_sections.rs diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests/switch_library.rs similarity index 100% rename from crates/ui-egui/src/tests_switch_library.rs rename to crates/ui-egui/src/tests/switch_library.rs diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests/sync.rs similarity index 100% rename from crates/ui-egui/src/tests_sync.rs rename to crates/ui-egui/src/tests/sync.rs diff --git a/crates/ui-egui/src/tests_tallies.rs b/crates/ui-egui/src/tests/tallies.rs similarity index 100% rename from crates/ui-egui/src/tests_tallies.rs rename to crates/ui-egui/src/tests/tallies.rs diff --git a/crates/ui-egui/src/tests_temp_slider.rs b/crates/ui-egui/src/tests/temp_slider.rs similarity index 100% rename from crates/ui-egui/src/tests_temp_slider.rs rename to crates/ui-egui/src/tests/temp_slider.rs diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests/titlebar.rs similarity index 100% rename from crates/ui-egui/src/tests_titlebar.rs rename to crates/ui-egui/src/tests/titlebar.rs diff --git a/crates/ui-egui/src/tests_tool_overlay.rs b/crates/ui-egui/src/tests/tool_overlay.rs similarity index 100% rename from crates/ui-egui/src/tests_tool_overlay.rs rename to crates/ui-egui/src/tests/tool_overlay.rs diff --git a/crates/ui-egui/src/tests_unsaved.rs b/crates/ui-egui/src/tests/unsaved.rs similarity index 100% rename from crates/ui-egui/src/tests_unsaved.rs rename to crates/ui-egui/src/tests/unsaved.rs diff --git a/crates/ui-egui/src/tests_view_cost.rs b/crates/ui-egui/src/tests/view_cost.rs similarity index 100% rename from crates/ui-egui/src/tests_view_cost.rs rename to crates/ui-egui/src/tests/view_cost.rs diff --git a/crates/ui-egui/src/tests_wb_selector.rs b/crates/ui-egui/src/tests/wb_selector.rs similarity index 100% rename from crates/ui-egui/src/tests_wb_selector.rs rename to crates/ui-egui/src/tests/wb_selector.rs diff --git a/crates/ui-egui/src/tests_zoom.rs b/crates/ui-egui/src/tests/zoom.rs similarity index 100% rename from crates/ui-egui/src/tests_zoom.rs rename to crates/ui-egui/src/tests/zoom.rs diff --git a/crates/ui-egui/src/tests_zoom_keys.rs b/crates/ui-egui/src/tests/zoom_keys.rs similarity index 100% rename from crates/ui-egui/src/tests_zoom_keys.rs rename to crates/ui-egui/src/tests/zoom_keys.rs From 9cc8a1c7658b2271210b65596bc82253e00f7253 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:24:54 +0000 Subject: [PATCH 074/191] refactor(lightcraft-engine): move tests_*.rs to tests/ --- crates/engine/src/lib.rs | 87 +------------------ crates/engine/src/tests.rs | 47 ++++++++++ .../album_order.rs} | 0 .../album_remove.rs} | 0 .../albums_local.rs} | 0 .../src/{tests_color.rs => tests/color.rs} | 0 .../constrain_crop.rs} | 0 .../crop_angle.rs} | 0 .../crop_aspect.rs} | 0 .../{tests_denoise.rs => tests/denoise.rs} | 0 .../src/{tests_export.rs => tests/export.rs} | 0 .../face_models.rs} | 0 .../face_recognize.rs} | 0 .../face_tiles.rs} | 0 .../filesystem_history.rs} | 0 .../focus_scan.rs} | 0 .../{tests_folders.rs => tests/folders.rs} | 0 .../forget_local.rs} | 0 .../history_cursor.rs} | 0 .../hold_view.rs} | 0 .../ids_params.rs} | 0 .../src/{tests_import.rs => tests/import.rs} | 0 .../import_move.rs} | 0 .../keyword_list.rs} | 0 .../src/{tests_libops.rs => tests/libops.rs} | 0 .../{tests_library.rs => tests/library.rs} | 0 .../lightroom_folder.rs} | 0 .../mask_effects.rs} | 0 .../src/{tests_merge.rs => tests/merge.rs} | 0 .../move_to_share.rs} | 0 .../{tests_organize.rs => tests/organize.rs} | 0 .../{tests_persist.rs => tests/persist.rs} | 0 .../src/{tests_prefs.rs => tests/prefs.rs} | 0 .../presets_scale.rs} | 0 .../src/{tests_print.rs => tests/print.rs} | 0 .../{tests_process.rs => tests/process.rs} | 0 .../{tests_recheck.rs => tests/recheck.rs} | 0 .../{tests_segment.rs => tests/segment.rs} | 0 .../settings_files.rs} | 0 .../src/{tests_slide.rs => tests/slide.rs} | 0 .../slideshow_export.rs} | 0 .../src/{tests_spots.rs => tests/spots.rs} | 0 .../src/{tests_sync.rs => tests/sync.rs} | 0 .../view_follow.rs} | 0 .../engine/src/{tests_xmp.rs => tests/xmp.rs} | 0 45 files changed, 49 insertions(+), 85 deletions(-) rename crates/engine/src/{tests_album_order.rs => tests/album_order.rs} (100%) rename crates/engine/src/{tests_album_remove.rs => tests/album_remove.rs} (100%) rename crates/engine/src/{tests_albums_local.rs => tests/albums_local.rs} (100%) rename crates/engine/src/{tests_color.rs => tests/color.rs} (100%) rename crates/engine/src/{tests_constrain_crop.rs => tests/constrain_crop.rs} (100%) rename crates/engine/src/{tests_crop_angle.rs => tests/crop_angle.rs} (100%) rename crates/engine/src/{tests_crop_aspect.rs => tests/crop_aspect.rs} (100%) rename crates/engine/src/{tests_denoise.rs => tests/denoise.rs} (100%) rename crates/engine/src/{tests_export.rs => tests/export.rs} (100%) rename crates/engine/src/{tests_face_models.rs => tests/face_models.rs} (100%) rename crates/engine/src/{tests_face_recognize.rs => tests/face_recognize.rs} (100%) rename crates/engine/src/{tests_face_tiles.rs => tests/face_tiles.rs} (100%) rename crates/engine/src/{tests_filesystem_history.rs => tests/filesystem_history.rs} (100%) rename crates/engine/src/{tests_focus_scan.rs => tests/focus_scan.rs} (100%) rename crates/engine/src/{tests_folders.rs => tests/folders.rs} (100%) rename crates/engine/src/{tests_forget_local.rs => tests/forget_local.rs} (100%) rename crates/engine/src/{tests_history_cursor.rs => tests/history_cursor.rs} (100%) rename crates/engine/src/{tests_hold_view.rs => tests/hold_view.rs} (100%) rename crates/engine/src/{tests_ids_params.rs => tests/ids_params.rs} (100%) rename crates/engine/src/{tests_import.rs => tests/import.rs} (100%) rename crates/engine/src/{tests_import_move.rs => tests/import_move.rs} (100%) rename crates/engine/src/{tests_keyword_list.rs => tests/keyword_list.rs} (100%) rename crates/engine/src/{tests_libops.rs => tests/libops.rs} (100%) rename crates/engine/src/{tests_library.rs => tests/library.rs} (100%) rename crates/engine/src/{tests_lightroom_folder.rs => tests/lightroom_folder.rs} (100%) rename crates/engine/src/{tests_mask_effects.rs => tests/mask_effects.rs} (100%) rename crates/engine/src/{tests_merge.rs => tests/merge.rs} (100%) rename crates/engine/src/{tests_move_to_share.rs => tests/move_to_share.rs} (100%) rename crates/engine/src/{tests_organize.rs => tests/organize.rs} (100%) rename crates/engine/src/{tests_persist.rs => tests/persist.rs} (100%) rename crates/engine/src/{tests_prefs.rs => tests/prefs.rs} (100%) rename crates/engine/src/{tests_presets_scale.rs => tests/presets_scale.rs} (100%) rename crates/engine/src/{tests_print.rs => tests/print.rs} (100%) rename crates/engine/src/{tests_process.rs => tests/process.rs} (100%) rename crates/engine/src/{tests_recheck.rs => tests/recheck.rs} (100%) rename crates/engine/src/{tests_segment.rs => tests/segment.rs} (100%) rename crates/engine/src/{tests_settings_files.rs => tests/settings_files.rs} (100%) rename crates/engine/src/{tests_slide.rs => tests/slide.rs} (100%) rename crates/engine/src/{tests_slideshow_export.rs => tests/slideshow_export.rs} (100%) rename crates/engine/src/{tests_spots.rs => tests/spots.rs} (100%) rename crates/engine/src/{tests_sync.rs => tests/sync.rs} (100%) rename crates/engine/src/{tests_view_follow.rs => tests/view_follow.rs} (100%) rename crates/engine/src/{tests_xmp.rs => tests/xmp.rs} (100%) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff2b972c0..acaa18070 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1360,90 +1360,7 @@ pub fn json_delta(old: &Value, new: &Value) -> Option { #[cfg(test)] mod tests; #[cfg(test)] -mod tests_album_order; -#[cfg(test)] -mod tests_album_remove; -#[cfg(test)] -mod tests_albums_local; -#[cfg(test)] -mod tests_color; -#[cfg(test)] -mod tests_constrain_crop; -#[cfg(test)] -mod tests_crop_angle; -#[cfg(test)] -mod tests_crop_aspect; -#[cfg(test)] -mod tests_denoise; -#[cfg(test)] -mod tests_export; -#[cfg(test)] -mod tests_face_models; -#[cfg(test)] #[cfg(not(target_arch = "wasm32"))] -mod tests_face_recognize; +use tests::face_recognize as tests_face_recognize; #[cfg(test)] -#[cfg(not(target_arch = "wasm32"))] -mod tests_face_tiles; -#[cfg(test)] -mod tests_filesystem_history; -#[cfg(all(test, not(target_arch = "wasm32")))] -mod tests_focus_scan; -#[cfg(test)] -mod tests_folders; -#[cfg(test)] -mod tests_forget_local; -#[cfg(test)] -mod tests_history_cursor; -#[cfg(test)] -mod tests_hold_view; -#[cfg(test)] -mod tests_ids_params; -#[cfg(test)] -mod tests_import; -#[cfg(test)] -mod tests_import_move; -#[cfg(test)] -mod tests_keyword_list; -#[cfg(test)] -mod tests_libops; -#[cfg(test)] -mod tests_library; -#[cfg(test)] -mod tests_lightroom_folder; -#[cfg(test)] -mod tests_mask_effects; -#[cfg(test)] -mod tests_merge; -#[cfg(test)] -mod tests_move_to_share; -#[cfg(test)] -mod tests_organize; -#[cfg(test)] -mod tests_persist; -#[cfg(test)] -mod tests_prefs; -#[cfg(test)] -mod tests_presets_scale; -#[cfg(test)] -mod tests_print; -#[cfg(test)] -mod tests_process; -#[cfg(test)] -mod tests_recheck; -#[cfg(test)] -mod tests_segment; -#[cfg(test)] -mod tests_settings_files; -#[cfg(test)] -mod tests_slide; -#[cfg(test)] -mod tests_slideshow_export; -#[cfg(test)] -mod tests_spots; -#[cfg(test)] -mod tests_sync; -#[cfg(test)] -mod tests_view_follow; -#[cfg(test)] -mod tests_xmp; +use tests::xmp as tests_xmp; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 93f4e6adc..185c94f63 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2,6 +2,53 @@ use serde_json::{Value, json}; use crate::{Selection, Session, command_specs}; +mod album_order; +mod album_remove; +mod albums_local; +mod color; +mod constrain_crop; +mod crop_angle; +mod crop_aspect; +mod denoise; +mod export; +mod face_models; +#[cfg(not(target_arch = "wasm32"))] +pub(super) mod face_recognize; +#[cfg(not(target_arch = "wasm32"))] +mod face_tiles; +mod filesystem_history; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod focus_scan; +mod folders; +mod forget_local; +mod history_cursor; +mod hold_view; +mod ids_params; +mod import; +mod import_move; +mod keyword_list; +mod libops; +mod library; +mod lightroom_folder; +mod mask_effects; +mod merge; +mod move_to_share; +mod organize; +mod persist; +mod prefs; +mod presets_scale; +mod print; +mod process; +mod recheck; +mod segment; +mod settings_files; +mod slide; +mod slideshow_export; +mod spots; +mod sync; +mod view_follow; +pub(super) mod xmp; + fn demo() -> Session { Session::with_demo() } diff --git a/crates/engine/src/tests_album_order.rs b/crates/engine/src/tests/album_order.rs similarity index 100% rename from crates/engine/src/tests_album_order.rs rename to crates/engine/src/tests/album_order.rs diff --git a/crates/engine/src/tests_album_remove.rs b/crates/engine/src/tests/album_remove.rs similarity index 100% rename from crates/engine/src/tests_album_remove.rs rename to crates/engine/src/tests/album_remove.rs diff --git a/crates/engine/src/tests_albums_local.rs b/crates/engine/src/tests/albums_local.rs similarity index 100% rename from crates/engine/src/tests_albums_local.rs rename to crates/engine/src/tests/albums_local.rs diff --git a/crates/engine/src/tests_color.rs b/crates/engine/src/tests/color.rs similarity index 100% rename from crates/engine/src/tests_color.rs rename to crates/engine/src/tests/color.rs diff --git a/crates/engine/src/tests_constrain_crop.rs b/crates/engine/src/tests/constrain_crop.rs similarity index 100% rename from crates/engine/src/tests_constrain_crop.rs rename to crates/engine/src/tests/constrain_crop.rs diff --git a/crates/engine/src/tests_crop_angle.rs b/crates/engine/src/tests/crop_angle.rs similarity index 100% rename from crates/engine/src/tests_crop_angle.rs rename to crates/engine/src/tests/crop_angle.rs diff --git a/crates/engine/src/tests_crop_aspect.rs b/crates/engine/src/tests/crop_aspect.rs similarity index 100% rename from crates/engine/src/tests_crop_aspect.rs rename to crates/engine/src/tests/crop_aspect.rs diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests/denoise.rs similarity index 100% rename from crates/engine/src/tests_denoise.rs rename to crates/engine/src/tests/denoise.rs diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests/export.rs similarity index 100% rename from crates/engine/src/tests_export.rs rename to crates/engine/src/tests/export.rs diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests/face_models.rs similarity index 100% rename from crates/engine/src/tests_face_models.rs rename to crates/engine/src/tests/face_models.rs diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests/face_recognize.rs similarity index 100% rename from crates/engine/src/tests_face_recognize.rs rename to crates/engine/src/tests/face_recognize.rs diff --git a/crates/engine/src/tests_face_tiles.rs b/crates/engine/src/tests/face_tiles.rs similarity index 100% rename from crates/engine/src/tests_face_tiles.rs rename to crates/engine/src/tests/face_tiles.rs diff --git a/crates/engine/src/tests_filesystem_history.rs b/crates/engine/src/tests/filesystem_history.rs similarity index 100% rename from crates/engine/src/tests_filesystem_history.rs rename to crates/engine/src/tests/filesystem_history.rs diff --git a/crates/engine/src/tests_focus_scan.rs b/crates/engine/src/tests/focus_scan.rs similarity index 100% rename from crates/engine/src/tests_focus_scan.rs rename to crates/engine/src/tests/focus_scan.rs diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests/folders.rs similarity index 100% rename from crates/engine/src/tests_folders.rs rename to crates/engine/src/tests/folders.rs diff --git a/crates/engine/src/tests_forget_local.rs b/crates/engine/src/tests/forget_local.rs similarity index 100% rename from crates/engine/src/tests_forget_local.rs rename to crates/engine/src/tests/forget_local.rs diff --git a/crates/engine/src/tests_history_cursor.rs b/crates/engine/src/tests/history_cursor.rs similarity index 100% rename from crates/engine/src/tests_history_cursor.rs rename to crates/engine/src/tests/history_cursor.rs diff --git a/crates/engine/src/tests_hold_view.rs b/crates/engine/src/tests/hold_view.rs similarity index 100% rename from crates/engine/src/tests_hold_view.rs rename to crates/engine/src/tests/hold_view.rs diff --git a/crates/engine/src/tests_ids_params.rs b/crates/engine/src/tests/ids_params.rs similarity index 100% rename from crates/engine/src/tests_ids_params.rs rename to crates/engine/src/tests/ids_params.rs diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests/import.rs similarity index 100% rename from crates/engine/src/tests_import.rs rename to crates/engine/src/tests/import.rs diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests/import_move.rs similarity index 100% rename from crates/engine/src/tests_import_move.rs rename to crates/engine/src/tests/import_move.rs diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests/keyword_list.rs similarity index 100% rename from crates/engine/src/tests_keyword_list.rs rename to crates/engine/src/tests/keyword_list.rs diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests/libops.rs similarity index 100% rename from crates/engine/src/tests_libops.rs rename to crates/engine/src/tests/libops.rs diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests/library.rs similarity index 100% rename from crates/engine/src/tests_library.rs rename to crates/engine/src/tests/library.rs diff --git a/crates/engine/src/tests_lightroom_folder.rs b/crates/engine/src/tests/lightroom_folder.rs similarity index 100% rename from crates/engine/src/tests_lightroom_folder.rs rename to crates/engine/src/tests/lightroom_folder.rs diff --git a/crates/engine/src/tests_mask_effects.rs b/crates/engine/src/tests/mask_effects.rs similarity index 100% rename from crates/engine/src/tests_mask_effects.rs rename to crates/engine/src/tests/mask_effects.rs diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests/merge.rs similarity index 100% rename from crates/engine/src/tests_merge.rs rename to crates/engine/src/tests/merge.rs diff --git a/crates/engine/src/tests_move_to_share.rs b/crates/engine/src/tests/move_to_share.rs similarity index 100% rename from crates/engine/src/tests_move_to_share.rs rename to crates/engine/src/tests/move_to_share.rs diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests/organize.rs similarity index 100% rename from crates/engine/src/tests_organize.rs rename to crates/engine/src/tests/organize.rs diff --git a/crates/engine/src/tests_persist.rs b/crates/engine/src/tests/persist.rs similarity index 100% rename from crates/engine/src/tests_persist.rs rename to crates/engine/src/tests/persist.rs diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests/prefs.rs similarity index 100% rename from crates/engine/src/tests_prefs.rs rename to crates/engine/src/tests/prefs.rs diff --git a/crates/engine/src/tests_presets_scale.rs b/crates/engine/src/tests/presets_scale.rs similarity index 100% rename from crates/engine/src/tests_presets_scale.rs rename to crates/engine/src/tests/presets_scale.rs diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests/print.rs similarity index 100% rename from crates/engine/src/tests_print.rs rename to crates/engine/src/tests/print.rs diff --git a/crates/engine/src/tests_process.rs b/crates/engine/src/tests/process.rs similarity index 100% rename from crates/engine/src/tests_process.rs rename to crates/engine/src/tests/process.rs diff --git a/crates/engine/src/tests_recheck.rs b/crates/engine/src/tests/recheck.rs similarity index 100% rename from crates/engine/src/tests_recheck.rs rename to crates/engine/src/tests/recheck.rs diff --git a/crates/engine/src/tests_segment.rs b/crates/engine/src/tests/segment.rs similarity index 100% rename from crates/engine/src/tests_segment.rs rename to crates/engine/src/tests/segment.rs diff --git a/crates/engine/src/tests_settings_files.rs b/crates/engine/src/tests/settings_files.rs similarity index 100% rename from crates/engine/src/tests_settings_files.rs rename to crates/engine/src/tests/settings_files.rs diff --git a/crates/engine/src/tests_slide.rs b/crates/engine/src/tests/slide.rs similarity index 100% rename from crates/engine/src/tests_slide.rs rename to crates/engine/src/tests/slide.rs diff --git a/crates/engine/src/tests_slideshow_export.rs b/crates/engine/src/tests/slideshow_export.rs similarity index 100% rename from crates/engine/src/tests_slideshow_export.rs rename to crates/engine/src/tests/slideshow_export.rs diff --git a/crates/engine/src/tests_spots.rs b/crates/engine/src/tests/spots.rs similarity index 100% rename from crates/engine/src/tests_spots.rs rename to crates/engine/src/tests/spots.rs diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests/sync.rs similarity index 100% rename from crates/engine/src/tests_sync.rs rename to crates/engine/src/tests/sync.rs diff --git a/crates/engine/src/tests_view_follow.rs b/crates/engine/src/tests/view_follow.rs similarity index 100% rename from crates/engine/src/tests_view_follow.rs rename to crates/engine/src/tests/view_follow.rs diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests/xmp.rs similarity index 100% rename from crates/engine/src/tests_xmp.rs rename to crates/engine/src/tests/xmp.rs From 73adafbae06612238ae17835e4fd5ea14f62e051 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:28:42 +0000 Subject: [PATCH 075/191] refactor(lightcraft-engine): move tests_slide_template.rs to slide_template/tests.rs --- crates/engine/src/slide_template.rs | 1 - .../src/{tests_slide_template.rs => slide_template/tests.rs} | 0 2 files changed, 1 deletion(-) rename crates/engine/src/{tests_slide_template.rs => slide_template/tests.rs} (100%) diff --git a/crates/engine/src/slide_template.rs b/crates/engine/src/slide_template.rs index 32e26ed59..bf56a6569 100644 --- a/crates/engine/src/slide_template.rs +++ b/crates/engine/src/slide_template.rs @@ -628,5 +628,4 @@ impl Session { } #[cfg(test)] -#[path = "tests_slide_template.rs"] mod tests; diff --git a/crates/engine/src/tests_slide_template.rs b/crates/engine/src/slide_template/tests.rs similarity index 100% rename from crates/engine/src/tests_slide_template.rs rename to crates/engine/src/slide_template/tests.rs From f26ec05470e16a86e7c87e8d4d5d7046116a857c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:28:42 +0000 Subject: [PATCH 076/191] refactor(lightcraft-ui-egui): move panels/grid_header_tests.rs to panels/grid/header_tests.rs --- crates/ui-egui/src/panels/grid.rs | 1 - .../src/panels/{grid_header_tests.rs => grid/header_tests.rs} | 0 2 files changed, 1 deletion(-) rename crates/ui-egui/src/panels/{grid_header_tests.rs => grid/header_tests.rs} (100%) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index ba2e48869..c36feadf8 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1,7 +1,6 @@ //! Photo Grid (justified rows) and Square Grid. Virtualized: only visible cells request thumbnails. #[cfg(test)] -#[path = "grid_header_tests.rs"] mod header_tests; use std::collections::{HashMap, HashSet}; diff --git a/crates/ui-egui/src/panels/grid_header_tests.rs b/crates/ui-egui/src/panels/grid/header_tests.rs similarity index 100% rename from crates/ui-egui/src/panels/grid_header_tests.rs rename to crates/ui-egui/src/panels/grid/header_tests.rs From d4e44696fbaf01ccfdf75aab1da114cb287bff43 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:28:42 +0000 Subject: [PATCH 077/191] refactor(lightcraft-mcp): move progress.rs to server/progress.rs --- crates/mcp/src/server.rs | 1 - crates/mcp/src/{ => server}/progress.rs | 1 - .../src/{progress_tests.rs => server/progress/shutdown_tests.rs} | 0 3 files changed, 2 deletions(-) rename crates/mcp/src/{ => server}/progress.rs (99%) rename crates/mcp/src/{progress_tests.rs => server/progress/shutdown_tests.rs} (100%) diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index de086ca70..c00554fa8 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -4,7 +4,6 @@ use std::io::{BufRead, Write}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -#[path = "progress.rs"] mod progress; use serde_json::{Value, json}; diff --git a/crates/mcp/src/progress.rs b/crates/mcp/src/server/progress.rs similarity index 99% rename from crates/mcp/src/progress.rs rename to crates/mcp/src/server/progress.rs index 3f6a8deec..455962266 100644 --- a/crates/mcp/src/progress.rs +++ b/crates/mcp/src/server/progress.rs @@ -222,7 +222,6 @@ impl Server { } #[cfg(test)] -#[path = "progress_tests.rs"] mod shutdown_tests; #[cfg(test)] diff --git a/crates/mcp/src/progress_tests.rs b/crates/mcp/src/server/progress/shutdown_tests.rs similarity index 100% rename from crates/mcp/src/progress_tests.rs rename to crates/mcp/src/server/progress/shutdown_tests.rs From d151df014930367431b054b558d77d4487b7d6e9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:30:36 +0000 Subject: [PATCH 078/191] style: cargo fmt the moved test modules --- apps/lightcraft/src/logging/tests.rs | 3 +- apps/lightcraft/src/tests.rs | 3 +- crates/catalog/src/keywords/tests.rs | 19 ++--- crates/catalog/src/rules/tests.rs | 24 ++---- crates/engine/src/camera_preview/tests.rs | 29 ++------ crates/engine/src/crs_masks/tests.rs | 13 ++-- crates/engine/src/export/tests.rs | 43 +++-------- crates/engine/src/files/tests.rs | 16 ++-- crates/engine/src/lightroom_catalog/tests.rs | 9 +-- crates/engine/src/lightroom_sqlite/tests.rs | 5 +- crates/engine/src/media/tests.rs | 5 +- crates/engine/src/slide_overlay/tests.rs | 21 ++---- crates/engine/src/smart/tests.rs | 7 +- crates/faces/src/net/tests.rs | 23 ++---- crates/meta/src/focus/tests.rs | 27 ++----- crates/pipeline/src/optics/tests.rs | 10 +-- crates/ui-egui/src/headless/tests.rs | 78 +++++--------------- crates/ui-egui/src/i18n/tests.rs | 53 ++++--------- crates/ui-egui/src/menubar/tests.rs | 9 +-- crates/ui-egui/src/region/tests.rs | 10 +-- crates/ui-egui/src/render/thumbnail_tests.rs | 6 +- crates/ui-egui/src/shortcuts/tests.rs | 16 +--- crates/ui-egui/src/tests.rs | 4 +- crates/ui-egui/src/window_place/tests.rs | 8 +- 24 files changed, 122 insertions(+), 319 deletions(-) diff --git a/apps/lightcraft/src/logging/tests.rs b/apps/lightcraft/src/logging/tests.rs index e4d663191..b801c7d75 100644 --- a/apps/lightcraft/src/logging/tests.rs +++ b/apps/lightcraft/src/logging/tests.rs @@ -47,8 +47,7 @@ fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() { /// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger. #[test] fn lightcraft_log_keeps_its_meaning() { - let targets = - ["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"]; + let targets = ["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"]; let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace]; for value in ["info", "debug", "warn", "error", "verbose", "INFO"] { let f = Filter::parse(&filter_spec(Some(value), None)); diff --git a/apps/lightcraft/src/tests.rs b/apps/lightcraft/src/tests.rs index adbcadc20..0afbc4b2c 100644 --- a/apps/lightcraft/src/tests.rs +++ b/apps/lightcraft/src/tests.rs @@ -413,8 +413,7 @@ fn the_writer_follows_the_window_from_frame_to_frame() { let mut writer = PrefsWriter::default(); let ctx = egui::Context::default(); let pass = |look: &mut dyn FnMut(&egui::Context)| { - let raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() }; + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| look(ui.ctx())); out.textures_delta.clear(); out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay) diff --git a/crates/catalog/src/keywords/tests.rs b/crates/catalog/src/keywords/tests.rs index a177e6171..df65606ef 100644 --- a/crates/catalog/src/keywords/tests.rs +++ b/crates/catalog/src/keywords/tests.rs @@ -136,10 +136,7 @@ fn renaming_a_keyword_carries_its_listing_along() { let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap(); let undo = c.apply(op).unwrap(); assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]); - assert_eq!( - listed(&c), - [("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())] - ); + assert_eq!(listed(&c), [("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())]); c.apply(undo).unwrap(); assert_eq!(c.to_snapshot(), before); } @@ -161,11 +158,8 @@ fn merging_keeps_the_targets_attributes_and_gathers_synonyms() { let (mut c, _) = lib(&[&["holiday"], &["travel"]]); let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) }; c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap(); - c.apply(Op::SetKeyword { - path: "holiday".into(), - info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }), - }) - .unwrap(); + c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }) }) + .unwrap(); let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap(); c.apply(op).unwrap(); assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]); @@ -529,8 +523,11 @@ fn a_photo_supreme_vocabulary_file_reads() { let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect(); assert_eq!( paths, - ["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"] - .map(|p| if p == "Places" { p.to_string() } else { format!("Places|{p}") }) + ["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"].map(|p| if p == "Places" { + p.to_string() + } else { + format!("Places|{p}") + }) ); assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported"); assert!(read[1..].iter().all(|(_, i)| i.include_on_export)); diff --git a/crates/catalog/src/rules/tests.rs b/crates/catalog/src/rules/tests.rs index 4c4a9c8e8..8d9d649b9 100644 --- a/crates/catalog/src/rules/tests.rs +++ b/crates/catalog/src/rules/tests.rs @@ -22,8 +22,7 @@ fn rs(v: serde_json::Value) -> RuleSet { #[test] fn f32_boundary_values_match_gte_lte() { let cat = Catalog::new(); - let matches = - |p: &Photo, field: &str, op: &str, value: f64| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat); + let matches = |p: &Photo, field: &str, op: &str, value: f64| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat); for v in [1.4f32, 1.8, 2.8, 5.6, 0.95] { let mut p = photo(1); p.meta.aperture = Some(v); @@ -49,9 +48,7 @@ fn fields_ops_and_groups() { yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]})); no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]})); yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]})); - yes( - json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}), - ); + yes(json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]})); no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]})); // file path: the whole string (spaces included), either separator, case-insensitive @@ -116,9 +113,8 @@ fn problems_and_description() { fn keyword_count_rules() { let cat = Catalog::new(); let mut p = photo(1); // travel|italy|rome, food - let m = |p: &Photo, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat) - }; + let m = + |p: &Photo, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat); assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2))); assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2]))); p.meta.keywords.push("Food".into()); @@ -167,9 +163,8 @@ fn people_rules() { fn shutter_speed_rules() { let cat = Catalog::new(); let mut p = photo(1); - let m = |p: &Photo, op: &str, value: serde_json::Value| { - rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat) - }; + let m = + |p: &Photo, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat); assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded"); p.meta.shutter = "1/250".into(); assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60"))); @@ -279,9 +274,7 @@ fn size_rules_follow_orientation_and_crop() { assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24))); assert!(m(&p, "aspect", "isNot", json!("portrait"))); let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00"); - assert!( - !m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0)) - ); + assert!(!m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0))); assert!(m(&empty, "megapixels", "is", json!(0))); } @@ -358,8 +351,7 @@ fn yes_no_values() { let p = photo(1); // unedited assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no"); assert!( - !rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]})) - .matches(&p, &cat) + !rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]})).matches(&p, &cat) ); assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing"); let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]})); diff --git a/crates/engine/src/camera_preview/tests.rs b/crates/engine/src/camera_preview/tests.rs index bd2703f96..d8442589d 100644 --- a/crates/engine/src/camera_preview/tests.rs +++ b/crates/engine/src/camera_preview/tests.rs @@ -102,8 +102,7 @@ fn camera_toe_requires_lower_independent_dark_error() { .collect(), ) .unwrap(); - let held: Pairs = - sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect(); + let held: Pairs = sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect(); assert_eq!(held.len(), 200); assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y)))); assert!(rank_correlation_ties(&held) > 0.99); @@ -277,11 +276,7 @@ fn hue_table_follows_a_hue_dependent_camera_rendering() { for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { let ev = 0.05 + (i % 29) as f32 * 0.02; // mostly greys and mild colours, plus a patch of saturated yellow-green - *src = if i % 7 == 0 { - [ev * 0.75, ev, ev * 0.2] - } else { - [ev * (0.85 + (i % 11) as f32 * 0.03), ev, ev * (0.85 + (i % 13) as f32 * 0.025)] - }; + *src = if i % 7 == 0 { [ev * 0.75, ev, ev * 0.2] } else { [ev * (0.85 + (i % 11) as f32 * 0.03), ev, ev * (0.85 + (i % 13) as f32 * 0.025)] }; let (h, s) = hue_saturation(to.apply(src.map(f64::from))).unwrap(); // the camera turns hues in 50°..110° (ProPhoto) by up to +15° and boosts their saturation let k = (1.0 - ((h - 80.0) / 30.0).powi(2)).max(0.0) * s.min(1.0); @@ -1465,12 +1460,7 @@ fn corpus_arw_white_balance_in_kelvin_through_the_presets() { let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}"); - assert!( - (info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0, - "{name}: {} {}", - info.as_shot_temp, - info.as_shot_tint - ); + assert!((info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0, "{name}: {} {}", info.as_shot_temp, info.as_shot_tint); let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default()); assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}"); } @@ -1622,8 +1612,7 @@ fn clipped_sky_does_not_dull_the_look() { colourfulness_ratio(&old, &sensor, &reference, &clipped, mid.clone()), colourfulness_ratio(&old, &sensor, &reference, &clipped, bright.clone()), ); - let new_ratio = - (colourfulness_ratio(&new, &sensor, &reference, &clipped, mid), colourfulness_ratio(&new, &sensor, &reference, &clipped, bright)); + let new_ratio = (colourfulness_ratio(&new, &sensor, &reference, &clipped, mid), colourfulness_ratio(&new, &sensor, &reference, &clipped, bright)); // with the clipped sky in the pairs, the bright colours lose much of their colour assert!(old_ratio.1 < 0.8, "earlier fit: colourfulness vs camera {old_ratio:?}, chroma {:?}", old.tone.chroma()); // without it, the render keeps the camera's colourfulness at every brightness @@ -1764,12 +1753,7 @@ fn a_near_black_scene_gets_tone_without_learning_colour() { assert!(look.hue_sat.is_none()); let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); let error = |tone: &ToneMap| { - sensor - .data - .iter() - .zip(&reference.data) - .map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2)) - .sum::() + sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2)).sum::() }; assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001); assert_eq!(look.tone.apply(0.0), 0.0); @@ -1863,8 +1847,7 @@ fn partial_look_brightens_a_dull_scene_without_learning_colour() { assert_eq!(look.matrix, Mat3::IDENTITY, "no colour matrix from a colourless scene"); let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); let neutral = ToneMap::new(0.0, 0.0, 0.0); - let lightness = - |tone: &ToneMap| sensor.data.iter().map(|p| f64::from(tone.apply(luminance_2020(*p)))).sum::() / sensor.data.len() as f64; + let lightness = |tone: &ToneMap| sensor.data.iter().map(|p| f64::from(tone.apply(luminance_2020(*p)))).sum::() / sensor.data.len() as f64; let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / reference.data.len() as f64; assert!((lightness(&tone) / target - 1.0).abs() < 0.05, "{} vs {target}", lightness(&tone)); assert!(lightness(&neutral) < target * 0.6, "the neutral fallback is far darker"); diff --git a/crates/engine/src/crs_masks/tests.rs b/crates/engine/src/crs_masks/tests.rs index 4e507df3e..13386b235 100644 --- a/crates/engine/src/crs_masks/tests.rs +++ b/crates/engine/src/crs_masks/tests.rs @@ -71,9 +71,8 @@ fn corrections_become_masks() { fn masks_render() { let (s, _) = settings(); let img = lightcraft_scenes::demo_library()[0].render(96, 64); - let render = |d: &DevelopSettings| { - lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data - }; + let render = + |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data; assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); } @@ -115,8 +114,7 @@ fn lrtemplate_corrections() { fn presets_add_masks_once() { let (s, _) = settings(); let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}}); - let p = - lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false }; + let p = lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false }; let mut mine = DevelopSettings::default(); mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() }); let once = p.apply(&mine, 1.0); @@ -325,8 +323,7 @@ fn spots_render() { let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); let s = DevelopSettings::default().merged(&partial).unwrap(); let img = lightcraft_scenes::demo_library()[0].render(96, 64); - let render = |d: &DevelopSettings| { - lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data - }; + let render = + |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data; assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture"); } diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs index ca3fba3f8..5ad311c7d 100644 --- a/crates/engine/src/export/tests.rs +++ b/crates/engine/src/export/tests.rs @@ -77,9 +77,7 @@ fn hdr_jpeg_size_limit_respected() { width: img.width, height: img.height, space: OutputSpace::Srgb, - samples: DeepSamples::F32( - img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect(), - ), + samples: DeepSamples::F32(img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect()), }; let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() }; let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap(); @@ -375,8 +373,7 @@ fn watermark_preview_scales_the_minimum_text_size() { } fn vertical_japanese() -> Watermark { - let options = - ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); + let options = ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); options.watermark.unwrap() } @@ -543,14 +540,8 @@ fn graphic_watermark_is_placed_scaled_and_blended() { std::fs::write(&logo_path, png).unwrap(); let mut img = Rgba8::new(400, 300); img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); - let wm = Watermark { - image: logo_path.to_string_lossy().into(), - image_width: 0.25, - opacity: 1.0, - inset_x: 0.0, - inset_y: 0.0, - ..Default::default() - }; + let wm = + Watermark { image: logo_path.to_string_lossy().into(), image_width: 0.25, opacity: 1.0, inset_x: 0.0, inset_y: 0.0, ..Default::default() }; draw_watermark(&mut img, &wm); // 100 px wide × 50 px high in the bottom-right corner assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280)); @@ -836,8 +827,7 @@ fn every_space_embeds_its_own_profile_and_round_trips() { let src = lightcraft_raster::Rgb32f::filled(16, 16, [0.18; 3]); let base = lightcraft_pipeline::RenderRequest::fit(16, 16); move |space| { - lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base }) - .image + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base }).image } }; let reference = @@ -915,11 +905,8 @@ fn sixteen_bit_tiff_and_png_have_no_banding() { assert!(levels > 10 * levels8, "{levels}"); assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); // sharpening and watermarking work on deep images too - let o = ExportOptions { - sharpen: SharpenFor::Matte, - watermark: Some(Watermark { text: "LC".into(), size: 0.5, ..Default::default() }), - ..tiff(None) - }; + let o = + ExportOptions { sharpen: SharpenFor::Matte, watermark: Some(Watermark { text: "LC".into(), size: 0.5, ..Default::default() }), ..tiff(None) }; assert!(encode_rendered(&r16, &o, None).is_ok()); } @@ -1176,10 +1163,7 @@ fn destination_mode_param() { assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}"); assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err()); assert!( - ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"})) - .unwrap_err() - .to_string() - .contains("did you mean `destinationMode`") + ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"})).unwrap_err().to_string().contains("did you mean `destinationMode`") ); // saved (a preset, `lastExport`) and read back let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; @@ -1207,8 +1191,7 @@ fn accepted_choices_are_the_ones_exported() { assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}"); } } - for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)] - { + for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)] { assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want); } for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] { @@ -1402,9 +1385,8 @@ fn a_panic_in_progress_does_not_hang_the_lanes() { assert!(calls < 2, "progress panics on its second call"); true }; - let r = crate::guard::catch("test batch", || { - run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2) - }); + let r = + crate::guard::catch("test batch", || run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2)); tx.send(r.and_then(|r| r)).ok(); }); let r = rx.recv_timeout(std::time::Duration::from_secs(60)); @@ -1418,8 +1400,7 @@ fn side_by_side_batches_report_failures_in_order() { use lightcraft_catalog::{Op, Photo, PhotoId, Source}; let mut s = crate::Session::with_demo(); let mut ids: Vec<_> = s.visible().iter().copied().take(3).collect(); - let gone = - Photo::new(PhotoId(9_496), Source::File { path: "no/such/dir/gone.jpg".into() }, "gone.jpg", "JPEG", 600, 400, "2026-10-09T00:00:00"); + let gone = Photo::new(PhotoId(9_496), Source::File { path: "no/such/dir/gone.jpg".into() }, "gone.jpg", "JPEG", 600, 400, "2026-10-09T00:00:00"); s.catalog.apply(Op::AddPhoto { photo: Box::new(gone) }).unwrap(); ids.insert(1, PhotoId(9_496)); let o = ExportOptions::from_json(&json!({"format": "jpeg", "width": 40})); diff --git a/crates/engine/src/files/tests.rs b/crates/engine/src/files/tests.rs index d266ad35a..e8bcebe4c 100644 --- a/crates/engine/src/files/tests.rs +++ b/crates/engine/src/files/tests.rs @@ -333,11 +333,7 @@ fn preview_loading_plans_the_smallest_covering_jpeg() { assert!(&window == want, "a {edge} px box takes the {which} JPEG"); let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview"); let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too"); - assert_eq!( - (quick.width, quick.height, quick.data), - (whole.width, whole.height, whole.data), - "{edge} px: the plan and the whole file agree" - ); + assert_eq!((quick.width, quick.height, quick.data), (whole.width, whole.height, whole.data), "{edge} px: the plan and the whole file agree"); } let _ = std::fs::remove_dir_all(&dir); } @@ -994,8 +990,7 @@ fn darkened_ramp(rows: std::ops::Range, height: usize) -> ([[u8; 4]; 3], let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; let w = 1600usize; let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32; - let sensor = - Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); + let sensor = Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); let y = (rows.start + rows.end) / 2; let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap(); let mut img = sensor.clone(); @@ -1126,10 +1121,9 @@ fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() { (a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n]) }; let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize); - for (side, (n, f)) in - [(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))] - .into_iter() - .enumerate() + for (side, (n, f)) in [(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))] + .into_iter() + .enumerate() { for c in 0..2 { near[side][c] += n[c]; diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index e9cab38f9..654b40458 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -364,8 +364,7 @@ fn deferred_auto_tone_sentinels_never_become_real_slider_values() { let mut data = sample(); data.photos.truncate(1); data.photos[0].image.remove("masterImage"); - data.photos[0].settings = - "s = { AutoTone = true, Exposure2012 = -999999, Contrast2012 = -999999, Shadows2012 = 65, Temperature = 6150 }".into(); + data.photos[0].settings = "s = { AutoTone = true, Exposure2012 = -999999, Contrast2012 = -999999, Shadows2012 = 65, Temperature = 6150 }".into(); data.photos[0].xmp = r#""#.into(); let mut s = crate::Session::new(); let report = apply(&mut s, data, false).unwrap(); @@ -376,8 +375,7 @@ fn deferred_auto_tone_sentinels_never_become_real_slider_values() { assert_eq!(p.develop.light.blacks, -22.0); assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); let (partial, _) = - mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) - .unwrap(); + mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5).unwrap(); assert_eq!(partial["light"]["exposure"], -5.0); assert_eq!(partial["light"]["contrast"], -100.0); } @@ -804,8 +802,7 @@ fn oversized_optional_table_warns_and_is_skipped() { page[8..10].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); page[5..7].copy_from_slice(&u16::try_from(start).unwrap_or(0).to_be_bytes()); let db = Database::open_with_wal(main, &[]).unwrap(); - let reader = - Reader { db, tables: vec![LiveTable { name: "optional_history".into(), rootpage: 2, column_names: Some(vec!["payload".into()]) }] }; + let reader = Reader { db, tables: vec![LiveTable { name: "optional_history".into(), rootpage: 2, column_names: Some(vec!["payload".into()]) }] }; let mut warnings = Vec::new(); let rows = reader.optional_table("optional_history", &mut warnings).unwrap(); assert!(rows.is_empty()); diff --git a/crates/engine/src/lightroom_sqlite/tests.rs b/crates/engine/src/lightroom_sqlite/tests.rs index 0c5b53f6a..f34160ff7 100644 --- a/crates/engine/src/lightroom_sqlite/tests.rs +++ b/crates/engine/src/lightroom_sqlite/tests.rs @@ -249,10 +249,7 @@ fn synthetic_btree_substitutes_rowid_and_reads_schema() { malformed[92..96].copy_from_slice(&1u32.to_be_bytes()); malformed[28..32].copy_from_slice(&3u32.to_be_bytes()); let oversized = Scratch::new(&malformed); - assert_eq!( - Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(), - "SQLite header page count exceeds image" - ); + assert_eq!(Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(), "SQLite header page count exceeds image"); } #[test] diff --git a/crates/engine/src/media/tests.rs b/crates/engine/src/media/tests.rs index 95a2c2964..d36284262 100644 --- a/crates/engine/src/media/tests.rs +++ b/crates/engine/src/media/tests.rs @@ -156,10 +156,7 @@ fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_a let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() }; let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data; assert_eq!(new, old, "the look of an existing edit changed"); - assert_ne!( - new, - lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data - ); + assert_ne!(new, lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data); // accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched s.accept(&r); let p = s.catalog.photo(id).unwrap(); diff --git a/crates/engine/src/slide_overlay/tests.rs b/crates/engine/src/slide_overlay/tests.rs index d2884c745..aa21d3703 100644 --- a/crates/engine/src/slide_overlay/tests.rs +++ b/crates/engine/src/slide_overlay/tests.rs @@ -117,10 +117,8 @@ fn rating_stars_count_matches_the_rating() { fn text_with_a_shadow_draws_the_shadow_at_its_angle() { let l = layout(800, 500); let s = OverlaySettings::default() - .merged( - &json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0, - "shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]}), - ) + .merged(&json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0, + "shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]})) .unwrap(); let o = SlideOverlays::resolve(&s, Some(&photo())); let b = o.boxes(&l)[0].1; @@ -165,10 +163,9 @@ fn identity_plate_image_draws_at_its_opacity_and_a_missing_one_is_reported() { let behind = s.merged(&json!({"identityPlate": {"behind": true, "anchor": "center"}})).unwrap(); let photo_img = Image::filled(30, 20, [0u8, 0, 255, 255]); let ob = SlideOverlays::resolve(&behind, None); - let (img, l2) = - lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| { - ob.draw_behind(i, l) - }); + let (img, l2) = lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| { + ob.draw_behind(i, l) + }); let _ = l2; assert_eq!(img.get(400, 250), [0, 0, 255, 255], "the photo is on top"); // a missing file: reported, nothing drawn, no panic @@ -249,14 +246,10 @@ fn hostile_settings_are_refused_or_clamped_and_never_panic() { wild.texts[0].place.offset_y = f64::INFINITY; wild.watermark.size = f32::NAN; let w = wild.sanitized(); - assert_eq!( - (w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size), - (IdentityPlate::default().size, 0.0, Watermark::default().size) - ); + assert_eq!((w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size), (IdentityPlate::default().size, 0.0, Watermark::default().size)); // drawing hostile text at every size: empty, huge, CJK, RTL, control characters let huge = "W".repeat(50_000); - for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"] - { + for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"] { let s = d .merged(&json!({"identityPlate": {"enabled": true, "text": text, "size": PLATE_SIZE_MAX}, "ratingStars": {"enabled": true}, "addText": {"text": text, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX}, "showWatermark": true, "watermark": text})) .unwrap(); diff --git a/crates/engine/src/smart/tests.rs b/crates/engine/src/smart/tests.rs index 882fad689..e4bbd548c 100644 --- a/crates/engine/src/smart/tests.rs +++ b/crates/engine/src/smart/tests.rs @@ -191,11 +191,8 @@ fn smooth_scene(w: usize, h: usize) -> Rgb32f { /// input is clamped into 0..1, the only range a proxy ever stores. fn lab(c: [f32; 3]) -> [f64; 3] { let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64); - let (x, y, z) = ( - (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, - 0.2126 * r + 0.7152 * g + 0.0722 * b, - (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883, - ); + let (x, y, z) = + ((0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, 0.2126 * r + 0.7152 * g + 0.0722 * b, (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883); let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 }; let (fx, fy, fz) = (f(x), f(y), f(z)); [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] diff --git a/crates/faces/src/net/tests.rs b/crates/faces/src/net/tests.rs index 964d1f3ae..cd8dda7e5 100644 --- a/crates/faces/src/net/tests.rs +++ b/crates/faces/src/net/tests.rs @@ -43,8 +43,7 @@ fn reference_conv(x: &Tensor, wd: &[f32], dims: [usize; 4], bias: &[f32], s: usi for kx in 0..kw { let (iy, ix) = ((oy * s + ky) as isize - p as isize, (ox * s + kx) as isize - p as isize); if iy >= 0 && ix >= 0 && (iy as usize) < h && (ix as usize) < wi { - acc += - x.data[((g * cin_g + cl) * h + iy as usize) * wi + ix as usize] * wd[((co * cin_g + cl) * kh + ky) * kw + kx]; + acc += x.data[((g * cin_g + cl) * h + iy as usize) * wi + ix as usize] * wd[((co * cin_g + cl) * kh + ky) * kw + kx]; } } } @@ -73,10 +72,8 @@ fn convolutions_match_a_slow_reference() { let bias: Vec = (0..cout).map(|i| i as f32 * 0.25 - 0.5).collect(); let x = input(cin, h, wi, |i| ((i * 13 % 17) as f32 - 8.0) * 0.2); let attrs = [("group", Attr::Int(groups as i64)), ("strides", Attr::Ints(vec![s as i64; 2])), ("pads", Attr::Ints(vec![p as i64; 4]))]; - let g = graph( - vec![node("Conv", &["x", "w", "b"], &attrs)], - vec![("w", w(&[cout, cin_g, k, k], wd.clone())), ("b", w(&[cout], bias.clone()))], - ); + let g = + graph(vec![node("Conv", &["x", "w", "b"], &attrs)], vec![("w", w(&[cout, cin_g, k, k], wd.clone())), ("b", w(&[cout], bias.clone()))]); let got = run(g, x.clone()).unwrap(); let want = reference_conv(&x, &wd, [cout, cin_g, k, k], &bias, s, p, groups); assert_eq!(got.shape, want.shape, "{cin} {cout} {groups} {k} {s} {p}"); @@ -102,8 +99,8 @@ fn the_small_operators() { .unwrap(); assert_eq!((m.shape.clone(), m.data.clone()), (vec![1, 1, 2, 2], vec![-1.0, 1.0, 7.0, 9.0])); // Resize x2 nearest - let z = run(graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 2.0, 2.0]))]), input(1, 2, 2, |i| i as f32)) - .unwrap(); + let z = + run(graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 2.0, 2.0]))]), input(1, 2, 2, |i| i as f32)).unwrap(); assert_eq!(z.shape, vec![1, 1, 4, 4]); assert_eq!(&z.data[..8], &[0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0]); // Add @@ -364,10 +361,7 @@ fn unsupported_and_inconsistent_models_are_errors() { let x = input(1, 3, 3, |i| i as f32); for (what, g) in [ ("unknown op", graph(vec![node("Softmax", &["x"], &[])], vec![])), - ( - "dilated conv", - graph(vec![node("Conv", &["x", "w"], &[("dilations", Attr::Ints(vec![2, 2]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))]), - ), + ("dilated conv", graph(vec![node("Conv", &["x", "w"], &[("dilations", Attr::Ints(vec![2, 2]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))])), ("conv without weights", graph(vec![node("Conv", &["x", "w"], &[])], vec![])), ("channels do not match", graph(vec![node("Conv", &["x", "w"], &[])], vec![("w", w(&[1, 5, 1, 1], vec![1.0; 5]))])), ("kernel too big", graph(vec![node("Conv", &["x", "w"], &[])], vec![("w", w(&[1, 1, 5, 5], vec![1.0; 25]))])), @@ -378,10 +372,7 @@ fn unsupported_and_inconsistent_models_are_errors() { graph(vec![node("Conv", &["x", "w"], &[("pads", Attr::Ints(vec![-1, 0, 0, 0]))])], vec![("w", w(&[1, 1, 1, 1], vec![1.0]))]), ), ("bad perm", graph(vec![node("Transpose", &["x"], &[("perm", Attr::Ints(vec![0, 0, 1, 2]))])], vec![])), - ( - "huge reshape", - graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![i64::MAX, 1]) })]), - ), + ("huge reshape", graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![i64::MAX, 1]) })])), ("reshape mismatch", graph(vec![node("Reshape", &["x", "s"], &[])], vec![("s", Weight { dims: vec![2], data: Data::I64(vec![4, 4]) })])), ("non-integer resize", graph(vec![node("Resize", &["x", "", "s"], &[])], vec![("s", w(&[4], vec![1.0, 1.0, 1.5, 1.5]))])), ("add that broadcasts", graph(vec![node("Add", &["x", "c"], &[])], vec![])), diff --git a/crates/meta/src/focus/tests.rs b/crates/meta/src/focus/tests.rs index 7c1df95db..e5f79fbfe 100644 --- a/crates/meta/src/focus/tests.rs +++ b/crates/meta/src/focus/tests.rs @@ -147,12 +147,9 @@ fn a_block_that_disagrees_is_ignored() { #[test] fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() { - for fs in [ - Value::Undefined(vec![0, 0, 0, 0, 0, 0]), - Value::Undefined(vec![1]), - Value::Short(vec![5]), - Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0]), - ] { + for fs in + [Value::Undefined(vec![0, 0, 0, 0, 0, 0]), Value::Undefined(vec![1]), Value::Short(vec![5]), Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0])] + { let mut ifd = alpha1(); set(&mut ifd, FOCUS_FRAME_SIZE, fs); let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); @@ -416,16 +413,8 @@ fn real_sony_files_match_exiftool() { // (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point) let cases: [(&str, u16, Want); 8] = [ // α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking - ( - "2023/11-november/17th/_DSC6954.ARW", - 1, - Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"])), - ), - ( - "2023/11-november/17th/_DSC6894.ARW", - 1, - Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"])), - ), + ("2023/11-november/17th/_DSC6954.ARW", 1, Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"]))), + ("2023/11-november/17th/_DSC6894.ARW", 1, Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"]))), // α1, manual focus (the location 4441 3060 is where the frame was parked) ("2023/11-november/15th/_DSC6613.ARW", 1, None), // α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880 @@ -433,11 +422,7 @@ fn real_sony_files_match_exiftool() { // α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking ("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))), // A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking - ( - "2020/10-october/31st/DSC00065.ARW", - 8, - Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"])), - ), + ("2020/10-october/31st/DSC00065.ARW", 8, Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"]))), // A7S III upside down, AF-C, a centred Flexible Spot: a real point ("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))), // A7S III, manual focus diff --git a/crates/pipeline/src/optics/tests.rs b/crates/pipeline/src/optics/tests.rs index 228a99f4e..a404222e8 100644 --- a/crates/pipeline/src/optics/tests.rs +++ b/crates/pipeline/src/optics/tests.rs @@ -156,10 +156,7 @@ fn interval_framing_encloses_every_f64_result() { }; let (x, y) = (x.clamp(ix.lo(), ix.hi()), y.clamp(iy.lo(), iy.hi())); let (_, (fx, fy)) = wp.framing(&a, x, y); - assert!( - sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), - "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}" - ); + assert!(sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}"); } } } @@ -405,10 +402,7 @@ fn warp_rectilinear2_interval_framing_encloses_every_f64_result() { for j in 0..=4 { let (x, y) = ((x0 + bw * i as f64 / 4.0).clamp(ix.lo(), ix.hi()), (y0 + bh * j as f64 / 4.0).clamp(iy.lo(), iy.hi())); let (_, (fx, fy)) = wp.framing(&a, x, y); - assert!( - sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), - "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}" - ); + assert!(sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}"); } } } diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs index a67760bc9..80d559efc 100644 --- a/crates/ui-egui/src/headless/tests.rs +++ b/crates/ui-egui/src/headless/tests.rs @@ -886,11 +886,9 @@ fn a_persons_page_shows_their_faces_and_back_returns_to_everyone() { description: None, }; // Jane Doe twice in the first photo and once in the second; John Roe in the third - for (id, regions) in [ - (ids[0], vec![face(0.1, "Jane Doe"), face(0.5, "jane doe")]), - (ids[1], vec![face(0.3, "Jane Doe")]), - (ids[2], vec![face(0.3, "John Roe")]), - ] { + for (id, regions) in + [(ids[0], vec![face(0.1, "Jane Doe"), face(0.5, "jane doe")]), (ids[1], vec![face(0.3, "Jane Doe")]), (ids[2], vec![face(0.3, "John Roe")])] + { let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); meta.regions = regions; h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); @@ -994,8 +992,7 @@ fn person_more_confirms_all_above_with_one_undo_step() { pending: 0, }; let set_page = |h: &mut Headless| { - h.app.caches.person_page = - Some(("Jane Doe".into(), h.app.session.catalog.revision, h.app.caches.faces_indexed, 0.0, Arc::new(page.clone()))); + h.app.caches.person_page = Some(("Jane Doe".into(), h.app.session.catalog.revision, h.app.caches.faces_indexed, 0.0, Arc::new(page.clone()))); h.step(); }; h.app.ui.dismissed_faces.insert((ids[4].0, 0)); @@ -1009,15 +1006,7 @@ fn person_more_confirms_all_above_with_one_undo_step() { let names = |h: &Headless| ids.iter().map(|id| h.app.session.catalog.photo(*id).unwrap().meta.regions[0].name.clone()).collect::>(); assert_eq!( names(&h), - vec![ - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - None, - None, - Some("John Roe".into()) - ] + vec![Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into()), None, None, Some("John Roe".into())] ); assert_eq!(h.app.session.undo.len(), undo + 1); h.request("engine.execute", json!({"command": "edit.undo"}), t); @@ -1058,15 +1047,7 @@ fn person_more_confirms_all_above_with_one_undo_step() { h.step(); assert_eq!( names(&h), - vec![ - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - Some("Jane Doe".into()), - None, - None, - Some("John Roe".into()) - ] + vec![Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into()), None, None, Some("John Roe".into())] ); assert_eq!(h.app.session.undo.len(), undo + 1); h.request("engine.execute", json!({"command": "view.photoGrid"}), t); @@ -2061,8 +2042,7 @@ fn a_slow_photos_import_never_blocks_the_window() { Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() }) })); let t0 = Instant::now(); - let r = - h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "album": "Slow"}}), t); + let r = h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "album": "Slow"}}), t); assert!(t0.elapsed() < Duration::from_secs(5), "answered at once: {r}"); assert_eq!(r["result"]["running"], true, "{r}"); go.send(()).unwrap(); @@ -2108,8 +2088,7 @@ fn a_headless_run_waits_for_its_photos_import_before_ending() { std::fs::write(&m, b"done").unwrap(); Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() }) })); - let r = - h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "wait": false}}), t); + let r = h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "wait": false}}), t); assert_eq!(r["result"]["running"], true, "{r}"); assert!(!marker.exists(), "still importing"); let release = std::thread::spawn(move || { @@ -2214,11 +2193,8 @@ fn drag_photos_onto_an_album() { h.settle(SETTLE); let rect = |h: &mut Headless, id: &str| { let w = h.request("ui.widgets", json!({"filter": id}), t); - let r = w["result"] - .as_array() - .and_then(|a| a.iter().find(|x| x["id"] == id)) - .map(|x| x["rect"].clone()) - .unwrap_or_else(|| panic!("{id}: {w}")); + let r = + w["result"].as_array().and_then(|a| a.iter().find(|x| x["id"] == id)).map(|x| x["rect"].clone()).unwrap_or_else(|| panic!("{id}: {w}")); let f = |i: usize| r[i].as_f64().unwrap(); (f(0) + f(2) / 2.0, f(1) + f(3) / 2.0) }; @@ -2616,25 +2592,12 @@ fn info_mixed_fields_are_the_ones_that_differ() { let ids: Vec = s.visible_cloned().iter().take(3).map(|p| p.0).collect(); let all = |key: &str, v: serde_json::Value| json!({"ids": ids, key: v}); // (every field the panel compares starts the same on the three photos) - let fields = [ - "title", - "caption", - "altText", - "extendedDescription", - "copyright", - "usageTerms", - "copyrightUrl", - "creator", - "location", - "state", - "country", - ]; + let fields = + ["title", "caption", "altText", "extendedDescription", "copyright", "usageTerms", "copyrightUrl", "creator", "location", "state", "country"]; for key in fields { s.execute("photo.setMeta", &all(key, json!(""))).unwrap(); } - for (key, v) in - [("title", json!("Same")), ("city", json!("Porto")), ("copyrightStatus", json!("copyrighted")), ("gps", json!([41.15, -8.61]))] - { + for (key, v) in [("title", json!("Same")), ("city", json!("Porto")), ("copyrightStatus", json!("copyrighted")), ("gps", json!([41.15, -8.61]))] { s.execute("photo.setMeta", &all(key, v)).unwrap(); } let photos: Vec = ids.iter().map(|&i| lightcraft_catalog::PhotoId(i)).collect(); @@ -3279,8 +3242,7 @@ fn browsing_the_same_folder_again_does_not_restart() { } Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) })); - let browse = - |h: &mut Headless| h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); + let browse = |h: &mut Headless| h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); assert_eq!(browse(&mut h)["result"]["scanning"], true); std::thread::sleep(Duration::from_millis(200)); assert_eq!(browse(&mut h)["result"]["scanning"], true); @@ -3585,8 +3547,7 @@ fn import_review_offers_recently_deleted_files() { for i in 0..2u8 { let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; - let png = - lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); } let services = crate::Services { png: None, ..Default::default() }; @@ -3594,8 +3555,7 @@ fn import_review_offers_recently_deleted_files() { app.ui.view = crate::state::ViewMode::PhotoGrid; let mut h = Headless::new(app, [1300.0, 900.0], 1.0); let t = Duration::from_secs(10); - let r = - h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); + let r = h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); let id = r["result"]["imported"][0].as_u64().unwrap(); h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); @@ -4066,8 +4026,7 @@ fn import_rename_tags_insert_at_the_cursor() { app.ui.view = crate::state::ViewMode::PhotoGrid; let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); let t = Duration::from_secs(10); - let r = - h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t); assert_eq!(r["result"]["scanning"], true, "{r}"); h.settle(SETTLE); let r = h.request("ui.clickWidget", json!({"id": "button:importCopy"}), t); @@ -4142,8 +4101,7 @@ fn display_profile_converts_previews() { let mut h = demo([1000.0, 700.0]); let t = Duration::from_secs(10); h.request("ui.set", json!({"view": "detail"}), t); - let thumb_slot = - |h: &Headless| h.app.renderer.textures.keys().copied().filter(|s| matches!(s, Slot::Thumb(_))).min_by_key(|s| format!("{s:?}")); + let thumb_slot = |h: &Headless| h.app.renderer.textures.keys().copied().filter(|s| matches!(s, Slot::Thumb(_))).min_by_key(|s| format!("{s:?}")); let ready = |h: &Headless| { h.app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.pixels.is_some()) && thumb_slot(h).is_some() diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index b83fb910c..8e361ee12 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -87,11 +87,8 @@ fn catalogs_load() { /// `locales/*.json`, sorted. fn catalog_files() -> Vec { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - let mut files: Vec = std::fs::read_dir(dir) - .unwrap() - .map(|entry| entry.unwrap().path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "json")) - .collect(); + let mut files: Vec = + std::fs::read_dir(dir).unwrap().map(|entry| entry.unwrap().path()).filter(|path| path.extension().is_some_and(|ext| ext == "json")).collect(); files.sort(); files } @@ -282,11 +279,8 @@ fn catalog_characters_are_paintable() { ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { let font = egui::FontId::new(13.0, family.clone()); - let mut missing: Vec = messages - .values() - .flat_map(|message| message.chars()) - .filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch)) - .collect(); + let mut missing: Vec = + messages.values().flat_map(|message| message.chars()).filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch)).collect(); missing.sort_unstable(); missing.dedup(); assert!( @@ -426,9 +420,8 @@ fn choice_values_read_as_words_in_every_language() { fn problems_read_in_every_language() { use lightcraft_catalog::rules::Issue; let cat = lightcraft_catalog::Catalog::new(); - let problems = |rules: serde_json::Value| { - serde_json::from_value::(serde_json::json!({"rules": rules})).unwrap().check(&cat) - }; + let problems = + |rules: serde_json::Value| serde_json::from_value::(serde_json::json!({"rules": rules})).unwrap().check(&cat); let p = problems(serde_json::json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "title", "op": "contains", "value": ""}])); assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); @@ -566,11 +559,7 @@ fn german_switches_via_menu_and_control_and_persists() { assert!(rules_label(&album, &catalog).ends_with(" “Ausgeschlossen”"), "{}", rules_label(&album, &catalog)); let rules = lightcraft_catalog::RuleSet { mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { - field: "copyrightStatus".into(), - op: "is".into(), - value: serde_json::json!("publicDomain"), - }], + rules: vec![lightcraft_catalog::Rule::Field { field: "copyrightStatus".into(), op: "is".into(), value: serde_json::json!("publicDomain") }], }; let public_domain = format!(" {}", choice_text_in(Locale::De, "copyrightStatus", "publicDomain")); assert!(rules_label(&rules, &catalog).ends_with(&public_domain), "{}", rules_label(&rules, &catalog)); @@ -596,10 +585,8 @@ fn german_switches_via_menu_and_control_and_persists() { assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} {blue}")); let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Green], ..both }; assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} ({blue} {or} {green})")); - let filter = lightcraft_catalog::Filter { - labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], - ..Default::default() - }; + let filter = + lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], ..Default::default() }; let blue = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Blue); let red = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Red); let label = filter_label(&filter, &app.session.catalog); @@ -773,9 +760,7 @@ fn craft_fonts_render_cjk_in_the_ui() { set_language(language); let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); ctx.fonts_mut(|fonts| { - for family in - [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] - { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] { let font = egui::FontId::new(13.0, family); for ch in sample.chars() { assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}"); @@ -952,12 +937,8 @@ fn translation_gaps_report() { let messages: std::collections::BTreeSet = plain_messages().into_iter().filter(|m| !notes.contains(&m.as_str())).collect(); let formats = format_messages(); let quoted = |list: &[&str]| list.iter().map(|m| format!("{m:?}")).collect::>().join(", "); - let mut out = format!( - "Translation gaps, shown in English ({} messages, {} formats, {} What's New lines):\n", - messages.len(), - formats.len(), - notes.len() - ); + let mut out = + format!("Translation gaps, shown in English ({} messages, {} formats, {} What's New lines):\n", messages.len(), formats.len(), notes.len()); for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { let catalog = language.catalog(); let own_formats = formats_catalog(language.code()); @@ -1051,11 +1032,8 @@ fn formats_catalog(code: &str) -> BTreeMap { /// Every format message: the keys of every formats catalog and the message of every /// `tr_format!` call in `src/` outside its test modules. fn format_messages() -> std::collections::BTreeSet { - let mut all: std::collections::BTreeSet = Locale::ALL - .iter() - .filter(|language| **language != Locale::En) - .flat_map(|language| formats_catalog(language.code()).into_keys()) - .collect(); + let mut all: std::collections::BTreeSet = + Locale::ALL.iter().filter(|language| **language != Locale::En).flat_map(|language| formats_catalog(language.code()).into_keys()).collect(); let mut dirs = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; while let Some(dir) = dirs.pop() { for path in std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()) { @@ -1125,8 +1103,7 @@ fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_d let png = |name: &str, seed: u8| { let data: Vec<[u8; 4]> = (0..48 * 32).map(|i| [(i % 48 * 5) as u8, (i / 48 * 7) as u8, seed, 255]).collect(); let img = lightcraft_raster::Rgba8 { width: 48, height: 32, data }; - let bytes = - lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(dir.join(name), bytes).unwrap(); dir.join(name).to_string_lossy().into_owned() }; diff --git a/crates/ui-egui/src/menubar/tests.rs b/crates/ui-egui/src/menubar/tests.rs index e5645918a..1e2fe388d 100644 --- a/crates/ui-egui/src/menubar/tests.rs +++ b/crates/ui-egui/src/menubar/tests.rs @@ -354,8 +354,7 @@ fn select_by_submenu_and_text_prompt() { let mut app = app(); let bar = menu_bar(&app); let edit = &bar.iter().find(|(t, _)| t == "Edit").unwrap().1; - let Some(MenuNode::Submenu { children, .. }) = edit.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Select by")) - else { + let Some(MenuNode::Submenu { children, .. }) = edit.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Select by")) else { panic!("no Select by submenu") }; let Some(MenuNode::Item { id, params, .. }) = children.first() else { panic!() }; @@ -468,10 +467,8 @@ fn in_window_menu_headings_are_spaced_apart() { fn a_refused_backup_from_the_menu_shows_why() { // the host refuses, as the web one does while changes are still unsaved (issue #1508) let refusal = "Backup not started: 1 change could not be saved to browser storage yet"; - let services = crate::Services { - backup_library: Some(Box::new(move |_: &mut lightcraft_engine::Session| Err(refusal.to_string()))), - ..Default::default() - }; + let services = + crate::Services { backup_library: Some(Box::new(move |_: &mut lightcraft_engine::Session| Err(refusal.to_string()))), ..Default::default() }; let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); let t = std::time::Duration::from_secs(30); diff --git a/crates/ui-egui/src/region/tests.rs b/crates/ui-egui/src/region/tests.rs index c4558c1d8..33cda088c 100644 --- a/crates/ui-egui/src/region/tests.rs +++ b/crates/ui-egui/src/region/tests.rs @@ -586,14 +586,8 @@ fn the_margin_grows_with_the_zoom_up_to_a_cap() { #[test] fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { use lightcraft_catalog::PhotoId; - let v = RegionView { - photo: PhotoId(1), - before: false, - key: 7, - full: (6000, 4000), - window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, - settings: 11, - }; + let v = + RegionView { photo: PhotoId(1), before: false, key: 7, full: (6000, 4000), window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, settings: 11 }; assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false)); assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo"); assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom"); diff --git a/crates/ui-egui/src/render/thumbnail_tests.rs b/crates/ui-egui/src/render/thumbnail_tests.rs index ddad90970..984ce0462 100644 --- a/crates/ui-egui/src/render/thumbnail_tests.rs +++ b/crates/ui-egui/src/render/thumbnail_tests.rs @@ -92,8 +92,7 @@ fn queue_promotions_running_work_and_cancellation() { } fn queued_quick_thumbnail(id: PhotoId) -> (Arc, QuickJob) { let mut session = Session::new(); - session.media.preview_loader = - Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); + session.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }))); let mut raw = (*photo()).clone(); raw.id = id; raw.kind = lightcraft_catalog::MediaKind::Raw; @@ -651,8 +650,7 @@ fn undo_to_existing_texture_supersedes_pending_edit() { let original = app.renderer.thumb(PhotoId(1)).unwrap().pixels.clone().unwrap(); let mut edit = lightcraft_develop::DevelopSettings::default(); edit.light.exposure = 1.0; - let undo = - app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + let undo = app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); request(&mut app); let (_, edited) = take_job(&work); app.session.catalog.apply(undo).unwrap(); diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 1254e8ab3..a6fbb1333 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -687,25 +687,13 @@ mod issue1771 { #[test] fn issue1771_repeat_event_opens_once_and_detail_keeps_selection() { let mut h = grid(ViewMode::PhotoGrid); - h.events.push(egui::Event::Key { - key: egui::Key::Enter, - physical_key: None, - pressed: true, - repeat: true, - modifiers: egui::Modifiers::NONE, - }); + h.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: true, modifiers: egui::Modifiers::NONE }); h.step(); h.step(); assert_eq!(h.app.ui.view, ViewMode::Detail); assert_eq!(h.app.session.active(), Some(PhotoId(2))); assert_eq!(h.app.session.selection.ids.len(), 3); - h.events.push(egui::Event::Key { - key: egui::Key::Enter, - physical_key: None, - pressed: true, - repeat: true, - modifiers: egui::Modifiers::NONE, - }); + h.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: true, modifiers: egui::Modifiers::NONE }); h.step(); h.step(); assert_eq!(h.app.ui.view, ViewMode::Detail); diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 9487734b2..49174ff0d 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -19,6 +19,7 @@ mod dialog_primary; mod dialog_settings; mod dialog_tab; mod dialogs; +mod docking_acceptance; mod drop_import; mod edit_layout; mod empty_library; @@ -49,6 +50,7 @@ mod masking; mod masking_layout; mod menubar; mod merge_preview; +mod model_setup; mod offline; mod open_lightroom; pub(super) mod panels; @@ -80,5 +82,3 @@ mod view_cost; mod wb_selector; mod zoom; mod zoom_keys; -mod model_setup; -mod docking_acceptance; diff --git a/crates/ui-egui/src/window_place/tests.rs b/crates/ui-egui/src/window_place/tests.rs index 0fba2b0c7..80d463f19 100644 --- a/crates/ui-egui/src/window_place/tests.rs +++ b/crates/ui-egui/src/window_place/tests.rs @@ -266,11 +266,9 @@ fn a_zoomed_window_is_still_zoomed_on_another_screen_where_the_system_does_not_s fn nothing_waits_while_the_window_is_full_screen_or_minimized() { let rest = place([1300.0, 800.0], false); for live in [true, false] { - for away in [ - Seen { full_screen: true, ..window(SCREEN) }, - Seen { minimized: true, ..window([160.0, 28.0]) }, - Seen { size: None, ..window(SCREEN) }, - ] { + for away in + [Seen { full_screen: true, ..window(SCREEN) }, Seen { minimized: true, ..window([160.0, 28.0]) }, Seen { size: None, ..window(SCREEN) }] + { let mut watch = WindowWatch::told_live(live); watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); assert!(watch.waits(1.0).is_some()); From 4826fff7346f831d5bd7d17ef3218ac3dd7e1012 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 11:34:06 -0700 Subject: [PATCH 079/191] Originals: a Windows file index of 0 does not identify a file same_file::is_same_file treats two files with the same volume serial number and file index as one file, also when both report index 0. The Windows comparison now reads both identities through winapi-util and accepts a match only when both file indexes are nonzero; otherwise the resolved paths are compared, as for a volume without file indexes. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/catalog/Cargo.toml | 2 +- crates/catalog/src/safe_file.rs | 33 +++++++++++++++++++++++++++++---- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e9acf98c..0fcf8a35c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3221,11 +3221,11 @@ dependencies = [ "lightcraft-testkit", "log", "proptest", - "same-file", "serde", "serde_json", "thiserror 2.0.21", "web-time", + "winapi-util", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fc2d90d2e..4e122eb24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" -same-file = "1" # lightcraft-catalog reads a file's volume serial number and file index with it on Windows +winapi-util = "0.1" # lightcraft-catalog reads a file's volume serial number and file index with it on Windows proptest = "1" bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 7a9c80439..b12e18803 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -21,7 +21,7 @@ chrono = { workspace = true } web-time = { workspace = true } [target.'cfg(windows)'.dependencies] -same-file = { workspace = true } +winapi-util = { workspace = true } [dev-dependencies] lightcraft-geom = { workspace = true } diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index 9b8bf5bcd..bc05d4139 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -301,13 +301,13 @@ fn compare_files(a: &Path, b: &Path, strict: bool) -> io::Result { return Ok(false); } // A hard link has its own path but shares the volume serial number and file index with - // its original. A volume that does not report them is compared by resolved path below. + // its original. A volume that reports file index 0 is compared by resolved path below. // On ReFS two files can share the 64-bit file index, so the lengths are compared first. #[cfg(windows)] { - match ::same_file::is_same_file(a, b) { - Ok(true) => return Ok(true), - Ok(false) => {} + match file_ids(a, b) { + Ok((ia, ib)) if same_file_id(ia, ib) => return Ok(true), + Ok(_) => {} Err(e) if strict => return Err(e), Err(_) => {} } @@ -319,6 +319,22 @@ fn compare_files(a: &Path, b: &Path, strict: bool) -> io::Result { } } +/// The volume serial number and file index of `a` and `b`, read while both files are open. +#[cfg(windows)] +fn file_ids(a: &Path, b: &Path) -> io::Result<((u64, u64), (u64, u64))> { + use winapi_util::{Handle, file::information}; + let (ha, hb) = (Handle::from_path_any(a)?, Handle::from_path_any(b)?); + let (ia, ib) = (information(&ha)?, information(&hb)?); + Ok(((ia.volume_serial_number(), ia.file_index()), (ib.volume_serial_number(), ib.file_index()))) +} + +/// Whether two (volume serial number, file index) pairs name one file. A file index of 0 is not +/// an identity and matches nothing. +#[cfg(any(windows, test))] +fn same_file_id(a: (u64, u64), b: (u64, u64)) -> bool { + a.1 != 0 && b.1 != 0 && a == b +} + #[cfg(test)] mod tests { use super::*; @@ -480,4 +496,13 @@ mod tests { assert!(!same_file_strict(&p.join("under-a-file.jpg"), &p).unwrap()); let _ = fs::remove_dir_all(&d); } + + #[test] + fn a_zero_file_index_is_not_a_file_identity() { + assert!(same_file_id((7, 42), (7, 42))); + assert!(!same_file_id((7, 42), (7, 43))); + assert!(!same_file_id((7, 42), (8, 42))); + assert!(!same_file_id((7, 0), (7, 0)), "two files on a volume that reports index 0 are not the same file"); + assert!(!same_file_id((0, 0), (0, 0))); + } } From ee590a5b9b0a2771e402018751c4e45699b8c45c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:43:59 +0000 Subject: [PATCH 080/191] refactor(lightcraft-ui-egui): move panels/dialogs.rs to dialogs.rs --- crates/ui-egui/src/{panels => }/dialogs.rs | 2 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/src/panels/settings.rs | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) rename crates/ui-egui/src/{panels => }/dialogs.rs (99%) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/dialogs.rs similarity index 99% rename from crates/ui-egui/src/panels/dialogs.rs rename to crates/ui-egui/src/dialogs.rs index f26b43325..cae0dcee7 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -176,7 +176,7 @@ fn watermark_preview( pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", "Contributors"), ("models", "Models")]; /// Help ▸ What's New (docs/whats-new.md). -pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); +pub const WHATS_NEW: &str = include_str!("../../../docs/whats-new.md"); /// The import review and Settings keep window ids of their own, so the size each is given /// doesn't carry over to the other dialogs. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2dcc84189..dbbd5e0ed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ mod command_error; pub mod control; pub mod credits; pub mod date_picker; +pub mod dialogs; pub mod docking; mod drop_overlay; pub mod export_task; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index e3300e04f..fcb36f344 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -7,7 +7,6 @@ pub mod compare; pub mod crop_overlay; pub mod denoise; pub mod detail; -pub mod dialogs; pub mod edit; pub mod empty_library; pub mod faces; @@ -44,6 +43,7 @@ pub mod unnamed; use egui::{Align2, Rect, pos2, vec2}; use crate::LightcraftApp; +pub use crate::dialogs; use crate::theme::Tokens; /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 338d55c00..f68da16f1 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -876,7 +876,7 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { /// which, the three lines each of Info 1 and Info 2 shows, which of them shows briefly when the /// photo changes, and whether the loupe shows its loading and rendering message /// (`view.viewOptions`). Changes apply as they are made. -pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { +pub(crate) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use crate::state::{InfoOverlay as I, LoupeInfo, LoupeInfoField as F}; heading(ui, t, crate::i18n::tr("Loupe View")); let mut show = app.ui.info_overlay != I::Off; From e8003d6adf4c2ae68d9178f5f13e41c178a97cdf Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:44:52 +0000 Subject: [PATCH 081/191] refactor(lightcraft-ui-egui): move import.rs to dialogs/import.rs --- crates/ui-egui/src/dialogs.rs | 2 ++ crates/ui-egui/src/{ => dialogs}/import.rs | 0 crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => dialogs}/import.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cae0dcee7..93ec27044 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7,6 +7,8 @@ use crate::LightcraftApp; use crate::state::Dialog; use crate::theme::Tokens; +pub mod import; + /// Rows the Rename dialog previews. const RENAME_PREVIEW_ROWS: usize = 6; diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/dialogs/import.rs similarity index 100% rename from crates/ui-egui/src/import.rs rename to crates/ui-egui/src/dialogs/import.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dbbd5e0ed..495e58d0e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,7 +23,6 @@ mod frame_rate; pub mod headless; pub mod i18n; pub mod icons; -pub mod import; pub mod lightroom_destination; pub mod lightroom_import; pub mod links; @@ -66,6 +65,7 @@ use serde_json::Value; pub use command_error::CommandError; pub use control::{ControlRequest, ControlResponse}; +pub use dialogs::import; pub use state::UiState; const TOAST_SECONDS: f64 = 1.4; From e48ef0a64b3323bf8bfad0b78b82dcf229f1e822 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:45:35 +0000 Subject: [PATCH 082/191] refactor(lightcraft-ui-egui): move print_view.rs to dialogs/print_view.rs --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/{ => dialogs}/print_view.rs | 0 crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => dialogs}/print_view.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 93ec27044..da289fbbd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ use crate::state::Dialog; use crate::theme::Tokens; pub mod import; +pub mod print_view; /// Rows the Rename dialog previews. const RENAME_PREVIEW_ROWS: usize = 6; diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/dialogs/print_view.rs similarity index 100% rename from crates/ui-egui/src/print_view.rs rename to crates/ui-egui/src/dialogs/print_view.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 495e58d0e..035175171 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,7 +33,6 @@ pub mod merge; mod model_setup; pub mod panels; pub mod pick; -pub mod print_view; pub mod progress; pub mod rating; pub mod recheck; @@ -66,6 +65,7 @@ use serde_json::Value; pub use command_error::CommandError; pub use control::{ControlRequest, ControlResponse}; pub use dialogs::import; +pub use dialogs::print_view; pub use state::UiState; const TOAST_SECONDS: f64 = 1.4; From f7e10b760a689013324b1db873eea7f4d467c6db Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:46:26 +0000 Subject: [PATCH 083/191] refactor(lightcraft-ui-egui): move merge.rs to dialogs/merge.rs --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/{ => dialogs}/merge.rs | 0 crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => dialogs}/merge.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index da289fbbd..d305965cf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ use crate::state::Dialog; use crate::theme::Tokens; pub mod import; +pub mod merge; pub mod print_view; /// Rows the Rename dialog previews. diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/dialogs/merge.rs similarity index 100% rename from crates/ui-egui/src/merge.rs rename to crates/ui-egui/src/dialogs/merge.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 035175171..9c541028b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,7 +29,6 @@ pub mod links; pub mod menu_level; pub mod menubar; pub mod menus; -pub mod merge; mod model_setup; pub mod panels; pub mod pick; @@ -65,6 +64,7 @@ use serde_json::Value; pub use command_error::CommandError; pub use control::{ControlRequest, ControlResponse}; pub use dialogs::import; +pub use dialogs::merge; pub use dialogs::print_view; pub use state::UiState; From a81beeb57bafdfd112c749c912fb330b61bc05ea Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:47:01 +0000 Subject: [PATCH 084/191] refactor(lightcraft-ui-egui): move slideshow_export.rs to dialogs/slideshow_export.rs --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/{ => dialogs}/slideshow_export.rs | 0 crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => dialogs}/slideshow_export.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d305965cf..ae4046cbe 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,6 +10,7 @@ use crate::theme::Tokens; pub mod import; pub mod merge; pub mod print_view; +pub mod slideshow_export; /// Rows the Rename dialog previews. const RENAME_PREVIEW_ROWS: usize = 6; diff --git a/crates/ui-egui/src/slideshow_export.rs b/crates/ui-egui/src/dialogs/slideshow_export.rs similarity index 100% rename from crates/ui-egui/src/slideshow_export.rs rename to crates/ui-egui/src/dialogs/slideshow_export.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c541028b..3dfbe6f04 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,7 +40,6 @@ pub mod reload; pub mod render; pub mod shortcuts; pub mod sidebar; -pub mod slideshow_export; pub mod softpaint; pub mod state; pub mod sync; @@ -66,6 +65,7 @@ pub use control::{ControlRequest, ControlResponse}; pub use dialogs::import; pub use dialogs::merge; pub use dialogs::print_view; +pub use dialogs::slideshow_export; pub use state::UiState; const TOAST_SECONDS: f64 = 1.4; From 82c5d8327941d156769752e5fd2c00933dba18b1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:47:38 +0000 Subject: [PATCH 085/191] refactor(lightcraft-ui-egui): move lightroom_destination.rs to dialogs/lightroom_destination.rs --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/{ => dialogs}/lightroom_destination.rs | 0 crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => dialogs}/lightroom_destination.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ae4046cbe..7aaab7b64 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ use crate::state::Dialog; use crate::theme::Tokens; pub mod import; +pub mod lightroom_destination; pub mod merge; pub mod print_view; pub mod slideshow_export; diff --git a/crates/ui-egui/src/lightroom_destination.rs b/crates/ui-egui/src/dialogs/lightroom_destination.rs similarity index 100% rename from crates/ui-egui/src/lightroom_destination.rs rename to crates/ui-egui/src/dialogs/lightroom_destination.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3dfbe6f04..e01bcfdd3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,7 +23,6 @@ mod frame_rate; pub mod headless; pub mod i18n; pub mod icons; -pub mod lightroom_destination; pub mod lightroom_import; pub mod links; pub mod menu_level; @@ -63,6 +62,7 @@ use serde_json::Value; pub use command_error::CommandError; pub use control::{ControlRequest, ControlResponse}; pub use dialogs::import; +pub use dialogs::lightroom_destination; pub use dialogs::merge; pub use dialogs::print_view; pub use dialogs::slideshow_export; From 3371bb8de295ad4e2d8e5d0f9195859f33ed5ad3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:48:11 +0000 Subject: [PATCH 086/191] refactor(lightcraft-ui-egui): move credits.rs to dialogs/credits.rs --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/{ => dialogs}/credits.rs | 0 crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => dialogs}/credits.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7aaab7b64..9673872c5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7,6 +7,7 @@ use crate::LightcraftApp; use crate::state::Dialog; use crate::theme::Tokens; +pub mod credits; pub mod import; pub mod lightroom_destination; pub mod merge; diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/dialogs/credits.rs similarity index 100% rename from crates/ui-egui/src/credits.rs rename to crates/ui-egui/src/dialogs/credits.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e01bcfdd3..f36d420d3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -12,7 +12,6 @@ pub mod button; pub mod canvas_keys; mod command_error; pub mod control; -pub mod credits; pub mod date_picker; pub mod dialogs; pub mod docking; @@ -61,6 +60,7 @@ use serde_json::Value; pub use command_error::CommandError; pub use control::{ControlRequest, ControlResponse}; +pub use dialogs::credits; pub use dialogs::import; pub use dialogs::lightroom_destination; pub use dialogs::merge; From fb5334a167c582be922e75eb8261f4b6afeacfc9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:48:59 +0000 Subject: [PATCH 087/191] refactor(lightcraft-ui-egui): move updates_ui.rs to dialogs/updates_ui.rs --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/{ => dialogs}/updates_ui.rs | 0 crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => dialogs}/updates_ui.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9673872c5..0d3d216dc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -13,6 +13,7 @@ pub mod lightroom_destination; pub mod merge; pub mod print_view; pub mod slideshow_export; +pub mod updates_ui; /// Rows the Rename dialog previews. const RENAME_PREVIEW_ROWS: usize = 6; diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs similarity index 100% rename from crates/ui-egui/src/updates_ui.rs rename to crates/ui-egui/src/dialogs/updates_ui.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f36d420d3..2900869ec 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,7 +44,6 @@ pub mod sync; pub mod tasks; pub mod text_field; pub mod theme; -pub mod updates_ui; pub mod widgets; pub mod window_place; @@ -66,6 +65,7 @@ pub use dialogs::lightroom_destination; pub use dialogs::merge; pub use dialogs::print_view; pub use dialogs::slideshow_export; +pub use dialogs::updates_ui; pub use state::UiState; const TOAST_SECONDS: f64 = 1.4; From 906150ae503df998e9a11a7f1c9e657464dc8b77 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:50:12 +0000 Subject: [PATCH 088/191] refactor(lightcraft-ui-egui): move panels/settings.rs to dialogs/settings.rs --- crates/ui-egui/src/dialogs.rs | 1 + .../src/{panels => dialogs}/settings.rs | 30 +++++++++---------- crates/ui-egui/src/panels.rs | 2 +- 3 files changed, 17 insertions(+), 16 deletions(-) rename crates/ui-egui/src/{panels => dialogs}/settings.rs (98%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0d3d216dc..372477983 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -12,6 +12,7 @@ pub mod import; pub mod lightroom_destination; pub mod merge; pub mod print_view; +pub mod settings; pub mod slideshow_export; pub mod updates_ui; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/dialogs/settings.rs similarity index 98% rename from crates/ui-egui/src/panels/settings.rs rename to crates/ui-egui/src/dialogs/settings.rs index f68da16f1..7cc30acb2 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/dialogs/settings.rs @@ -46,8 +46,8 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &str) { "display" => display_tab(app, ui, &t), "interface" => interface_tab(app, ui, &t), "slideshow" => slideshow_tab(app, ui, &t, true), - "faces" => super::faces::settings_tab(app, ui, &t), - "denoise" => super::denoise::settings_tab(app, ui, &t), + "faces" => crate::panels::faces::settings_tab(app, ui, &t), + "denoise" => crate::panels::denoise::settings_tab(app, ui, &t), _ => general_tab(app, ui, &t), } } @@ -105,10 +105,10 @@ pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_ // page, it scrolls with the page instead (as in the short window above). let mut pinned = false; if tab == "slideshow" { - let slide_h = (height * PINNED_PREVIEW_SHARE - PREVIEW_CHROME).max(super::slide_layout::PREVIEW_MIN_H); + let slide_h = (height * PINNED_PREVIEW_SHARE - PREVIEW_CHROME).max(crate::panels::slide_layout::PREVIEW_MIN_H); if height - (slide_h + PREVIEW_CHROME) >= PAGE_MIN_HEIGHT { let before = ui.cursor().top(); - super::slide_layout::preview(app, ui, &Tokens::get(ui.ctx()), slide_h); + crate::panels::slide_layout::preview(app, ui, &Tokens::get(ui.ctx()), slide_h); ui.add_space(2.0); ui.separator(); height = (height - (ui.cursor().top() - before)).max(PAGE_MIN_HEIGHT); @@ -185,12 +185,12 @@ fn button_width(ui: &egui::Ui, text: &str) -> f32 { label_width(ui, text, egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.x } -pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { +pub(crate) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { ui.add_space(4.0); ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text)); } -pub(super) fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { +pub(crate) fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { ui.horizontal(|ui| { ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(LABEL_W); @@ -209,19 +209,19 @@ pub(super) fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnc .inner } -pub(super) fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) { +pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) { ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim)); } /// A checkbox addressable as `check:{id}`; true when toggled. -pub(super) fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool { +pub(crate) fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool { let r = ui.checkbox(value, crate::i18n::tr(label)); register(ui, format!("check:{id}"), r.rect); r.changed() } /// Mutually exclusive buttons (`button:{id}-{index}`). -pub(super) fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool { +pub(crate) fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool { let mut changed = false; ui.spacing_mut().item_spacing.x = 4.0; for (i, (v, l)) in options.iter().enumerate() { @@ -876,7 +876,7 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { /// which, the three lines each of Info 1 and Info 2 shows, which of them shows briefly when the /// photo changes, and whether the loupe shows its loading and rendering message /// (`view.viewOptions`). Changes apply as they are made. -pub(crate) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { +pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use crate::state::{InfoOverlay as I, LoupeInfo, LoupeInfoField as F}; heading(ui, t, crate::i18n::tr("Loupe View")); let mut show = app.ui.info_overlay != I::Off; @@ -948,18 +948,18 @@ fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &s /// `with_preview` the preview is left out: [`window`] has pinned it above the page. fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, with_preview: bool) { if with_preview { - super::slide_layout::preview(app, ui, t, f32::INFINITY); + crate::panels::slide_layout::preview(app, ui, t, f32::INFINITY); ui.add_space(6.0); } - super::slide_templates::section(app, ui, t); + crate::panels::slide_templates::section(app, ui, t); ui.add_space(6.0); playback_section(app, ui, t); ui.add_space(6.0); - super::slide_layout::sections(app, ui, t); + crate::panels::slide_layout::sections(app, ui, t); ui.add_space(6.0); - super::slide_overlays::sections(app, ui, t); + crate::panels::slide_overlays::sections(app, ui, t); ui.add_space(6.0); - super::slide_titles::sections(app, ui, t); + crate::panels::slide_titles::sections(app, ui, t); ui.add_space(6.0); export_section(app, ui, t); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index fcb36f344..fbbe3a568 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -30,7 +30,6 @@ pub mod right; pub mod rules_editor; pub mod rules_move; pub mod second; -pub mod settings; pub mod slide_layout; pub mod slide_overlays; pub mod slide_templates; @@ -44,6 +43,7 @@ use egui::{Align2, Rect, pos2, vec2}; use crate::LightcraftApp; pub use crate::dialogs; +pub use crate::dialogs::settings; use crate::theme::Tokens; /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], From d6db5a34a7432a7cdc52a0b12df483996debc438 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:51:02 +0000 Subject: [PATCH 089/191] refactor(lightcraft-ui-egui): move panels/faces.rs to dialogs/faces.rs --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/{panels => dialogs}/faces.rs | 2 +- crates/ui-egui/src/panels.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) rename crates/ui-egui/src/{panels => dialogs}/faces.rs (99%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 372477983..90b4f07b6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ use crate::state::Dialog; use crate::theme::Tokens; pub mod credits; +pub mod faces; pub mod import; pub mod lightroom_destination; pub mod merge; diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/dialogs/faces.rs similarity index 99% rename from crates/ui-egui/src/panels/faces.rs rename to crates/ui-egui/src/dialogs/faces.rs index 76cd1295c..1811ec0a9 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/dialogs/faces.rs @@ -11,8 +11,8 @@ use egui::RichText; use serde_json::{Value, json}; -use super::settings::{check, heading, hint}; use crate::LightcraftApp; +use crate::panels::settings::{check, heading, hint}; use crate::theme::Tokens; use crate::widgets::register; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index fbbe3a568..7d9ece5fa 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -9,7 +9,6 @@ pub mod denoise; pub mod detail; pub mod edit; pub mod empty_library; -pub mod faces; pub mod filterbar; pub mod grid; pub mod histogram; @@ -43,6 +42,7 @@ use egui::{Align2, Rect, pos2, vec2}; use crate::LightcraftApp; pub use crate::dialogs; +pub use crate::dialogs::faces; pub use crate::dialogs::settings; use crate::theme::Tokens; From 1dd486dec9e044b2deebcbfe686814d0b8547dc2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:51:37 +0000 Subject: [PATCH 090/191] refactor(lightcraft-ui-egui): move panels/keymap.rs to dialogs/keymap.rs --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/{panels => dialogs}/keymap.rs | 0 crates/ui-egui/src/panels.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{panels => dialogs}/keymap.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 90b4f07b6..49372d950 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,6 +10,7 @@ use crate::theme::Tokens; pub mod credits; pub mod faces; pub mod import; +pub mod keymap; pub mod lightroom_destination; pub mod merge; pub mod print_view; diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/dialogs/keymap.rs similarity index 100% rename from crates/ui-egui/src/panels/keymap.rs rename to crates/ui-egui/src/dialogs/keymap.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7d9ece5fa..dd9357eec 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -12,7 +12,6 @@ pub mod empty_library; pub mod filterbar; pub mod grid; pub mod histogram; -pub mod keymap; pub mod keyword_list; pub mod keywording; pub mod layout_overlay; @@ -43,6 +42,7 @@ use egui::{Align2, Rect, pos2, vec2}; use crate::LightcraftApp; pub use crate::dialogs; pub use crate::dialogs::faces; +pub use crate::dialogs::keymap; pub use crate::dialogs::settings; use crate::theme::Tokens; From 0419f5b38bb09c2fa5015a818123a634a66835f4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:52:17 +0000 Subject: [PATCH 091/191] refactor(lightcraft-ui-egui): move panels/rules_editor.rs to dialogs/rules_editor.rs --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/{panels => dialogs}/rules_editor.rs | 0 crates/ui-egui/src/panels.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{panels => dialogs}/rules_editor.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 49372d950..00b131713 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -14,6 +14,7 @@ pub mod keymap; pub mod lightroom_destination; pub mod merge; pub mod print_view; +pub mod rules_editor; pub mod settings; pub mod slideshow_export; pub mod updates_ui; diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/dialogs/rules_editor.rs similarity index 100% rename from crates/ui-egui/src/panels/rules_editor.rs rename to crates/ui-egui/src/dialogs/rules_editor.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index dd9357eec..6e0c571db 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -25,7 +25,6 @@ pub mod person; pub mod presets; pub mod profiles; pub mod right; -pub mod rules_editor; pub mod rules_move; pub mod second; pub mod slide_layout; @@ -43,6 +42,7 @@ use crate::LightcraftApp; pub use crate::dialogs; pub use crate::dialogs::faces; pub use crate::dialogs::keymap; +pub use crate::dialogs::rules_editor; pub use crate::dialogs::settings; use crate::theme::Tokens; From 56640576e0b13c9ebd1333dcb1c8f3df18aead28 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:52:49 +0000 Subject: [PATCH 092/191] refactor(lightcraft-ui-egui): move panels/rules_move.rs to dialogs/rules_editor/rules_move.rs --- crates/ui-egui/src/dialogs/rules_editor.rs | 2 ++ .../ui-egui/src/{panels => dialogs/rules_editor}/rules_move.rs | 0 crates/ui-egui/src/panels.rs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{panels => dialogs/rules_editor}/rules_move.rs (100%) diff --git a/crates/ui-egui/src/dialogs/rules_editor.rs b/crates/ui-egui/src/dialogs/rules_editor.rs index 509a096ec..67f5e4bb4 100644 --- a/crates/ui-egui/src/dialogs/rules_editor.rs +++ b/crates/ui-egui/src/dialogs/rules_editor.rs @@ -11,6 +11,8 @@ use crate::date_picker::{DatePicker, PickedDate}; use crate::theme::Tokens; use crate::widgets::register; +pub mod rules_move; + /// A sensible starting value for `field` / `op`. pub fn default_value(field: &str, op: &str) -> Value { match (field_kind(field), op) { diff --git a/crates/ui-egui/src/panels/rules_move.rs b/crates/ui-egui/src/dialogs/rules_editor/rules_move.rs similarity index 100% rename from crates/ui-egui/src/panels/rules_move.rs rename to crates/ui-egui/src/dialogs/rules_editor/rules_move.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6e0c571db..321414767 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -25,7 +25,6 @@ pub mod person; pub mod presets; pub mod profiles; pub mod right; -pub mod rules_move; pub mod second; pub mod slide_layout; pub mod slide_overlays; @@ -43,6 +42,7 @@ pub use crate::dialogs; pub use crate::dialogs::faces; pub use crate::dialogs::keymap; pub use crate::dialogs::rules_editor; +pub use crate::dialogs::rules_editor::rules_move; pub use crate::dialogs::settings; use crate::theme::Tokens; From f6e6d5ebc4136ecfa90a1e3b5c53beca1d12f8e8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:53:20 +0000 Subject: [PATCH 093/191] refactor(lightcraft-ui-egui): move panels/slide_layout.rs to dialogs/settings/slide_layout.rs --- crates/ui-egui/src/dialogs/settings.rs | 2 ++ .../{panels => dialogs/settings}/slide_layout.rs | 14 +++++++------- crates/ui-egui/src/panels.rs | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) rename crates/ui-egui/src/{panels => dialogs/settings}/slide_layout.rs (96%) diff --git a/crates/ui-egui/src/dialogs/settings.rs b/crates/ui-egui/src/dialogs/settings.rs index 7cc30acb2..5fcb721eb 100644 --- a/crates/ui-egui/src/dialogs/settings.rs +++ b/crates/ui-egui/src/dialogs/settings.rs @@ -20,6 +20,8 @@ use crate::state::{GridBadges, PREVIEW_LIMITS, StartupView}; use crate::theme::Tokens; use crate::widgets::register; +pub mod slide_layout; + /// (id, label) of the tabs, in order. pub const TABS: &[(&str, &str)] = &[ ("general", "General"), diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/dialogs/settings/slide_layout.rs similarity index 96% rename from crates/ui-egui/src/panels/slide_layout.rs rename to crates/ui-egui/src/dialogs/settings/slide_layout.rs index 82387f1e6..2fc55abda 100644 --- a/crates/ui-egui/src/panels/slide_layout.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_layout.rs @@ -14,8 +14,8 @@ use lightcraft_engine::slide_template::SlideLook; use lightcraft_slideshow::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX}; use serde_json::{Value, json}; -use super::settings::{check, choices, heading, hint, row}; use crate::LightcraftApp; +use crate::panels::settings::{check, choices, heading, hint, row}; use crate::render::Slot; use crate::theme::Tokens; use crate::widgets::register; @@ -169,7 +169,7 @@ fn screen_aspect(ctx: &egui::Context) -> f64 { pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f32) { heading(ui, t, "Preview"); // the template the pointer is over in the Template Browser, else the settings - let template = super::slide_templates::hovered(ui.ctx()).and_then(|id| app.session.slide_template(&id).ok()); + let template = crate::panels::slide_templates::hovered(ui.ctx()).and_then(|id| app.session.slide_template(&id).ok()); let look = template.as_ref().map_or_else(|| SlideLook::of(&app.session), |t| t.look.clone()); let s = look.layout.clone(); let overlays = look.overlays.clone(); @@ -188,7 +188,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f3 register(ui, "slideshow:preview", rect); let p = ui.painter_at(room.expand(1.0)); p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside); - super::slide_titles::preview_choice(ui); + crate::panels::slide_titles::preview_choice(ui); if let Some(tpl) = &template { // (said over the bottom of the preview and not below it, which would move the page) let name = if tpl.builtin { crate::i18n::tr(&tpl.name).to_string() } else { tpl.name.clone() }; @@ -208,7 +208,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f3 paint.rect_filled(band, 0.0, Color32::from_black_alpha(190)); paint.galley(pos2(band.left() + 4.0, band.top() + 3.0), galley, Color32::WHITE); } - if let Some(title) = super::slide_titles::preview_shows(ui.ctx()) { + if let Some(title) = crate::panels::slide_titles::preview_shows(ui.ctx()) { title_preview(app, ui, t, rect, title, &look); return; } @@ -239,7 +239,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f3 let still = if last == key { now - since } else { 0.0 }; // the first preview (or another photo's) at once, a changing layout once it has settled if current.is_none() || still >= DEBOUNCE { - let side = super::detail::texture_side(ui.ctx()); + let side = crate::panels::detail::texture_side(ui.ctx()); let edge = app.ui.settings.loupe_edge(pw.max(ph) as f32, usize::MAX, side); if let Some(job) = app.session.slide_job_look(&s, &overlays, id, pw, ph, Some(edge), false) { app.renderer.request(Slot::SlidePreview, job, 90); @@ -252,7 +252,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f3 p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(11.5), Color32::from_gray(200)); return; }; - let drawn = super::detail::fit_texture_rect(rect, tex.size); + let drawn = crate::panels::detail::fit_texture_rect(rect, tex.size); p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); if s.show_guides { // the margins, as lines across the slide (where the image cell's edges are) @@ -296,7 +296,7 @@ fn title_preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, rect: R let p = ui.painter_at(rect.expand(1.0)); match app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == lightcraft_catalog::PhotoId(0)) { Some(tex) => { - let drawn = super::detail::fit_texture_rect(rect, tex.size); + let drawn = crate::panels::detail::fit_texture_rect(rect, tex.size); p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } None => { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 321414767..6ea225051 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -26,7 +26,6 @@ pub mod presets; pub mod profiles; pub mod right; pub mod second; -pub mod slide_layout; pub mod slide_overlays; pub mod slide_templates; pub mod slide_titles; @@ -44,6 +43,7 @@ pub use crate::dialogs::keymap; pub use crate::dialogs::rules_editor; pub use crate::dialogs::rules_editor::rules_move; pub use crate::dialogs::settings; +pub use crate::dialogs::settings::slide_layout; use crate::theme::Tokens; /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], From 34daf9dd60ac32052ed38ae7ac0773935dc9fa51 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:53:50 +0000 Subject: [PATCH 094/191] refactor(lightcraft-ui-egui): move panels/slide_overlays.rs to dialogs/settings/slide_overlays.rs --- crates/ui-egui/src/dialogs/settings.rs | 1 + .../ui-egui/src/{panels => dialogs/settings}/slide_overlays.rs | 2 +- crates/ui-egui/src/panels.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) rename crates/ui-egui/src/{panels => dialogs/settings}/slide_overlays.rs (99%) diff --git a/crates/ui-egui/src/dialogs/settings.rs b/crates/ui-egui/src/dialogs/settings.rs index 5fcb721eb..a58f50eb8 100644 --- a/crates/ui-egui/src/dialogs/settings.rs +++ b/crates/ui-egui/src/dialogs/settings.rs @@ -21,6 +21,7 @@ use crate::theme::Tokens; use crate::widgets::register; pub mod slide_layout; +pub mod slide_overlays; /// (id, label) of the tabs, in order. pub const TABS: &[(&str, &str)] = &[ diff --git a/crates/ui-egui/src/panels/slide_overlays.rs b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs similarity index 99% rename from crates/ui-egui/src/panels/slide_overlays.rs rename to crates/ui-egui/src/dialogs/settings/slide_overlays.rs index e9c88f0b3..3d7412b58 100644 --- a/crates/ui-egui/src/panels/slide_overlays.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs @@ -13,8 +13,8 @@ use lightcraft_engine::slide_overlay::{ }; use serde_json::{Value, json}; -use super::settings::{check, choices, heading, hint, row}; use crate::LightcraftApp; +use crate::panels::settings::{check, choices, heading, hint, row}; use crate::text_field::TextField; use crate::theme::Tokens; use crate::widgets::register; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6ea225051..e6dbdbce4 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -26,7 +26,6 @@ pub mod presets; pub mod profiles; pub mod right; pub mod second; -pub mod slide_overlays; pub mod slide_templates; pub mod slide_titles; pub mod slideshow; @@ -44,6 +43,7 @@ pub use crate::dialogs::rules_editor; pub use crate::dialogs::rules_editor::rules_move; pub use crate::dialogs::settings; pub use crate::dialogs::settings::slide_layout; +pub use crate::dialogs::settings::slide_overlays; use crate::theme::Tokens; /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], From f3d88622391cd93f70c7424ec131110f1eb5897b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:54:17 +0000 Subject: [PATCH 095/191] refactor(lightcraft-ui-egui): move panels/slide_templates.rs to dialogs/settings/slide_templates.rs --- crates/ui-egui/src/dialogs/settings.rs | 1 + .../ui-egui/src/{panels => dialogs/settings}/slide_templates.rs | 2 +- crates/ui-egui/src/panels.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) rename crates/ui-egui/src/{panels => dialogs/settings}/slide_templates.rs (99%) diff --git a/crates/ui-egui/src/dialogs/settings.rs b/crates/ui-egui/src/dialogs/settings.rs index a58f50eb8..30d70c5ff 100644 --- a/crates/ui-egui/src/dialogs/settings.rs +++ b/crates/ui-egui/src/dialogs/settings.rs @@ -22,6 +22,7 @@ use crate::widgets::register; pub mod slide_layout; pub mod slide_overlays; +pub mod slide_templates; /// (id, label) of the tabs, in order. pub const TABS: &[(&str, &str)] = &[ diff --git a/crates/ui-egui/src/panels/slide_templates.rs b/crates/ui-egui/src/dialogs/settings/slide_templates.rs similarity index 99% rename from crates/ui-egui/src/panels/slide_templates.rs rename to crates/ui-egui/src/dialogs/settings/slide_templates.rs index 3929be5ed..d32cb0552 100644 --- a/crates/ui-egui/src/panels/slide_templates.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_templates.rs @@ -13,8 +13,8 @@ use egui::{RichText, Sense, Stroke, vec2}; use lightcraft_engine::slide_template::{PREVIOUS, SlideLook, Template}; use serde_json::json; -use super::settings::{heading, hint}; use crate::LightcraftApp; +use crate::panels::settings::{heading, hint}; use crate::text_field::TextField; use crate::theme::Tokens; use crate::widgets::{register, register_response}; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index e6dbdbce4..f83eabce2 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -26,7 +26,6 @@ pub mod presets; pub mod profiles; pub mod right; pub mod second; -pub mod slide_templates; pub mod slide_titles; pub mod slideshow; pub mod strip; @@ -44,6 +43,7 @@ pub use crate::dialogs::rules_editor::rules_move; pub use crate::dialogs::settings; pub use crate::dialogs::settings::slide_layout; pub use crate::dialogs::settings::slide_overlays; +pub use crate::dialogs::settings::slide_templates; use crate::theme::Tokens; /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], From c7171d6834a7a045c869da35e872bb4b61c7c929 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:54:50 +0000 Subject: [PATCH 096/191] refactor(lightcraft-ui-egui): move panels/slide_titles.rs to dialogs/settings/slide_titles.rs --- crates/ui-egui/src/dialogs/settings.rs | 1 + crates/ui-egui/src/{panels => dialogs/settings}/slide_titles.rs | 2 +- crates/ui-egui/src/panels.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) rename crates/ui-egui/src/{panels => dialogs/settings}/slide_titles.rs (99%) diff --git a/crates/ui-egui/src/dialogs/settings.rs b/crates/ui-egui/src/dialogs/settings.rs index 30d70c5ff..03573a213 100644 --- a/crates/ui-egui/src/dialogs/settings.rs +++ b/crates/ui-egui/src/dialogs/settings.rs @@ -23,6 +23,7 @@ use crate::widgets::register; pub mod slide_layout; pub mod slide_overlays; pub mod slide_templates; +pub mod slide_titles; /// (id, label) of the tabs, in order. pub const TABS: &[(&str, &str)] = &[ diff --git a/crates/ui-egui/src/panels/slide_titles.rs b/crates/ui-egui/src/dialogs/settings/slide_titles.rs similarity index 99% rename from crates/ui-egui/src/panels/slide_titles.rs rename to crates/ui-egui/src/dialogs/settings/slide_titles.rs index b3c103402..79d0dfd3f 100644 --- a/crates/ui-egui/src/panels/slide_titles.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_titles.rs @@ -12,8 +12,8 @@ use lightcraft_engine::slide_title::{TitleContent, TitleScreen}; use lightcraft_engine::slideshow::{SLIDE_MAX, SLIDE_MIN, Title}; use serde_json::{Value, json}; -use super::settings::{check, choices, heading, hint, row}; use crate::LightcraftApp; +use crate::panels::settings::{check, choices, heading, hint, row}; use crate::text_field::TextField; use crate::theme::Tokens; use crate::widgets::register; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f83eabce2..4c2bed935 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -26,7 +26,6 @@ pub mod presets; pub mod profiles; pub mod right; pub mod second; -pub mod slide_titles; pub mod slideshow; pub mod strip; pub mod topbar; @@ -44,6 +43,7 @@ pub use crate::dialogs::settings; pub use crate::dialogs::settings::slide_layout; pub use crate::dialogs::settings::slide_overlays; pub use crate::dialogs::settings::slide_templates; +pub use crate::dialogs::settings::slide_titles; use crate::theme::Tokens; /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], From 64b6a0e3c23df9f2bba59716ec0bbdeb2e6c6aff Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:55:25 +0000 Subject: [PATCH 097/191] refactor(lightcraft-ui-egui): move menubar.rs to menus/menubar.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus.rs | 2 ++ crates/ui-egui/src/{ => menus}/menubar.rs | 0 crates/ui-egui/src/{ => menus}/menubar/tests.rs | 0 4 files changed, 3 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => menus}/menubar.rs (100%) rename crates/ui-egui/src/{ => menus}/menubar/tests.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2900869ec..039d9745c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,7 +25,6 @@ pub mod icons; pub mod lightroom_import; pub mod links; pub mod menu_level; -pub mod menubar; pub mod menus; mod model_setup; pub mod panels; @@ -66,6 +65,7 @@ pub use dialogs::merge; pub use dialogs::print_view; pub use dialogs::slideshow_export; pub use dialogs::updates_ui; +pub use menus::menubar; pub use state::UiState; const TOAST_SECONDS: f64 = 1.4; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1ed6dbbf1..bd74923af 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -8,6 +8,8 @@ use crate::LightcraftApp; use crate::pick::{PickRequest, Picked}; use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; +pub mod menubar; + /// (id, label, shortcut, menu path) pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menus/menubar.rs similarity index 100% rename from crates/ui-egui/src/menubar.rs rename to crates/ui-egui/src/menus/menubar.rs diff --git a/crates/ui-egui/src/menubar/tests.rs b/crates/ui-egui/src/menus/menubar/tests.rs similarity index 100% rename from crates/ui-egui/src/menubar/tests.rs rename to crates/ui-egui/src/menus/menubar/tests.rs From 74836daa84452c4e74ea0820ff5a86547c1a404f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:55:56 +0000 Subject: [PATCH 098/191] refactor(lightcraft-ui-egui): move menu_level.rs to menus/menu_level.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/{ => menus}/menu_level.rs | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => menus}/menu_level.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 039d9745c..c6150e735 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,7 +24,6 @@ pub mod i18n; pub mod icons; pub mod lightroom_import; pub mod links; -pub mod menu_level; pub mod menus; mod model_setup; pub mod panels; @@ -65,6 +64,7 @@ pub use dialogs::merge; pub use dialogs::print_view; pub use dialogs::slideshow_export; pub use dialogs::updates_ui; +pub use menus::menu_level; pub use menus::menubar; pub use state::UiState; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bd74923af..63b7b3f63 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -8,6 +8,7 @@ use crate::LightcraftApp; use crate::pick::{PickRequest, Picked}; use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; +pub mod menu_level; pub mod menubar; /// (id, label, shortcut, menu path) diff --git a/crates/ui-egui/src/menu_level.rs b/crates/ui-egui/src/menus/menu_level.rs similarity index 100% rename from crates/ui-egui/src/menu_level.rs rename to crates/ui-egui/src/menus/menu_level.rs From 6ea7f1dc5575964bf3f9c6e49894505d0c533e14 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:02:14 +0000 Subject: [PATCH 099/191] refactor(lightcraft-ui-egui): move album_picker.rs to widgets/album_picker.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/widgets.rs | 1 + crates/ui-egui/src/{ => widgets}/album_picker.rs | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => widgets}/album_picker.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c6150e735..81654b52e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,7 +6,6 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -pub mod album_picker; pub mod brush_live; pub mod button; pub mod canvas_keys; @@ -67,6 +66,7 @@ pub use dialogs::updates_ui; pub use menus::menu_level; pub use menus::menubar; pub use state::UiState; +pub use widgets::album_picker; const TOAST_SECONDS: f64 = 1.4; /// The `path` of a display profile read from the screen rather than chosen as a file. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index eb5e62589..ec2a474ec 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1114,6 +1114,7 @@ pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> S #[cfg(test)] #[path = "widgets/tests_numeric_entry.rs"] mod numeric_entry_tests; +pub mod album_picker; #[cfg(test)] #[path = "widgets/tests_slider_reset.rs"] diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/widgets/album_picker.rs similarity index 100% rename from crates/ui-egui/src/album_picker.rs rename to crates/ui-egui/src/widgets/album_picker.rs From 2a4bd99a4de69745f80140e08a508e359649126c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:02:54 +0000 Subject: [PATCH 100/191] refactor(lightcraft-ui-egui): move date_picker.rs to widgets/date_picker.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/widgets.rs | 3 ++- crates/ui-egui/src/{ => widgets}/date_picker.rs | 0 3 files changed, 3 insertions(+), 2 deletions(-) rename crates/ui-egui/src/{ => widgets}/date_picker.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 81654b52e..fec24c148 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,7 +11,6 @@ pub mod button; pub mod canvas_keys; mod command_error; pub mod control; -pub mod date_picker; pub mod dialogs; pub mod docking; mod drop_overlay; @@ -67,6 +66,7 @@ pub use menus::menu_level; pub use menus::menubar; pub use state::UiState; pub use widgets::album_picker; +pub use widgets::date_picker; const TOAST_SECONDS: f64 = 1.4; /// The `path` of a display profile read from the screen rather than chosen as a file. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ec2a474ec..9e637d32f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1111,10 +1111,11 @@ pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> S "…".to_string() } +pub mod album_picker; +pub mod date_picker; #[cfg(test)] #[path = "widgets/tests_numeric_entry.rs"] mod numeric_entry_tests; -pub mod album_picker; #[cfg(test)] #[path = "widgets/tests_slider_reset.rs"] diff --git a/crates/ui-egui/src/date_picker.rs b/crates/ui-egui/src/widgets/date_picker.rs similarity index 100% rename from crates/ui-egui/src/date_picker.rs rename to crates/ui-egui/src/widgets/date_picker.rs From 204f8ea365b56d80130b87e1471db4d5a8883da5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:03:35 +0000 Subject: [PATCH 101/191] refactor(lightcraft-ui-egui): move button.rs to widgets/button.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/widgets.rs | 1 + crates/ui-egui/src/{ => widgets}/button.rs | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => widgets}/button.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fec24c148..2ddadf2c4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,7 +7,6 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod brush_live; -pub mod button; pub mod canvas_keys; mod command_error; pub mod control; @@ -66,6 +65,7 @@ pub use menus::menu_level; pub use menus::menubar; pub use state::UiState; pub use widgets::album_picker; +pub use widgets::button; pub use widgets::date_picker; const TOAST_SECONDS: f64 = 1.4; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e637d32f..38b80aab3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1112,6 +1112,7 @@ pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> S } pub mod album_picker; +pub mod button; pub mod date_picker; #[cfg(test)] #[path = "widgets/tests_numeric_entry.rs"] diff --git a/crates/ui-egui/src/button.rs b/crates/ui-egui/src/widgets/button.rs similarity index 100% rename from crates/ui-egui/src/button.rs rename to crates/ui-egui/src/widgets/button.rs From 8a9de1f616207bcd4988104652799325f237956c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:04:15 +0000 Subject: [PATCH 102/191] refactor(lightcraft-ui-egui): move rating.rs to widgets/rating.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/widgets.rs | 1 + crates/ui-egui/src/{ => widgets}/rating.rs | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => widgets}/rating.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2ddadf2c4..ee632375f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,7 +26,6 @@ mod model_setup; pub mod panels; pub mod pick; pub mod progress; -pub mod rating; pub mod recheck; pub mod region; pub mod reload; @@ -67,6 +66,7 @@ pub use state::UiState; pub use widgets::album_picker; pub use widgets::button; pub use widgets::date_picker; +pub use widgets::rating; const TOAST_SECONDS: f64 = 1.4; /// The `path` of a display profile read from the screen rather than chosen as a file. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 38b80aab3..e5b03b942 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1117,6 +1117,7 @@ pub mod date_picker; #[cfg(test)] #[path = "widgets/tests_numeric_entry.rs"] mod numeric_entry_tests; +pub mod rating; #[cfg(test)] #[path = "widgets/tests_slider_reset.rs"] diff --git a/crates/ui-egui/src/rating.rs b/crates/ui-egui/src/widgets/rating.rs similarity index 100% rename from crates/ui-egui/src/rating.rs rename to crates/ui-egui/src/widgets/rating.rs From 0d3ee604f342aa133d10223e193910fd39f3f559 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:05:04 +0000 Subject: [PATCH 103/191] refactor(lightcraft-ui-egui): move text_field.rs to widgets/text_field.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/widgets.rs | 1 + crates/ui-egui/src/{ => widgets}/text_field.rs | 0 crates/ui-egui/src/{ => widgets}/text_field/tests.rs | 0 4 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => widgets}/text_field.rs (100%) rename crates/ui-egui/src/{ => widgets}/text_field/tests.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ee632375f..64ebdd52f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,7 +36,6 @@ pub mod softpaint; pub mod state; pub mod sync; pub mod tasks; -pub mod text_field; pub mod theme; pub mod widgets; pub mod window_place; @@ -67,6 +66,7 @@ pub use widgets::album_picker; pub use widgets::button; pub use widgets::date_picker; pub use widgets::rating; +pub use widgets::text_field; const TOAST_SECONDS: f64 = 1.4; /// The `path` of a display profile read from the screen rather than chosen as a file. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e5b03b942..3096bd3b6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1118,6 +1118,7 @@ pub mod date_picker; #[path = "widgets/tests_numeric_entry.rs"] mod numeric_entry_tests; pub mod rating; +pub mod text_field; #[cfg(test)] #[path = "widgets/tests_slider_reset.rs"] diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/widgets/text_field.rs similarity index 100% rename from crates/ui-egui/src/text_field.rs rename to crates/ui-egui/src/widgets/text_field.rs diff --git a/crates/ui-egui/src/text_field/tests.rs b/crates/ui-egui/src/widgets/text_field/tests.rs similarity index 100% rename from crates/ui-egui/src/text_field/tests.rs rename to crates/ui-egui/src/widgets/text_field/tests.rs From b17f6cbe939c94a67a7db976c9b0339a9bb0ae02 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:05:46 +0000 Subject: [PATCH 104/191] refactor(lightcraft-ui-egui): move progress.rs to widgets/progress.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/widgets.rs | 1 + crates/ui-egui/src/{ => widgets}/progress.rs | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => widgets}/progress.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64ebdd52f..906f3b377 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,7 +25,6 @@ pub mod menus; mod model_setup; pub mod panels; pub mod pick; -pub mod progress; pub mod recheck; pub mod region; pub mod reload; @@ -65,6 +64,7 @@ pub use state::UiState; pub use widgets::album_picker; pub use widgets::button; pub use widgets::date_picker; +pub use widgets::progress; pub use widgets::rating; pub use widgets::text_field; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3096bd3b6..0a613baed 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1117,6 +1117,7 @@ pub mod date_picker; #[cfg(test)] #[path = "widgets/tests_numeric_entry.rs"] mod numeric_entry_tests; +pub mod progress; pub mod rating; pub mod text_field; diff --git a/crates/ui-egui/src/progress.rs b/crates/ui-egui/src/widgets/progress.rs similarity index 100% rename from crates/ui-egui/src/progress.rs rename to crates/ui-egui/src/widgets/progress.rs From 7707047684552ae5029f89de1373e3efcc82caca Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:05:53 +0000 Subject: [PATCH 105/191] refactor(lightcraft-engine): move media thumbnail_hash_tests to media/thumbnail_hash_tests.rs --- crates/engine/src/media.rs | 119 +----------------- .../engine/src/media/thumbnail_hash_tests.rs | 115 +++++++++++++++++ 2 files changed, 116 insertions(+), 118 deletions(-) create mode 100644 crates/engine/src/media/thumbnail_hash_tests.rs diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 537f56a5e..353825465 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1752,124 +1752,7 @@ pub struct ProbeInfo { pub type FileProbe = Arc Result + Send + Sync>; #[cfg(test)] -mod thumbnail_hash_tests { - use super::*; - - fn session() -> crate::Session { - let mut s = crate::Session::new(); - let p = Photo::new(PhotoId(1), Source::File { path: "hash-fixture.jpg".into() }, "fixture.jpg", "JPEG", 6000, 4000, "2026-01-01"); - s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); - s - } - #[test] - fn hashes_match_uncached_keys_and_are_not_recomputed_for_sizes_metadata_or_before() { - let mut s = session(); - for edge in [256, 384, 512, 256] { - let p = s.catalog.photo(PhotoId(1)).unwrap().clone(); - let b = thumb_bucket(edge); - // Independent original formula: never asks the new hash cache for expected values. - let settings = p.develop.hash64(); - let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64; - let expected = - settings ^ ((b as u64) << 40) ^ ((b as u64) << 20) ^ 1 ^ p.id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17); - let disk = Hasher128::new() - .str(&content_key(&p)) - .u64(settings) - .u64(b as u64) - .u64(RENDER_CACHE_VERSION) - .u64(crate::camera_profiles::cache_key()) - .finish(); - let job = s.thumb_job(p.id, edge).unwrap(); - assert_eq!(job.key, expected); - assert_eq!(job.cache.unwrap().1, disk); - } - s.catalog.apply(Op::SetRating { id: PhotoId(1), rating: 4 }).unwrap(); - s.thumb_job(PhotoId(1), 256).unwrap(); - s.render_job(PhotoId(1), 256, 256, true, true).unwrap(); - s.thumb_job(PhotoId(1), 256).unwrap(); - assert_eq!(s.media.settings_hashes.computations, 1); - let mut edit = DevelopSettings::default(); - edit.light.exposure = 1.0; - let undo = s.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); - s.thumb_job(PhotoId(1), 256).unwrap(); - s.thumb_job(PhotoId(1), 384).unwrap(); - assert_eq!(s.media.settings_hashes.computations, 2); - s.catalog.apply(undo).unwrap(); - s.thumb_job(PhotoId(1), 256).unwrap(); - assert_eq!(s.media.settings_hashes.computations, 3); - } - #[test] - fn hash_cache_is_bounded_weak_and_shared_settings_remain_independent() { - let mut hashes = SettingsHashes::default(); - let shared = Arc::new(DevelopSettings::default()); - for id in 1..=SETTINGS_HASH_ENTRIES as u64 * 3 { - assert_eq!(hashes.get(PhotoId(id), &shared), shared.hash64()); - } - assert_eq!(hashes.entries.len(), SETTINGS_HASH_ENTRIES); - assert_eq!(hashes.order.len(), SETTINGS_HASH_ENTRIES); - assert_eq!(Arc::strong_count(&shared), 1); - let mut edit = (*shared).clone(); - edit.light.exposure = 1.0; - let edit = Arc::new(edit); - let a = PhotoId(10000); - let b = PhotoId(10001); - let initial = hashes.get(a, &shared); - assert_eq!(hashes.get(b, &shared), initial); - assert_ne!(hashes.get(a, &edit), initial); - assert_eq!(hashes.get(b, &shared), initial); - let mut s = session(); - s.thumb_job(PhotoId(1), 256).unwrap(); - s.media.clear_sources(); - assert!(s.media.settings_hashes.entries.is_empty()); - assert!(s.media.settings_hashes.order.is_empty()); - } - #[test] - fn source_from_before_content_reload_is_not_accepted() { - let mut s = session(); - s.media.file_loader = Some(Arc::new(|_, _| Ok((Rgb32f::new(8, 8), SourceInfo::default())))); - let old = s.thumb_job(PhotoId(1), 256).unwrap().run(); - assert!(old.loaded.is_some()); - s.catalog - .apply(Op::SetContent { - id: PhotoId(1), - width: 6000, - height: 4000, - file_size: 99, - content_hash: Some("replacement".into()), - preview_only: None, - preview_checked: String::new(), - }) - .unwrap(); - s.media.forget(PhotoId(1)); - s.accept(&old); - assert_eq!(s.media.source_usage().0, 0); - } - /// A panic while rendering (here in the decoder) used to end the pool's worker thread without - /// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now, - /// for renders and for stand-ins, answering the request it came from. - #[test] - fn a_render_that_panics_fails_instead_of_never_answering() { - let mut s = session(); - s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); - let mut job = s.thumb_job(PhotoId(1), 256).unwrap(); - job.request_id = 42; - let (key, level, source_key) = (job.key, job.level, job.source_key); - let r = job.run(); - assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key)); - assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug")); - let quick = QuickJob { - request_id: 7, - photo: PhotoId(1), - key: 9, - cached: Vec::new(), - embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option { panic!("preview reader bug") }), 256)), - small: None, - }; - let r = quick.run(); - assert_eq!((r.request_id, r.key), (7, 9)); - assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug")); - } -} +mod thumbnail_hash_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/media/thumbnail_hash_tests.rs b/crates/engine/src/media/thumbnail_hash_tests.rs new file mode 100644 index 000000000..fdbd20f80 --- /dev/null +++ b/crates/engine/src/media/thumbnail_hash_tests.rs @@ -0,0 +1,115 @@ +use super::*; + +fn session() -> crate::Session { + let mut s = crate::Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: "hash-fixture.jpg".into() }, "fixture.jpg", "JPEG", 6000, 4000, "2026-01-01"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s +} +#[test] +fn hashes_match_uncached_keys_and_are_not_recomputed_for_sizes_metadata_or_before() { + let mut s = session(); + for edge in [256, 384, 512, 256] { + let p = s.catalog.photo(PhotoId(1)).unwrap().clone(); + let b = thumb_bucket(edge); + // Independent original formula: never asks the new hash cache for expected values. + let settings = p.develop.hash64(); + let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64; + let expected = settings ^ ((b as u64) << 40) ^ ((b as u64) << 20) ^ 1 ^ p.id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17); + let disk = Hasher128::new() + .str(&content_key(&p)) + .u64(settings) + .u64(b as u64) + .u64(RENDER_CACHE_VERSION) + .u64(crate::camera_profiles::cache_key()) + .finish(); + let job = s.thumb_job(p.id, edge).unwrap(); + assert_eq!(job.key, expected); + assert_eq!(job.cache.unwrap().1, disk); + } + s.catalog.apply(Op::SetRating { id: PhotoId(1), rating: 4 }).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + s.render_job(PhotoId(1), 256, 256, true, true).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + assert_eq!(s.media.settings_hashes.computations, 1); + let mut edit = DevelopSettings::default(); + edit.light.exposure = 1.0; + let undo = s.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + s.thumb_job(PhotoId(1), 384).unwrap(); + assert_eq!(s.media.settings_hashes.computations, 2); + s.catalog.apply(undo).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + assert_eq!(s.media.settings_hashes.computations, 3); +} +#[test] +fn hash_cache_is_bounded_weak_and_shared_settings_remain_independent() { + let mut hashes = SettingsHashes::default(); + let shared = Arc::new(DevelopSettings::default()); + for id in 1..=SETTINGS_HASH_ENTRIES as u64 * 3 { + assert_eq!(hashes.get(PhotoId(id), &shared), shared.hash64()); + } + assert_eq!(hashes.entries.len(), SETTINGS_HASH_ENTRIES); + assert_eq!(hashes.order.len(), SETTINGS_HASH_ENTRIES); + assert_eq!(Arc::strong_count(&shared), 1); + let mut edit = (*shared).clone(); + edit.light.exposure = 1.0; + let edit = Arc::new(edit); + let a = PhotoId(10000); + let b = PhotoId(10001); + let initial = hashes.get(a, &shared); + assert_eq!(hashes.get(b, &shared), initial); + assert_ne!(hashes.get(a, &edit), initial); + assert_eq!(hashes.get(b, &shared), initial); + let mut s = session(); + s.thumb_job(PhotoId(1), 256).unwrap(); + s.media.clear_sources(); + assert!(s.media.settings_hashes.entries.is_empty()); + assert!(s.media.settings_hashes.order.is_empty()); +} +#[test] +fn source_from_before_content_reload_is_not_accepted() { + let mut s = session(); + s.media.file_loader = Some(Arc::new(|_, _| Ok((Rgb32f::new(8, 8), SourceInfo::default())))); + let old = s.thumb_job(PhotoId(1), 256).unwrap().run(); + assert!(old.loaded.is_some()); + s.catalog + .apply(Op::SetContent { + id: PhotoId(1), + width: 6000, + height: 4000, + file_size: 99, + content_hash: Some("replacement".into()), + preview_only: None, + preview_checked: String::new(), + }) + .unwrap(); + s.media.forget(PhotoId(1)); + s.accept(&old); + assert_eq!(s.media.source_usage().0, 0); +} +/// A panic while rendering (here in the decoder) used to end the pool's worker thread without +/// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now, +/// for renders and for stand-ins, answering the request it came from. +#[test] +fn a_render_that_panics_fails_instead_of_never_answering() { + let mut s = session(); + s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); + let mut job = s.thumb_job(PhotoId(1), 256).unwrap(); + job.request_id = 42; + let (key, level, source_key) = (job.key, job.level, job.source_key); + let r = job.run(); + assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key)); + assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug")); + let quick = QuickJob { + request_id: 7, + photo: PhotoId(1), + key: 9, + cached: Vec::new(), + embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option { panic!("preview reader bug") }), 256)), + small: None, + }; + let r = quick.run(); + assert_eq!((r.request_id, r.key), (7, 9)); + assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug")); +} From 658f3c26d5a044cc4972cebbdc960f4a91d488d1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:05:53 +0000 Subject: [PATCH 106/191] refactor(lightcraft-engine): move denoise gpu_tests to denoise/gpu_tests.rs --- crates/engine/src/denoise.rs | 309 +------------------------ crates/engine/src/denoise/gpu_tests.rs | 306 ++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 308 deletions(-) create mode 100644 crates/engine/src/denoise/gpu_tests.rs diff --git a/crates/engine/src/denoise.rs b/crates/engine/src/denoise.rs index 8793b3a5b..89af8463a 100644 --- a/crates/engine/src/denoise.rs +++ b/crates/engine/src/denoise.rs @@ -1643,311 +1643,4 @@ impl Session { } #[cfg(all(test, feature = "denoise"))] -mod gpu_tests { - use super::*; - use lightcraft_denoise::manifest::{Domain, Gain}; - - /// A folder of the test's own: the card's set-up leaves its marker beside the model. - fn folder(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("lc-engine-gpu-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - /// The model file is read again when the card is set up (for the first photo), so it stays until the test ends. - fn model(name: &str, tile: u64) -> (Arc, lightcraft_denoise::runtime::CpuRunner, PathBuf) { - let path = folder(name).join("model.onnx"); - std::fs::write(&path, lightcraft_denoise::synthetic::unet_onnx(tile, 8, 2, 7)).unwrap(); - let manifest = DenoiserManifest { - id: "synthetic".into(), - name: "Synthetic".into(), - version: "1".into(), - licence: Default::default(), - source: None, - sha256: None, - size_bytes: None, - provenance: String::new(), - domain: Domain::BayerToRgb, - tile: tile as u32, - overlap: 16, - gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 }, - }; - let loaded = default_loader()(&path, &manifest).unwrap(); - let cpu = lightcraft_denoise::runtime::CpuRunner::load(&path, &manifest).unwrap(); - (loaded, cpu, path) - } - - fn worst(want: &[f32], got: &[f32]) -> f32 { - assert_eq!(want.len(), got.len()); - let scale = want.iter().fold(1e-9f32, |m, v| m.max(v.abs())); - want.iter().zip(got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale - } - - #[test] - fn a_tile_on_the_card_is_the_cpus_tile() { - let (model, cpu, path) = model("same", 64); - let (input, _) = lightcraft_denoise::runtime::test_tile(64); - let want = cpu.run(&input).unwrap(); - let (runner, at_once) = model.runner(RunOn::Gpu, 16); - assert!(worst(&want, &runner.run(&input).unwrap()) < 1e-3); - let device = model.device(RunOn::Gpu); - eprintln!("device: {device}"); - match device["kind"].as_str() { - Some("gpu") => { - assert!(device["adapter"].as_str().is_some_and(|a| !a.is_empty())); - assert!(at_once <= GPU_PARALLEL, "a card is fed by a few tiles at once"); - assert!(device["cardMs"].as_f64().is_some_and(|ms| ms > 0.0) && device["cpuMs"].as_f64().is_some_and(|ms| ms > 0.0), "{device}"); - } - Some("cpu") => { - assert!(device["reason"].as_str().is_some_and(|r| !r.is_empty()), "the CPU is only used for a reason: {device}"); - assert_eq!(at_once, 16); - } - other => panic!("the first photo decides where the work runs, not {other:?}"), - } - assert!(!path.with_file_name(GPU_SETUP_MARKER).exists(), "a set-up that ended leaves no marker"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - /// A server on this computer running `cpu` for the model with `sha256`. - fn server(cpu: lightcraft_denoise::runtime::CpuRunner, sha256: &str, tile: usize) -> String { - use lightcraft_denoise::remote::{ModelId, ServerOptions, serve}; - let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = l.local_addr().unwrap().to_string(); - let id = ModelId { id: "synthetic".into(), version: "1".into(), sha256: sha256.into() }; - let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| cpu.run(input)); - let opts = ServerOptions { token: "remote-test-token-0123".into(), ..Default::default() }; - std::thread::spawn(move || serve(l, id, tile, "CPU (test server)".into(), run, opts, Arc::new(|_| {}))); - addr - } - - fn with_remote(path: &Path, tile: u64, addr: &str, sha256: &str) -> Network { - let manifest = DenoiserManifest { - id: "synthetic".into(), - name: "Synthetic".into(), - version: "1".into(), - licence: Default::default(), - source: None, - sha256: None, - size_bytes: None, - provenance: String::new(), - domain: Domain::BayerToRgb, - tile: tile as u32, - overlap: 16, - gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 }, - }; - let remote = RemoteSide::new(lightcraft_denoise::remote::Remote::new(addr, "remote-test-token-0123", sha256, tile as usize)); - network(path, &manifest, Some(remote)).unwrap() - } - - #[test] - fn a_remote_server_shares_the_tiles_and_drops_out_when_it_cannot() { - let (_, cpu, path) = model("remote", 64); - let sha = lightcraft_denoise::hash::sha256_file(&path).unwrap(); - let (input, _) = lightcraft_denoise::runtime::test_tile(64); - let want = cpu.run(&input).unwrap(); - // the same model on the server: its slots come on top of this computer's threads, answers within f16 rounding - let addr = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), &sha, 64); - let net = with_remote(&path, 64, &addr, &sha); - let (runner, at_once) = net.runner(RunOn::Cpu, 2); - assert_eq!(at_once, 2 + 4, "four server slots on top of two local threads"); - assert!(worst(&want, &runner.run(&input).unwrap()) < 5e-3); - let d = net.device(RunOn::Cpu); - assert_eq!(d["remote"]["state"], "used", "{d}"); - assert_eq!(d["remote"]["device"], "CPU (test server)"); - // a local card faster than the server there and back (here a stand-in that takes no time): nothing is shared, - // the photo never waits for the slower server, and the status says why - let mut net = with_remote(&path, 64, &addr, &sha); - net.gpu = OnceLock::from(Ok(GpuSide { runner: Box::new(cpu.clone()), adapter: "Fast card".into(), card_ms: 1e-6, cpu_ms: 1000.0 })); - let (_, at_once) = net.runner(RunOn::Gpu, 8); - assert_eq!(at_once, GPU_PARALLEL, "no server slots"); - let d = net.device(RunOn::Gpu); - assert_eq!(d["remote"]["state"], "slower", "{d}"); - assert!(d["remote"]["remoteMs"].as_f64().is_some_and(|ms| ms > 0.0) && d["remote"]["localMs"].as_f64().is_some(), "{d}"); - // a server running another model is refused: everything runs here - let other = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), "0000", 64); - let net = with_remote(&path, 64, &other, &sha); - let (runner, at_once) = net.runner(RunOn::Cpu, 2); - assert_eq!(at_once, 2); - assert_eq!(runner.run(&input).unwrap(), want, "the CPU path, bit for bit"); - let d = net.device(RunOn::Cpu); - assert!(d["remote"]["reason"].as_str().is_some_and(|r| r.contains("another model")), "{d}"); - // nobody listening: the same - let net = with_remote(&path, 64, "127.0.0.1:1", &sha); - assert_eq!(net.runner(RunOn::Cpu, 3).1, 3); - assert_eq!(net.device(RunOn::Cpu)["remote"]["state"], "unavailable"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn with_the_processor_chosen_everything_runs_on_the_cpu() { - let (model, cpu, path) = model("cpu", 64); - let (input, _) = lightcraft_denoise::runtime::test_tile(64); - let (runner, at_once) = model.runner(RunOn::Cpu, 16); - assert_eq!(runner.run(&input).unwrap(), cpu.run(&input).unwrap(), "the CPU path is the CPU runner, bit for bit"); - assert_eq!(at_once, 16, "the threads asked for are the threads used"); - assert_eq!(model.device(RunOn::Cpu)["kind"], "cpu"); - assert_eq!(model.device(RunOn::Auto)["kind"], "pending", "the card was not even set up"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn the_card_is_used_where_it_is_faster_than_the_processor() { - // measured: a tile alone on one CPU thread 1.4 s; an RTX 4090 Laptop 13 ms; a Radeon 610M 490 ms - for threads in [1, 2, 8, 16, 64] { - assert!(card_is_faster(13.0, 1400.0, threads), "a big card wins at {threads}"); - } - assert!(card_is_faster(490.0, 1400.0, 2), "a small card beats the two threads background work gets"); - assert!(!card_is_faster(490.0, 1400.0, 16), "and loses to sixteen"); - assert!(!card_is_faster(f64::INFINITY, 1400.0, 1) && !card_is_faster(f64::NAN, 1400.0, 1), "a card that never ran is never faster"); - assert!(!card_is_faster(10.0, 0.0, 1)); - } - - /// A card that gives numbers that are not finite. - struct Garbage(AtomicUsize); - - impl TileRunner for Garbage { - fn run(&self, input: &[f32]) -> Result, RunError> { - self.0.fetch_add(1, Ordering::Relaxed); - let n = input.len() / 4 * 12; - Ok(vec![f32::NAN; n]) - } - } - - #[test] - fn a_tile_the_card_gets_wrong_is_run_on_the_processor_and_three_stop_the_card() { - let (_, cpu, path) = model("garbage", 64); - let card = GpuSide { runner: Box::new(Garbage(AtomicUsize::new(0))), adapter: "Test card".into(), card_ms: 1.0, cpu_ms: 1000.0 }; - let model = Network { - cpu: cpu.clone(), - path: path.clone(), - tile: 32, - gpu: OnceLock::from(Ok(card)), - fallbacks: AtomicUsize::new(0), - last_threads: AtomicUsize::new(0), - remote: None, - local_card: AtomicBool::new(true), - }; - let (input, _) = lightcraft_denoise::runtime::test_tile(64); - let want = cpu.run(&input).unwrap(); - let (runner, at_once) = model.runner(RunOn::Auto, 8); - assert_eq!(at_once, GPU_PARALLEL, "the (fast) card is chosen"); - for _ in 0..GPU_MAX_FALLBACKS { - assert_eq!(runner.run(&input).unwrap(), want, "a wrong tile is the CPU's tile"); - } - let device = model.device(RunOn::Auto); - assert_eq!(device["kind"], "cpu", "{device}"); - assert!(device["reason"].as_str().unwrap().contains("tiles wrong"), "{device}"); - assert_eq!(device["fellBack"], GPU_MAX_FALLBACKS); - let (_, at_once) = model.runner(RunOn::Gpu, 8); - assert_eq!(at_once, 8, "the next photo runs on the CPU, even with the card chosen"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn a_slow_card_is_left_out_unless_it_is_chosen() { - let (_, cpu, path) = model("slow", 64); - let slow = GpuSide { runner: Box::new(cpu.clone()), adapter: "Slow card".into(), card_ms: 490.0, cpu_ms: 1400.0 }; - let model = Network { - cpu, - path: path.clone(), - tile: 32, - gpu: OnceLock::from(Ok(slow)), - fallbacks: AtomicUsize::new(0), - last_threads: AtomicUsize::new(0), - remote: None, - local_card: AtomicBool::new(true), - }; - assert_eq!(model.runner(RunOn::Auto, 16).1, 16, "sixteen threads beat it"); - let device = model.device(RunOn::Auto); - assert_eq!((device["kind"].as_str(), device["reason"].as_str()), (Some("cpu"), Some("it is faster than Slow card on this computer"))); - assert_eq!(device["cardMs"], 490.0, "the times measured are reported"); - assert_eq!(device["retry"], false, "trying again would not make it faster"); - assert_eq!(model.runner(RunOn::Auto, 2).1, GPU_PARALLEL.min(2), "two threads do not"); - assert_eq!(model.device(RunOn::Auto)["kind"], "gpu"); - assert_eq!(model.runner(RunOn::Gpu, 16).1, GPU_PARALLEL, "chosen, it is used anyway"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn a_set_up_marker_left_behind_keeps_the_card_off() { - let (model, _, path) = model("crashed", 64); - let marker = path.with_file_name(GPU_SETUP_MARKER); - std::fs::write(&marker, "setting up the graphics card for AI Denoise (unix time 0)").unwrap(); - let (_, at_once) = model.runner(RunOn::Gpu, 4); - assert_eq!(at_once, 4, "the CPU does the work"); - let device = model.device(RunOn::Gpu); - assert_eq!((device["kind"].as_str(), device["reason"].as_str()), (Some("cpu"), Some(GPU_CRASHED)), "{device}"); - assert_eq!(device["retry"], true, "the interface offers to try again"); - assert!(marker.exists(), "it stays until the user chooses where denoise runs"); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn a_set_up_runs_beside_the_cpu_and_removes_its_marker() { - let dir = folder("setup"); - let marker = dir.join(GPU_SETUP_MARKER); - let seen = Arc::new(AtomicBool::new(false)); - let (m, s) = (marker.clone(), seen.clone()); - let r = set_up_card( - &marker, - Duration::from_secs(10), - move || { - s.store(m.exists(), Ordering::Relaxed); - Ok(7) - }, - || "cpu", - ); - assert_eq!(r, Ok((7, "cpu"))); - assert!(seen.load(Ordering::Relaxed), "the marker is there while the card is set up"); - assert!(!marker.exists(), "and gone after"); - // a set-up that fails or panics removes it too - assert_eq!(set_up_card(&marker, Duration::from_secs(10), || Err::<(), _>("no card".into()), || ()), Err("no card".into())); - assert!(!marker.exists()); - let r = set_up_card(&marker, Duration::from_secs(10), || -> Result<(), String> { panic!("driver") }, || ()); - assert!(r.is_err() && !marker.exists(), "{r:?}"); - // a marker found before the set-up starts means the last one took LightCraft down: the card is not touched - std::fs::write(&marker, "left behind").unwrap(); - let touched = Arc::new(AtomicBool::new(false)); - let t = touched.clone(); - let r = set_up_card( - &marker, - Duration::from_secs(10), - move || { - t.store(true, Ordering::Relaxed); - Ok(()) - }, - || (), - ); - assert_eq!(r, Err(GPU_CRASHED.to_string())); - assert!(!touched.load(Ordering::Relaxed) && marker.exists()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_set_up_that_takes_too_long_is_given_up_on() { - let dir = folder("slowsetup"); - let marker = dir.join(GPU_SETUP_MARKER); - let started = web_time::Instant::now(); - let slow = || -> Result<(), String> { - std::thread::sleep(Duration::from_millis(600)); - Ok(()) - }; - let r = set_up_card(&marker, Duration::from_millis(50), slow, || ()); - assert!(r.as_ref().is_err_and(|e| e.contains("took longer") && worth_retrying(e)), "{r:?}"); - assert!(started.elapsed() < Duration::from_millis(500), "the caller does not wait for the card"); - // while the first is still stuck, another set-up of the same model does not start a second one - let again = set_up_card(&marker, Duration::from_secs(5), || Ok(()), || ()); - assert!(again.is_err_and(|e| e.contains("still setting up") && worth_retrying(&e))); - // once it ends its marker goes, and the card can be set up again - while marker.exists() { - assert!(started.elapsed() < Duration::from_secs(10), "the stuck set-up ended"); - std::thread::sleep(Duration::from_millis(20)); - } - while SETTING_UP.lock().unwrap().iter().any(|m| m == &marker) { - std::thread::sleep(Duration::from_millis(5)); - } - assert_eq!(set_up_card(&marker, Duration::from_secs(5), || Ok(1), || 2), Ok((1, 2))); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod gpu_tests; diff --git a/crates/engine/src/denoise/gpu_tests.rs b/crates/engine/src/denoise/gpu_tests.rs new file mode 100644 index 000000000..bb80cecda --- /dev/null +++ b/crates/engine/src/denoise/gpu_tests.rs @@ -0,0 +1,306 @@ +use super::*; +use lightcraft_denoise::manifest::{Domain, Gain}; + +/// A folder of the test's own: the card's set-up leaves its marker beside the model. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lc-engine-gpu-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// The model file is read again when the card is set up (for the first photo), so it stays until the test ends. +fn model(name: &str, tile: u64) -> (Arc, lightcraft_denoise::runtime::CpuRunner, PathBuf) { + let path = folder(name).join("model.onnx"); + std::fs::write(&path, lightcraft_denoise::synthetic::unet_onnx(tile, 8, 2, 7)).unwrap(); + let manifest = DenoiserManifest { + id: "synthetic".into(), + name: "Synthetic".into(), + version: "1".into(), + licence: Default::default(), + source: None, + sha256: None, + size_bytes: None, + provenance: String::new(), + domain: Domain::BayerToRgb, + tile: tile as u32, + overlap: 16, + gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 }, + }; + let loaded = default_loader()(&path, &manifest).unwrap(); + let cpu = lightcraft_denoise::runtime::CpuRunner::load(&path, &manifest).unwrap(); + (loaded, cpu, path) +} + +fn worst(want: &[f32], got: &[f32]) -> f32 { + assert_eq!(want.len(), got.len()); + let scale = want.iter().fold(1e-9f32, |m, v| m.max(v.abs())); + want.iter().zip(got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale +} + +#[test] +fn a_tile_on_the_card_is_the_cpus_tile() { + let (model, cpu, path) = model("same", 64); + let (input, _) = lightcraft_denoise::runtime::test_tile(64); + let want = cpu.run(&input).unwrap(); + let (runner, at_once) = model.runner(RunOn::Gpu, 16); + assert!(worst(&want, &runner.run(&input).unwrap()) < 1e-3); + let device = model.device(RunOn::Gpu); + eprintln!("device: {device}"); + match device["kind"].as_str() { + Some("gpu") => { + assert!(device["adapter"].as_str().is_some_and(|a| !a.is_empty())); + assert!(at_once <= GPU_PARALLEL, "a card is fed by a few tiles at once"); + assert!(device["cardMs"].as_f64().is_some_and(|ms| ms > 0.0) && device["cpuMs"].as_f64().is_some_and(|ms| ms > 0.0), "{device}"); + } + Some("cpu") => { + assert!(device["reason"].as_str().is_some_and(|r| !r.is_empty()), "the CPU is only used for a reason: {device}"); + assert_eq!(at_once, 16); + } + other => panic!("the first photo decides where the work runs, not {other:?}"), + } + assert!(!path.with_file_name(GPU_SETUP_MARKER).exists(), "a set-up that ended leaves no marker"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +/// A server on this computer running `cpu` for the model with `sha256`. +fn server(cpu: lightcraft_denoise::runtime::CpuRunner, sha256: &str, tile: usize) -> String { + use lightcraft_denoise::remote::{ModelId, ServerOptions, serve}; + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = l.local_addr().unwrap().to_string(); + let id = ModelId { id: "synthetic".into(), version: "1".into(), sha256: sha256.into() }; + let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| cpu.run(input)); + let opts = ServerOptions { token: "remote-test-token-0123".into(), ..Default::default() }; + std::thread::spawn(move || serve(l, id, tile, "CPU (test server)".into(), run, opts, Arc::new(|_| {}))); + addr +} + +fn with_remote(path: &Path, tile: u64, addr: &str, sha256: &str) -> Network { + let manifest = DenoiserManifest { + id: "synthetic".into(), + name: "Synthetic".into(), + version: "1".into(), + licence: Default::default(), + source: None, + sha256: None, + size_bytes: None, + provenance: String::new(), + domain: Domain::BayerToRgb, + tile: tile as u32, + overlap: 16, + gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 }, + }; + let remote = RemoteSide::new(lightcraft_denoise::remote::Remote::new(addr, "remote-test-token-0123", sha256, tile as usize)); + network(path, &manifest, Some(remote)).unwrap() +} + +#[test] +fn a_remote_server_shares_the_tiles_and_drops_out_when_it_cannot() { + let (_, cpu, path) = model("remote", 64); + let sha = lightcraft_denoise::hash::sha256_file(&path).unwrap(); + let (input, _) = lightcraft_denoise::runtime::test_tile(64); + let want = cpu.run(&input).unwrap(); + // the same model on the server: its slots come on top of this computer's threads, answers within f16 rounding + let addr = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), &sha, 64); + let net = with_remote(&path, 64, &addr, &sha); + let (runner, at_once) = net.runner(RunOn::Cpu, 2); + assert_eq!(at_once, 2 + 4, "four server slots on top of two local threads"); + assert!(worst(&want, &runner.run(&input).unwrap()) < 5e-3); + let d = net.device(RunOn::Cpu); + assert_eq!(d["remote"]["state"], "used", "{d}"); + assert_eq!(d["remote"]["device"], "CPU (test server)"); + // a local card faster than the server there and back (here a stand-in that takes no time): nothing is shared, + // the photo never waits for the slower server, and the status says why + let mut net = with_remote(&path, 64, &addr, &sha); + net.gpu = OnceLock::from(Ok(GpuSide { runner: Box::new(cpu.clone()), adapter: "Fast card".into(), card_ms: 1e-6, cpu_ms: 1000.0 })); + let (_, at_once) = net.runner(RunOn::Gpu, 8); + assert_eq!(at_once, GPU_PARALLEL, "no server slots"); + let d = net.device(RunOn::Gpu); + assert_eq!(d["remote"]["state"], "slower", "{d}"); + assert!(d["remote"]["remoteMs"].as_f64().is_some_and(|ms| ms > 0.0) && d["remote"]["localMs"].as_f64().is_some(), "{d}"); + // a server running another model is refused: everything runs here + let other = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), "0000", 64); + let net = with_remote(&path, 64, &other, &sha); + let (runner, at_once) = net.runner(RunOn::Cpu, 2); + assert_eq!(at_once, 2); + assert_eq!(runner.run(&input).unwrap(), want, "the CPU path, bit for bit"); + let d = net.device(RunOn::Cpu); + assert!(d["remote"]["reason"].as_str().is_some_and(|r| r.contains("another model")), "{d}"); + // nobody listening: the same + let net = with_remote(&path, 64, "127.0.0.1:1", &sha); + assert_eq!(net.runner(RunOn::Cpu, 3).1, 3); + assert_eq!(net.device(RunOn::Cpu)["remote"]["state"], "unavailable"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn with_the_processor_chosen_everything_runs_on_the_cpu() { + let (model, cpu, path) = model("cpu", 64); + let (input, _) = lightcraft_denoise::runtime::test_tile(64); + let (runner, at_once) = model.runner(RunOn::Cpu, 16); + assert_eq!(runner.run(&input).unwrap(), cpu.run(&input).unwrap(), "the CPU path is the CPU runner, bit for bit"); + assert_eq!(at_once, 16, "the threads asked for are the threads used"); + assert_eq!(model.device(RunOn::Cpu)["kind"], "cpu"); + assert_eq!(model.device(RunOn::Auto)["kind"], "pending", "the card was not even set up"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn the_card_is_used_where_it_is_faster_than_the_processor() { + // measured: a tile alone on one CPU thread 1.4 s; an RTX 4090 Laptop 13 ms; a Radeon 610M 490 ms + for threads in [1, 2, 8, 16, 64] { + assert!(card_is_faster(13.0, 1400.0, threads), "a big card wins at {threads}"); + } + assert!(card_is_faster(490.0, 1400.0, 2), "a small card beats the two threads background work gets"); + assert!(!card_is_faster(490.0, 1400.0, 16), "and loses to sixteen"); + assert!(!card_is_faster(f64::INFINITY, 1400.0, 1) && !card_is_faster(f64::NAN, 1400.0, 1), "a card that never ran is never faster"); + assert!(!card_is_faster(10.0, 0.0, 1)); +} + +/// A card that gives numbers that are not finite. +struct Garbage(AtomicUsize); + +impl TileRunner for Garbage { + fn run(&self, input: &[f32]) -> Result, RunError> { + self.0.fetch_add(1, Ordering::Relaxed); + let n = input.len() / 4 * 12; + Ok(vec![f32::NAN; n]) + } +} + +#[test] +fn a_tile_the_card_gets_wrong_is_run_on_the_processor_and_three_stop_the_card() { + let (_, cpu, path) = model("garbage", 64); + let card = GpuSide { runner: Box::new(Garbage(AtomicUsize::new(0))), adapter: "Test card".into(), card_ms: 1.0, cpu_ms: 1000.0 }; + let model = Network { + cpu: cpu.clone(), + path: path.clone(), + tile: 32, + gpu: OnceLock::from(Ok(card)), + fallbacks: AtomicUsize::new(0), + last_threads: AtomicUsize::new(0), + remote: None, + local_card: AtomicBool::new(true), + }; + let (input, _) = lightcraft_denoise::runtime::test_tile(64); + let want = cpu.run(&input).unwrap(); + let (runner, at_once) = model.runner(RunOn::Auto, 8); + assert_eq!(at_once, GPU_PARALLEL, "the (fast) card is chosen"); + for _ in 0..GPU_MAX_FALLBACKS { + assert_eq!(runner.run(&input).unwrap(), want, "a wrong tile is the CPU's tile"); + } + let device = model.device(RunOn::Auto); + assert_eq!(device["kind"], "cpu", "{device}"); + assert!(device["reason"].as_str().unwrap().contains("tiles wrong"), "{device}"); + assert_eq!(device["fellBack"], GPU_MAX_FALLBACKS); + let (_, at_once) = model.runner(RunOn::Gpu, 8); + assert_eq!(at_once, 8, "the next photo runs on the CPU, even with the card chosen"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn a_slow_card_is_left_out_unless_it_is_chosen() { + let (_, cpu, path) = model("slow", 64); + let slow = GpuSide { runner: Box::new(cpu.clone()), adapter: "Slow card".into(), card_ms: 490.0, cpu_ms: 1400.0 }; + let model = Network { + cpu, + path: path.clone(), + tile: 32, + gpu: OnceLock::from(Ok(slow)), + fallbacks: AtomicUsize::new(0), + last_threads: AtomicUsize::new(0), + remote: None, + local_card: AtomicBool::new(true), + }; + assert_eq!(model.runner(RunOn::Auto, 16).1, 16, "sixteen threads beat it"); + let device = model.device(RunOn::Auto); + assert_eq!((device["kind"].as_str(), device["reason"].as_str()), (Some("cpu"), Some("it is faster than Slow card on this computer"))); + assert_eq!(device["cardMs"], 490.0, "the times measured are reported"); + assert_eq!(device["retry"], false, "trying again would not make it faster"); + assert_eq!(model.runner(RunOn::Auto, 2).1, GPU_PARALLEL.min(2), "two threads do not"); + assert_eq!(model.device(RunOn::Auto)["kind"], "gpu"); + assert_eq!(model.runner(RunOn::Gpu, 16).1, GPU_PARALLEL, "chosen, it is used anyway"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn a_set_up_marker_left_behind_keeps_the_card_off() { + let (model, _, path) = model("crashed", 64); + let marker = path.with_file_name(GPU_SETUP_MARKER); + std::fs::write(&marker, "setting up the graphics card for AI Denoise (unix time 0)").unwrap(); + let (_, at_once) = model.runner(RunOn::Gpu, 4); + assert_eq!(at_once, 4, "the CPU does the work"); + let device = model.device(RunOn::Gpu); + assert_eq!((device["kind"].as_str(), device["reason"].as_str()), (Some("cpu"), Some(GPU_CRASHED)), "{device}"); + assert_eq!(device["retry"], true, "the interface offers to try again"); + assert!(marker.exists(), "it stays until the user chooses where denoise runs"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn a_set_up_runs_beside_the_cpu_and_removes_its_marker() { + let dir = folder("setup"); + let marker = dir.join(GPU_SETUP_MARKER); + let seen = Arc::new(AtomicBool::new(false)); + let (m, s) = (marker.clone(), seen.clone()); + let r = set_up_card( + &marker, + Duration::from_secs(10), + move || { + s.store(m.exists(), Ordering::Relaxed); + Ok(7) + }, + || "cpu", + ); + assert_eq!(r, Ok((7, "cpu"))); + assert!(seen.load(Ordering::Relaxed), "the marker is there while the card is set up"); + assert!(!marker.exists(), "and gone after"); + // a set-up that fails or panics removes it too + assert_eq!(set_up_card(&marker, Duration::from_secs(10), || Err::<(), _>("no card".into()), || ()), Err("no card".into())); + assert!(!marker.exists()); + let r = set_up_card(&marker, Duration::from_secs(10), || -> Result<(), String> { panic!("driver") }, || ()); + assert!(r.is_err() && !marker.exists(), "{r:?}"); + // a marker found before the set-up starts means the last one took LightCraft down: the card is not touched + std::fs::write(&marker, "left behind").unwrap(); + let touched = Arc::new(AtomicBool::new(false)); + let t = touched.clone(); + let r = set_up_card( + &marker, + Duration::from_secs(10), + move || { + t.store(true, Ordering::Relaxed); + Ok(()) + }, + || (), + ); + assert_eq!(r, Err(GPU_CRASHED.to_string())); + assert!(!touched.load(Ordering::Relaxed) && marker.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_set_up_that_takes_too_long_is_given_up_on() { + let dir = folder("slowsetup"); + let marker = dir.join(GPU_SETUP_MARKER); + let started = web_time::Instant::now(); + let slow = || -> Result<(), String> { + std::thread::sleep(Duration::from_millis(600)); + Ok(()) + }; + let r = set_up_card(&marker, Duration::from_millis(50), slow, || ()); + assert!(r.as_ref().is_err_and(|e| e.contains("took longer") && worth_retrying(e)), "{r:?}"); + assert!(started.elapsed() < Duration::from_millis(500), "the caller does not wait for the card"); + // while the first is still stuck, another set-up of the same model does not start a second one + let again = set_up_card(&marker, Duration::from_secs(5), || Ok(()), || ()); + assert!(again.is_err_and(|e| e.contains("still setting up") && worth_retrying(&e))); + // once it ends its marker goes, and the card can be set up again + while marker.exists() { + assert!(started.elapsed() < Duration::from_secs(10), "the stuck set-up ended"); + std::thread::sleep(Duration::from_millis(20)); + } + while SETTING_UP.lock().unwrap().iter().any(|m| m == &marker) { + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(set_up_card(&marker, Duration::from_secs(5), || Ok(1), || 2), Ok((1, 2))); + let _ = std::fs::remove_dir_all(&dir); +} From 745111dc6d1f11d4b0915583f2e3db850892500e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:05:54 +0000 Subject: [PATCH 107/191] refactor(lightcraft-ui-egui): move panels/right tests to panels/right/tests.rs --- crates/ui-egui/src/panels/right.rs | 35 +----------------------- crates/ui-egui/src/panels/right/tests.rs | 32 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 34 deletions(-) create mode 100644 crates/ui-egui/src/panels/right/tests.rs diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 45a7bf257..341259dbc 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -1596,37 +1596,4 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } #[cfg(test)] -mod tests { - // Feature: the History panel greys out the steps after the cursor - #[test] - fn history_rows_after_the_cursor_are_greyed() { - use super::{HistoryRow, history_row}; - let rows: Vec<_> = (0..4).map(|i| history_row(i, Some(1))).collect(); - assert_eq!(rows, [HistoryRow::Done, HistoryRow::Current, HistoryRow::Undone, HistoryRow::Undone]); - assert_eq!(history_row(3, Some(3)), HistoryRow::Current, "at the latest step nothing is greyed"); - assert_eq!(history_row(0, None), HistoryRow::Done); - } - - // Feature: the aspect button tells what is locked - #[test] - fn aspect_label_names_presets_in_either_orientation() { - assert_eq!(super::aspect_label(None, Some(1.5)), "Free"); - assert_eq!(super::aspect_label(Some((1600, 900)), None), "16 × 9"); - assert_eq!(super::aspect_label(Some((900, 1600)), None), "16 × 9", "portrait 16:9"); - assert_eq!(super::aspect_label(Some((400, 500)), None), "4 × 5 / 8 × 10"); - } - - #[test] - fn aspect_label_prefers_original_over_a_coinciding_preset_and_never_shows_pixel_sizes() { - assert_eq!(super::aspect_label(Some((300, 200)), Some(1.5)), "Original"); - assert_eq!(super::aspect_label(Some((600_000, 400_000)), Some(1.5)), "Original"); - assert_eq!(super::aspect_label(Some((137, 100)), Some(1.5)), "1.37 : 1"); - assert_eq!(super::aspect_label(Some((0, 5)), None), "Free"); - } - - #[test] - fn short_times() { - assert_eq!(super::short_time("2026-09-30T12:00:05"), "Sep 30, 2026, 12:00 PM"); - assert_eq!(super::short_time("2026-01-02"), "Jan 2, 2026"); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/right/tests.rs b/crates/ui-egui/src/panels/right/tests.rs new file mode 100644 index 000000000..1f1d60d60 --- /dev/null +++ b/crates/ui-egui/src/panels/right/tests.rs @@ -0,0 +1,32 @@ +// Feature: the History panel greys out the steps after the cursor +#[test] +fn history_rows_after_the_cursor_are_greyed() { + use super::{HistoryRow, history_row}; + let rows: Vec<_> = (0..4).map(|i| history_row(i, Some(1))).collect(); + assert_eq!(rows, [HistoryRow::Done, HistoryRow::Current, HistoryRow::Undone, HistoryRow::Undone]); + assert_eq!(history_row(3, Some(3)), HistoryRow::Current, "at the latest step nothing is greyed"); + assert_eq!(history_row(0, None), HistoryRow::Done); +} + +// Feature: the aspect button tells what is locked +#[test] +fn aspect_label_names_presets_in_either_orientation() { + assert_eq!(super::aspect_label(None, Some(1.5)), "Free"); + assert_eq!(super::aspect_label(Some((1600, 900)), None), "16 × 9"); + assert_eq!(super::aspect_label(Some((900, 1600)), None), "16 × 9", "portrait 16:9"); + assert_eq!(super::aspect_label(Some((400, 500)), None), "4 × 5 / 8 × 10"); +} + +#[test] +fn aspect_label_prefers_original_over_a_coinciding_preset_and_never_shows_pixel_sizes() { + assert_eq!(super::aspect_label(Some((300, 200)), Some(1.5)), "Original"); + assert_eq!(super::aspect_label(Some((600_000, 400_000)), Some(1.5)), "Original"); + assert_eq!(super::aspect_label(Some((137, 100)), Some(1.5)), "1.37 : 1"); + assert_eq!(super::aspect_label(Some((0, 5)), None), "Free"); +} + +#[test] +fn short_times() { + assert_eq!(super::short_time("2026-09-30T12:00:05"), "Sep 30, 2026, 12:00 PM"); + assert_eq!(super::short_time("2026-01-02"), "Jan 2, 2026"); +} From 9b197d82154034a88bcf245e268fa1272117c5e6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:08:55 +0000 Subject: [PATCH 108/191] refactor(lightcraft-engine): split files.rs into files/ --- crates/engine/src/files.rs | 1079 +--------------------------- crates/engine/src/files/colour.rs | 53 ++ crates/engine/src/files/fs.rs | 131 ++++ crates/engine/src/files/load.rs | 350 +++++++++ crates/engine/src/files/preview.rs | 320 +++++++++ crates/engine/src/files/probe.rs | 223 ++++++ 6 files changed, 1096 insertions(+), 1060 deletions(-) create mode 100644 crates/engine/src/files/colour.rs create mode 100644 crates/engine/src/files/fs.rs create mode 100644 crates/engine/src/files/load.rs create mode 100644 crates/engine/src/files/preview.rs create mode 100644 crates/engine/src/files/probe.rs diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index d446d1801..5ab68ff0e 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -13,225 +13,26 @@ use lightcraft_raster::resample::{Filter, fit}; use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo, ReducedLoader}; -fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { - let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default(); - let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string(); - let meta = Meta { - camera, - // (a camera that writes no lens name, like Nikon's DSLRs, still says what range the lens covers) - lens: m.lens_model.clone().or_else(|| m.lens_spec.and_then(lightcraft_meta::lens_spec_name)).unwrap_or_default(), - focal_mm: m.focal_length.map(|f| f as f32), - aperture: m.f_number.map(|f| f as f32), - shutter, - iso: m.iso, - location: m.sublocation.clone().unwrap_or_default(), - city: m.city.clone().unwrap_or_default(), - state: m.state.clone().unwrap_or_default(), - country: m.country.clone().unwrap_or_default(), - alt_text: m.alt_text.clone().unwrap_or_default(), - extended_description: m.extended_description.clone().unwrap_or_default(), - gps: m.gps.as_ref().map(|g| (g.latitude, g.longitude)), - title: m.title.clone().unwrap_or_default(), - caption: m.caption.clone().unwrap_or_default(), - copyright: m.copyright.clone().unwrap_or_default(), - copyright_status: lightcraft_catalog::CopyrightStatus::from_marked(m.copyright_marked), - usage_terms: m.usage_terms.clone().unwrap_or_default(), - copyright_url: m.copyright_url.clone().unwrap_or_default(), - creator: m.artist.clone().unwrap_or_default(), - // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels - keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), - regions: m.regions.clone(), - focus: m.focus.clone(), - }; - (meta, m.capture_time.as_ref().map(|d| d.to_iso())) -} +mod colour; +mod fs; +mod load; +mod preview; +mod probe; -/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or the -/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the -/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. -pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option { - use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage, WARP_STAGES}; - use lightcraft_geom::Point; - use lightcraft_raw::Opcode; - let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64); - if aw < 2.0 || ah < 2.0 { - return None; - } - let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); - let (cx0, cy0, cw, ch) = - if c.width == 0 || c.height == 0 { (0.0, 0.0, aw, ah) } else { (c.x as f64, c.y as f64, c.width as f64, c.height as f64) }; - let long = cw.max(ch); - // opcode centres are relative to the (uncropped) active area, in pixel-index units - let centre = |rel: [f64; 2]| -> (Point, f64) { - let (px, py) = (rel[0] * (aw - 1.0), rel[1] * (ah - 1.0)); - let m = [(0.0, 0.0), (aw - 1.0, 0.0), (0.0, ah - 1.0), (aw - 1.0, ah - 1.0)] - .iter() - .map(|&(x, y)| (x - px).hypot(y - py)) - .fold(0.0, f64::max) - .max(1e-9); - (Point::new((px + 0.5 - cx0) / cw, (py + 0.5 - cy0) / ch), m / long) - }; - let mut lens = EmbeddedLens::default(); - // opcodes in list order, minus the ones the DNG 1.6 skip rule drops (a WarpRectilinear that follows an optional - // WarpRectilinear2) - let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3).collect(); - // A DNG with a WarpRectilinear2 keeps every warp opcode as a chain of stages (each warps the one before's output); - // without one the first WarpRectilinear is the lens warp, as it always was. - let chain = ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })); - let mut stages = Vec::new(); - for op in ops { - match op { - Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } if !planes.is_empty() => { - let (center, radius) = centre(*center); - let p = |i: usize| planes[i.min(planes.len() - 1)]; - stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: *reciprocal }); - } - Opcode::WarpRectilinear { planes, center } if chain && !planes.is_empty() => { - // WarpRectilinear is the even-power case: kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶, no radius limit - let (center, radius) = centre(*center); - let p = |i: usize| { - let k = planes[i.min(planes.len() - 1)]; - let mut s = [0.0; lightcraft_raw::opcodes::WR2_LEN]; - s[0] = k[0]; - s[2] = k[1]; - s[4] = k[2]; - s[6] = k[3]; - s[15] = k[4]; - s[16] = k[5]; - s[18] = 1e6; - s - }; - stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: false }); - } - Opcode::WarpRectilinear { planes, center } if !chain && !planes.is_empty() && lens.warp.is_none() => { - let (center, radius) = centre(*center); - let p = |i: usize| planes[i.min(planes.len() - 1)]; - lens.warp = Some(EmbeddedWarp { planes: [p(0), p(1), p(2)], center, radius }); - } - Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => { - let (center, radius) = centre(*center); - lens.vignette = Some(EmbeddedVignette { k: *k, center, radius }); - } - _ => {} - } - } - // (more stages than a lens keeps: the first ones; real files carry two) - for (slot, st) in lens.stages.iter_mut().zip(stages.into_iter().take(WARP_STAGES)) { - *slot = Some(st); - } - if lens.warp.is_none() && lens.vignette.is_none() && !lens.has_stages() { - return None; - } - Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch)) -} - -fn ext_upper(name: &str) -> String { - std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default() -} - -/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed: -/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too -/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside -/// compressed data that is all there: such a file imports and shows as unreadable when rendered). -pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { - let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string()); - let m = lightcraft_meta::extract(bytes); - let (meta, captured) = meta_of(&m); - if lightcraft_raw::probe(bytes).is_some() { - let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) { - Ok(r) => r, - Err(Ok(why)) => { - // a raw variant we can't decode yet: describe it from its embedded preview - let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?; - return Ok(ProbeInfo { - width: w, - height: h, - format: ext_upper(name), - kind: MediaKind::Raw, - file_size: bytes.len() as u64, - captured, - meta, - as_shot_wb: None, - content_hash, - xmp: lightcraft_meta::embedded(bytes).xmp, - preview_only: Some(why), - ..Default::default() - }); - } - Err(Err(e)) => return Err(e), - }; - let (w, h) = raw.developed_size(); - let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32); - if raw.orientation.swaps_axes() { - std::mem::swap(&mut w, &mut h); - } - let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); - let as_shot_wb = Some(scale.as_shot(lightcraft_raw::color::as_shot_white_xy_of(&raw))); - let embedded_lens = crate::lens_profiles::for_raw(&raw, || m.clone()); - return Ok(ProbeInfo { - embedded_lens, - width: w, - height: h, - format: ext_upper(name), - kind: MediaKind::Raw, - file_size: bytes.len() as u64, - captured, - file_modified: None, - meta, - as_shot_wb, - content_hash, - xmp: lightcraft_meta::embedded(bytes).xmp, - preview_only: None, - }); - } - let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?; - if let Some(why) = fmt.not_decodable() { - // e.g. "Heif files can't be opened: HEIC/HEIF support isn't included in this build of LightCraft" - return Err(format!("{fmt:?} files can't be opened: {why}")); - } - // headers only (issue #367: decoding the pixels was nearly all of an import's CPU time) - let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?; - let o = Orientation::from_exif(header.orientation); - let (mut w, mut h) = (header.width, header.height); - if o.swaps_axes() { - std::mem::swap(&mut w, &mut h); - } - let format = match ext_upper(name).as_str() { - "JPG" | "JPEG" => "JPEG".to_string(), - "" => format!("{fmt:?}").to_uppercase(), - e => e.to_string(), - }; - Ok(ProbeInfo { - width: w, - height: h, - format, - kind: MediaKind::Image, - file_size: bytes.len() as u64, - captured, - file_modified: None, - meta, - as_shot_wb: None, - content_hash, - embedded_lens: None, - xmp: None, - preview_only: None, - }) -} - -/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error -/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX -/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed. -/// So does any other failure of a recognised raw (damaged or oversized raw data, unreadable TIFF -/// structure): its preview may still be intact. Only a file that is not a raw reports an error. -fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result { - use lightcraft_raw::RawError; - match e { - RawError::Unsupported(why) => Ok(why), - e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")), - RawError::NotRaw => Err(RawError::NotRaw.to_string()), - e => Ok(e.to_string()), - } -} +pub use colour::bin_factor; +pub(crate) use colour::clipped_white; +#[cfg(test)] +use fs::quick_image_srgb; +pub use fs::{fs_hooks, fs_pair_loader, fs_reduced_loader}; +pub(crate) use load::{DngWhite, WbScaleOf, dng_tone_curve}; +pub use load::{load_bytes, load_vec, load_vec_pair, load_vec_reduced}; +#[cfg(test)] +use preview::PLAN_PREFIX; +pub(crate) use preview::decode_raw_preview; +pub use preview::{PreviewRead, embedded_preview_srgb, fs_preview_loader, load_embedded_preview, preview_window}; +pub use probe::{embedded_lens, probe_bytes}; +#[cfg(test)] +use probe::{meta_of, preview_reason}; /// Sensor clip level (normalised) for highlight reconstruction. const HIGHLIGHT_CLIP: f32 = 0.99; @@ -306,847 +107,5 @@ impl CameraColourModel<'_> { } } -/// The white-balanced camera RGB that `model` (white-balanced camera RGB → linear Rec.2020) renders -/// neutral, as bright as it renders `[1, 1, 1]`: what fully clipped highlights are rebuilt to, so they come out -/// neutral whatever the colour model does to the camera's own neutral (a look fitted to the camera's JPEG or a -/// camera profile often tints it by a few per cent, which any darkening of the highlights would show). -/// `[1, 1, 1]` (the camera's own neutral, as before this existed) when the model can't be trusted with it: not -/// invertible or nearly so, an answer far from the camera's neutral (a sane colour model moves white by a few per -/// cent), or one that isn't neutral through the `f32` matrix the decode applies. -pub(crate) fn clipped_white(model: &lightcraft_color::Mat3) -> [f32; 3] { - const CAMERA: [f32; 3] = [1.0; 3]; - /// Largest condition number (max row sum norm) of a model trusted for this. - const CONDITION: f64 = 1e3; - /// Largest factor between the answer and the camera's neutral, per channel. - const SPREAD: f64 = 4.0; - let norm = |m: &lightcraft_color::Mat3| m.0.iter().map(|row| row.iter().map(|v| v.abs()).sum::()).fold(0.0, f64::max); - let Some(inverse) = model.inverse() else { return CAMERA }; - let condition = norm(model) * norm(&inverse); - if !condition.is_finite() || condition > CONDITION { - return CAMERA; - } - let luma = |v: [f64; 3]| { - let c = model.apply(v); - (0..3).map(|i| c[i] * f64::from(lightcraft_color::LUMA_2020[i])).sum::() - }; - let n = inverse.apply([1.0; 3]); - let (target, own) = (luma([1.0; 3]), luma(n)); - if !(target > 0.0 && own > 0.0 && target.is_finite() && own.is_finite()) { - return CAMERA; - } - let white = n.map(|v| v * target / own); - if !white.iter().all(|v| v.is_finite() && (1.0 / SPREAD..=SPREAD).contains(v)) { - return CAMERA; - } - let white = white.map(|v| v as f32); - // neutral through the matrix as the decode applies it - let m = model.to_f32(); - let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * white[0] + m[i][1] * white[1] + m[i][2] * white[2]); - let (lo, hi) = (out[0].min(out[1]).min(out[2]), out[0].max(out[1]).max(out[2])); - if lo > 0.0 && hi.is_finite() && hi - lo <= 1e-3 * hi { white } else { CAMERA } -} - -/// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels: -/// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the -/// 2560 px preview). X-Trans can only bin 3× (its 6×6 pattern), and its full demosaic is ~5× the -/// cost of Bayer's, so 3× is accepted down to 75 % (a 24 MP X-Trans preview is then ~2000 px -/// instead of a ~1.2 s full demosaic; zooming in still uses the full-size source). -/// `None` = demosaic at full size. -pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option { - let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); - let long = if c.width > 1 && c.height > 1 { c.width.max(c.height) } else { raw.active_area.width.max(raw.active_area.height) }; - let need = (max_edge.saturating_mul(9) / 10).max(1); - let need3 = (max_edge.saturating_mul(3) / 4).max(1); - [8usize, 6, 4, 3, 2].into_iter().find(|&k| raw.can_bin(k) && (long / k >= need || (k == 3 && !raw.can_bin(2) && long / k >= need3))) -} - -/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented. -/// -/// Runs on a rayon worker: its many short parallel loops then start on the worker's own queue -/// instead of each one waking the pool from outside and waiting for it (which costs more than the -/// loops themselves when the machine is busy). -pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { - rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) -} - -/// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded -/// (less memory held while it is developed). -pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) -} - -/// [`load_vec`] for a thumbnail: a DNG that keeps reduced-resolution copies of its raw image (Lightroom's HDR and -/// panorama merges) develops the smallest that covers `max_edge` instead of the whole image. The flag says whether -/// it did: such a picture is not the full image, and an export never renders from it. -pub fn load_vec_reduced(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo, bool), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, true).map(|(img, _, info, reduced)| (img, info, reduced))) -} - -/// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain -/// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all -/// there is) and the source facts. Both pictures are the same size. -pub fn load_vec_pair(bytes: Vec, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option, SourceInfo), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec), false).map(|(img, twin, info, _)| (img, twin, info))) -} - -/// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default -/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned (plain means: the -/// plain development's clip mask tells highlight reconstruction which of them held clipped samples). `None` when -/// the product is missing, damaged, made from something else or not the size of this raw's active area. -fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, factor: usize) -> Option { - use lightcraft_denoise::product::{self, Window}; - let a = raw.active_area; - let header = product::read_header_at(&spec.product).ok()?; - if header.key != spec.key || (header.width, header.height) != (a.width, a.height) { - return None; - } - if raw.opcodes.list3.is_empty() { - let c = raw.develop_crop(a.width, a.height); - let window = Window { x: c.x, y: c.y, width: c.width, height: c.height }; - product::read_window(&spec.product, &spec.key, Some(window), factor, None).ok() - } else { - // (the plain development is not binned either when the file has opcodes of this kind: `factor` is 1) - let whole = product::read(&spec.product, &spec.key, 1).ok()?; - Some(raw.finish_demosaiced(whole)) - } -} - -fn load_bytes_now( - bytes: std::borrow::Cow<'_, [u8]>, - max_edge: usize, - denoise: Option<&DenoiseSpec>, - allow_reduced: bool, -) -> Result<(Rgb32f, Option, SourceInfo, bool), String> { - if lightcraft_raw::probe(&bytes).is_some() { - // a thumbnail (`allow_reduced`, never an export) of a DNG that keeps smaller copies of its raw image - // (Lightroom's merges) develops the smallest that covers it; a denoised photo's twin needs the full mosaic - let reduced = (allow_reduced && denoise.is_none() && max_edge < usize::MAX) - .then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()) - .flatten(); - let used_reduced = reduced.is_some(); - let decoded = match reduced { - Some(r) => Ok(r), - None => lightcraft_raw::decode(&bytes), - }; - let mut raw = match decoded.map_err(|e| preview_reason(&bytes, e)) { - Ok(r) => r, - Err(Ok(why)) => { - // show the camera's embedded JPEG (rendered, not raw) until the variant is supported - return load_embedded_preview(&bytes, max_edge) - .map(|(img, info)| (img, None, info, false)) - .ok_or(format!("unsupported raw ({why}) without an embedded preview")); - } - Err(Err(e)) => return Err(e), - }; - // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in - // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). - let info = raw.info(); - let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); - // (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it) - let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3); - // A DNG's preview already has its lens opcodes applied. Keep them for its single tone fit, - // then remove them again: the rendered source leaves those corrections to the pipeline. - let dng_lens_ops = (raw.format == lightcraft_raw::RawFormat::Dng && raw.opcodes.list3.iter().any(|op| op.is_lens_correction())) - .then(|| raw.opcodes.list3.clone()); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or - // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) - let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); - // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join( - || { - let removed = dng_lens_ops.map(|ops| std::mem::replace(&mut raw.opcodes.list3, ops)); - // With the opcodes restored, the proxy already includes vignetting; do not apply it twice. - let embedded = if removed.is_some() { None } else { lens.as_ref() }; - let colour = crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), embedded); - if let Some(ops) = removed { - raw.opcodes.list3 = ops; - } - colour - }, - || dng_mattes(&bytes, &info), - ); - drop(bytes); - // Previews and thumbnails bin the mosaic straight to (about) the size they need; only - // larger levels (exports, 1:1) demosaic the whole sensor. - let t0 = web_time::Instant::now(); - let bin = bin_factor(&raw, max_edge); - // (binned: block means, and which colours had a clipped sample in each block) - let binned = match bin { - Some(k) => raw.develop_binned_masked(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, - None => None, - }; - let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 }; - let (img, clip_mask) = match binned { - Some((img, mask)) => (img, Some(mask)), - None => { - let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; - (raw.develop(method).map_err(|e| e.to_string())?, None) - } - }; - // the same development from the denoised mosaic, when the photo has been denoised - let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height); - // the samples aren't needed any more (the colour model below reads only the tags) - raw.data = lightcraft_raw::RawData::U16(Vec::new()); - let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new); - // A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6. - let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)); - let colour = CameraColourModel { - wb: t.wb, - matrix: camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix), - gain: 2f32.powf(t.baseline_exposure as f32), - tables: tables.as_ref(), - hue_sat: hue_sat.as_ref(), - }; - // camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied, - // fitted to `max_edge` and upright (the same for the plain and the denoised picture) - let finish = |mut img: Rgb32f| { - let mut stages = vec![("develop", t0.elapsed())]; - stages.push(("transform", t0.elapsed())); - match &clip_mask { - Some(mask) => colour.rebuild_highlights_masked(&mut img, mask), - None => colour.rebuild_highlights(&mut img), - } - stages.push(("highlights", t0.elapsed())); - colour.apply(&mut img); - stages.push(("colour", t0.elapsed())); - // a full-size render keeps the picture (fitting it to its own size would copy it, ~390 MB at 33 MP) - let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Box) } else { img }; - stages.push(("fit", t0.elapsed())); - let img = img.into_oriented(raw.orientation); - stages.push(("orient", t0.elapsed())); - (img, stages) - }; - let (img, stages) = finish(img); - if lightcraft_pipeline::profiling() { - let mut prev = std::time::Duration::ZERO; - let parts: Vec = stages - .iter() - .map(|(n, t)| { - let d = *t - prev; - prev = *t; - format!("{n} {:.1}", d.as_secs_f64() * 1e3) - }) - .collect(); - eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); - } - let twin = twin.map(|picture| finish(picture).0); - // White balance re-evaluates the file's own colour model (when it has one and no file-local look matrix sits - // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the - // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale - // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). - let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)) - .then(|| { - let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; - Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) - }); - let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); - let local_tone = local_tone(&raw); - let (temp, tint) = scale.as_shot(xy); - return Ok(( - img, - twin, - SourceInfo { - raw: true, - as_shot_temp: temp, - as_shot_tint: tint, - lens, - relative_wb: scale.relative, - legacy_relative_wb: scale.legacy_relative, - camera_color, - camera_tone, - mattes, - local_tone, - // (the catalog knows the original's size: `media::source_info`) - native_long: None, - }, - used_reduced, - )); - } - let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; - drop(bytes); - let img = d.to_working(); - let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; - Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default(), false)) -} - -/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from -/// the photo's format and camera (`lightcraft_catalog::relative_wb_camera`; a test keeps the two in step). -/// -/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without -/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured -/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one -/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the -/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). -pub(crate) struct WbScaleOf { - own_matrix: bool, - relative: bool, - /// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used. - legacy_relative: bool, - /// The as-shot white through the measured matrices or the presets, when that is where the scale comes from. - scale_white: Option, - /// The camera's measured matrices, when they give the scale. - camera: Option<&'static lightcraft_raw::spectral::Camera>, -} - -/// The white balance a DNG of a raw's camera RGB (Photo Merge) declares so that it shows as its source does -/// ([`WbScaleOf::dng_white`]). -pub(crate) enum DngWhite { - /// Colour tags with the camera's measured matrices: the DNG reads every white through them, as the source does. - Matrices(Box), - /// The white the source's scale shows at As Shot. - At(lightcraft_color::Xy), -} - -impl WbScaleOf { - pub(crate) fn new( - format: lightcraft_raw::RawFormat, - color: &lightcraft_raw::ColorData, - wb_multipliers: Option<[f32; 3]>, - meta: &lightcraft_meta::Metadata, - ) -> Self { - let own_matrix = lightcraft_raw::color::has_matrix(color); - let fitted = crate::camera_preview::file_local_look(format) && !own_matrix; - let model = meta.model.as_deref().filter(|_| fitted); - let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model)); - let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model)); - let scale_white = match camera { - Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers), - None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers), - None => None, - }; - let covered = camera.is_some() || presets; - WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white, camera } - } - - /// What a DNG of this raw's camera RGB declares so that it shows the white balance as this scale does: the - /// camera's measured matrices when they give the scale, else the white the scale shows at As Shot (the presets' - /// reading; 6500 K / 0 on the relative scale; else `own`, the white the file's tags give). - pub(crate) fn dng_white(&self, color: &lightcraft_raw::ColorData, own: lightcraft_color::Xy) -> DngWhite { - if let Some(camera) = self.camera { - let mut c = lightcraft_raw::ColorData { profile: Default::default(), kelvin_presets: None, ..color.clone() }; - if camera.fill(&mut c) { - return DngWhite::Matrices(Box::new(c)); - } - } - if self.relative { - return DngWhite::At(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)); - } - DngWhite::At(self.scale_white.unwrap_or(own)) - } - /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through - /// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the - /// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to - /// `own`, D65. - fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) { - if self.relative { - return (6500.0, 0.0); - } - let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own)); - (t.round(), tint.round()) - } -} - -/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in -/// the active area: rendered when the photo's "Camera local tone mapping" option is on. -fn local_tone(raw: &lightcraft_raw::RawImage) -> Option> { - let map = raw.color.profile.gain_table_map.clone()?; - let a = raw.active_area; - let (aw, ah) = (a.width.max(1) as f64, a.height.max(1) as f64); - let c = raw.develop_crop(a.width, a.height); - let rect = [c.x as f64 / aw, c.y as f64 / ah, c.width as f64 / aw, c.height as f64 / ah]; - let placement = lightcraft_raw::gaintable::SourcePlacement { rect, orientation: raw.orientation }; - Some(Arc::new(lightcraft_pipeline::local_tone::LocalTone { map, placement })) -} - -/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop, -/// oriented like it). Person mattes without a confidently selected pixel are left out (the -/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky). -fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawInfo) -> Option> { - use lightcraft_pipeline::masks::{MatteKind, Mattes}; - if raw.format != lightcraft_raw::RawFormat::Dng { - return None; - } - let mut mattes = Mattes::default(); - for m in lightcraft_raw::semantic_masks(bytes) { - let Some(kind) = matte_kind(&m.name) else { continue }; - let Some(img) = m.developed(raw.active_area, raw.crop) else { continue }; - if kind != MatteKind::Sky && !img.data.iter().any(|&v| v >= 128) { - continue; - } - mattes.push(kind, img.into_oriented(raw.orientation)); - } - (!mattes.is_empty()).then(|| Arc::new(mattes)) -} - -/// What a semantic mask selects, by its `SemanticName`: Apple's (iPhone ProRAW) are named after -/// their auxiliary image types, `urn:com:apple:photo::aux:`. Other names: `None`. -fn matte_kind(name: &str) -> Option { - use lightcraft_pipeline::masks::MatteKind::*; - let kind = name.strip_prefix("urn:com:apple:photo:")?.rsplit(':').next()?; - Some(match kind { - "semanticskymatte" => Sky, - "semanticskinmatte" => Skin, - "semantichairmatte" => Hair, - "semanticteethmatte" => Teeth, - "semanticglassesmatte" => Glasses, - "portraiteffectsmatte" => Person, - _ => return None, - }) -} - -/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the -/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white -/// the camera tone's shoulder continues it. -pub(crate) fn dng_tone_curve(curve: &lightcraft_raw::profile::ToneCurve) -> Option { - let knots: [[f32; 2]; 32] = std::array::from_fn(|i| { - let x = 2f32.powf(-12.0 + 12.0 * i as f32 / 31.0); - [x, curve.eval(x).min(0.9995)] - }); - lightcraft_pipeline::tone::CameraTone::new(knots) -} - -/// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's. -fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { - if jpeg_orientation > 1 { - return Orientation::from_exif(jpeg_orientation); - } - lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal) -} - -/// What stands in for a raw file we can't decode: its largest embedded JPEG (with `covering`, the smallest one that is -/// at least that big, [`lightcraft_raw::embedded_preview_covering`]), else, for a TIFF-based raw, the file's own first -/// image when the TIFF reader can show it (a reduced RGB copy: the only preview some containers carry). Returns image -/// bytes for [`lightcraft_codecs::decode`]. -fn stand_in_image(bytes: &[u8], covering: Option) -> Option> { - let jpeg = match covering { - Some(edge) => lightcraft_raw::embedded_preview_covering(bytes, u32::try_from(edge).unwrap_or(u32::MAX)), - None => lightcraft_raw::embedded_preview(bytes), - }; - if let Some(jpeg) = jpeg { - return Some(std::borrow::Cow::Owned(jpeg)); - } - let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported()); - (undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff)) - .then_some(std::borrow::Cow::Borrowed(bytes)) -} - -/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting (`covering`: see -/// [`stand_in_image`]). -pub(crate) fn decode_raw_preview( - bytes: &[u8], - opts: lightcraft_codecs::DecodeOptions, - covering: Option, -) -> Option { - let jpeg = stand_in_image(bytes, covering)?; - // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview - // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders - let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]); - match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) { - Some(space) => { - let space = match space { - lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb, - lightcraft_raw::PreviewColorSpace::AdobeRgb => lightcraft_codecs::NamedSpace::AdobeRgb, - }; - lightcraft_codecs::decode_jpeg_with_fallback(&jpeg, opts, space).ok() - } - None => lightcraft_codecs::decode(&jpeg, opts).ok(), - } -} - -/// Oriented size of the embedded preview of a raw file. -fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { - let jpeg = stand_in_image(bytes, None)?; - // headers only, as for other images (issue #367): decoding a full-size preview just for its - // size took about a second per file in an import; a decode remains the fallback - let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) { - Ok(header) => (header.width, header.height, header.orientation), - Err(_) => { - let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; - (d.source_width, d.source_height, d.orientation) - } - }; - if preview_orientation(bytes, orientation).swaps_axes() { - std::mem::swap(&mut w, &mut h); - } - Some((w, h)) -} - -/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented. -pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> { - // always the largest JPEG: a source's geometry must not depend on its size level (previews match exports) - let d = decode_raw_preview(bytes, fit_box(max_edge), None)?; - let img = d.to_working(); - let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; - Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default())) -} - -/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the -/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). The smallest -/// embedded JPEG that covers `max_edge` is decoded, not the largest. -pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option { - let mut d = decode_raw_preview(bytes, fit_box(max_edge), Some(max_edge))?; - if d.image.width.max(d.image.height) > max_edge { - d.image = fit(&d.image, max_edge, max_edge, Filter::Box); - d.alpha = None; - } - let o = preview_orientation(bytes, d.orientation); - Some(d.to_srgb8().oriented(o)) -} - -/// Decode options fitting into a `max_edge` square; `usize::MAX` (a full-size load) or any edge past -/// `u32::MAX` asks for the largest box rather than wrapping to a small one. -fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions { - let e = u32::try_from(max_edge).unwrap_or(u32::MAX); - lightcraft_codecs::DecodeOptions::fit(e, e) -} - -/// Filesystem-backed embedded-preview hook (native). -/// -/// The quick path reads the file's header and its embedded preview and nothing else (see -/// [`preview_window`]); a container the header cannot point into is planned from its tags -/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a -/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts -/// the whole file, which is what the decode of one can hold. -pub fn fs_preview_loader() -> PreviewLoader { - Arc::new(|path: &str, max_edge: usize| { - let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); - let gate = crate::memory::work_gate(); - let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) }; - if let Some(image) = preview_window(path, max_edge) - .ok() - .flatten() - .and_then(|(header, window, read)| quick_image_srgb(&header, &window, read.adobe_rgb, max_edge)) - { - return Some(image); - } - embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge) - }) -} - -/// What [`preview_window`] read from a file, so a caller can see what a preview cost. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PreviewRead { - /// Bytes read. - pub read: u64, - /// The file's size. - pub total: u64, - /// Whether the preview came from a bounded read (`false`: the whole file was read, which is what - /// every preview cost before the header path existed). - pub header_path: bool, - /// Whether the sparse planner named the ranges, rather than a format's own header fields: the - /// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on - /// the header path, and on the whole-file fallback). - pub planned: bool, - /// Adobe RGB when the container says the preview is in it and the JPEG doesn't say otherwise - /// (`decode_raw_preview` applies the same declaration): the quick decode applies it too. - pub adobe_rgb: bool, -} - -impl PreviewRead { - /// The share of the file that was read, `0.0`..=`1.0`. - pub fn fraction(self) -> f64 { - if self.total == 0 { - return 0.0; - } - (self.read as f64 / self.total as f64).clamp(0.0, 1.0) - } -} - -/// The file's header and its embedded preview, read without the rest of the file: `(header bytes, -/// preview bytes, what was read)`. `None` when the header points at no preview this build can find, -/// which is the caller's cue to read the file whole. `max_edge` is the size the preview is for: where a file keeps -/// several JPEGs (the plan route), the window is the one [`embedded_preview_srgb`] decodes for that size, the smallest -/// that covers it ([`lightcraft_raw::embedded_preview_covering`]). -/// -/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`), -/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten -/// times the wait (measured on that file: 2.5 s local against 25.6 s over SMB). `embedded_preview` -/// still needs the whole container, so the header comes back with the window; for a preview taken -/// out of a container that carries no preview colour space of its own (Nikon's maker note) the -/// whole-file fallback stays in charge. -pub fn preview_window(path: &str, max_edge: usize) -> Result, Vec, PreviewRead)>, String> { - use std::io::{Read, Seek, SeekFrom}; - - let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; - let total = file.metadata().map_err(|e| format!("{path}: {e}"))?.len(); - let mut prefix = read_prefix(&mut file, path, total)?; - - // What the header itself declares, before the container reader is asked: it clamps a slice to - // the buffer it was given, so a prefix that stops inside the declared preview yields a - // self-consistently short range that looks trustworthy (measured on a real 56 MB DSCF9492.RAF: - // a 4 KiB prefix reports 3948 bytes where the file holds 5.6 MB). Reading the declared extent - // first costs nothing — it lives in the first bytes of every format covered here. - if let Some(extent) = raf_preview_extent(&prefix) - && extent > prefix.len() as u64 - { - prefix = read_prefix_at(&mut file, path, extent.min(total))?; - } - let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { - // The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose - // preview tag sits past the prefix, the containers whose preview only a scan finds: ask the - // sparse planner which ranges `embedded_preview` reads before handing the caller the - // whole-file fallback. - return planned_preview(&mut file, path, total, max_edge); - }; - // A preview at the file's end may have been padded past it: clamp rather than fail. - let end = range.offset.saturating_add(range.window_len).min(total); - let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?; - // When the prefix already covers the range (the usual case: the prefix was grown to the declared - // extent), take the window out of it. Reading those bytes twice would double a 5.6 MB preview. - let prefix_len = prefix.len() as u64; - let from_prefix = usize::try_from(range.offset).ok().and_then(|start| prefix.get(start..start.checked_add(len)?)).map(<[u8]>::to_vec); - let (window, bytes_read) = match from_prefix { - Some(window) => (window, prefix_len), - None => { - let mut window = vec![0u8; len]; - file.seek(SeekFrom::Start(range.offset)).map_err(|e| format!("{path}: {e}"))?; - file.read_exact(&mut window).map_err(|e| format!("{path}: {e}"))?; - (window, prefix_len + len as u64) - } - }; - - // A lying pointer must not cost the user their preview: hand the caller the whole-file fallback. - if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() { - return Ok(None); - } - let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false, adobe_rgb: false }; - // On a network share the bytes are the wait, so say what this preview actually cost. A window - // taken from the prefix cost nothing beyond the prefix itself. - if lightcraft_pipeline::profiling() { - eprintln!("[profile] preview {path}: read {} of {} bytes (header {}, window {})", read.read, total, prefix_len, window.len()); - } - Ok(Some((prefix, window, read))) -} - -/// The most times the sparse planner may name ranges before the preview gives up: the plan is -/// monotone (it never asks for a range twice), so a real file needs a handful. -const MAX_PREVIEW_PLAN_ROUNDS: usize = 8; -/// Tenths of a file the planner may have read before the preview gives up and the whole-file path -/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the -/// file has stopped being one. -const MAX_PREVIEW_PLAN_SHARE: u64 = 6; -/// The largest file the planner path builds its buffer for. The raws it serves are far below this; -/// a file past it is read whole as before rather than allocating its length up front. -const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30; -/// The header the quick path is handed on the planner route. The header route grows its prefix to -/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out -/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough -/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes. -const PLAN_PREFIX: u64 = 64 * 1024; - -/// The preview of a container the header-only path cannot answer for, from the byte ranges -/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads. -/// -/// The planner is asked about a buffer of the file's whole length in which only the ranges named so -/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The -/// buffer is the file's length, which the loader's memory gate already counts, and its untouched -/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in -/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so -/// this path's cost is the bytes read, not a promise about RSS. -/// -/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the -/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it -/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when -/// the quick path could not reproduce what the whole-file path shows (the two container facts -/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded -/// gives what the whole file gives), so a window that still fails to decode ends in the same -/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture. -fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: usize) -> Result, Vec, PreviewRead)>, String> { - use std::io::{Read, Seek, SeekFrom}; - - if total > MAX_PREVIEW_PLAN_BYTES { - return Ok(None); - } - let Ok(len) = usize::try_from(total) else { return Ok(None) }; - let mut buf = vec![0u8; len]; - let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?; - let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) }; - head.copy_from_slice(&header); - let mut loaded = lightcraft_raw::sparse::Ranges::new(); - loaded.insert(0, header.len() as u64); - for _ in 0..MAX_PREVIEW_PLAN_ROUNDS { - match lightcraft_raw::sparse::preview_plan(&buf, &loaded) { - lightcraft_raw::sparse::HeaderPlan::Need(ranges) => { - let wanted = loaded.total().saturating_add(ranges.total()); - if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) { - return Ok(None); - } - for &(start, end) in ranges.spans() { - let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) }; - let Some(dst) = buf.get_mut(a..b) else { return Ok(None) }; - file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?; - file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?; - loaded.insert(start, end); - } - } - lightcraft_raw::sparse::HeaderPlan::Ready => { - // the plan loads every preview's strips, so the JPEG picked for `max_edge` is the whole file's pick - let edge = u32::try_from(max_edge).unwrap_or(u32::MAX); - let Some(window) = lightcraft_raw::embedded_preview_covering(&buf, edge) else { return Ok(None) }; - // The quick path hands the JPEG to the decoder alone, so what the *container* says - // about the preview has to be something the bytes it is handed can show. Two container - // facts change the pixels and are not in the JPEG: the preview's colour space (which - // the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries - // no orientation of its own, the file's EXIF orientation. The colour space is read - // here and handed on ([`PreviewRead::adobe_rgb`]: a Sony α1 writes Adobe RGB previews, - // which used to send each of them to the whole-file path, issue #1440); a container - // that says sRGB agrees with the JPEG-only decode (its own declaration, else sRGB). - // Where the orientation is present and the header cannot answer for it, the - // whole-file path keeps the preview. - let adobe_rgb = lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb); - // `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side - // first: a JPEG that carries its own orientation needs nothing from the container. - if lightcraft_meta::extract(&window).orientation.is_none() - && lightcraft_meta::extract(&header).orientation.is_none() - && lightcraft_meta::extract(&buf).orientation.is_some() - { - return Ok(None); - } - let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true, adobe_rgb }; - // A plan-driven preview is the one that used to cost the whole file, so it is worth - // saying what it cost now too. - if lightcraft_pipeline::profiling() { - eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len()); - } - // The planner's buffer is the file's length, so give its pages back the way a decode - // does (see `fs_pair_loader`). - drop(buf); - crate::memory::release(); - return Ok(Some((header, window, read))); - } - lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None), - } - } - Ok(None) -} - -/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter), -/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in. -fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result, String> { - let len = lightcraft_raw::PREVIEW_PREFIX.min(usize::try_from(total).unwrap_or(usize::MAX)); - read_prefix_at(file, path, len as u64) -} - -/// The file's first `len` bytes, read from the start. -fn read_prefix_at(file: &mut std::fs::File, path: &str, len: u64) -> Result, String> { - use std::io::{Read, Seek, SeekFrom}; - let len = usize::try_from(len).map_err(|_| format!("{path}: file too large"))?; - let mut prefix = vec![0u8; len]; - file.seek(SeekFrom::Start(0)).map_err(|e| format!("{path}: {e}"))?; - file.read_exact(&mut prefix).map_err(|e| format!("{path}: {e}"))?; - Ok(prefix) -} - -/// The end of a Fuji RAF's preview block, from the two big-endian u32s every RAF header carries at -/// 84 (offset) and 88 (length) — the same fields `lightcraft_raw`'s RAF reader uses, read here -/// without the rest of the file so the prefix can be made big enough first. -fn raf_preview_extent(prefix: &[u8]) -> Option { - if !prefix.starts_with(b"FUJIFILMCCD-RAW") { - return None; - } - let u32_at = |at: usize| prefix.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])).map(u64::from); - let (offset, len) = (u32_at(84)?, u32_at(88)?); - (offset > 0 && len > 0).then(|| offset.checked_add(len)).flatten() -} - -/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of -/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container. -/// `adobe_rgb`: the container declares the preview Adobe RGB ([`PreviewRead::adobe_rgb`]), applied -/// as [`decode_raw_preview`] applies it. `None` when the bytes are not a decodable image. -fn quick_image_srgb(header: &[u8], window: &[u8], adobe_rgb: bool, max_edge: usize) -> Option { - let decoded = if adobe_rgb && window.starts_with(&[0xff, 0xd8]) { - lightcraft_codecs::decode_jpeg_with_fallback(window, fit_box(max_edge), lightcraft_codecs::NamedSpace::AdobeRgb) - } else { - lightcraft_codecs::decode(window, fit_box(max_edge)) - }; - let mut d = decoded.ok()?; - if d.image.width.max(d.image.height) > max_edge { - d.image = fit(&d.image, max_edge, max_edge, Filter::Box); - d.alpha = None; - } - // The JPEG's own EXIF orientation, else the raw's (from its header), as the whole-file path does - Some(d.to_srgb8().oriented(preview_orientation(header, d.orientation))) -} - -/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's -/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load. -pub fn fs_pair_loader() -> PairLoader { - Arc::new(|path: &str, max_edge: usize, spec: &DenoiseSpec| { - let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0); - let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 4 } else { 10 }; - let gate = crate::memory::work_gate(); - let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let r = load_vec_pair(bytes, max_edge, spec); - crate::memory::release(); - r - }) -} - -/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead. -/// -/// Their working memory is bounded by [`crate::memory::work_gate`]: background loads (grid -/// thumbnails, neighbour prefetch: [`crate::memory::in_background`]) and import probes wait while -/// too much is in flight, each counting its file's size times the expansion of decoding it; -/// interactive loads (the loupe, exports) never wait — they are counted, so background work -/// yields to them. -pub fn fs_hooks() -> (FileLoader, FileProbe) { - let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec(bytes, max_edge))); - let probe: FileProbe = Arc::new(|path: &str| { - let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); - let _permit = crate::memory::work_gate().acquire(len); - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let mut info = probe_bytes(path, &bytes)?; - info.file_modified = file_modified(path); - Ok(info) - }); - (loader, probe) -} - -/// The filesystem [`ReducedLoader`]: [`load_vec_reduced`] of the file, gated like [`fs_hooks`]' loader. -pub fn fs_reduced_loader() -> ReducedLoader { - Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec_reduced(bytes, max_edge))) -} - -/// Read `path` and `load` it, waiting on [`crate::memory::work_gate`] as [`fs_hooks`] explains. -fn fs_load(path: &str, max_edge: usize, load: impl FnOnce(Vec) -> Result) -> Result { - let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); - let weight = len.saturating_mul(if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }); - let gate = crate::memory::work_gate(); - let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let r = load(bytes); - // the file, the samples and the intermediate images are gone: give their pages back - crate::memory::release(); - r -} - -/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the -/// filesystem doesn't say or the time is before 1970. -fn file_modified(path: &str) -> Option { - let t = std::fs::metadata(path).ok()?.modified().ok()?; - let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?; - Some(crate::import::civil(secs)) -} - -impl crate::Session { - /// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos - /// runner ([`crate::apple_photos::osascript`]; installed, not run). - pub fn with_fs(mut self) -> Self { - let (l, p) = fs_hooks(); - self.media.file_loader = Some(l); - self.media.reduced_loader = Some(fs_reduced_loader()); - self.media.file_probe = Some(p); - self.media.preview_loader = Some(fs_preview_loader()); - self.media.denoise.loader = Some(fs_pair_loader()); - #[cfg(target_os = "macos")] - { - self.apple_photos = Some(crate::apple_photos::osascript()); - } - self - } -} - #[cfg(test)] pub(crate) mod tests; diff --git a/crates/engine/src/files/colour.rs b/crates/engine/src/files/colour.rs new file mode 100644 index 000000000..cc005089a --- /dev/null +++ b/crates/engine/src/files/colour.rs @@ -0,0 +1,53 @@ +/// The white-balanced camera RGB that `model` (white-balanced camera RGB → linear Rec.2020) renders +/// neutral, as bright as it renders `[1, 1, 1]`: what fully clipped highlights are rebuilt to, so they come out +/// neutral whatever the colour model does to the camera's own neutral (a look fitted to the camera's JPEG or a +/// camera profile often tints it by a few per cent, which any darkening of the highlights would show). +/// `[1, 1, 1]` (the camera's own neutral, as before this existed) when the model can't be trusted with it: not +/// invertible or nearly so, an answer far from the camera's neutral (a sane colour model moves white by a few per +/// cent), or one that isn't neutral through the `f32` matrix the decode applies. +pub(crate) fn clipped_white(model: &lightcraft_color::Mat3) -> [f32; 3] { + const CAMERA: [f32; 3] = [1.0; 3]; + /// Largest condition number (max row sum norm) of a model trusted for this. + const CONDITION: f64 = 1e3; + /// Largest factor between the answer and the camera's neutral, per channel. + const SPREAD: f64 = 4.0; + let norm = |m: &lightcraft_color::Mat3| m.0.iter().map(|row| row.iter().map(|v| v.abs()).sum::()).fold(0.0, f64::max); + let Some(inverse) = model.inverse() else { return CAMERA }; + let condition = norm(model) * norm(&inverse); + if !condition.is_finite() || condition > CONDITION { + return CAMERA; + } + let luma = |v: [f64; 3]| { + let c = model.apply(v); + (0..3).map(|i| c[i] * f64::from(lightcraft_color::LUMA_2020[i])).sum::() + }; + let n = inverse.apply([1.0; 3]); + let (target, own) = (luma([1.0; 3]), luma(n)); + if !(target > 0.0 && own > 0.0 && target.is_finite() && own.is_finite()) { + return CAMERA; + } + let white = n.map(|v| v * target / own); + if !white.iter().all(|v| v.is_finite() && (1.0 / SPREAD..=SPREAD).contains(v)) { + return CAMERA; + } + let white = white.map(|v| v as f32); + // neutral through the matrix as the decode applies it + let m = model.to_f32(); + let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * white[0] + m[i][1] * white[1] + m[i][2] * white[2]); + let (lo, hi) = (out[0].min(out[1]).min(out[2]), out[0].max(out[1]).max(out[2])); + if lo > 0.0 && hi.is_finite() && hi - lo <= 1e-3 * hi { white } else { CAMERA } +} + +/// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels: +/// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the +/// 2560 px preview). X-Trans can only bin 3× (its 6×6 pattern), and its full demosaic is ~5× the +/// cost of Bayer's, so 3× is accepted down to 75 % (a 24 MP X-Trans preview is then ~2000 px +/// instead of a ~1.2 s full demosaic; zooming in still uses the full-size source). +/// `None` = demosaic at full size. +pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option { + let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); + let long = if c.width > 1 && c.height > 1 { c.width.max(c.height) } else { raw.active_area.width.max(raw.active_area.height) }; + let need = (max_edge.saturating_mul(9) / 10).max(1); + let need3 = (max_edge.saturating_mul(3) / 4).max(1); + [8usize, 6, 4, 3, 2].into_iter().find(|&k| raw.can_bin(k) && (long / k >= need || (k == 3 && !raw.can_bin(2) && long / k >= need3))) +} diff --git a/crates/engine/src/files/fs.rs b/crates/engine/src/files/fs.rs new file mode 100644 index 000000000..dd1f5273c --- /dev/null +++ b/crates/engine/src/files/fs.rs @@ -0,0 +1,131 @@ +use super::load::{load_vec, load_vec_pair, load_vec_reduced}; +use super::preview::{fit_box, fs_preview_loader, preview_orientation}; +use super::probe::probe_bytes; +use super::*; + +/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter), +/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in. +pub(super) fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result, String> { + let len = lightcraft_raw::PREVIEW_PREFIX.min(usize::try_from(total).unwrap_or(usize::MAX)); + read_prefix_at(file, path, len as u64) +} + +/// The file's first `len` bytes, read from the start. +pub(super) fn read_prefix_at(file: &mut std::fs::File, path: &str, len: u64) -> Result, String> { + use std::io::{Read, Seek, SeekFrom}; + let len = usize::try_from(len).map_err(|_| format!("{path}: file too large"))?; + let mut prefix = vec![0u8; len]; + file.seek(SeekFrom::Start(0)).map_err(|e| format!("{path}: {e}"))?; + file.read_exact(&mut prefix).map_err(|e| format!("{path}: {e}"))?; + Ok(prefix) +} + +/// The end of a Fuji RAF's preview block, from the two big-endian u32s every RAF header carries at +/// 84 (offset) and 88 (length) — the same fields `lightcraft_raw`'s RAF reader uses, read here +/// without the rest of the file so the prefix can be made big enough first. +pub(super) fn raf_preview_extent(prefix: &[u8]) -> Option { + if !prefix.starts_with(b"FUJIFILMCCD-RAW") { + return None; + } + let u32_at = |at: usize| prefix.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])).map(u64::from); + let (offset, len) = (u32_at(84)?, u32_at(88)?); + (offset > 0 && len > 0).then(|| offset.checked_add(len)).flatten() +} + +/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of +/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container. +/// `adobe_rgb`: the container declares the preview Adobe RGB ([`PreviewRead::adobe_rgb`]), applied +/// as [`decode_raw_preview`] applies it. `None` when the bytes are not a decodable image. +pub(super) fn quick_image_srgb(header: &[u8], window: &[u8], adobe_rgb: bool, max_edge: usize) -> Option { + let decoded = if adobe_rgb && window.starts_with(&[0xff, 0xd8]) { + lightcraft_codecs::decode_jpeg_with_fallback(window, fit_box(max_edge), lightcraft_codecs::NamedSpace::AdobeRgb) + } else { + lightcraft_codecs::decode(window, fit_box(max_edge)) + }; + let mut d = decoded.ok()?; + if d.image.width.max(d.image.height) > max_edge { + d.image = fit(&d.image, max_edge, max_edge, Filter::Box); + d.alpha = None; + } + // The JPEG's own EXIF orientation, else the raw's (from its header), as the whole-file path does + Some(d.to_srgb8().oriented(preview_orientation(header, d.orientation))) +} + +/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's +/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load. +pub fn fs_pair_loader() -> PairLoader { + Arc::new(|path: &str, max_edge: usize, spec: &DenoiseSpec| { + let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0); + let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 4 } else { 10 }; + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let r = load_vec_pair(bytes, max_edge, spec); + crate::memory::release(); + r + }) +} + +/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead. +/// +/// Their working memory is bounded by [`crate::memory::work_gate`]: background loads (grid +/// thumbnails, neighbour prefetch: [`crate::memory::in_background`]) and import probes wait while +/// too much is in flight, each counting its file's size times the expansion of decoding it; +/// interactive loads (the loupe, exports) never wait — they are counted, so background work +/// yields to them. +pub fn fs_hooks() -> (FileLoader, FileProbe) { + let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec(bytes, max_edge))); + let probe: FileProbe = Arc::new(|path: &str| { + let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); + let _permit = crate::memory::work_gate().acquire(len); + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let mut info = probe_bytes(path, &bytes)?; + info.file_modified = file_modified(path); + Ok(info) + }); + (loader, probe) +} + +/// The filesystem [`ReducedLoader`]: [`load_vec_reduced`] of the file, gated like [`fs_hooks`]' loader. +pub fn fs_reduced_loader() -> ReducedLoader { + Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec_reduced(bytes, max_edge))) +} + +/// Read `path` and `load` it, waiting on [`crate::memory::work_gate`] as [`fs_hooks`] explains. +fn fs_load(path: &str, max_edge: usize, load: impl FnOnce(Vec) -> Result) -> Result { + let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); + let weight = len.saturating_mul(if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }); + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let r = load(bytes); + // the file, the samples and the intermediate images are gone: give their pages back + crate::memory::release(); + r +} + +/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the +/// filesystem doesn't say or the time is before 1970. +fn file_modified(path: &str) -> Option { + let t = std::fs::metadata(path).ok()?.modified().ok()?; + let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?; + Some(crate::import::civil(secs)) +} + +impl crate::Session { + /// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos + /// runner ([`crate::apple_photos::osascript`]; installed, not run). + pub fn with_fs(mut self) -> Self { + let (l, p) = fs_hooks(); + self.media.file_loader = Some(l); + self.media.reduced_loader = Some(fs_reduced_loader()); + self.media.file_probe = Some(p); + self.media.preview_loader = Some(fs_preview_loader()); + self.media.denoise.loader = Some(fs_pair_loader()); + #[cfg(target_os = "macos")] + { + self.apple_photos = Some(crate::apple_photos::osascript()); + } + self + } +} diff --git a/crates/engine/src/files/load.rs b/crates/engine/src/files/load.rs new file mode 100644 index 000000000..52b01d7f7 --- /dev/null +++ b/crates/engine/src/files/load.rs @@ -0,0 +1,350 @@ +use super::colour::bin_factor; +use super::preview::{fit_box, load_embedded_preview}; +use super::probe::preview_reason; +use super::*; + +/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented. +/// +/// Runs on a rayon worker: its many short parallel loops then start on the worker's own queue +/// instead of each one waking the pool from outside and waiting for it (which costs more than the +/// loops themselves when the machine is busy). +pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { + rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) +} + +/// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded +/// (less memory held while it is developed). +pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) +} + +/// [`load_vec`] for a thumbnail: a DNG that keeps reduced-resolution copies of its raw image (Lightroom's HDR and +/// panorama merges) develops the smallest that covers `max_edge` instead of the whole image. The flag says whether +/// it did: such a picture is not the full image, and an export never renders from it. +pub fn load_vec_reduced(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo, bool), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, true).map(|(img, _, info, reduced)| (img, info, reduced))) +} + +/// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain +/// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all +/// there is) and the source facts. Both pictures are the same size. +pub fn load_vec_pair(bytes: Vec, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option, SourceInfo), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec), false).map(|(img, twin, info, _)| (img, twin, info))) +} + +/// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default +/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned (plain means: the +/// plain development's clip mask tells highlight reconstruction which of them held clipped samples). `None` when +/// the product is missing, damaged, made from something else or not the size of this raw's active area. +fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, factor: usize) -> Option { + use lightcraft_denoise::product::{self, Window}; + let a = raw.active_area; + let header = product::read_header_at(&spec.product).ok()?; + if header.key != spec.key || (header.width, header.height) != (a.width, a.height) { + return None; + } + if raw.opcodes.list3.is_empty() { + let c = raw.develop_crop(a.width, a.height); + let window = Window { x: c.x, y: c.y, width: c.width, height: c.height }; + product::read_window(&spec.product, &spec.key, Some(window), factor, None).ok() + } else { + // (the plain development is not binned either when the file has opcodes of this kind: `factor` is 1) + let whole = product::read(&spec.product, &spec.key, 1).ok()?; + Some(raw.finish_demosaiced(whole)) + } +} + +fn load_bytes_now( + bytes: std::borrow::Cow<'_, [u8]>, + max_edge: usize, + denoise: Option<&DenoiseSpec>, + allow_reduced: bool, +) -> Result<(Rgb32f, Option, SourceInfo, bool), String> { + if lightcraft_raw::probe(&bytes).is_some() { + // a thumbnail (`allow_reduced`, never an export) of a DNG that keeps smaller copies of its raw image + // (Lightroom's merges) develops the smallest that covers it; a denoised photo's twin needs the full mosaic + let reduced = (allow_reduced && denoise.is_none() && max_edge < usize::MAX) + .then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()) + .flatten(); + let used_reduced = reduced.is_some(); + let decoded = match reduced { + Some(r) => Ok(r), + None => lightcraft_raw::decode(&bytes), + }; + let mut raw = match decoded.map_err(|e| preview_reason(&bytes, e)) { + Ok(r) => r, + Err(Ok(why)) => { + // show the camera's embedded JPEG (rendered, not raw) until the variant is supported + return load_embedded_preview(&bytes, max_edge) + .map(|(img, info)| (img, None, info, false)) + .ok_or(format!("unsupported raw ({why}) without an embedded preview")); + } + Err(Err(e)) => return Err(e), + }; + // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in + // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). + let info = raw.info(); + let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); + // (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it) + let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3); + // A DNG's preview already has its lens opcodes applied. Keep them for its single tone fit, + // then remove them again: the rendered source leaves those corrections to the pipeline. + let dng_lens_ops = (raw.format == lightcraft_raw::RawFormat::Dng && raw.opcodes.list3.iter().any(|op| op.is_lens_correction())) + .then(|| raw.opcodes.list3.clone()); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or + // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) + let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); + // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted + let ((xy, t, camera_look), mattes) = rayon::join( + || { + let removed = dng_lens_ops.map(|ops| std::mem::replace(&mut raw.opcodes.list3, ops)); + // With the opcodes restored, the proxy already includes vignetting; do not apply it twice. + let embedded = if removed.is_some() { None } else { lens.as_ref() }; + let colour = crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), embedded); + if let Some(ops) = removed { + raw.opcodes.list3 = ops; + } + colour + }, + || dng_mattes(&bytes, &info), + ); + drop(bytes); + // Previews and thumbnails bin the mosaic straight to (about) the size they need; only + // larger levels (exports, 1:1) demosaic the whole sensor. + let t0 = web_time::Instant::now(); + let bin = bin_factor(&raw, max_edge); + // (binned: block means, and which colours had a clipped sample in each block) + let binned = match bin { + Some(k) => raw.develop_binned_masked(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, + None => None, + }; + let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 }; + let (img, clip_mask) = match binned { + Some((img, mask)) => (img, Some(mask)), + None => { + let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; + (raw.develop(method).map_err(|e| e.to_string())?, None) + } + }; + // the same development from the denoised mosaic, when the photo has been denoised + let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height); + // the samples aren't needed any more (the colour model below reads only the tags) + raw.data = lightcraft_raw::RawData::U16(Vec::new()); + let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new); + // A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6. + let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)); + let colour = CameraColourModel { + wb: t.wb, + matrix: camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix), + gain: 2f32.powf(t.baseline_exposure as f32), + tables: tables.as_ref(), + hue_sat: hue_sat.as_ref(), + }; + // camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied, + // fitted to `max_edge` and upright (the same for the plain and the denoised picture) + let finish = |mut img: Rgb32f| { + let mut stages = vec![("develop", t0.elapsed())]; + stages.push(("transform", t0.elapsed())); + match &clip_mask { + Some(mask) => colour.rebuild_highlights_masked(&mut img, mask), + None => colour.rebuild_highlights(&mut img), + } + stages.push(("highlights", t0.elapsed())); + colour.apply(&mut img); + stages.push(("colour", t0.elapsed())); + // a full-size render keeps the picture (fitting it to its own size would copy it, ~390 MB at 33 MP) + let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Box) } else { img }; + stages.push(("fit", t0.elapsed())); + let img = img.into_oriented(raw.orientation); + stages.push(("orient", t0.elapsed())); + (img, stages) + }; + let (img, stages) = finish(img); + if lightcraft_pipeline::profiling() { + let mut prev = std::time::Duration::ZERO; + let parts: Vec = stages + .iter() + .map(|(n, t)| { + let d = *t - prev; + prev = *t; + format!("{n} {:.1}", d.as_secs_f64() * 1e3) + }) + .collect(); + eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); + } + let twin = twin.map(|picture| finish(picture).0); + // White balance re-evaluates the file's own colour model (when it has one and no file-local look matrix sits + // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the + // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale + // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)) + .then(|| { + let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; + Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) + }); + let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); + let local_tone = local_tone(&raw); + let (temp, tint) = scale.as_shot(xy); + return Ok(( + img, + twin, + SourceInfo { + raw: true, + as_shot_temp: temp, + as_shot_tint: tint, + lens, + relative_wb: scale.relative, + legacy_relative_wb: scale.legacy_relative, + camera_color, + camera_tone, + mattes, + local_tone, + // (the catalog knows the original's size: `media::source_info`) + native_long: None, + }, + used_reduced, + )); + } + let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; + drop(bytes); + let img = d.to_working(); + let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; + Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default(), false)) +} + +/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from +/// the photo's format and camera (`lightcraft_catalog::relative_wb_camera`; a test keeps the two in step). +/// +/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without +/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured +/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one +/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the +/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). +pub(crate) struct WbScaleOf { + pub(super) own_matrix: bool, + pub(super) relative: bool, + /// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used. + pub(super) legacy_relative: bool, + /// The as-shot white through the measured matrices or the presets, when that is where the scale comes from. + pub(super) scale_white: Option, + /// The camera's measured matrices, when they give the scale. + camera: Option<&'static lightcraft_raw::spectral::Camera>, +} + +/// The white balance a DNG of a raw's camera RGB (Photo Merge) declares so that it shows as its source does +/// ([`WbScaleOf::dng_white`]). +pub(crate) enum DngWhite { + /// Colour tags with the camera's measured matrices: the DNG reads every white through them, as the source does. + Matrices(Box), + /// The white the source's scale shows at As Shot. + At(lightcraft_color::Xy), +} + +impl WbScaleOf { + pub(crate) fn new( + format: lightcraft_raw::RawFormat, + color: &lightcraft_raw::ColorData, + wb_multipliers: Option<[f32; 3]>, + meta: &lightcraft_meta::Metadata, + ) -> Self { + let own_matrix = lightcraft_raw::color::has_matrix(color); + let fitted = crate::camera_preview::file_local_look(format) && !own_matrix; + let model = meta.model.as_deref().filter(|_| fitted); + let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model)); + let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model)); + let scale_white = match camera { + Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers), + None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers), + None => None, + }; + let covered = camera.is_some() || presets; + WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white, camera } + } + + /// What a DNG of this raw's camera RGB declares so that it shows the white balance as this scale does: the + /// camera's measured matrices when they give the scale, else the white the scale shows at As Shot (the presets' + /// reading; 6500 K / 0 on the relative scale; else `own`, the white the file's tags give). + pub(crate) fn dng_white(&self, color: &lightcraft_raw::ColorData, own: lightcraft_color::Xy) -> DngWhite { + if let Some(camera) = self.camera { + let mut c = lightcraft_raw::ColorData { profile: Default::default(), kelvin_presets: None, ..color.clone() }; + if camera.fill(&mut c) { + return DngWhite::Matrices(Box::new(c)); + } + } + if self.relative { + return DngWhite::At(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)); + } + DngWhite::At(self.scale_white.unwrap_or(own)) + } + /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through + /// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the + /// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to + /// `own`, D65. + pub(super) fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) { + if self.relative { + return (6500.0, 0.0); + } + let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own)); + (t.round(), tint.round()) + } +} + +/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in +/// the active area: rendered when the photo's "Camera local tone mapping" option is on. +fn local_tone(raw: &lightcraft_raw::RawImage) -> Option> { + let map = raw.color.profile.gain_table_map.clone()?; + let a = raw.active_area; + let (aw, ah) = (a.width.max(1) as f64, a.height.max(1) as f64); + let c = raw.develop_crop(a.width, a.height); + let rect = [c.x as f64 / aw, c.y as f64 / ah, c.width as f64 / aw, c.height as f64 / ah]; + let placement = lightcraft_raw::gaintable::SourcePlacement { rect, orientation: raw.orientation }; + Some(Arc::new(lightcraft_pipeline::local_tone::LocalTone { map, placement })) +} + +/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop, +/// oriented like it). Person mattes without a confidently selected pixel are left out (the +/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky). +fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawInfo) -> Option> { + use lightcraft_pipeline::masks::{MatteKind, Mattes}; + if raw.format != lightcraft_raw::RawFormat::Dng { + return None; + } + let mut mattes = Mattes::default(); + for m in lightcraft_raw::semantic_masks(bytes) { + let Some(kind) = matte_kind(&m.name) else { continue }; + let Some(img) = m.developed(raw.active_area, raw.crop) else { continue }; + if kind != MatteKind::Sky && !img.data.iter().any(|&v| v >= 128) { + continue; + } + mattes.push(kind, img.into_oriented(raw.orientation)); + } + (!mattes.is_empty()).then(|| Arc::new(mattes)) +} + +/// What a semantic mask selects, by its `SemanticName`: Apple's (iPhone ProRAW) are named after +/// their auxiliary image types, `urn:com:apple:photo::aux:`. Other names: `None`. +fn matte_kind(name: &str) -> Option { + use lightcraft_pipeline::masks::MatteKind::*; + let kind = name.strip_prefix("urn:com:apple:photo:")?.rsplit(':').next()?; + Some(match kind { + "semanticskymatte" => Sky, + "semanticskinmatte" => Skin, + "semantichairmatte" => Hair, + "semanticteethmatte" => Teeth, + "semanticglassesmatte" => Glasses, + "portraiteffectsmatte" => Person, + _ => return None, + }) +} + +/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the +/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white +/// the camera tone's shoulder continues it. +pub(crate) fn dng_tone_curve(curve: &lightcraft_raw::profile::ToneCurve) -> Option { + let knots: [[f32; 2]; 32] = std::array::from_fn(|i| { + let x = 2f32.powf(-12.0 + 12.0 * i as f32 / 31.0); + [x, curve.eval(x).min(0.9995)] + }); + lightcraft_pipeline::tone::CameraTone::new(knots) +} diff --git a/crates/engine/src/files/preview.rs b/crates/engine/src/files/preview.rs new file mode 100644 index 000000000..49430e62a --- /dev/null +++ b/crates/engine/src/files/preview.rs @@ -0,0 +1,320 @@ +use super::fs::{quick_image_srgb, raf_preview_extent, read_prefix, read_prefix_at}; +use super::*; + +/// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's. +pub(super) fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { + if jpeg_orientation > 1 { + return Orientation::from_exif(jpeg_orientation); + } + lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal) +} + +/// What stands in for a raw file we can't decode: its largest embedded JPEG (with `covering`, the smallest one that is +/// at least that big, [`lightcraft_raw::embedded_preview_covering`]), else, for a TIFF-based raw, the file's own first +/// image when the TIFF reader can show it (a reduced RGB copy: the only preview some containers carry). Returns image +/// bytes for [`lightcraft_codecs::decode`]. +fn stand_in_image(bytes: &[u8], covering: Option) -> Option> { + let jpeg = match covering { + Some(edge) => lightcraft_raw::embedded_preview_covering(bytes, u32::try_from(edge).unwrap_or(u32::MAX)), + None => lightcraft_raw::embedded_preview(bytes), + }; + if let Some(jpeg) = jpeg { + return Some(std::borrow::Cow::Owned(jpeg)); + } + let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported()); + (undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff)) + .then_some(std::borrow::Cow::Borrowed(bytes)) +} + +/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting (`covering`: see +/// [`stand_in_image`]). +pub(crate) fn decode_raw_preview( + bytes: &[u8], + opts: lightcraft_codecs::DecodeOptions, + covering: Option, +) -> Option { + let jpeg = stand_in_image(bytes, covering)?; + // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview + // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders + let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]); + match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) { + Some(space) => { + let space = match space { + lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb, + lightcraft_raw::PreviewColorSpace::AdobeRgb => lightcraft_codecs::NamedSpace::AdobeRgb, + }; + lightcraft_codecs::decode_jpeg_with_fallback(&jpeg, opts, space).ok() + } + None => lightcraft_codecs::decode(&jpeg, opts).ok(), + } +} + +/// Oriented size of the embedded preview of a raw file. +pub(super) fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { + let jpeg = stand_in_image(bytes, None)?; + // headers only, as for other images (issue #367): decoding a full-size preview just for its + // size took about a second per file in an import; a decode remains the fallback + let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) { + Ok(header) => (header.width, header.height, header.orientation), + Err(_) => { + let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; + (d.source_width, d.source_height, d.orientation) + } + }; + if preview_orientation(bytes, orientation).swaps_axes() { + std::mem::swap(&mut w, &mut h); + } + Some((w, h)) +} + +/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented. +pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> { + // always the largest JPEG: a source's geometry must not depend on its size level (previews match exports) + let d = decode_raw_preview(bytes, fit_box(max_edge), None)?; + let img = d.to_working(); + let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; + Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default())) +} + +/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the +/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). The smallest +/// embedded JPEG that covers `max_edge` is decoded, not the largest. +pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option { + let mut d = decode_raw_preview(bytes, fit_box(max_edge), Some(max_edge))?; + if d.image.width.max(d.image.height) > max_edge { + d.image = fit(&d.image, max_edge, max_edge, Filter::Box); + d.alpha = None; + } + let o = preview_orientation(bytes, d.orientation); + Some(d.to_srgb8().oriented(o)) +} + +/// Decode options fitting into a `max_edge` square; `usize::MAX` (a full-size load) or any edge past +/// `u32::MAX` asks for the largest box rather than wrapping to a small one. +pub(super) fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions { + let e = u32::try_from(max_edge).unwrap_or(u32::MAX); + lightcraft_codecs::DecodeOptions::fit(e, e) +} + +/// Filesystem-backed embedded-preview hook (native). +/// +/// The quick path reads the file's header and its embedded preview and nothing else (see +/// [`preview_window`]); a container the header cannot point into is planned from its tags +/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a +/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts +/// the whole file, which is what the decode of one can hold. +pub fn fs_preview_loader() -> PreviewLoader { + Arc::new(|path: &str, max_edge: usize| { + let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) }; + if let Some(image) = preview_window(path, max_edge) + .ok() + .flatten() + .and_then(|(header, window, read)| quick_image_srgb(&header, &window, read.adobe_rgb, max_edge)) + { + return Some(image); + } + embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge) + }) +} + +/// What [`preview_window`] read from a file, so a caller can see what a preview cost. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PreviewRead { + /// Bytes read. + pub read: u64, + /// The file's size. + pub total: u64, + /// Whether the preview came from a bounded read (`false`: the whole file was read, which is what + /// every preview cost before the header path existed). + pub header_path: bool, + /// Whether the sparse planner named the ranges, rather than a format's own header fields: the + /// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on + /// the header path, and on the whole-file fallback). + pub planned: bool, + /// Adobe RGB when the container says the preview is in it and the JPEG doesn't say otherwise + /// (`decode_raw_preview` applies the same declaration): the quick decode applies it too. + pub adobe_rgb: bool, +} + +impl PreviewRead { + /// The share of the file that was read, `0.0`..=`1.0`. + pub fn fraction(self) -> f64 { + if self.total == 0 { + return 0.0; + } + (self.read as f64 / self.total as f64).clamp(0.0, 1.0) + } +} + +/// The file's header and its embedded preview, read without the rest of the file: `(header bytes, +/// preview bytes, what was read)`. `None` when the header points at no preview this build can find, +/// which is the caller's cue to read the file whole. `max_edge` is the size the preview is for: where a file keeps +/// several JPEGs (the plan route), the window is the one [`embedded_preview_srgb`] decodes for that size, the smallest +/// that covers it ([`lightcraft_raw::embedded_preview_covering`]). +/// +/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`), +/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten +/// times the wait (measured on that file: 2.5 s local against 25.6 s over SMB). `embedded_preview` +/// still needs the whole container, so the header comes back with the window; for a preview taken +/// out of a container that carries no preview colour space of its own (Nikon's maker note) the +/// whole-file fallback stays in charge. +pub fn preview_window(path: &str, max_edge: usize) -> Result, Vec, PreviewRead)>, String> { + use std::io::{Read, Seek, SeekFrom}; + + let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let total = file.metadata().map_err(|e| format!("{path}: {e}"))?.len(); + let mut prefix = read_prefix(&mut file, path, total)?; + + // What the header itself declares, before the container reader is asked: it clamps a slice to + // the buffer it was given, so a prefix that stops inside the declared preview yields a + // self-consistently short range that looks trustworthy (measured on a real 56 MB DSCF9492.RAF: + // a 4 KiB prefix reports 3948 bytes where the file holds 5.6 MB). Reading the declared extent + // first costs nothing — it lives in the first bytes of every format covered here. + if let Some(extent) = raf_preview_extent(&prefix) + && extent > prefix.len() as u64 + { + prefix = read_prefix_at(&mut file, path, extent.min(total))?; + } + let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { + // The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose + // preview tag sits past the prefix, the containers whose preview only a scan finds: ask the + // sparse planner which ranges `embedded_preview` reads before handing the caller the + // whole-file fallback. + return planned_preview(&mut file, path, total, max_edge); + }; + // A preview at the file's end may have been padded past it: clamp rather than fail. + let end = range.offset.saturating_add(range.window_len).min(total); + let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?; + // When the prefix already covers the range (the usual case: the prefix was grown to the declared + // extent), take the window out of it. Reading those bytes twice would double a 5.6 MB preview. + let prefix_len = prefix.len() as u64; + let from_prefix = usize::try_from(range.offset).ok().and_then(|start| prefix.get(start..start.checked_add(len)?)).map(<[u8]>::to_vec); + let (window, bytes_read) = match from_prefix { + Some(window) => (window, prefix_len), + None => { + let mut window = vec![0u8; len]; + file.seek(SeekFrom::Start(range.offset)).map_err(|e| format!("{path}: {e}"))?; + file.read_exact(&mut window).map_err(|e| format!("{path}: {e}"))?; + (window, prefix_len + len as u64) + } + }; + + // A lying pointer must not cost the user their preview: hand the caller the whole-file fallback. + if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() { + return Ok(None); + } + let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false, adobe_rgb: false }; + // On a network share the bytes are the wait, so say what this preview actually cost. A window + // taken from the prefix cost nothing beyond the prefix itself. + if lightcraft_pipeline::profiling() { + eprintln!("[profile] preview {path}: read {} of {} bytes (header {}, window {})", read.read, total, prefix_len, window.len()); + } + Ok(Some((prefix, window, read))) +} + +/// The most times the sparse planner may name ranges before the preview gives up: the plan is +/// monotone (it never asks for a range twice), so a real file needs a handful. +const MAX_PREVIEW_PLAN_ROUNDS: usize = 8; + +/// Tenths of a file the planner may have read before the preview gives up and the whole-file path +/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the +/// file has stopped being one. +const MAX_PREVIEW_PLAN_SHARE: u64 = 6; + +/// The largest file the planner path builds its buffer for. The raws it serves are far below this; +/// a file past it is read whole as before rather than allocating its length up front. +const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30; + +/// The header the quick path is handed on the planner route. The header route grows its prefix to +/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out +/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough +/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes. +pub(super) const PLAN_PREFIX: u64 = 64 * 1024; + +/// The preview of a container the header-only path cannot answer for, from the byte ranges +/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads. +/// +/// The planner is asked about a buffer of the file's whole length in which only the ranges named so +/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The +/// buffer is the file's length, which the loader's memory gate already counts, and its untouched +/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in +/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so +/// this path's cost is the bytes read, not a promise about RSS. +/// +/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the +/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it +/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when +/// the quick path could not reproduce what the whole-file path shows (the two container facts +/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded +/// gives what the whole file gives), so a window that still fails to decode ends in the same +/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture. +fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: usize) -> Result, Vec, PreviewRead)>, String> { + use std::io::{Read, Seek, SeekFrom}; + + if total > MAX_PREVIEW_PLAN_BYTES { + return Ok(None); + } + let Ok(len) = usize::try_from(total) else { return Ok(None) }; + let mut buf = vec![0u8; len]; + let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?; + let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) }; + head.copy_from_slice(&header); + let mut loaded = lightcraft_raw::sparse::Ranges::new(); + loaded.insert(0, header.len() as u64); + for _ in 0..MAX_PREVIEW_PLAN_ROUNDS { + match lightcraft_raw::sparse::preview_plan(&buf, &loaded) { + lightcraft_raw::sparse::HeaderPlan::Need(ranges) => { + let wanted = loaded.total().saturating_add(ranges.total()); + if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) { + return Ok(None); + } + for &(start, end) in ranges.spans() { + let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) }; + let Some(dst) = buf.get_mut(a..b) else { return Ok(None) }; + file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?; + file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?; + loaded.insert(start, end); + } + } + lightcraft_raw::sparse::HeaderPlan::Ready => { + // the plan loads every preview's strips, so the JPEG picked for `max_edge` is the whole file's pick + let edge = u32::try_from(max_edge).unwrap_or(u32::MAX); + let Some(window) = lightcraft_raw::embedded_preview_covering(&buf, edge) else { return Ok(None) }; + // The quick path hands the JPEG to the decoder alone, so what the *container* says + // about the preview has to be something the bytes it is handed can show. Two container + // facts change the pixels and are not in the JPEG: the preview's colour space (which + // the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries + // no orientation of its own, the file's EXIF orientation. The colour space is read + // here and handed on ([`PreviewRead::adobe_rgb`]: a Sony α1 writes Adobe RGB previews, + // which used to send each of them to the whole-file path, issue #1440); a container + // that says sRGB agrees with the JPEG-only decode (its own declaration, else sRGB). + // Where the orientation is present and the header cannot answer for it, the + // whole-file path keeps the preview. + let adobe_rgb = lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb); + // `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side + // first: a JPEG that carries its own orientation needs nothing from the container. + if lightcraft_meta::extract(&window).orientation.is_none() + && lightcraft_meta::extract(&header).orientation.is_none() + && lightcraft_meta::extract(&buf).orientation.is_some() + { + return Ok(None); + } + let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true, adobe_rgb }; + // A plan-driven preview is the one that used to cost the whole file, so it is worth + // saying what it cost now too. + if lightcraft_pipeline::profiling() { + eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len()); + } + // The planner's buffer is the file's length, so give its pages back the way a decode + // does (see `fs_pair_loader`). + drop(buf); + crate::memory::release(); + return Ok(Some((header, window, read))); + } + lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None), + } + } + Ok(None) +} diff --git a/crates/engine/src/files/probe.rs b/crates/engine/src/files/probe.rs new file mode 100644 index 000000000..aff0b884c --- /dev/null +++ b/crates/engine/src/files/probe.rs @@ -0,0 +1,223 @@ +use super::load::WbScaleOf; +use super::preview::embedded_preview_size; +use super::*; + +pub(super) fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { + let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default(); + let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string(); + let meta = Meta { + camera, + // (a camera that writes no lens name, like Nikon's DSLRs, still says what range the lens covers) + lens: m.lens_model.clone().or_else(|| m.lens_spec.and_then(lightcraft_meta::lens_spec_name)).unwrap_or_default(), + focal_mm: m.focal_length.map(|f| f as f32), + aperture: m.f_number.map(|f| f as f32), + shutter, + iso: m.iso, + location: m.sublocation.clone().unwrap_or_default(), + city: m.city.clone().unwrap_or_default(), + state: m.state.clone().unwrap_or_default(), + country: m.country.clone().unwrap_or_default(), + alt_text: m.alt_text.clone().unwrap_or_default(), + extended_description: m.extended_description.clone().unwrap_or_default(), + gps: m.gps.as_ref().map(|g| (g.latitude, g.longitude)), + title: m.title.clone().unwrap_or_default(), + caption: m.caption.clone().unwrap_or_default(), + copyright: m.copyright.clone().unwrap_or_default(), + copyright_status: lightcraft_catalog::CopyrightStatus::from_marked(m.copyright_marked), + usage_terms: m.usage_terms.clone().unwrap_or_default(), + copyright_url: m.copyright_url.clone().unwrap_or_default(), + creator: m.artist.clone().unwrap_or_default(), + // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels + keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), + regions: m.regions.clone(), + focus: m.focus.clone(), + }; + (meta, m.capture_time.as_ref().map(|d| d.to_iso())) +} + +/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or the +/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the +/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. +pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage, WARP_STAGES}; + use lightcraft_geom::Point; + use lightcraft_raw::Opcode; + let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64); + if aw < 2.0 || ah < 2.0 { + return None; + } + let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); + let (cx0, cy0, cw, ch) = + if c.width == 0 || c.height == 0 { (0.0, 0.0, aw, ah) } else { (c.x as f64, c.y as f64, c.width as f64, c.height as f64) }; + let long = cw.max(ch); + // opcode centres are relative to the (uncropped) active area, in pixel-index units + let centre = |rel: [f64; 2]| -> (Point, f64) { + let (px, py) = (rel[0] * (aw - 1.0), rel[1] * (ah - 1.0)); + let m = [(0.0, 0.0), (aw - 1.0, 0.0), (0.0, ah - 1.0), (aw - 1.0, ah - 1.0)] + .iter() + .map(|&(x, y)| (x - px).hypot(y - py)) + .fold(0.0, f64::max) + .max(1e-9); + (Point::new((px + 0.5 - cx0) / cw, (py + 0.5 - cy0) / ch), m / long) + }; + let mut lens = EmbeddedLens::default(); + // opcodes in list order, minus the ones the DNG 1.6 skip rule drops (a WarpRectilinear that follows an optional + // WarpRectilinear2) + let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3).collect(); + // A DNG with a WarpRectilinear2 keeps every warp opcode as a chain of stages (each warps the one before's output); + // without one the first WarpRectilinear is the lens warp, as it always was. + let chain = ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })); + let mut stages = Vec::new(); + for op in ops { + match op { + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } if !planes.is_empty() => { + let (center, radius) = centre(*center); + let p = |i: usize| planes[i.min(planes.len() - 1)]; + stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: *reciprocal }); + } + Opcode::WarpRectilinear { planes, center } if chain && !planes.is_empty() => { + // WarpRectilinear is the even-power case: kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶, no radius limit + let (center, radius) = centre(*center); + let p = |i: usize| { + let k = planes[i.min(planes.len() - 1)]; + let mut s = [0.0; lightcraft_raw::opcodes::WR2_LEN]; + s[0] = k[0]; + s[2] = k[1]; + s[4] = k[2]; + s[6] = k[3]; + s[15] = k[4]; + s[16] = k[5]; + s[18] = 1e6; + s + }; + stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: false }); + } + Opcode::WarpRectilinear { planes, center } if !chain && !planes.is_empty() && lens.warp.is_none() => { + let (center, radius) = centre(*center); + let p = |i: usize| planes[i.min(planes.len() - 1)]; + lens.warp = Some(EmbeddedWarp { planes: [p(0), p(1), p(2)], center, radius }); + } + Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => { + let (center, radius) = centre(*center); + lens.vignette = Some(EmbeddedVignette { k: *k, center, radius }); + } + _ => {} + } + } + // (more stages than a lens keeps: the first ones; real files carry two) + for (slot, st) in lens.stages.iter_mut().zip(stages.into_iter().take(WARP_STAGES)) { + *slot = Some(st); + } + if lens.warp.is_none() && lens.vignette.is_none() && !lens.has_stages() { + return None; + } + Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch)) +} + +fn ext_upper(name: &str) -> String { + std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default() +} + +/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed: +/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too +/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside +/// compressed data that is all there: such a file imports and shows as unreadable when rendered). +pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { + let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string()); + let m = lightcraft_meta::extract(bytes); + let (meta, captured) = meta_of(&m); + if lightcraft_raw::probe(bytes).is_some() { + let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) { + Ok(r) => r, + Err(Ok(why)) => { + // a raw variant we can't decode yet: describe it from its embedded preview + let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?; + return Ok(ProbeInfo { + width: w, + height: h, + format: ext_upper(name), + kind: MediaKind::Raw, + file_size: bytes.len() as u64, + captured, + meta, + as_shot_wb: None, + content_hash, + xmp: lightcraft_meta::embedded(bytes).xmp, + preview_only: Some(why), + ..Default::default() + }); + } + Err(Err(e)) => return Err(e), + }; + let (w, h) = raw.developed_size(); + let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32); + if raw.orientation.swaps_axes() { + std::mem::swap(&mut w, &mut h); + } + let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); + let as_shot_wb = Some(scale.as_shot(lightcraft_raw::color::as_shot_white_xy_of(&raw))); + let embedded_lens = crate::lens_profiles::for_raw(&raw, || m.clone()); + return Ok(ProbeInfo { + embedded_lens, + width: w, + height: h, + format: ext_upper(name), + kind: MediaKind::Raw, + file_size: bytes.len() as u64, + captured, + file_modified: None, + meta, + as_shot_wb, + content_hash, + xmp: lightcraft_meta::embedded(bytes).xmp, + preview_only: None, + }); + } + let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?; + if let Some(why) = fmt.not_decodable() { + // e.g. "Heif files can't be opened: HEIC/HEIF support isn't included in this build of LightCraft" + return Err(format!("{fmt:?} files can't be opened: {why}")); + } + // headers only (issue #367: decoding the pixels was nearly all of an import's CPU time) + let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?; + let o = Orientation::from_exif(header.orientation); + let (mut w, mut h) = (header.width, header.height); + if o.swaps_axes() { + std::mem::swap(&mut w, &mut h); + } + let format = match ext_upper(name).as_str() { + "JPG" | "JPEG" => "JPEG".to_string(), + "" => format!("{fmt:?}").to_uppercase(), + e => e.to_string(), + }; + Ok(ProbeInfo { + width: w, + height: h, + format, + kind: MediaKind::Image, + file_size: bytes.len() as u64, + captured, + file_modified: None, + meta, + as_shot_wb: None, + content_hash, + embedded_lens: None, + xmp: None, + preview_only: None, + }) +} + +/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error +/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX +/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed. +/// So does any other failure of a recognised raw (damaged or oversized raw data, unreadable TIFF +/// structure): its preview may still be intact. Only a file that is not a raw reports an error. +pub(super) fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result { + use lightcraft_raw::RawError; + match e { + RawError::Unsupported(why) => Ok(why), + e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")), + RawError::NotRaw => Err(RawError::NotRaw.to_string()), + e => Ok(e.to_string()), + } +} From e3c594cab88f93839388e480ab8dae8ae483c851 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:08:55 +0000 Subject: [PATCH 109/191] refactor(lightcraft-engine): split export.rs into export/ --- crates/engine/src/export.rs | 2331 +------------------------ crates/engine/src/export/batch.rs | 198 +++ crates/engine/src/export/encoding.rs | 322 ++++ crates/engine/src/export/options.rs | 305 ++++ crates/engine/src/export/params.rs | 615 +++++++ crates/engine/src/export/place.rs | 174 ++ crates/engine/src/export/prepare.rs | 402 +++++ crates/engine/src/export/watermark.rs | 306 ++++ 8 files changed, 2356 insertions(+), 2297 deletions(-) create mode 100644 crates/engine/src/export/batch.rs create mode 100644 crates/engine/src/export/encoding.rs create mode 100644 crates/engine/src/export/options.rs create mode 100644 crates/engine/src/export/params.rs create mode 100644 crates/engine/src/export/place.rs create mode 100644 crates/engine/src/export/prepare.rs create mode 100644 crates/engine/src/export/watermark.rs diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 1ce4a0fee..8ba08021f 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -11,616 +11,44 @@ pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace}; use lightcraft_raster::Rgba8; use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ExportFormat { - #[default] - Jpeg, - Png, - Tiff, - /// A flat PSD (8/16-bit, no layers): what an image editor such as PhotoCraft saves back to. - Psd, - Webp, - Avif, - /// JPEG XL: lossy (VarDCT) or lossless, 8/16-bit, HDR as Rec. 2020 PQ. - Jxl, - /// The original file, unchanged, with an XMP sidecar holding the edits. - Original, - /// A DNG of the raw data (raw photos only), the edits embedded as XMP. - Dng, - /// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in. - RenderedDng, -} - -impl ExportFormat { - pub fn parse(s: &str) -> Option { - Some(match s.to_ascii_lowercase().as_str() { - "jpeg" | "jpg" => Self::Jpeg, - "png" => Self::Png, - "tiff" | "tif" => Self::Tiff, - "psd" => Self::Psd, - "webp" => Self::Webp, - "avif" => Self::Avif, - "jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl, - "original" => Self::Original, - "dng" => Self::Dng, - "rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng, - _ => return None, - }) - } - pub fn extension(self) -> &'static str { - match self { - Self::Jpeg => "jpg", - Self::Png => "png", - Self::Tiff => "tif", - Self::Psd => "psd", - Self::Webp => "webp", - Self::Avif => "avif", - Self::Jxl => "jxl", - // the original keeps its own extension (see `ExportOptions::file_name_for`) - Self::Original => "", - Self::Dng | Self::RenderedDng => "dng", - } - } - - /// Formats that render pixels (everything but `Original` and `Dng`). - pub fn is_rendered(self) -> bool { - !matches!(self, Self::Original | Self::Dng) - } -} - -/// How the output size is chosen (see [`Resize`]). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ResizeMode { - /// `value` = long edge in pixels. - #[default] - LongEdge, - /// `value` = short edge in pixels. - ShortEdge, - /// `value` = width in pixels. - Width, - /// `value` = height in pixels. - Height, - /// Fit inside `value` wide × `height` tall as the photo stands: width ≤ `value`, height ≤ - /// `height` (Lightroom's "Width & Height"). - WidthHeight, - /// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the - /// larger; Lightroom's "Dimensions"). - Dimensions, - /// `value` = megapixels. - Megapixels, - /// `value` = percent of the full size. - Percent, -} - -/// Output size: a mode, its value(s) and whether smaller photos are left at their own size. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Resize { - pub mode: ResizeMode, - pub value: f32, - /// Second dimension for [`ResizeMode::Dimensions`] and [`ResizeMode::WidthHeight`]. - pub height: u32, - /// Never upscale past the photo's own (cropped) size. - pub dont_enlarge: bool, -} - -impl Default for Resize { - fn default() -> Self { - Self { mode: ResizeMode::LongEdge, value: 2048.0, height: 2048, dont_enlarge: true } - } -} - -/// The most pixels an export enlarged past its photo's own size can have: 512 megapixels, also -/// Lightroom's ceiling. A full-size or smaller export is never held to it. -pub const MAX_ENLARGED_PIXELS: f64 = 512e6; - -impl Resize { - pub fn long_edge(px: u32) -> Self { - Self { value: px as f32, ..Default::default() } - } - - /// Scale factor from a `w × h` (cropped, full-resolution) photo to the output. - pub fn scale(&self, w: f64, h: f64) -> f64 { - let (w, h) = (w.max(1.0), h.max(1.0)); - let (long, short) = (w.max(h), w.min(h)); - let v = (self.value as f64).max(0.0); - let k = match self.mode { - ResizeMode::LongEdge => v / long, - ResizeMode::ShortEdge => v / short, - ResizeMode::Width => v / w, - ResizeMode::Height => v / h, - ResizeMode::WidthHeight => (v / w).min(self.height as f64 / h), - ResizeMode::Dimensions => { - let (a, b) = (v, self.height as f64); - (a.max(b) / long).min(a.min(b) / short) - } - ResizeMode::Megapixels => (v * 1e6 / (w * h)).sqrt(), - ResizeMode::Percent => v / 100.0, - }; - let k = if self.dont_enlarge { k.min(1.0) } else { k }; - // an enlargement stops at 512 megapixels (Lightroom's ceiling too): 20000 typed for 2000 - // with Don't Enlarge off asked for gigapixels, more memory than a computer has - let k = k.min((MAX_ENLARGED_PIXELS / (w * h)).sqrt().max(1.0)); - // at least one pixel, at most the encoders' 65535 limit; the limit wins for a photo more - // than 65535 times as long as it is short (where `clamp` would panic: min above max) - k.max(1.0 / short).min(65_535.0 / long) - } - - /// Output size for a `w × h` (cropped, full-resolution) photo. - pub fn apply(&self, w: f64, h: f64) -> (usize, usize) { - let k = self.scale(w, h); - (((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1)) - } -} - -/// What happens when an output file already exists. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Conflict { - /// Add `-2`, `-3`, … to the name. - #[default] - Unique, - Overwrite, - /// Leave the existing file and skip the photo. - Skip, -} - -/// Where a batch's files go (Lightroom Classic's Export To). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DestinationMode { - /// One folder for the whole batch ([`Destination::dir`]). - #[default] - SpecificFolder, - /// Each photo's own folder: the folder its original is in. A photo without one on disk (a - /// demo photo, a photo in the browser's storage) or whose folder is gone gets an error. - SameAsOriginal, -} - -impl DestinationMode { - /// `specificFolder` / `sameAsOriginal` (any case). - pub fn parse(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "specificfolder" => Some(Self::SpecificFolder), - "sameasoriginal" => Some(Self::SameAsOriginal), - _ => None, - } - } -} - -impl Conflict { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "unique" => Some(Self::Unique), - "overwrite" => Some(Self::Overwrite), - "skip" => Some(Self::Skip), - _ => None, - } - } -} - -/// A named set of `app.export` params. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ExportPreset { - pub name: String, - pub params: serde_json::Value, -} - -/// The built-in export presets (File → Export with Preset). -pub fn builtin_presets() -> Vec { - let p = |name: &str, params: serde_json::Value| ExportPreset { name: name.into(), params }; - vec![ - p("JPEG (Small)", serde_json::json!({"format": "jpeg", "quality": 85, "longEdge": 2048, "colorSpace": "srgb"})), - p("JPEG (Large)", serde_json::json!({"format": "jpeg", "quality": 92, "longEdge": 0, "colorSpace": "srgb"})), - p("Original + Settings", serde_json::json!({"format": "original"})), - p("DNG", serde_json::json!({"format": "dng"})), - ] -} - -/// Output sharpening target (applied after resizing, in display-encoded values). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SharpenFor { - #[default] - None, - Screen, - Matte, - Glossy, -} - -impl SharpenFor { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "none" => Some(Self::None), - "screen" => Some(Self::Screen), - "matte" => Some(Self::Matte), - "glossy" => Some(Self::Glossy), - _ => None, - } - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SharpenAmount { - Low, - #[default] - Standard, - High, -} - -impl SharpenAmount { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "low" => Some(Self::Low), - "standard" => Some(Self::Standard), - "high" => Some(Self::High), - _ => None, - } - } -} - -/// Where a watermark sits. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Anchor { - TopLeft, - Top, - TopRight, - Left, - Center, - Right, - BottomLeft, - Bottom, - #[default] - BottomRight, -} - -/// A text watermark, sized relative to the image so every export size looks the same. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Watermark { - pub text: String, - /// Basic Japanese vertical lettering: upright glyphs in columns from right to left. - pub vertical: bool, - /// Text height as a fraction of the image's short edge. - pub size: f32, - /// 0..1. - pub opacity: f32, - pub anchor: Anchor, - /// Margin from the left or right edge, as a fraction of the short edge (unused when the anchor - /// is centred across: top, center, bottom). - pub inset_x: f32, - /// Margin from the top or bottom edge, as a fraction of the short edge (unused when the anchor - /// is centred down: left, center, right). - pub inset_y: f32, - /// Degrees clockwise: 0, 90, 180 or 270 (anything else draws unrotated). The watermark is - /// sized before it is turned, and the insets are measured to the turned watermark. - pub rotation: u16, - /// sRGB colour. - pub color: [u8; 3], - /// Soft dark drop shadow for legibility on bright areas. - pub shadow: bool, - /// A graphic (PNG with transparency, JPEG, …) drawn instead of the text (empty = text). - pub image: String, - /// The graphic's width as a fraction of the photo's width. - pub image_width: f32, - /// The colour space the photo is in (set by the encoder; the graphic is converted into it). - #[serde(skip)] - pub target: Option, -} - -impl Default for Watermark { - fn default() -> Self { - Self { - text: String::new(), - vertical: false, - size: 0.035, - opacity: 0.7, - anchor: Anchor::BottomRight, - inset_x: 0.025, - inset_y: 0.025, - rotation: 0, - color: [255; 3], - shadow: true, - image: String::new(), - image_width: 0.2, - target: None, - } - } -} - -impl Watermark { - /// `rotation` in clockwise quarter turns. - fn quarter_turns(&self) -> u8 { - match self.rotation { - 90 => 1, - 180 => 2, - 270 => 3, - _ => 0, - } - } -} - -/// Where the point `(x, y)` of a `w × h` box lands when the box is turned `turns` quarter turns -/// clockwise (for pixel coordinates: the turned box is `h × w` for odd turns). -fn turn(turns: u8, x: f32, y: f32, w: f32, h: f32) -> (f32, f32) { - match turns { - 1 => (h - 1.0 - y, x), - 2 => (w - 1.0 - x, h - 1.0 - y), - 3 => (y, w - 1.0 - x), - _ => (x, y), - } -} - #[cfg(test)] use crate::text::{TEXT_FONT as WATERMARK_FONT, cell_glyphs as watermark_cell_glyphs, faces as watermark_fonts}; -/// Draw `wm` onto `img` (straight alpha blending of the encoded values). -pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { - blend_watermark(img, wm, 1.0); -} +mod batch; +mod encoding; +mod options; +mod params; +mod place; +mod prepare; +mod watermark; -/// Draw `wm` onto `img`, a preview of an export whose short edge is `export_short_edge` pixels. -/// The smallest text size and shadow offset scale with the preview, so it looks like the file. -pub fn draw_watermark_preview(img: &mut Rgba8, wm: &Watermark, export_short_edge: usize) { - let short = img.width.min(img.height); - let scale = if export_short_edge == 0 { 1.0 } else { short as f32 / export_short_edge as f32 }; - blend_watermark(img, wm, scale); -} - -fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) { - let width = img.width; - watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, floor_scale, |x, y, k, col| { - let p = &mut img.data[y * width + x]; - for c in 0..3 { - p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; - } - }); -} - -/// Draw `wm` into the `w × h` area of `img` whose top-left corner is (`x0`, `y0`), sized and -/// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`. -pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) { - let (width, height) = (img.width, img.height); - watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { - let (px, py) = (x0.saturating_add(x), y0.saturating_add(y)); - if px >= width || py >= height { - return; - } - if let Some(p) = img.data.get_mut(py * width + px) { - for c in 0..3 { - p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; - } - } - }); -} - -/// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float -/// images are linear and blend in linear light). -pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { - let (width, trc) = (img.width, img.space.trc()); - match &mut img.samples { - DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { - let i = (y * width + x) * 3; - for c in 0..3 { - let p = v[i + c] as f32; - v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16; - } - }), - DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { - let i = (y * width + x) * 3; - for c in 0..3 { - let target = trc.decode(col[c] as f32 / 255.0); - v[i + c] += (target - v[i + c]) * k; - } - }), - } -} - -/// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for -/// every covered pixel (shadow pass first). `craft` is [`crate::fonts::CRAFT_FONTS`] (a parameter -/// so tests can render without it). `floor_scale` is this image's size over the exported file's -/// (1 when drawing the file itself): the 6 px minimum text size is a minimum in the file. -fn watermark_coverage( - width: usize, - height: usize, - wm: &Watermark, - craft: &'static [crate::fonts::CraftFont], - floor_scale: f32, - mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), -) { - if !wm.image.trim().is_empty() { - logo_coverage(width, height, wm, blend_px); - return; - } - let text = wm.text.trim(); - if text.is_empty() || width == 0 || height == 0 { - return; - } - let short = width.min(height) as f32; - let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0 * floor_scale); - let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return }; - let (tw, th) = (layout.width, layout.height); - // laid out unturned in a tw × th box, placed by the box it becomes - let turns = wm.quarter_turns(); - let (bw, bh) = if turns % 2 == 1 { (th, tw) } else { (tw, th) }; - let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); - let (w, h) = (width as f32, height as f32); - use Anchor::*; - let ox = match wm.anchor { - TopLeft | Left | BottomLeft => ix, - Top | Center | Bottom => (w - bw) / 2.0, - TopRight | Right | BottomRight => w - ix - bw, - }; - let oy = match wm.anchor { - TopLeft | Top | TopRight => iy, - Left | Center | Right => (h - bh) / 2.0, - BottomLeft | Bottom | BottomRight => h - iy - bh, - }; - let alpha = wm.opacity.clamp(0.0, 1.0); - let passes: &[(f32, [u8; 3], f32)] = - if wm.shadow { &[((px * 0.05).max(floor_scale), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; - for &(off, col, a) in passes { - let mut put = |gx: i32, gy: i32, cov: f32| { - if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { - return; - } - blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col); - }; - if turns == 0 { - layout.draw_in(ox + off, oy + off, w, h, &mut put); - } else { - // turned with the box; the shadow still falls down and to the right - layout.draw_points(|x, y, cov| { - let (x, y) = turn(turns, x, y, tw, th); - put((ox + off + x) as i32, (oy + off + y) as i32, cov); - }); - } - } -} - -/// A watermark graphic, decoded once per file (and again when the file changes). -pub(crate) fn watermark_logo(path: &str) -> Option> { - use std::collections::HashMap; - use std::sync::{Arc, Mutex}; - static CACHE: Mutex)>>> = Mutex::new(None); - let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?; - let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); - let map = c.get_or_insert_with(HashMap::new); - if let Some((m, img)) = map.get(path) - && *m == modified - { - return Some(img.clone()); - } - let img = Arc::new(read_graphic(path).ok()?); - map.insert(path.to_string(), (modified, img.clone())); - Some(img) -} - -/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the -/// watermark logo, the loupe's layout image. Applies EXIF orientation before either consumer sizes it. -/// The error says why it couldn't be read or decoded. -pub fn read_graphic(path: &str) -> Result { - let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?; - let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?; - Ok(decoded.to_srgb8().into_oriented(lightcraft_geom::Orientation::from_exif(decoded.orientation))) -} - -/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to -/// `image_width` of the photo, placed like the text, blended with its alpha × opacity. -fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { - let Some(logo) = watermark_logo(wm.image.trim()) else { - log::warn!("watermark: can't read {}", wm.image); - return; - }; - if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 { - return; - } - let turns = wm.quarter_turns(); - // Reserve the inset on anchored axes before fitting the turned box. Centered axes - // ignore insets, just as placement below does. One scale preserves the graphic's shape. - let short = width.min(height) as f32; - let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); - use Anchor::*; - let available_w = width as f64 - if matches!(wm.anchor, Top | Center | Bottom) { 0.0 } else { ix as f64 }; - let available_h = height as f64 - if matches!(wm.anchor, Left | Center | Right) { 0.0 } else { iy as f64 }; - let (max_w, max_h) = if turns % 2 == 1 { (available_h, available_w) } else { (available_w, available_h) }; - let scale = - (wm.image_width.clamp(0.01, 1.0) as f64 * width as f64 / logo.width as f64).min(max_w / logo.width as f64).min(max_h / logo.height as f64); - let lw = (logo.width as f64 * scale).round().max(1.0) as usize; - let lh = (logo.height as f64 * scale).round().max(1.0) as usize; - let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| { - let p = logo.get(x, y); - let a = p[3] as f32 / 255.0; - [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] - }); - let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell); - let (bw, bh) = if turns % 2 == 1 { (lh, lw) } else { (lw, lh) }; - let (w, h, lwf, lhf) = (width as f32, height as f32, bw as f32, bh as f32); - let ox = match wm.anchor { - TopLeft | Left | BottomLeft => ix, - Top | Center | Bottom => (w - lwf) / 2.0, - TopRight | Right | BottomRight => w - ix - lwf, - } - .max(0.0) as usize; - let oy = match wm.anchor { - TopLeft | Top | TopRight => iy, - Left | Center | Right => (h - lhf) / 2.0, - BottomLeft | Bottom | BottomRight => h - iy - lhf, - } - .max(0.0) as usize; - let space = wm.target.unwrap_or(OutputSpace::Srgb); - let op = wm.opacity.clamp(0.0, 1.0); - for y in 0..lh { - for x in 0..lw { - let [r, g, b, a] = scaled.get(x, y); - let a = a.clamp(0.0, 1.0); - let (tx, ty) = turn(turns, x as f32, y as f32, lw as f32, lh as f32); - let (px, py) = (ox + tx as usize, oy + ty as usize); - if a <= 1e-4 || px >= width || py >= height { - continue; - } - let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8); - blend_px(px, py, a * op, srgb8_in(space, c)); - } - } -} - -/// Which metadata is embedded in exported files. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum MetadataPolicy { - /// Everything we know: camera, capture settings, lens, location, title, caption, keywords, copyright. - #[default] - All, - /// Everything except camera/lens make, model and capture settings. - AllExceptCamera, - /// Copyright and creator only. - Copyright, - /// Nothing (the sRGB profile is still embedded). - None, -} - -impl MetadataPolicy { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "all" => Some(Self::All), - "allexceptcamera" => Some(Self::AllExceptCamera), - "copyright" => Some(Self::Copyright), - "none" => Some(Self::None), - _ => None, - } - } -} - -/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or -/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum HdrCurve { - #[default] - Pq, - Hlg, -} - -impl HdrCurve { - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "pq" => Some(Self::Pq), - "hlg" => Some(Self::Hlg), - _ => None, - } - } - fn codec(self) -> lightcraft_codecs::HdrTransfer { - match self { - Self::Pq => lightcraft_codecs::HdrTransfer::Pq, - Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg, - } - } -} +#[cfg(test)] +use batch::run_batch_with; +pub use batch::{export_parallelism, run_batch}; +pub use encoding::{ + encode_deep, encode_gain_map_jpeg, encode_image, encode_rendered, encode_with_metadata, export_metadata, named_space, output_sharpen, + output_sharpen_deep, removes_location, srgb8_in, +}; +pub use options::{ + Conflict, DestinationMode, ExportFormat, ExportPreset, HdrCurve, MAX_ENLARGED_PIXELS, MetadataPolicy, Resize, ResizeMode, SharpenAmount, + SharpenFor, builtin_presets, +}; +pub use params::{ + OPTION_PARAMS, TARGET_PARAMS, WATERMARK_IMAGE_WIDTH_RANGE, WATERMARK_INSET_RANGE, WATERMARK_PARAMS, WATERMARK_SIZE_RANGE, + apple_photos_unsupported, +}; +#[cfg(test)] +use place::Placer; +pub use place::{write_file, write_file_durable, write_file_new}; +pub use prepare::{ + AfterExport, Destination, DngCompression, Exported, PreparedExport, export_batch, export_photo, output_size, prepare_batch, prepare_export, +}; +#[cfg(test)] +use prepare::{render_weight, too_big_for_memory}; +#[cfg(test)] +use watermark::watermark_coverage; +pub(crate) use watermark::watermark_logo; +pub use watermark::{Anchor, Watermark, draw_watermark, draw_watermark_deep, draw_watermark_in, draw_watermark_preview, read_graphic}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -686,1696 +114,5 @@ pub struct ExportOptions { pub hdr_transfer: HdrCurve, } -impl Default for ExportOptions { - fn default() -> Self { - Self { - format: ExportFormat::Jpeg, - quality: 90, - resize: None, - ppi: 240, - limit_kb: None, - sharpen: SharpenFor::None, - sharpen_amount: SharpenAmount::Standard, - naming: "{name}".into(), - start_number: 1, - destination_mode: DestinationMode::SpecificFolder, - subfolder: String::new(), - conflict: Conflict::Unique, - tiff_compression: TiffCompression::Deflate, - dng_compression: DngCompression::Lossless, - metadata: MetadataPolicy::All, - remove_location: false, - watermark: None, - color_space: OutputSpace::Srgb, - bit_depth: None, - hdr: false, - jxl_lossless: false, - jxl_effort: JxlEffort::Normal, - jxl_progressive: false, - add_to_photos: false, - photos_album: String::new(), - hdr_transfer: HdrCurve::Pq, - } - } -} - -/// The `app.export` params that are not export options: which photos, where to, a preset to start -/// from, and the desktop app's background flag. -pub const TARGET_PARAMS: &[&str] = &["id", "ids", "path", "dir", "preset", "background"]; - -/// The export option params [`ExportOptions::from_json`] reads (and [`ExportOptions::to_json`] writes). -pub const OPTION_PARAMS: &[&str] = &[ - "format", - "quality", - "resize", - "longEdge", - "shortEdge", - "width", - "height", - "megapixels", - "percent", - "fit", - "dontEnlarge", - "ppi", - "limitKb", - "sharpen", - "sharpenAmount", - "naming", - "startNumber", - "destinationMode", - "subfolder", - "conflict", - "tiffCompression", - "dngCompression", - "metadata", - "removeLocation", - "watermark", - "colorSpace", - "bitDepth", - "hdr", - "jxlLossless", - "jxlEffort", - "jxlProgressive", - "addToPhotos", - "photosAlbum", - "hdrTransfer", -]; - -/// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the -/// session has the runner; see [`crate::Session::after_export`]). -pub fn apple_photos_unsupported() -> Option<&'static str> { - (!cfg!(target_os = "macos")).then_some("Apple Photos is only available on macOS") -} - -/// The keys of a `watermark` object ([`Watermark`], camelCase). `inset` sets `insetX` and `insetY` -/// together (what versions before the two margins wrote); either of those, when given, wins. -pub const WATERMARK_PARAMS: &[&str] = - &["text", "vertical", "size", "opacity", "anchor", "insetX", "insetY", "inset", "rotation", "color", "shadow", "image", "imageWidth"]; - -/// Text height as a fraction of the short edge: the range the renderer draws without clamping. -pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5); -/// Margin as a fraction of the short edge. -pub const WATERMARK_INSET_RANGE: (f32, f32) = (0.0, 0.4); -/// A graphic's width as a fraction of the photo's width. -pub const WATERMARK_IMAGE_WIDTH_RANGE: (f32, f32) = (0.01, 1.0); - -/// Levenshtein distance, for "did you mean" on a misspelled key (both inputs are short). -fn edit_distance(a: &str, b: &str) -> usize { - let (a, b): (Vec, Vec) = (a.chars().collect(), b.chars().collect()); - let mut prev: Vec = (0..=b.len()).collect(); - for (i, ca) in a.iter().enumerate() { - let mut cur = vec![i + 1; b.len() + 1]; - for (j, cb) in b.iter().enumerate() { - let sub = prev.get(j).copied().unwrap_or(0) + usize::from(ca != cb); - let del = prev.get(j + 1).copied().unwrap_or(0) + 1; - let ins = cur.get(j).copied().unwrap_or(0) + 1; - if let Some(c) = cur.get_mut(j + 1) { - *c = sub.min(del).min(ins); - } - } - prev = cur; - } - prev.last().copied().unwrap_or(0) -} - -/// `unknown parameter \`k\``, naming the closest known key when one is near. -fn unknown_key(what: &str, k: &str, known: &[&str]) -> String { - let near = known.iter().map(|n| (edit_distance(&k.to_ascii_lowercase(), &n.to_ascii_lowercase()), *n)).filter(|(d, _)| *d <= 2).min(); - match near { - Some((_, n)) => format!("unknown {what} `{k}` (did you mean `{n}`?)"), - None => format!("unknown {what} `{k}` (one of {})", known.join(", ")), - } -} - -/// A JSON integer (`92`; never `92.5` or `"92"`), as [`ExportOptions::from_json`] reads them. -fn whole(v: &serde_json::Value) -> Option { - v.as_i64() -} - -/// A `watermark` object as a [`Watermark`]; a legacy `inset` fills whichever of `insetX` and -/// `insetY` is missing. -fn watermark_from_json(v: &serde_json::Value) -> serde_json::Result { - let mut v = v.clone(); - if let Some(o) = v.as_object_mut() - && let Some(inset) = o.remove("inset") - { - o.entry("insetX").or_insert_with(|| inset.clone()); - o.entry("insetY").or_insert(inset); - } - serde_json::from_value(v) -} - -impl ExportOptions { - /// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or - /// newer version may have written and this one doesn't know dropped, also inside `watermark`. - /// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are - /// not the caller's typo, and refusing them would make a preset or Export with Previous fail - /// for good after an update. A `bitDepth` the stored format doesn't have (written at the - /// format's own depth before issue #1952) is dropped too. - pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value { - let mut p = Self::known_keys(p); - Self::drop_unfit_depth(&mut p, ExportFormat::default()); - p - } - - /// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be - /// known once the call's own params (`path`) are merged in. - pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value { - let mut p = p.clone(); - if let Some(o) = p.as_object_mut() { - o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str())); - if let Some(w) = o.get_mut("watermark").and_then(serde_json::Value::as_object_mut) { - w.retain(|k, _| WATERMARK_PARAMS.contains(&k.as_str())); - } - } - p - } - - /// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with - /// `fallback`) doesn't have: it was written at the format's own depth before issue #1952. - pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) { - let format = Self::export_format(p, fallback); - if let Some(o) = p.as_object_mut() - && let Some(depth) = o.get("bitDepth").and_then(whole) - && !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth) - { - o.remove("bitDepth"); - } - } - - /// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an - /// unknown parameter or a value that cannot be read as what it is for is an error, never a - /// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`) - /// are accepted and left to the caller. - pub fn from_params(p: &serde_json::Value) -> crate::Result { - Self::from_params_or(p, ExportFormat::default()) - } - - /// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no - /// format; the format returned is [`ExportOptions::export_format`], the one written. - pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result { - let format = Self::export_format(p, fallback); - Self::validate_as("app.export", p, Some(format))?; - Ok(Self { format, ..Self::from_json(p) }) - } - - /// The format `app.export` writes: `format` (`null`: the default), else the one the extension of - /// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there, - /// the headless backend PNG), else the default. - pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat { - use serde_json::Value; - match (p.get("format"), p.get("path").and_then(Value::as_str)) { - (Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(), - (None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback), - (None, None) => ExportFormat::default(), - } - } - - /// Check `app.export` params for `cmd` (the command named in the error): every key must be an - /// export option ([`OPTION_PARAMS`]) or a target ([`TARGET_PARAMS`]), every value must be the - /// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`] - /// with its sizes in range (issue #183). `null` means "not given", as a dropped key does. - pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> { - Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default()))) - } - - /// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported - /// (`None`: not known yet, as for a preset without `format`). - pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option) -> crate::Result<()> { - use serde_json::Value; - let bad = |msg: String| crate::cmd::bad(cmd, msg); - let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) }; - let known: Vec<&str> = OPTION_PARAMS.iter().chain(TARGET_PARAMS).copied().collect(); - let int = |k: &str, v: &Value, lo: i64, hi: i64| -> crate::Result { - whole(v).filter(|i| (lo..=hi).contains(i)).ok_or_else(|| bad(format!("`{k}` must be an integer {lo}..{hi}"))) - }; - let num = |k: &str, v: &Value, lo: f64, hi: f64| -> crate::Result { - v.as_f64().filter(|f| f.is_finite() && (lo..=hi).contains(f)).ok_or_else(|| bad(format!("`{k}` must be a number {lo}..{hi}"))) - }; - fn string<'a>(cmd: &str, k: &str, v: &'a Value) -> crate::Result<&'a str> { - v.as_str().ok_or_else(|| crate::cmd::bad(cmd, format!("`{k}` must be a string"))) - } - let boolean = |k: &str, v: &Value| -> crate::Result { v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))) }; - let one_of = |k: &str, v: &Value, options: &[&str]| -> crate::Result<()> { - let s = string(cmd, k, v)?; - if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } - }; - // a choice `from_json` reads with the same `parse`, so what passes here is what gets exported - let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> { - if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } - }; - for (k, v) in obj { - if v.is_null() { - continue; - } - match k.as_str() { - "format" => { - if ExportFormat::parse(string(cmd, k, v)?).is_none() { - return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|jxl|dng|original"))); - } - } - "quality" => { - int(k, v, 1, 100)?; - } - "ppi" => { - int(k, v, 1, u16::MAX as i64)?; - } - "limitKb" => { - int(k, v, 0, u32::MAX as i64)?; - } - "startNumber" => { - int(k, v, 0, 999_999)?; - } - "bitDepth" => { - if !matches!(whole(v), Some(8 | 10 | 16 | 32)) { - return Err(bad(format!("`{k}` must be 8, 10, 16 or 32"))); - } - } - "longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => { - num(k, v, 0.0, 1.0e9)?; - } - "fit" => one_of(k, v, &["dimensions", "widthHeight"])?, - "resize" => { - let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) }; - if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) { - return Err(bad(unknown_key("resize key", x, &["mode", "value", "height", "dontEnlarge"]))); - } - serde_json::from_value::(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?; - } - "dontEnlarge" | "removeLocation" | "background" | "hdr" | "jxlLossless" | "jxlProgressive" => { - boolean(k, v)?; - } - "addToPhotos" => { - if boolean(k, v)? - && let Some(why) = apple_photos_unsupported() - { - return Err(bad(format!("`{k}`: {why}"))); - } - } - "photosAlbum" => { - if string(cmd, k, v)?.contains('\0') { - return Err(bad(format!("`{k}` can't contain a NUL character"))); - } - } - "naming" | "subfolder" | "path" | "dir" | "preset" => { - string(cmd, k, v)?; - } - "sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?, - "sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?, - "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, - "conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?, - "destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?, - "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, - "jxlEffort" => one_of(k, v, &["fast", "normal"])?, - "dngCompression" => { - serde_json::from_value::(v.clone()) - .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?; - } - "metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?, - "colorSpace" => { - if OutputSpace::parse(string(cmd, k, v)?).is_none() { - return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020"))); - } - } - "watermark" => Self::validate_watermark(cmd, v)?, - "id" => { - int(k, v, 0, i64::MAX)?; - } - "ids" => { - let ok = v.as_array().is_some_and(|a| a.iter().all(|x| x.is_u64())); - if !ok { - return Err(bad(format!("`{k}` must be an array of photo ids"))); - } - } - _ => return Err(bad(unknown_key("parameter", k, &known))), - } - } - // issue #1952: a depth the format doesn't have would be written at the format's own depth - if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) { - let mut depths: Vec = Self::bit_depths(format).iter().map(|d| d.0).collect(); - if !depths.iter().any(|d| i64::from(*d) == depth) { - depths.sort_unstable(); - let mut list: Vec = depths.iter().map(u8::to_string).collect(); - let last = list.pop().unwrap_or_default(); - let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) }; - let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); - return Err(bad(format!("`bitDepth` must be {list} for {name}"))); - } - } - Ok(()) - } - - /// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183). - pub(crate) fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { - use serde_json::Value; - let bad = |msg: String| crate::cmd::bad(cmd, msg); - let obj = match v { - Value::String(_) => return Ok(()), - Value::Object(o) => o, - _ => { - return Err(bad( - "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}".into(), - )); - } - }; - let frac = |k: &str, v: &Value, (lo, hi): (f32, f32), unit: &str| -> crate::Result<()> { - match v.as_f64() { - Some(f) if f.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&f) => Ok(()), - _ => Err(bad(format!("`watermark.{k}` must be a number {lo}..{hi} ({unit})"))), - } - }; - for (k, v) in obj { - if v.is_null() { - continue; - } - match k.as_str() { - "text" | "image" => { - v.as_str().ok_or_else(|| bad(format!("`watermark.{k}` must be a string")))?; - } - "vertical" | "shadow" => { - v.as_bool().ok_or_else(|| bad(format!("`watermark.{k}` must be true or false")))?; - } - "size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?, - "opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?, - "insetX" | "insetY" | "inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?, - "imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?, - "rotation" => { - if !matches!(whole(v), Some(0 | 90 | 180 | 270)) { - return Err(bad("`watermark.rotation` must be 0, 90, 180 or 270 (degrees clockwise)".into())); - } - } - "anchor" => { - serde_json::from_value::(v.clone()).map_err(|_| { - bad("`watermark.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight".into()) - })?; - } - "color" => { - let ok = v.as_array().is_some_and(|a| a.len() == 3 && a.iter().all(|c| matches!(whole(c), Some(0..=255)))); - if !ok { - return Err(bad("`watermark.color` must be [r, g, b] with 0..255 each (sRGB)".into())); - } - } - _ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))), - } - } - watermark_from_json(v).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}"))) - } - - /// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`, - /// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`, - /// `addToPhotos`, `photosAlbum`) and the - /// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H), - /// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus - /// `dontEnlarge` (default true). - /// - /// Lenient: anything unknown or unreadable falls back to the default, which suits stored - /// prefs and dialog prefill. A command that is about to export uses - /// [`ExportOptions::from_params`], which validates first. - pub fn from_json(p: &serde_json::Value) -> Self { - use serde_json::Value; - let d = Self::default(); - let s = |k: &str| p.get(k).and_then(Value::as_str); - let u = |k: &str| p.get(k).and_then(Value::as_u64); - fn enm(p: &Value, k: &str) -> Option { - p.get(k).cloned().and_then(|v| serde_json::from_value(v).ok()) - } - Self { - format: s("format").and_then(ExportFormat::parse).unwrap_or(d.format), - quality: u("quality").map_or(d.quality, |q| q.clamp(1, 100) as u8), - resize: Self::resize_from_json(p), - ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16), - limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), - sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen), - sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount), - naming: s("naming").map_or(d.naming, str::to_string), - start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), - destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode), - subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), - conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict), - tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { - Some("none") => TiffCompression::None, - Some("lzw") => TiffCompression::Lzw, - Some("zip" | "deflate") => TiffCompression::Deflate, - _ => d.tiff_compression, - }, - dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression), - metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata), - remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), - watermark: match p.get("watermark") { - Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), - Some(v @ Value::Object(_)) => watermark_from_json(v).ok(), - _ => None, - } - .filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()), - color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), - bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8), - hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr), - jxl_lossless: p.get("jxlLossless").and_then(Value::as_bool).unwrap_or(d.jxl_lossless), - jxl_effort: s("jxlEffort").and_then(JxlEffort::parse).unwrap_or(d.jxl_effort), - jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive), - add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos), - photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()), - hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer), - } - } - - fn resize_from_json(p: &serde_json::Value) -> Option { - use serde_json::Value; - let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| *v > 0.0); - let dont_enlarge = p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(true); - if let Some(r) = p.get("resize").filter(|v| v.is_object()) { - let r: Resize = serde_json::from_value(r.clone()).ok()?; - return (r.value > 0.0).then_some(Resize { dont_enlarge: p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(r.dont_enlarge), ..r }); - } - let r = |mode, value: f64| Resize { mode, value: value.min(65_535.0) as f32, height: 0, dont_enlarge }; - // `fit: widthHeight` binds each edge to its own number; the default fits either orientation - let bound = p.get("fit").and_then(Value::as_str).is_some_and(|fit| fit.eq_ignore_ascii_case("widthHeight")); - Some(match (f("width"), f("height")) { - (Some(w), Some(h)) => { - Resize { height: h.min(65_535.0) as u32, ..r(if bound { ResizeMode::WidthHeight } else { ResizeMode::Dimensions }, w) } - } - (Some(w), None) => r(ResizeMode::Width, w), - (None, Some(h)) => r(ResizeMode::Height, h), - (None, None) => { - if let Some(v) = f("longEdge") { - r(ResizeMode::LongEdge, v) - } else if let Some(v) = f("shortEdge") { - r(ResizeMode::ShortEdge, v) - } else if let Some(v) = f("megapixels") { - r(ResizeMode::Megapixels, v) - } else { - let v = f("percent")?; - r(ResizeMode::Percent, v) - } - } - }) - } - - /// These options as `app.export` params ([`ExportOptions::from_json`] reads them back - /// unchanged; full size is written as `longEdge: 0`). - pub fn to_json(&self) -> serde_json::Value { - let mut v = serde_json::to_value(self).unwrap_or_default(); - if let Some(o) = v.as_object_mut() { - o.remove("resize"); - if let Some(sz) = Self::resize_json(self.resize.as_ref()).as_object() { - o.extend(sz.clone()); - } - if self.resize.is_none() { - o.insert("longEdge".into(), 0.into()); - } - o.retain(|_, v| !v.is_null()); - } - v - } - - /// Whether `p` names an output size (any of the size params, even 0 = full size). - pub fn has_size_param(p: &serde_json::Value) -> bool { - ["resize", "longEdge", "shortEdge", "width", "height", "megapixels", "percent"].iter().any(|k| p.get(k).is_some()) - } - - /// The size params of [`ExportOptions::from_json`] for `resize` (`{}` = full size). - pub fn resize_json(resize: Option<&Resize>) -> serde_json::Value { - match resize { - None => serde_json::json!({}), - Some(r) => serde_json::json!({"resize": r, "dontEnlarge": r.dont_enlarge}), - } - } - - /// The sample format the file is written with (unsupported requests fall back to the closest). - pub fn effective_depth(&self) -> OutputDepth { - match (self.format, self.bit_depth) { - (ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng, _) => OutputDepth::U8, - (ExportFormat::Png, _) if self.hdr => OutputDepth::F32Hdr, - (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, - (ExportFormat::Png, _) => OutputDepth::U8, - (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, - (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, - (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, - (ExportFormat::Tiff, _) => OutputDepth::U16, - (ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr, - (ExportFormat::RenderedDng, _) => OutputDepth::F32Linear, - (ExportFormat::Psd, Some(8)) => OutputDepth::U8, - (ExportFormat::Psd, _) => OutputDepth::U16, - (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, - (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, - (ExportFormat::Avif, _) => OutputDepth::U8, - (ExportFormat::Jxl, _) if self.hdr => OutputDepth::F32Hdr, - (ExportFormat::Jxl, Some(10 | 16 | 32)) => OutputDepth::U16, - (ExportFormat::Jxl, _) => OutputDepth::U8, - } - } - - /// The bit depths `format` offers (value, label); the first is its default. - pub fn bit_depths(format: ExportFormat) -> &'static [(u8, &'static str)] { - match format { - ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng => &[(8, "8-bit")], - ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], - ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], - ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")], - ExportFormat::RenderedDng => &[(16, "16-bit float")], - ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], - ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")], - } - } - - /// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF, - /// PNG or JPEG XL (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`]. - pub fn hdr_output(&self) -> bool { - self.hdr - && matches!( - (self.format, self.bit_depth), - (ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl, _) | (ExportFormat::Tiff, Some(32)) - ) - } - - /// The colour space the file is actually written in (AVIF: sRGB; AVIF, PNG and JPEG XL HDR: - /// Rec. 2020; lossy JPEG XL: Adobe RGB and ProPhoto as Rec. 2020, the widest space its encoder - /// can declare without a profile). - pub fn effective_space(&self) -> OutputSpace { - match self.format { - ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl if self.hdr => OutputSpace::Rec2020, - ExportFormat::Avif => OutputSpace::Srgb, - ExportFormat::Jxl if !self.jxl_lossless && matches!(self.color_space, OutputSpace::AdobeRgb | OutputSpace::ProPhoto) => { - OutputSpace::Rec2020 - } - _ => self.color_space, - } - } - - /// The JPEG XL encoder settings for these options, writing samples in `space`. - fn jxl(&self, space: OutputSpace, hdr: bool) -> JxlOptions { - let colour = match space { - _ if hdr => JxlColour::Rec2020Pq, - OutputSpace::DisplayP3 => JxlColour::DisplayP3, - OutputSpace::Rec2020 => JxlColour::Rec2020, - // lossless files carry the ICC profile; lossy ones never get here (`effective_space`) - OutputSpace::Srgb | OutputSpace::AdobeRgb | OutputSpace::ProPhoto => JxlColour::Srgb, - }; - JxlOptions { quality: self.quality, lossless: self.jxl_lossless, effort: self.jxl_effort, progressive: self.jxl_progressive, colour } - } - - /// Output file name for photo `p` at 1-based position `seq` in a batch (the original's - /// extension is kept for [`ExportFormat::Original`]). - pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String { - let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() }; - let n = seq + self.start_number.max(1) as usize - 1; - let name = crate::rename::expand_tokens(base, p, n, 3); - let mut name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect(); - let (stem, ext) = p.file_name.rsplit_once('.').unwrap_or((&p.file_name, "")); - if name.trim().is_empty() { - // every token came out empty (no title, no capture time…): keep the original name - name = stem.to_string(); - } - if self.format == ExportFormat::Original { - let base = name.trim_end_matches('.'); - if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") } - } else { - format!("{name}.{}", self.format.extension()) - } - } -} - -/// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). -pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) { - let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); - if let Some(out) = unsharp(img.width, img.height, &src, target, amount) { - for (p, v) in img.data.iter_mut().zip(out) { - for c in 0..3 { - p[c] = v[c].round().clamp(0.0, 255.0) as u8; - } - } - } -} - -/// [`output_sharpen`] on a high-bit-depth image (float images are sharpened in linear light). -pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: SharpenAmount) { - let (w, h) = (img.width, img.height); - match &mut img.samples { - DeepSamples::U16(v) => { - let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect(); - if let Some(out) = unsharp(w, h, &src, target, amount) { - for (d, s) in v.iter_mut().zip(out.as_flattened()) { - *d = s.round().clamp(0.0, 65535.0) as u16; - } - } - } - DeepSamples::F32(v) => { - let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect(); - if let Some(out) = unsharp(w, h, &src, target, amount) { - for (d, s) in v.iter_mut().zip(out.as_flattened()) { - *d = s.max(0.0); - } - } - } - } -} - -/// The sharpened values of `src` (`w × h`), or `None` when nothing is to be done. -fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: SharpenAmount) -> Option> { - let (radius, base) = match target { - SharpenFor::None => return None, - SharpenFor::Screen => (1usize, 0.35f32), - SharpenFor::Matte => (2, 0.6), - SharpenFor::Glossy => (1, 0.5), - }; - let k = base - * match amount { - SharpenAmount::Low => 0.6, - SharpenAmount::Standard => 1.0, - SharpenAmount::High => 1.5, - }; - if w < 3 || h < 3 { - return None; - } - let r = radius as isize; - let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> { - let mut out = vec![[0.0; 3]; inp.len()]; - for y in 0..h { - for x in 0..w { - let mut acc = [0.0f32; 3]; - for d in -r..=r { - let (sx, sy) = if horizontal { - ((x as isize + d).clamp(0, w as isize - 1) as usize, y) - } else { - (x, (y as isize + d).clamp(0, h as isize - 1) as usize) - }; - let p = inp[sy * w + sx]; - acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; - } - let n = (2 * r + 1) as f32; - out[y * w + x] = acc.map(|v| v / n); - } - } - out - }; - let blur = box_pass(&box_pass(src, true), false); - Some(src.iter().zip(&blur).map(|(s, b)| std::array::from_fn(|c| s[c] + (s[c] - b[c]) * k)).collect()) -} - -/// Encode a rendered display-referred image according to `o` (its pixels must already be in -/// `o.effective_space()`). Resizing to `long_edge` is the -/// caller's job (render at that size); sharpening is applied here. -pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { - encode_with_metadata(img, o, None) -} - -/// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP. -pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - let img = finish_8bit(img, o); - let space = o.effective_space(); - let profile = icc::write_named(named_space(space)); - let exif = meta.map(lightcraft_meta::write_exif); - let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); - let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; - // Opaque photo exports need no alpha channel (#1670). Keep real transparency for - // callers of this encoder, and leave unrelated formats on their existing path. - let rgb: Option> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp) - && img.data.iter().all(|p| p.last() == Some(&255))) - .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect()); - let e = match &rgb { - Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)), - None => EncodeImage::rgba8(&img), - }; - let r = match o.format { - ExportFormat::Jpeg => { - // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). - let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; - return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta)); - } - ExportFormat::Png => encode::encode_png(&e, &meta), - ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), - ExportFormat::Psd => encode::encode_psd(&e, &meta), - ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta), - ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), - ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta), - f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), - ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()), - }; - r.map_err(|e| e.to_string()) -} - -/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs. -fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result>) -> Result, String> { - let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) }; - let limit = u64::from(kb) * 1024; - let (mut lo, mut hi) = (1u8, o.quality.min(100)); - let mut best = None; - while lo <= hi { - let mid = lo + (hi - lo) / 2; - let b = jpeg(mid).map_err(|e| e.to_string())?; - if b.len() as u64 <= limit { - best = Some(b); - lo = mid + 1; - } else if mid == 1 { - break; - } else { - hi = mid - 1; - } - } - best.ok_or_else(|| format!("cannot fit within {kb} KB")) -} - -/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. -fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { - let mut img = img.clone(); - output_sharpen(&mut img, o.sharpen, o.sharpen_amount); - let space = o.effective_space(); - if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; - draw_watermark(&mut img, &wm); - } - img -} - -/// Output sharpening and the watermark on a high-bit-depth render. -fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage { - let mut img = img.clone(); - output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); - if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; - draw_watermark_deep(&mut img, &wm); - } - img -} - -/// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space) -/// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured -/// against the base as written, so its 8-bit rounding is handed back by the map. -pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - use lightcraft_codecs::gainmap; - let base = finish_8bit(sdr, o); - let hdr = finish_deep(hdr, o); - let DeepSamples::F32(hv) = &hdr.samples else { - return Err("HDR export needs a float HDR render".into()); - }; - let (w, h) = (base.width, base.height); - if (hdr.width, hdr.height) != (w, h) || hv.len() < w * h * 3 { - return Err("HDR and SDR renders differ in size".into()); - } - let space = o.effective_space(); - let trc = space.trc(); - let sdr_lin: Vec<[f32; 3]> = base.data.iter().map(|p| [0, 1, 2].map(|c| trc.decode(p[c] as f32 / 255.0))).collect(); - let hdr_lin: &[[f32; 3]] = hv.as_chunks::<3>().0; - let (map, gm) = gainmap::compute(&sdr_lin, hdr_lin, w, h, space.luma(), &gainmap::GainMapOptions::default()).map_err(|e| e.to_string())?; - let profile = icc::write_named(named_space(space)); - let exif = meta.map(lightcraft_meta::write_exif); - let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); - let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; - let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; - encode_jpeg_with_limit(o, |q| gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, q, sub, &em)) -} - -/// The codecs' name of an output space (for its ICC profile). -pub fn named_space(s: OutputSpace) -> NamedSpace { - match s { - OutputSpace::Srgb => NamedSpace::Srgb, - OutputSpace::DisplayP3 => NamedSpace::DisplayP3, - OutputSpace::AdobeRgb => NamedSpace::AdobeRgb, - OutputSpace::ProPhoto => NamedSpace::ProPhoto, - OutputSpace::Rec2020 => NamedSpace::Rec2020, - } -} - -/// An 8-bit sRGB colour expressed in `space` (encoded with its curve). -pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] { - if space == OutputSpace::Srgb { - return c; - } - let lin = c.map(lightcraft_color::transfer::decode_srgb8); - let m = lightcraft_color::SRGB.to_space(&space.rgb_space()).to_f32(); - let t = space.trc(); - std::array::from_fn(|i| { - let v = m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2]; - (t.encode(v) * 255.0 + 0.5) as u8 - }) -} - -/// Encode a render according to `o`: its high-bit-depth samples when it has them (16-bit PNG/TIFF, -/// 10-bit AVIF, 32-bit float linear TIFF), else its 8-bit image. -pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - match &r.deep { - Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta), - _ => encode_with_metadata(&r.image, o, meta), - } -} - -/// Encode a high-bit-depth image (see [`encode_rendered`]). -pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - let meta_src = meta; - let img = finish_deep(img, o); - let profile = match img.samples { - DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), - DeepSamples::U16(_) => icc::write_named(named_space(img.space)), - }; - let exif = meta.map(lightcraft_meta::write_exif); - let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); - let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; - let (w, h) = (img.width as u32, img.height as u32); - let e = match &img.samples { - DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)), - DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)), - }; - let r = match (o.format, &img.samples) { - (ExportFormat::Png, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => lightcraft_codecs::encode_png_pq(w, h, v, &meta), - (ExportFormat::Png, _) => encode::encode_png(&e, &meta), - (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), - (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), - (ExportFormat::RenderedDng, DeepSamples::F32(v)) => { - let m = meta_src.cloned().unwrap_or_default(); - return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string()); - } - (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { - lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec()) - } - (ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta), - (ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => { - lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, true), &meta) - } - (ExportFormat::Jxl, DeepSamples::U16(_)) => lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, false), &meta), - (f, _) => return Err(format!("{f:?} export is 8-bit only")), - }; - r.map_err(|e| e.to_string()) -} - -/// Whether the export of `photo` under `o` leaves out its location (GPS and place names). -/// (`photo` and `catalog` let library-level privacy rules add to the export option.) -pub fn removes_location(_photo: &lightcraft_catalog::Photo, _catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> bool { - o.remove_location -} - -/// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. -/// Keywords follow the catalog's keyword list (`Catalog::export_keywords`). -pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option { - let m = &photo.meta; - let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); - // copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL - let mut out = Metadata { - copyright: text(&m.copyright), - copyright_marked: m.copyright_status.marked(), - usage_terms: text(&m.usage_terms), - copyright_url: text(&m.copyright_url), - artist: text(&m.creator), - software: Some("LightCraft".into()), - ..Default::default() - }; - match o.metadata { - MetadataPolicy::None => return None, - MetadataPolicy::Copyright => return Some(out), - MetadataPolicy::All | MetadataPolicy::AllExceptCamera => {} - } - out.title = text(&m.title); - out.caption = text(&m.caption); - out.alt_text = text(&m.alt_text); - out.extended_description = text(&m.extended_description); - if !o.remove_location { - out.sublocation = text(&m.location); - out.city = text(&m.city); - out.state = text(&m.state); - out.country = text(&m.country); - } - let keywords = catalog.export_keywords(&m.keywords); - out.keywords = keywords.flat; - out.hierarchical_keywords = keywords.hierarchical; - out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso); - out.rating = (photo.rating > 0).then_some(photo.rating as i8); - // Pixels are exported upright: orientation is baked in. - out.orientation = Some(lightcraft_meta::Orientation::Normal); - if !o.remove_location { - out.gps = m.gps.map(|(latitude, longitude)| Gps { latitude, longitude, altitude: None }); - } - if o.metadata == MetadataPolicy::All { - out.model = text(&m.camera); - out.lens_model = text(&m.lens); - out.focal_length = m.focal_mm.map(f64::from); - out.f_number = m.aperture.map(f64::from); - out.exposure_time = lightcraft_catalog::parse_shutter_seconds(&m.shutter); - out.iso = m.iso; - } - Some(out) -} - -/// One exported file. -pub struct Exported { - pub file_name: String, - pub bytes: Vec, - pub width: usize, - pub height: usize, - /// Files written next to it, named like it with another extension: `(extension, bytes)` (the - /// XMP sidecar of an `Original` export). - pub sidecars: Vec<(&'static str, Vec)>, - /// Set when the render was tried on the GPU and fell back to the CPU: why (see - /// `docs/gpu-pipeline.md`). The file holds the CPU render either way. - pub gpu_fallback: Option, -} - -/// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`). -pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) { - let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); - // whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie - let (w, h) = (w.round().max(1.0), h.round().max(1.0)); - match &o.resize { - Some(r) => r.apply(w, h), - None => (w as usize, h as usize), - } -} - -/// Render photo `id` at the requested size and encode it (or copy / convert its original for -/// [`ExportFormat::Original`] / [`ExportFormat::Dng`]). -pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result { - prepare_export(session, id, o, seq)?.run() -} - -/// One photo's export, set up from the session ([`prepare_export`]). [`PreparedExport::run`] does -/// the heavy part (read, decode, render, encode) without the session, so it can run on another -/// thread. -pub struct PreparedExport { - pub photo: lightcraft_catalog::PhotoId, - pub file_name: String, - /// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None` - /// for a demo photo or one in the browser's storage. - pub source_dir: Option, - work: Work, - /// The library's originals, which [`run_batch`] never writes over (shared by a batch). - guard: std::sync::Arc, - /// Estimated working memory of [`Self::run`] (bytes), held from [`crate::memory::export_gate`] - /// while it runs beside other photos of a batch. - weight: usize, -} - -struct RenderWork { - job: crate::media::RenderJob, - /// The HDR rendition of a gain map JPEG (`job` renders its SDR base). - hdr_job: Option, - meta: Option, - opts: ExportOptions, -} - -enum Work { - Render(Box), - /// [`ExportFormat::Original`] (the file's bytes + an XMP sidecar) or [`ExportFormat::Dng`] (the - /// raw data re-encoded as a lossless DNG with the edits in its XMP). - File { - path: String, - read: Option, - packet: String, - dng: Option, - /// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size). - quality: u8, - lossy_edge: Option, - /// The original must not carry a location ([`removes_location`]). - remove_location: bool, - label: String, - size: (usize, usize), - }, -} - -/// Set up the export of photo `id` at 1-based position `seq` of a batch. (For a whole batch use -/// [`prepare_batch`]: it looks at the library's originals once.) -pub fn prepare_export( - session: &mut crate::Session, - id: lightcraft_catalog::PhotoId, - o: &ExportOptions, - seq: usize, -) -> Result { - let guard = std::sync::Arc::new(session.original_guard()); - prepare_guarded(session, id, o, seq, guard) -} - -/// [`prepare_export`] for each of `ids` in order (`{seq}` = position, from 1). -pub fn prepare_batch(session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], o: &ExportOptions) -> Result, String> { - let guard = std::sync::Arc::new(session.original_guard()); - ids.iter().enumerate().map(|(i, id)| prepare_guarded(session, *id, o, i + 1, guard.clone())).collect() -} - -fn prepare_guarded( - session: &mut crate::Session, - id: lightcraft_catalog::PhotoId, - o: &ExportOptions, - seq: usize, - guard: std::sync::Arc, -) -> Result { - let p = session.catalog.photo(id).ok_or("no such photo")?; - let file_name = o.file_name_for(p, seq); - let source_dir = original_folder(&p.source); - // HDR output only for photos edited in HDR; the rest of the batch exports as usual - let sdr_opts; - let o = if o.hdr && !p.develop.hdr.enabled { - sdr_opts = ExportOptions { hdr: false, ..o.clone() }; - &sdr_opts - } else { - o - }; - let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize); - let (work, weight) = if o.format.is_rendered() { - let (w, h) = output_size(p, o); - let (nw, nh) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); - let native_px = (nw.round().max(1.0) * nh.round().max(1.0)) as usize; - let label = p.file_name.clone(); - let meta = export_metadata(p, &session.catalog, o); - let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg; - let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?; - let weight = render_weight(full_px, long, job.level, w.saturating_mul(h)); - if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) { - return Err(format!("{label}: {why}")); - } - let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None }; - (Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight) - } else { - let lightcraft_catalog::Source::File { path } = &p.source else { - return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name)); - }; - let dng = (o.format == ExportFormat::Dng).then_some(o.dng_compression); - // the file's bytes; a DNG also decodes the raw data (16-bit samples) and writes it again - let weight = (p.file_size as usize).saturating_add(if dng.is_some() { full_px.saturating_mul(6) } else { 0 }); - let work = Work::File { - path: path.clone(), - read: session.media.file_bytes.clone(), - packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)), - dng, - quality: o.quality, - lossy_edge: o.resize.is_some().then(|| { - let (w, h) = output_size(p, o); - u32::try_from(w.max(h)).unwrap_or(u32::MAX) - }), - remove_location: removes_location(p, &session.catalog, o), - label: p.file_name.clone(), - size: (p.width as usize, p.height as usize), - }; - (work, weight) - }; - Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight }) -} - -/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo -/// photo, and on the web, where an original lives in the browser's storage, not in a folder. -fn original_folder(source: &lightcraft_catalog::Source) -> Option { - let lightcraft_catalog::Source::File { path } = source else { return None }; - if cfg!(target_arch = "wasm32") { - return None; - } - let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string(); - (!dir.is_empty()).then_some(dir) -} - -/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size, -/// can't be made on a computer with `ram` bytes, given its working memory `weight`: an export -/// enlarged past the photo's size that needs more memory than the computer has would fail to -/// allocate, which ends the process rather than the export. Full-size and smaller exports, and -/// computers whose memory isn't known, are never refused. -fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: usize, ram: Option) -> Option { - let ram = ram?; - if out.0.saturating_mul(out.1) <= native_px || weight <= ram { - return None; - } - let gb = |b: usize| b as f64 / (1u64 << 30) as f64; - Some(format!( - "exporting at {} × {} needs about {:.0} GB of memory, more than this computer has ({:.0} GB); choose a smaller size", - out.0, - out.1, - gb(weight).ceil(), - gb(ram) - )) -} - -/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level -/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the -/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export -/// peaks at ~1.2 GB, one at 2048 px from the 2560 px preview at ~150 MB. -fn render_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize { - let edge = level.max_edge().min(long.max(1)); - let scale = edge as f64 / long.max(1) as f64; - let src_px = (full_px as f64 * scale * scale).min(usize::MAX as f64 / 64.0) as usize; - src_px.saturating_mul(12).saturating_add(out_px.saturating_mul(36)) -} - -impl PreparedExport { - pub fn run(self) -> Result { - let file_name = self.file_name; - match self.work { - Work::Render(w) => { - let RenderWork { job, hdr_job, meta, opts } = *w; - let result = job.run(); - let r = result.rendered?; - let gpu_fallback = result.gpu_fallback; - let bytes = match hdr_job { - Some(hj) => { - let hr = hj.run().rendered?; - let hdr = hr.deep.ok_or("the HDR render has no float samples")?; - encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())? - } - None => encode_rendered(&r, &opts, meta.as_ref())?, - }; - Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback }) - } - Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => { - let bytes = match &read { - Some(r) => r(&path)?, - None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, - }; - let Some(dng) = dng else { - // the file is copied byte for byte: refuse rather than leak a location it embeds - if remove_location { - let metadata = lightcraft_meta::try_extract(&bytes) - .map_err(|e| format!("{label}: can't verify location metadata in this original file: {e}"))?; - if metadata.gps.is_some() { - return Err(format!( - "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" - )); - } - } - return Ok(Exported { - file_name, - bytes, - width: size.0, - height: size.1, - sidecars: vec![("xmp", packet.into_bytes())], - gpu_fallback: None, - }); - }; - if lightcraft_raw::probe(&bytes).is_none() { - return Err(format!("{label}: DNG export needs a raw photo")); - } - let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; - crate::camera_preview::with_starting_colour(&mut raw, &bytes); - drop(bytes); - let compression = match dng { - DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, - DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false }, - DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed, - DngCompression::Lossy => { - lightcraft_raw::DngCompression::LossyJpeg { tile: 256, quality: quality.clamp(1, 100), long_edge: lossy_edge } - } - }; - let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) - .map_err(|e| e.to_string())?; - Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new(), gpu_fallback: None }) - } - } - } -} - -/// How an exported DNG stores its raw data. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DngCompression { - /// Lossless JPEG (the usual DNG compression). - #[default] - Lossless, - /// Deflate (zip) tiles with prediction. - Deflate, - Uncompressed, - /// Lossy DNG: demosaiced linear data as JPEG tiles at the export quality, scaled to the resize - /// setting when there is one (Lightroom's "Use Lossy Compression"). - Lossy, -} - -/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy -/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each -/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file -/// already there is replaced). Either way a catalogued original (or its sidecar) -/// is never written over: see [`crate::originals`]. -#[derive(Clone, Debug, Default)] -pub struct Destination { - pub dir: String, - pub exact: Option, -} - -/// What happens after an export has written its files (Lightroom's Post-Processing): nothing, or -/// adding them to Apple Photos ([`ExportOptions::add_to_photos`]). Made by -/// [`crate::Session::after_export`] before the export starts, so an export asking for something -/// this session can't do fails before writing anything; for Photos it holds the import slot -/// (a [`crate::apple_photos::Reservation`]) until the files are handed over, and frees it when -/// dropped unused (the export failed or never ran). `Send`: a background export takes it to its -/// worker. -#[derive(Default)] -pub struct AfterExport { - #[cfg(not(target_arch = "wasm32"))] - apple_photos: Option, -} - -impl AfterExport { - /// Add the files to Apple Photos with `runner`, into `album` when it isn't blank, reserving - /// the import slot of `imports` now. (Front ends get this from - /// [`crate::Session::after_export`], which checks the platform first.) - #[cfg(not(target_arch = "wasm32"))] - pub fn apple_photos(runner: crate::apple_photos::Runner, album: &str, imports: &crate::apple_photos::Imports) -> Result { - Ok(Self { apple_photos: Some(imports.reserve(runner, album)?) }) - } - - /// Nothing to do. - #[cfg(not(target_arch = "wasm32"))] - pub fn is_none(&self) -> bool { - self.apple_photos.is_none() - } - - /// Nothing to do. - #[cfg(target_arch = "wasm32")] - pub fn is_none(&self) -> bool { - true - } - - /// Do it now for an export's per-photo results ([`run_batch`]'s: only the files written - /// count), waiting for Photos: for a worker thread (the app's background export) or a caller - /// without a window (MCP, lightcraft-cli). `None` when there was nothing to do, else the - /// outcome to report as the export's `applePhotos`: the finished job (`{job, running: false, - /// requested, album}` with `{imported, ids, warning?}`, `{error}`, or `{skipped}` when the - /// export was cancelled or wrote nothing). Never fails the export: its files are written - /// either way. - pub fn run(self, files: &[serde_json::Value], cancelled: bool) -> Option { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = self.apple_photos { - return Some(r.run(files, cancelled)); - } - let _ = (files, cancelled); - None - } - - /// Like [`AfterExport::run`], without waiting: Photos works on a worker thread and the - /// outcome is the running job (`{job, running: true, requested, album}`; its result comes - /// from `export.photosImports`, and the desktop app announces it). For a caller on the UI - /// thread, which must not wait minutes for Photos. - pub fn start(self, files: &[serde_json::Value]) -> Option { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = self.apple_photos { - return Some(r.start(files)); - } - let _ = files; - None - } - - /// [`AfterExport::start`] where the session answers on a UI thread - /// ([`crate::Session::apple_photos_background`]), else [`AfterExport::run`]. - pub fn finish(self, s: &crate::Session, files: &[serde_json::Value]) -> Option { - #[cfg(not(target_arch = "wasm32"))] - if s.apple_photos_background { - return self.start(files); - } - let _ = s; - self.run(files, false) - } -} - -impl crate::Session { - /// What an export with `o` does after writing its files; an error, before anything is written, - /// when it asks for Apple Photos and this platform or session has no way to reach it, or - /// another import is reserved or running. For Photos the import slot is reserved from now on. - pub fn after_export(&self, o: &ExportOptions) -> Result { - if !o.add_to_photos { - return Ok(AfterExport::default()); - } - if let Some(why) = apple_photos_unsupported() { - return Err(why.into()); - } - #[cfg(not(target_arch = "wasm32"))] - { - let runner = self.apple_photos.clone().ok_or_else(|| crate::apple_photos::unavailable().to_string())?; - let mut after = AfterExport::apple_photos(runner, &o.photos_album, &self.apple_photos_imports)?; - after.apple_photos = after.apple_photos.map(|r| r.shown_in(self.activity.clone())); - Ok(after) - } - #[cfg(target_arch = "wasm32")] - Err("Apple Photos is only available on macOS".into()) - } -} - -/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error. -pub fn export_batch( - session: &mut crate::Session, - ids: &[lightcraft_catalog::PhotoId], - o: &ExportOptions, - to: &Destination, - write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, - exists: &dyn Fn(&str) -> bool, -) -> Result, String> { - let items = prepare_batch(session, ids, o)?; - run_batch(items, o, to, write, exists, true, &mut |_, _| true) -} - -/// Write an exported or rendered file on disk: its folder is created if needed, and the file is -/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic_nosync`]: a temp file -/// renamed into place), so a failure part-way leaves any previous file intact and no truncated -/// one. Not synced to disk (issue #134): an export can always be made again from the original. -/// The native writer behind exports, renders and screenshots (app, CLI, MCP). -pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { - write_with(path, bytes, false) -} - -/// [`write_file`], synced to disk: for a written file that becomes a library photo (Edit a Copy) -/// and so can't simply be exported again once the catalog points at it. -pub fn write_file_durable(path: &str, bytes: &[u8]) -> Result<(), String> { - write_with(path, bytes, true) -} - -/// Write `path` only when nothing exists there: the destination is prepared as a complete temp -/// file and published with create-new semantics, so a file that appears after a caller's check is -/// never replaced. Screenshots of an initially absent path use this instead of the overwriting -/// writer. -pub fn write_file_new(path: &str, bytes: &[u8]) -> Result<(), String> { - let p = std::path::Path::new(path); - if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; - } - lightcraft_catalog::safe_file::write_new(p, bytes).map_err(|e| format!("{path}: {e}")) -} - -fn write_with(path: &str, bytes: &[u8], durable: bool) -> Result<(), String> { - use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync}; - let p = std::path::Path::new(path); - if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; - } - if durable { write_atomic(p, bytes) } else { write_atomic_nosync(p, bytes) }.map_err(|e| format!("{path}: {e}")) -} - -/// The path of the sidecar with extension `ext` of the exported file `main`. -fn sidecar_path(main: &str, ext: &str) -> String { - std::path::Path::new(main).with_extension(ext).to_string_lossy().to_string() -} - -/// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to -/// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its -/// sidecars as one: with Unique both get the same free name, with Skip the photo is skipped when -/// either is taken. A path that is a catalogued original (or its sidecar) is refused whatever the -/// policy. `progress(done, next file)` is called before each photo; returning false cancels the -/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`, -/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`. -/// -/// Several photos render side by side ([`export_parallelism`], within the memory of -/// [`crate::memory::export_gate`]), so one photo's decode, encode or GPU wait overlaps another's -/// render (issue #496). Paths, writes, results and `progress` still follow the batch order, and the -/// files are the same as one at a time. -pub fn run_batch( - items: Vec, - o: &ExportOptions, - to: &Destination, - write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, - exists: &dyn Fn(&str) -> bool, - stop_on_error: bool, - progress: &mut dyn FnMut(usize, &str) -> bool, -) -> Result, String> { - let lanes = export_parallelism(items.len()); - run_batch_with(items, &mut Placer::new(o, to, write, exists, stop_on_error), progress, lanes) -} - -/// How many photos of a batch of `n` render side by side. Each render already spreads its rows -/// over every core; the others fill the cores while it decodes, encodes or waits for the GPU. -/// One on wasm32 (no threads). -pub fn export_parallelism(n: usize) -> usize { - if cfg!(target_arch = "wasm32") { - return 1; - } - let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); - (cores / 4).clamp(1, 4).min(n.max(1)) -} - -/// [`run_batch`] with `lanes` photos in flight (the calling thread is one of them). -fn run_batch_with( - items: Vec, - placer: &mut Placer<'_>, - progress: &mut dyn FnMut(usize, &str) -> bool, - lanes: usize, -) -> Result, String> { - placer.single = items.len() == 1; - if lanes <= 1 || items.len() <= 1 { - for (i, item) in items.into_iter().enumerate() { - if !progress(i, &item.file_name) { - break; - } - let (photo, name, guard, source_dir) = (item.photo, item.file_name.clone(), item.guard.clone(), item.source_dir.clone()); - placer.place(photo, name, source_dir.as_deref(), &guard, run_gated(item))?; - } - return Ok(std::mem::take(&mut placer.out)); - } - let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone(), i.source_dir.clone())).collect(); - let queue = Queue { - lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }), - cv: std::sync::Condvar::new(), - ahead: lanes, - }; - std::thread::scope(|sc| { - // stops the lanes however this thread leaves the scope: a panic in `progress` or the placer - // would otherwise leave them waiting and the scope's join would never return - let _stop = StopOnDrop(&queue); - for k in 1..lanes { - let q = &queue; - // a lane that can't start leaves its photos to the others (and to this thread) - if std::thread::Builder::new().name(format!("export-{k}")).spawn_scoped(sc, move || q.work()).is_err() { - break; - } - } - let placed = || { - for (i, (photo, name, guard, source_dir)) in heads.into_iter().enumerate() { - if !progress(i, &name) { - break; - } - let e = queue.result(i); - placer.place(photo, name, source_dir.as_deref(), &guard, e)?; - } - Ok::<_, String>(()) - }; - placed() - })?; - Ok(std::mem::take(&mut placer.out)) -} - -/// Stops a [`Queue`] when dropped, also while unwinding from a panic. -struct StopOnDrop<'a>(&'a Queue); - -impl Drop for StopOnDrop<'_> { - fn drop(&mut self) { - self.0.stop(); - } -} - -/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic -/// becomes that photo's error, not the batch's end. -fn run_gated(item: PreparedExport) -> Result { - let _held = crate::memory::export_gate().acquire(item.weight); - let what = format!("exporting {}", item.file_name); - crate::guard::catch(&what, || item.run()).and_then(|r| r) -} - -/// The photos of a batch shared by its lanes: each lane takes the next one, at most -/// [`Queue::ahead`] past the last placed. That bounds the finished files waiting in memory for -/// their turn too (their working memory is given back to the gate as soon as they are encoded). -struct Queue { - lane: std::sync::Mutex, - cv: std::sync::Condvar, - ahead: usize, -} - -struct Lane { - items: Vec>, - /// The next photo to start. - next: usize, - /// Photos handed to the placer so far. - placed: usize, - stop: bool, - done: std::collections::HashMap>, -} - -impl Lane { - fn take_next(&mut self) -> Option<(usize, PreparedExport)> { - let i = self.next; - let item = self.items.get_mut(i)?.take()?; - self.next += 1; - Some((i, item)) - } -} - -impl Queue { - fn lock(&self) -> std::sync::MutexGuard<'_, Lane> { - self.lane.lock().unwrap_or_else(std::sync::PoisonError::into_inner) - } - - fn wait<'a>(&self, g: std::sync::MutexGuard<'a, Lane>) -> std::sync::MutexGuard<'a, Lane> { - self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner) - } - - fn finish(&self, i: usize, r: Result) { - self.lock().done.insert(i, r); - self.cv.notify_all(); - } - - /// A lane thread: run photos until none is left or the batch stops. - fn work(&self) { - loop { - let mut l = self.lock(); - let (i, item) = loop { - if l.stop || l.next >= l.items.len() { - return; - } - if l.next < l.placed.saturating_add(self.ahead) { - match l.take_next() { - Some(t) => break t, - None => return, - } - } - l = self.wait(l); - }; - drop(l); - self.finish(i, run_gated(item)); - } - } - - /// Photo `i`'s result, waiting for it; run here when no lane has started it yet. - fn result(&self, i: usize) -> Result { - let mut l = self.lock(); - loop { - if let Some(r) = l.done.remove(&i) { - l.placed = i + 1; - drop(l); - self.cv.notify_all(); - return r; - } - if l.next == i { - let Some((_, item)) = l.take_next() else { - return Err("export: photo missing from the batch".into()); - }; - drop(l); - self.finish(i, run_gated(item)); - l = self.lock(); - continue; - } - l = self.wait(l); - } - } - - fn stop(&self) { - self.lock().stop = true; - self.cv.notify_all(); - } -} - -/// Where a batch's files go: each photo's path (destination folder, subfolder, conflict policy, -/// sidecars, never an original) and its write, in batch order. -struct Placer<'a> { - o: &'a ExportOptions, - to: &'a Destination, - single: bool, - taken: std::collections::HashSet, - out: Vec, - write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>, - exists: &'a dyn Fn(&str) -> bool, - stop_on_error: bool, -} - -fn join_path(a: &str, b: &str) -> String { - if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) } -} - -impl<'a> Placer<'a> { - fn new( - o: &'a ExportOptions, - to: &'a Destination, - write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>, - exists: &'a dyn Fn(&str) -> bool, - stop_on_error: bool, - ) -> Placer<'a> { - Placer { o, to, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } - } - - /// The folder photo `name` (its original in `source_dir`) is written to: the batch's folder or - /// the photo's own ([`DestinationMode`]), plus the subfolder. An error for a photo with no - /// folder of its own, or whose folder is gone: never a file written somewhere else instead. - fn folder_for(&self, name: &str, source_dir: Option<&str>) -> Result { - let base = match self.o.destination_mode { - DestinationMode::SpecificFolder => self.to.dir.clone(), - DestinationMode::SameAsOriginal => { - let Some(d) = source_dir else { - return Err(format!( - "{name}: the photo has no folder on disk (a demo photo or one in the browser's storage), so it can't be exported to the same folder as its original; choose a specific folder" - )); - }; - if !(self.exists)(d) { - return Err(format!("{name}: the original's folder is missing ({d}); reconnect it or choose a specific folder")); - } - d.to_string() - } - }; - Ok(if self.o.subfolder.is_empty() { base } else { join_path(&base, &self.o.subfolder) }) - } - - /// Place photo `photo`'s export result (file `name`, its original in `source_dir`): write it - /// and record the outcome. `Err` only when it failed and `stop_on_error` is set. - fn place( - &mut self, - photo: lightcraft_catalog::PhotoId, - name: String, - source_dir: Option<&str>, - guard: &crate::originals::OriginalGuard, - e: Result, - ) -> Result<(), String> { - use serde_json::json; - // an exact file path needs no folder - let dir = if self.to.exact.is_some() && self.single { Ok(String::new()) } else { self.folder_for(&name, source_dir) }; - let (o, exists) = (self.o, self.exists); - let (dir, e) = match dir.and_then(|dir| e.map(|e| (dir, e))) { - Ok(v) => v, - Err(err) if self.stop_on_error => return Err(err), - Err(err) => { - self.out.push(json!({"photo": photo.0, "file": name, "error": err})); - return Ok(()); - } - }; - let dir = dir.as_str(); - // the exported file and its sidecars - let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::>(); - let path = match self.to.exact.as_deref().filter(|_| self.single) { - Some(p) => Ok(p.to_string()), - None => { - let path = join_path(dir, &e.file_name); - let taken = &self.taken; - let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p)); - if !busy(&path) { - Ok(path) - } else { - match o.conflict { - Conflict::Overwrite => Ok(path), - Conflict::Skip => { - self.out.push(json!({"skipped": path})); - return Ok(()); - } - Conflict::Unique => { - let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b))); - let name = |n: usize| join_path(dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}"))); - (2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name")) - } - } - } - } - }; - let file = path.clone().unwrap_or_else(|_| name.clone()); - let write = &mut *self.write; - let written = path.and_then(|path| { - let files = group(&path); - // never over an original, whatever the conflict policy or the exact path said - for f in &files { - guard.check(std::path::Path::new(f))?; - } - write(&path, &e.bytes)?; - let mut sidecars = Vec::new(); - for ((_, bytes), sc) in e.sidecars.iter().zip(files.iter().skip(1)) { - write(sc, bytes)?; - sidecars.push(sc.clone()); - } - Ok((path, files, sidecars)) - }); - match written { - Ok((path, files, sidecars)) => { - self.taken.extend(files); - let mut entry = json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}); - if let Some(why) = &e.gpu_fallback { - entry["gpuFallback"] = json!(why); - } - self.out.push(entry); - } - Err(err) if self.stop_on_error => return Err(err), - Err(err) => self.out.push(json!({"photo": photo.0, "file": file, "error": err})), - } - Ok(()) - } -} - #[cfg(test)] mod tests; diff --git a/crates/engine/src/export/batch.rs b/crates/engine/src/export/batch.rs new file mode 100644 index 000000000..a4bb859e2 --- /dev/null +++ b/crates/engine/src/export/batch.rs @@ -0,0 +1,198 @@ +use super::place::Placer; +use super::prepare::{Destination, Exported, PreparedExport}; +use super::*; + +/// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to +/// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its +/// sidecars as one: with Unique both get the same free name, with Skip the photo is skipped when +/// either is taken. A path that is a catalogued original (or its sidecar) is refused whatever the +/// policy. `progress(done, next file)` is called before each photo; returning false cancels the +/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`, +/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`. +/// +/// Several photos render side by side ([`export_parallelism`], within the memory of +/// [`crate::memory::export_gate`]), so one photo's decode, encode or GPU wait overlaps another's +/// render (issue #496). Paths, writes, results and `progress` still follow the batch order, and the +/// files are the same as one at a time. +pub fn run_batch( + items: Vec, + o: &ExportOptions, + to: &Destination, + write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &dyn Fn(&str) -> bool, + stop_on_error: bool, + progress: &mut dyn FnMut(usize, &str) -> bool, +) -> Result, String> { + let lanes = export_parallelism(items.len()); + run_batch_with(items, &mut Placer::new(o, to, write, exists, stop_on_error), progress, lanes) +} + +/// How many photos of a batch of `n` render side by side. Each render already spreads its rows +/// over every core; the others fill the cores while it decodes, encodes or waits for the GPU. +/// One on wasm32 (no threads). +pub fn export_parallelism(n: usize) -> usize { + if cfg!(target_arch = "wasm32") { + return 1; + } + let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + (cores / 4).clamp(1, 4).min(n.max(1)) +} + +/// [`run_batch`] with `lanes` photos in flight (the calling thread is one of them). +pub(super) fn run_batch_with( + items: Vec, + placer: &mut Placer<'_>, + progress: &mut dyn FnMut(usize, &str) -> bool, + lanes: usize, +) -> Result, String> { + placer.single = items.len() == 1; + if lanes <= 1 || items.len() <= 1 { + for (i, item) in items.into_iter().enumerate() { + if !progress(i, &item.file_name) { + break; + } + let (photo, name, guard, source_dir) = (item.photo, item.file_name.clone(), item.guard.clone(), item.source_dir.clone()); + placer.place(photo, name, source_dir.as_deref(), &guard, run_gated(item))?; + } + return Ok(std::mem::take(&mut placer.out)); + } + let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone(), i.source_dir.clone())).collect(); + let queue = Queue { + lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }), + cv: std::sync::Condvar::new(), + ahead: lanes, + }; + std::thread::scope(|sc| { + // stops the lanes however this thread leaves the scope: a panic in `progress` or the placer + // would otherwise leave them waiting and the scope's join would never return + let _stop = StopOnDrop(&queue); + for k in 1..lanes { + let q = &queue; + // a lane that can't start leaves its photos to the others (and to this thread) + if std::thread::Builder::new().name(format!("export-{k}")).spawn_scoped(sc, move || q.work()).is_err() { + break; + } + } + let placed = || { + for (i, (photo, name, guard, source_dir)) in heads.into_iter().enumerate() { + if !progress(i, &name) { + break; + } + let e = queue.result(i); + placer.place(photo, name, source_dir.as_deref(), &guard, e)?; + } + Ok::<_, String>(()) + }; + placed() + })?; + Ok(std::mem::take(&mut placer.out)) +} + +/// Stops a [`Queue`] when dropped, also while unwinding from a panic. +struct StopOnDrop<'a>(&'a Queue); + +impl Drop for StopOnDrop<'_> { + fn drop(&mut self) { + self.0.stop(); + } +} + +/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic +/// becomes that photo's error, not the batch's end. +fn run_gated(item: PreparedExport) -> Result { + let _held = crate::memory::export_gate().acquire(item.weight); + let what = format!("exporting {}", item.file_name); + crate::guard::catch(&what, || item.run()).and_then(|r| r) +} + +/// The photos of a batch shared by its lanes: each lane takes the next one, at most +/// [`Queue::ahead`] past the last placed. That bounds the finished files waiting in memory for +/// their turn too (their working memory is given back to the gate as soon as they are encoded). +struct Queue { + lane: std::sync::Mutex, + cv: std::sync::Condvar, + ahead: usize, +} + +struct Lane { + items: Vec>, + /// The next photo to start. + next: usize, + /// Photos handed to the placer so far. + placed: usize, + stop: bool, + done: std::collections::HashMap>, +} + +impl Lane { + fn take_next(&mut self) -> Option<(usize, PreparedExport)> { + let i = self.next; + let item = self.items.get_mut(i)?.take()?; + self.next += 1; + Some((i, item)) + } +} + +impl Queue { + fn lock(&self) -> std::sync::MutexGuard<'_, Lane> { + self.lane.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn wait<'a>(&self, g: std::sync::MutexGuard<'a, Lane>) -> std::sync::MutexGuard<'a, Lane> { + self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn finish(&self, i: usize, r: Result) { + self.lock().done.insert(i, r); + self.cv.notify_all(); + } + + /// A lane thread: run photos until none is left or the batch stops. + fn work(&self) { + loop { + let mut l = self.lock(); + let (i, item) = loop { + if l.stop || l.next >= l.items.len() { + return; + } + if l.next < l.placed.saturating_add(self.ahead) { + match l.take_next() { + Some(t) => break t, + None => return, + } + } + l = self.wait(l); + }; + drop(l); + self.finish(i, run_gated(item)); + } + } + + /// Photo `i`'s result, waiting for it; run here when no lane has started it yet. + fn result(&self, i: usize) -> Result { + let mut l = self.lock(); + loop { + if let Some(r) = l.done.remove(&i) { + l.placed = i + 1; + drop(l); + self.cv.notify_all(); + return r; + } + if l.next == i { + let Some((_, item)) = l.take_next() else { + return Err("export: photo missing from the batch".into()); + }; + drop(l); + self.finish(i, run_gated(item)); + l = self.lock(); + continue; + } + l = self.wait(l); + } + } + + fn stop(&self) { + self.lock().stop = true; + self.cv.notify_all(); + } +} diff --git a/crates/engine/src/export/encoding.rs b/crates/engine/src/export/encoding.rs new file mode 100644 index 000000000..c71903ac9 --- /dev/null +++ b/crates/engine/src/export/encoding.rs @@ -0,0 +1,322 @@ +use super::options::{ExportFormat, MetadataPolicy, SharpenAmount, SharpenFor}; +use super::watermark::{Watermark, draw_watermark, draw_watermark_deep}; +use super::*; + +/// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). +pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) { + let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); + if let Some(out) = unsharp(img.width, img.height, &src, target, amount) { + for (p, v) in img.data.iter_mut().zip(out) { + for c in 0..3 { + p[c] = v[c].round().clamp(0.0, 255.0) as u8; + } + } + } +} + +/// [`output_sharpen`] on a high-bit-depth image (float images are sharpened in linear light). +pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: SharpenAmount) { + let (w, h) = (img.width, img.height); + match &mut img.samples { + DeepSamples::U16(v) => { + let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect(); + if let Some(out) = unsharp(w, h, &src, target, amount) { + for (d, s) in v.iter_mut().zip(out.as_flattened()) { + *d = s.round().clamp(0.0, 65535.0) as u16; + } + } + } + DeepSamples::F32(v) => { + let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect(); + if let Some(out) = unsharp(w, h, &src, target, amount) { + for (d, s) in v.iter_mut().zip(out.as_flattened()) { + *d = s.max(0.0); + } + } + } + } +} + +/// The sharpened values of `src` (`w × h`), or `None` when nothing is to be done. +fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: SharpenAmount) -> Option> { + let (radius, base) = match target { + SharpenFor::None => return None, + SharpenFor::Screen => (1usize, 0.35f32), + SharpenFor::Matte => (2, 0.6), + SharpenFor::Glossy => (1, 0.5), + }; + let k = base + * match amount { + SharpenAmount::Low => 0.6, + SharpenAmount::Standard => 1.0, + SharpenAmount::High => 1.5, + }; + if w < 3 || h < 3 { + return None; + } + let r = radius as isize; + let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> { + let mut out = vec![[0.0; 3]; inp.len()]; + for y in 0..h { + for x in 0..w { + let mut acc = [0.0f32; 3]; + for d in -r..=r { + let (sx, sy) = if horizontal { + ((x as isize + d).clamp(0, w as isize - 1) as usize, y) + } else { + (x, (y as isize + d).clamp(0, h as isize - 1) as usize) + }; + let p = inp[sy * w + sx]; + acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; + } + let n = (2 * r + 1) as f32; + out[y * w + x] = acc.map(|v| v / n); + } + } + out + }; + let blur = box_pass(&box_pass(src, true), false); + Some(src.iter().zip(&blur).map(|(s, b)| std::array::from_fn(|c| s[c] + (s[c] - b[c]) * k)).collect()) +} + +/// Encode a rendered display-referred image according to `o` (its pixels must already be in +/// `o.effective_space()`). Resizing to `long_edge` is the +/// caller's job (render at that size); sharpening is applied here. +pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { + encode_with_metadata(img, o, None) +} + +/// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP. +pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + let img = finish_8bit(img, o); + let space = o.effective_space(); + let profile = icc::write_named(named_space(space)); + let exif = meta.map(lightcraft_meta::write_exif); + let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); + let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; + // Opaque photo exports need no alpha channel (#1670). Keep real transparency for + // callers of this encoder, and leave unrelated formats on their existing path. + let rgb: Option> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp) + && img.data.iter().all(|p| p.last() == Some(&255))) + .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect()); + let e = match &rgb { + Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)), + None => EncodeImage::rgba8(&img), + }; + let r = match o.format { + ExportFormat::Jpeg => { + // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). + let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; + return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta)); + } + ExportFormat::Png => encode::encode_png(&e, &meta), + ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), + ExportFormat::Psd => encode::encode_psd(&e, &meta), + ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta), + ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), + ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta), + f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), + ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()), + }; + r.map_err(|e| e.to_string()) +} + +/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs. +fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result>) -> Result, String> { + let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) }; + let limit = u64::from(kb) * 1024; + let (mut lo, mut hi) = (1u8, o.quality.min(100)); + let mut best = None; + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let b = jpeg(mid).map_err(|e| e.to_string())?; + if b.len() as u64 <= limit { + best = Some(b); + lo = mid + 1; + } else if mid == 1 { + break; + } else { + hi = mid - 1; + } + } + best.ok_or_else(|| format!("cannot fit within {kb} KB")) +} + +/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. +fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { + let mut img = img.clone(); + output_sharpen(&mut img, o.sharpen, o.sharpen_amount); + let space = o.effective_space(); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; + draw_watermark(&mut img, &wm); + } + img +} + +/// Output sharpening and the watermark on a high-bit-depth render. +fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage { + let mut img = img.clone(); + output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; + draw_watermark_deep(&mut img, &wm); + } + img +} + +/// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space) +/// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured +/// against the base as written, so its 8-bit rounding is handed back by the map. +pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + use lightcraft_codecs::gainmap; + let base = finish_8bit(sdr, o); + let hdr = finish_deep(hdr, o); + let DeepSamples::F32(hv) = &hdr.samples else { + return Err("HDR export needs a float HDR render".into()); + }; + let (w, h) = (base.width, base.height); + if (hdr.width, hdr.height) != (w, h) || hv.len() < w * h * 3 { + return Err("HDR and SDR renders differ in size".into()); + } + let space = o.effective_space(); + let trc = space.trc(); + let sdr_lin: Vec<[f32; 3]> = base.data.iter().map(|p| [0, 1, 2].map(|c| trc.decode(p[c] as f32 / 255.0))).collect(); + let hdr_lin: &[[f32; 3]] = hv.as_chunks::<3>().0; + let (map, gm) = gainmap::compute(&sdr_lin, hdr_lin, w, h, space.luma(), &gainmap::GainMapOptions::default()).map_err(|e| e.to_string())?; + let profile = icc::write_named(named_space(space)); + let exif = meta.map(lightcraft_meta::write_exif); + let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); + let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; + let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; + encode_jpeg_with_limit(o, |q| gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, q, sub, &em)) +} + +/// The codecs' name of an output space (for its ICC profile). +pub fn named_space(s: OutputSpace) -> NamedSpace { + match s { + OutputSpace::Srgb => NamedSpace::Srgb, + OutputSpace::DisplayP3 => NamedSpace::DisplayP3, + OutputSpace::AdobeRgb => NamedSpace::AdobeRgb, + OutputSpace::ProPhoto => NamedSpace::ProPhoto, + OutputSpace::Rec2020 => NamedSpace::Rec2020, + } +} + +/// An 8-bit sRGB colour expressed in `space` (encoded with its curve). +pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] { + if space == OutputSpace::Srgb { + return c; + } + let lin = c.map(lightcraft_color::transfer::decode_srgb8); + let m = lightcraft_color::SRGB.to_space(&space.rgb_space()).to_f32(); + let t = space.trc(); + std::array::from_fn(|i| { + let v = m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2]; + (t.encode(v) * 255.0 + 0.5) as u8 + }) +} + +/// Encode a render according to `o`: its high-bit-depth samples when it has them (16-bit PNG/TIFF, +/// 10-bit AVIF, 32-bit float linear TIFF), else its 8-bit image. +pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + match &r.deep { + Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta), + _ => encode_with_metadata(&r.image, o, meta), + } +} + +/// Encode a high-bit-depth image (see [`encode_rendered`]). +pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + let meta_src = meta; + let img = finish_deep(img, o); + let profile = match img.samples { + DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), + DeepSamples::U16(_) => icc::write_named(named_space(img.space)), + }; + let exif = meta.map(lightcraft_meta::write_exif); + let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); + let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; + let (w, h) = (img.width as u32, img.height as u32); + let e = match &img.samples { + DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)), + DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)), + }; + let r = match (o.format, &img.samples) { + (ExportFormat::Png, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => lightcraft_codecs::encode_png_pq(w, h, v, &meta), + (ExportFormat::Png, _) => encode::encode_png(&e, &meta), + (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), + (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), + (ExportFormat::RenderedDng, DeepSamples::F32(v)) => { + let m = meta_src.cloned().unwrap_or_default(); + return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string()); + } + (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { + lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec()) + } + (ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta), + (ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => { + lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, true), &meta) + } + (ExportFormat::Jxl, DeepSamples::U16(_)) => lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, false), &meta), + (f, _) => return Err(format!("{f:?} export is 8-bit only")), + }; + r.map_err(|e| e.to_string()) +} + +/// Whether the export of `photo` under `o` leaves out its location (GPS and place names). +/// (`photo` and `catalog` let library-level privacy rules add to the export option.) +pub fn removes_location(_photo: &lightcraft_catalog::Photo, _catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> bool { + o.remove_location +} + +/// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. +/// Keywords follow the catalog's keyword list (`Catalog::export_keywords`). +pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option { + let m = &photo.meta; + let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); + // copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL + let mut out = Metadata { + copyright: text(&m.copyright), + copyright_marked: m.copyright_status.marked(), + usage_terms: text(&m.usage_terms), + copyright_url: text(&m.copyright_url), + artist: text(&m.creator), + software: Some("LightCraft".into()), + ..Default::default() + }; + match o.metadata { + MetadataPolicy::None => return None, + MetadataPolicy::Copyright => return Some(out), + MetadataPolicy::All | MetadataPolicy::AllExceptCamera => {} + } + out.title = text(&m.title); + out.caption = text(&m.caption); + out.alt_text = text(&m.alt_text); + out.extended_description = text(&m.extended_description); + if !o.remove_location { + out.sublocation = text(&m.location); + out.city = text(&m.city); + out.state = text(&m.state); + out.country = text(&m.country); + } + let keywords = catalog.export_keywords(&m.keywords); + out.keywords = keywords.flat; + out.hierarchical_keywords = keywords.hierarchical; + out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso); + out.rating = (photo.rating > 0).then_some(photo.rating as i8); + // Pixels are exported upright: orientation is baked in. + out.orientation = Some(lightcraft_meta::Orientation::Normal); + if !o.remove_location { + out.gps = m.gps.map(|(latitude, longitude)| Gps { latitude, longitude, altitude: None }); + } + if o.metadata == MetadataPolicy::All { + out.model = text(&m.camera); + out.lens_model = text(&m.lens); + out.focal_length = m.focal_mm.map(f64::from); + out.f_number = m.aperture.map(f64::from); + out.exposure_time = lightcraft_catalog::parse_shutter_seconds(&m.shutter); + out.iso = m.iso; + } + Some(out) +} diff --git a/crates/engine/src/export/options.rs b/crates/engine/src/export/options.rs new file mode 100644 index 000000000..d1b767be2 --- /dev/null +++ b/crates/engine/src/export/options.rs @@ -0,0 +1,305 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExportFormat { + #[default] + Jpeg, + Png, + Tiff, + /// A flat PSD (8/16-bit, no layers): what an image editor such as PhotoCraft saves back to. + Psd, + Webp, + Avif, + /// JPEG XL: lossy (VarDCT) or lossless, 8/16-bit, HDR as Rec. 2020 PQ. + Jxl, + /// The original file, unchanged, with an XMP sidecar holding the edits. + Original, + /// A DNG of the raw data (raw photos only), the edits embedded as XMP. + Dng, + /// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in. + RenderedDng, +} + +impl ExportFormat { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "jpeg" | "jpg" => Self::Jpeg, + "png" => Self::Png, + "tiff" | "tif" => Self::Tiff, + "psd" => Self::Psd, + "webp" => Self::Webp, + "avif" => Self::Avif, + "jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl, + "original" => Self::Original, + "dng" => Self::Dng, + "rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng, + _ => return None, + }) + } + pub fn extension(self) -> &'static str { + match self { + Self::Jpeg => "jpg", + Self::Png => "png", + Self::Tiff => "tif", + Self::Psd => "psd", + Self::Webp => "webp", + Self::Avif => "avif", + Self::Jxl => "jxl", + // the original keeps its own extension (see `ExportOptions::file_name_for`) + Self::Original => "", + Self::Dng | Self::RenderedDng => "dng", + } + } + + /// Formats that render pixels (everything but `Original` and `Dng`). + pub fn is_rendered(self) -> bool { + !matches!(self, Self::Original | Self::Dng) + } +} + +/// How the output size is chosen (see [`Resize`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ResizeMode { + /// `value` = long edge in pixels. + #[default] + LongEdge, + /// `value` = short edge in pixels. + ShortEdge, + /// `value` = width in pixels. + Width, + /// `value` = height in pixels. + Height, + /// Fit inside `value` wide × `height` tall as the photo stands: width ≤ `value`, height ≤ + /// `height` (Lightroom's "Width & Height"). + WidthHeight, + /// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the + /// larger; Lightroom's "Dimensions"). + Dimensions, + /// `value` = megapixels. + Megapixels, + /// `value` = percent of the full size. + Percent, +} + +/// Output size: a mode, its value(s) and whether smaller photos are left at their own size. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Resize { + pub mode: ResizeMode, + pub value: f32, + /// Second dimension for [`ResizeMode::Dimensions`] and [`ResizeMode::WidthHeight`]. + pub height: u32, + /// Never upscale past the photo's own (cropped) size. + pub dont_enlarge: bool, +} + +impl Default for Resize { + fn default() -> Self { + Self { mode: ResizeMode::LongEdge, value: 2048.0, height: 2048, dont_enlarge: true } + } +} + +/// The most pixels an export enlarged past its photo's own size can have: 512 megapixels, also +/// Lightroom's ceiling. A full-size or smaller export is never held to it. +pub const MAX_ENLARGED_PIXELS: f64 = 512e6; + +impl Resize { + pub fn long_edge(px: u32) -> Self { + Self { value: px as f32, ..Default::default() } + } + + /// Scale factor from a `w × h` (cropped, full-resolution) photo to the output. + pub fn scale(&self, w: f64, h: f64) -> f64 { + let (w, h) = (w.max(1.0), h.max(1.0)); + let (long, short) = (w.max(h), w.min(h)); + let v = (self.value as f64).max(0.0); + let k = match self.mode { + ResizeMode::LongEdge => v / long, + ResizeMode::ShortEdge => v / short, + ResizeMode::Width => v / w, + ResizeMode::Height => v / h, + ResizeMode::WidthHeight => (v / w).min(self.height as f64 / h), + ResizeMode::Dimensions => { + let (a, b) = (v, self.height as f64); + (a.max(b) / long).min(a.min(b) / short) + } + ResizeMode::Megapixels => (v * 1e6 / (w * h)).sqrt(), + ResizeMode::Percent => v / 100.0, + }; + let k = if self.dont_enlarge { k.min(1.0) } else { k }; + // an enlargement stops at 512 megapixels (Lightroom's ceiling too): 20000 typed for 2000 + // with Don't Enlarge off asked for gigapixels, more memory than a computer has + let k = k.min((MAX_ENLARGED_PIXELS / (w * h)).sqrt().max(1.0)); + // at least one pixel, at most the encoders' 65535 limit; the limit wins for a photo more + // than 65535 times as long as it is short (where `clamp` would panic: min above max) + k.max(1.0 / short).min(65_535.0 / long) + } + + /// Output size for a `w × h` (cropped, full-resolution) photo. + pub fn apply(&self, w: f64, h: f64) -> (usize, usize) { + let k = self.scale(w, h); + (((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1)) + } +} + +/// What happens when an output file already exists. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Conflict { + /// Add `-2`, `-3`, … to the name. + #[default] + Unique, + Overwrite, + /// Leave the existing file and skip the photo. + Skip, +} + +/// Where a batch's files go (Lightroom Classic's Export To). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DestinationMode { + /// One folder for the whole batch ([`Destination::dir`]). + #[default] + SpecificFolder, + /// Each photo's own folder: the folder its original is in. A photo without one on disk (a + /// demo photo, a photo in the browser's storage) or whose folder is gone gets an error. + SameAsOriginal, +} + +impl DestinationMode { + /// `specificFolder` / `sameAsOriginal` (any case). + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "specificfolder" => Some(Self::SpecificFolder), + "sameasoriginal" => Some(Self::SameAsOriginal), + _ => None, + } + } +} + +impl Conflict { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "unique" => Some(Self::Unique), + "overwrite" => Some(Self::Overwrite), + "skip" => Some(Self::Skip), + _ => None, + } + } +} + +/// A named set of `app.export` params. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExportPreset { + pub name: String, + pub params: serde_json::Value, +} + +/// The built-in export presets (File → Export with Preset). +pub fn builtin_presets() -> Vec { + let p = |name: &str, params: serde_json::Value| ExportPreset { name: name.into(), params }; + vec![ + p("JPEG (Small)", serde_json::json!({"format": "jpeg", "quality": 85, "longEdge": 2048, "colorSpace": "srgb"})), + p("JPEG (Large)", serde_json::json!({"format": "jpeg", "quality": 92, "longEdge": 0, "colorSpace": "srgb"})), + p("Original + Settings", serde_json::json!({"format": "original"})), + p("DNG", serde_json::json!({"format": "dng"})), + ] +} + +/// Output sharpening target (applied after resizing, in display-encoded values). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharpenFor { + #[default] + None, + Screen, + Matte, + Glossy, +} + +impl SharpenFor { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "none" => Some(Self::None), + "screen" => Some(Self::Screen), + "matte" => Some(Self::Matte), + "glossy" => Some(Self::Glossy), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharpenAmount { + Low, + #[default] + Standard, + High, +} + +impl SharpenAmount { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "low" => Some(Self::Low), + "standard" => Some(Self::Standard), + "high" => Some(Self::High), + _ => None, + } + } +} + +/// Which metadata is embedded in exported files. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MetadataPolicy { + /// Everything we know: camera, capture settings, lens, location, title, caption, keywords, copyright. + #[default] + All, + /// Everything except camera/lens make, model and capture settings. + AllExceptCamera, + /// Copyright and creator only. + Copyright, + /// Nothing (the sRGB profile is still embedded). + None, +} + +impl MetadataPolicy { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "all" => Some(Self::All), + "allexceptcamera" => Some(Self::AllExceptCamera), + "copyright" => Some(Self::Copyright), + "none" => Some(Self::None), + _ => None, + } + } +} + +/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or +/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HdrCurve { + #[default] + Pq, + Hlg, +} + +impl HdrCurve { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "pq" => Some(Self::Pq), + "hlg" => Some(Self::Hlg), + _ => None, + } + } + pub(super) fn codec(self) -> lightcraft_codecs::HdrTransfer { + match self { + Self::Pq => lightcraft_codecs::HdrTransfer::Pq, + Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg, + } + } +} diff --git a/crates/engine/src/export/params.rs b/crates/engine/src/export/params.rs new file mode 100644 index 000000000..3ef9316da --- /dev/null +++ b/crates/engine/src/export/params.rs @@ -0,0 +1,615 @@ +use super::options::{Conflict, DestinationMode, ExportFormat, HdrCurve, MetadataPolicy, Resize, ResizeMode, SharpenAmount, SharpenFor}; +use super::prepare::DngCompression; +use super::watermark::{Anchor, Watermark}; +use super::*; + +impl Default for ExportOptions { + fn default() -> Self { + Self { + format: ExportFormat::Jpeg, + quality: 90, + resize: None, + ppi: 240, + limit_kb: None, + sharpen: SharpenFor::None, + sharpen_amount: SharpenAmount::Standard, + naming: "{name}".into(), + start_number: 1, + destination_mode: DestinationMode::SpecificFolder, + subfolder: String::new(), + conflict: Conflict::Unique, + tiff_compression: TiffCompression::Deflate, + dng_compression: DngCompression::Lossless, + metadata: MetadataPolicy::All, + remove_location: false, + watermark: None, + color_space: OutputSpace::Srgb, + bit_depth: None, + hdr: false, + jxl_lossless: false, + jxl_effort: JxlEffort::Normal, + jxl_progressive: false, + add_to_photos: false, + photos_album: String::new(), + hdr_transfer: HdrCurve::Pq, + } + } +} + +/// The `app.export` params that are not export options: which photos, where to, a preset to start +/// from, and the desktop app's background flag. +pub const TARGET_PARAMS: &[&str] = &["id", "ids", "path", "dir", "preset", "background"]; + +/// The export option params [`ExportOptions::from_json`] reads (and [`ExportOptions::to_json`] writes). +pub const OPTION_PARAMS: &[&str] = &[ + "format", + "quality", + "resize", + "longEdge", + "shortEdge", + "width", + "height", + "megapixels", + "percent", + "fit", + "dontEnlarge", + "ppi", + "limitKb", + "sharpen", + "sharpenAmount", + "naming", + "startNumber", + "destinationMode", + "subfolder", + "conflict", + "tiffCompression", + "dngCompression", + "metadata", + "removeLocation", + "watermark", + "colorSpace", + "bitDepth", + "hdr", + "jxlLossless", + "jxlEffort", + "jxlProgressive", + "addToPhotos", + "photosAlbum", + "hdrTransfer", +]; + +/// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the +/// session has the runner; see [`crate::Session::after_export`]). +pub fn apple_photos_unsupported() -> Option<&'static str> { + (!cfg!(target_os = "macos")).then_some("Apple Photos is only available on macOS") +} + +/// The keys of a `watermark` object ([`Watermark`], camelCase). `inset` sets `insetX` and `insetY` +/// together (what versions before the two margins wrote); either of those, when given, wins. +pub const WATERMARK_PARAMS: &[&str] = + &["text", "vertical", "size", "opacity", "anchor", "insetX", "insetY", "inset", "rotation", "color", "shadow", "image", "imageWidth"]; + +/// Text height as a fraction of the short edge: the range the renderer draws without clamping. +pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5); + +/// Margin as a fraction of the short edge. +pub const WATERMARK_INSET_RANGE: (f32, f32) = (0.0, 0.4); + +/// A graphic's width as a fraction of the photo's width. +pub const WATERMARK_IMAGE_WIDTH_RANGE: (f32, f32) = (0.01, 1.0); + +/// Levenshtein distance, for "did you mean" on a misspelled key (both inputs are short). +fn edit_distance(a: &str, b: &str) -> usize { + let (a, b): (Vec, Vec) = (a.chars().collect(), b.chars().collect()); + let mut prev: Vec = (0..=b.len()).collect(); + for (i, ca) in a.iter().enumerate() { + let mut cur = vec![i + 1; b.len() + 1]; + for (j, cb) in b.iter().enumerate() { + let sub = prev.get(j).copied().unwrap_or(0) + usize::from(ca != cb); + let del = prev.get(j + 1).copied().unwrap_or(0) + 1; + let ins = cur.get(j).copied().unwrap_or(0) + 1; + if let Some(c) = cur.get_mut(j + 1) { + *c = sub.min(del).min(ins); + } + } + prev = cur; + } + prev.last().copied().unwrap_or(0) +} + +/// `unknown parameter \`k\``, naming the closest known key when one is near. +fn unknown_key(what: &str, k: &str, known: &[&str]) -> String { + let near = known.iter().map(|n| (edit_distance(&k.to_ascii_lowercase(), &n.to_ascii_lowercase()), *n)).filter(|(d, _)| *d <= 2).min(); + match near { + Some((_, n)) => format!("unknown {what} `{k}` (did you mean `{n}`?)"), + None => format!("unknown {what} `{k}` (one of {})", known.join(", ")), + } +} + +/// A JSON integer (`92`; never `92.5` or `"92"`), as [`ExportOptions::from_json`] reads them. +fn whole(v: &serde_json::Value) -> Option { + v.as_i64() +} + +/// A `watermark` object as a [`Watermark`]; a legacy `inset` fills whichever of `insetX` and +/// `insetY` is missing. +fn watermark_from_json(v: &serde_json::Value) -> serde_json::Result { + let mut v = v.clone(); + if let Some(o) = v.as_object_mut() + && let Some(inset) = o.remove("inset") + { + o.entry("insetX").or_insert_with(|| inset.clone()); + o.entry("insetY").or_insert(inset); + } + serde_json::from_value(v) +} + +impl ExportOptions { + /// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or + /// newer version may have written and this one doesn't know dropped, also inside `watermark`. + /// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are + /// not the caller's typo, and refusing them would make a preset or Export with Previous fail + /// for good after an update. A `bitDepth` the stored format doesn't have (written at the + /// format's own depth before issue #1952) is dropped too. + pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value { + let mut p = Self::known_keys(p); + Self::drop_unfit_depth(&mut p, ExportFormat::default()); + p + } + + /// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be + /// known once the call's own params (`path`) are merged in. + pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str())); + if let Some(w) = o.get_mut("watermark").and_then(serde_json::Value::as_object_mut) { + w.retain(|k, _| WATERMARK_PARAMS.contains(&k.as_str())); + } + } + p + } + + /// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with + /// `fallback`) doesn't have: it was written at the format's own depth before issue #1952. + pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) { + let format = Self::export_format(p, fallback); + if let Some(o) = p.as_object_mut() + && let Some(depth) = o.get("bitDepth").and_then(whole) + && !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth) + { + o.remove("bitDepth"); + } + } + + /// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an + /// unknown parameter or a value that cannot be read as what it is for is an error, never a + /// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`) + /// are accepted and left to the caller. + pub fn from_params(p: &serde_json::Value) -> crate::Result { + Self::from_params_or(p, ExportFormat::default()) + } + + /// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no + /// format; the format returned is [`ExportOptions::export_format`], the one written. + pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result { + let format = Self::export_format(p, fallback); + Self::validate_as("app.export", p, Some(format))?; + Ok(Self { format, ..Self::from_json(p) }) + } + + /// The format `app.export` writes: `format` (`null`: the default), else the one the extension of + /// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there, + /// the headless backend PNG), else the default. + pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat { + use serde_json::Value; + match (p.get("format"), p.get("path").and_then(Value::as_str)) { + (Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(), + (None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback), + (None, None) => ExportFormat::default(), + } + } + + /// Check `app.export` params for `cmd` (the command named in the error): every key must be an + /// export option ([`OPTION_PARAMS`]) or a target ([`TARGET_PARAMS`]), every value must be the + /// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`] + /// with its sizes in range (issue #183). `null` means "not given", as a dropped key does. + pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> { + Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default()))) + } + + /// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported + /// (`None`: not known yet, as for a preset without `format`). + pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option) -> crate::Result<()> { + use serde_json::Value; + let bad = |msg: String| crate::cmd::bad(cmd, msg); + let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) }; + let known: Vec<&str> = OPTION_PARAMS.iter().chain(TARGET_PARAMS).copied().collect(); + let int = |k: &str, v: &Value, lo: i64, hi: i64| -> crate::Result { + whole(v).filter(|i| (lo..=hi).contains(i)).ok_or_else(|| bad(format!("`{k}` must be an integer {lo}..{hi}"))) + }; + let num = |k: &str, v: &Value, lo: f64, hi: f64| -> crate::Result { + v.as_f64().filter(|f| f.is_finite() && (lo..=hi).contains(f)).ok_or_else(|| bad(format!("`{k}` must be a number {lo}..{hi}"))) + }; + fn string<'a>(cmd: &str, k: &str, v: &'a Value) -> crate::Result<&'a str> { + v.as_str().ok_or_else(|| crate::cmd::bad(cmd, format!("`{k}` must be a string"))) + } + let boolean = |k: &str, v: &Value| -> crate::Result { v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))) }; + let one_of = |k: &str, v: &Value, options: &[&str]| -> crate::Result<()> { + let s = string(cmd, k, v)?; + if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } + }; + // a choice `from_json` reads with the same `parse`, so what passes here is what gets exported + let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> { + if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } + }; + for (k, v) in obj { + if v.is_null() { + continue; + } + match k.as_str() { + "format" => { + if ExportFormat::parse(string(cmd, k, v)?).is_none() { + return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|jxl|dng|original"))); + } + } + "quality" => { + int(k, v, 1, 100)?; + } + "ppi" => { + int(k, v, 1, u16::MAX as i64)?; + } + "limitKb" => { + int(k, v, 0, u32::MAX as i64)?; + } + "startNumber" => { + int(k, v, 0, 999_999)?; + } + "bitDepth" => { + if !matches!(whole(v), Some(8 | 10 | 16 | 32)) { + return Err(bad(format!("`{k}` must be 8, 10, 16 or 32"))); + } + } + "longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => { + num(k, v, 0.0, 1.0e9)?; + } + "fit" => one_of(k, v, &["dimensions", "widthHeight"])?, + "resize" => { + let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) }; + if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) { + return Err(bad(unknown_key("resize key", x, &["mode", "value", "height", "dontEnlarge"]))); + } + serde_json::from_value::(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?; + } + "dontEnlarge" | "removeLocation" | "background" | "hdr" | "jxlLossless" | "jxlProgressive" => { + boolean(k, v)?; + } + "addToPhotos" => { + if boolean(k, v)? + && let Some(why) = apple_photos_unsupported() + { + return Err(bad(format!("`{k}`: {why}"))); + } + } + "photosAlbum" => { + if string(cmd, k, v)?.contains('\0') { + return Err(bad(format!("`{k}` can't contain a NUL character"))); + } + } + "naming" | "subfolder" | "path" | "dir" | "preset" => { + string(cmd, k, v)?; + } + "sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?, + "sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?, + "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, + "conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?, + "destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?, + "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, + "jxlEffort" => one_of(k, v, &["fast", "normal"])?, + "dngCompression" => { + serde_json::from_value::(v.clone()) + .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?; + } + "metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?, + "colorSpace" => { + if OutputSpace::parse(string(cmd, k, v)?).is_none() { + return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020"))); + } + } + "watermark" => Self::validate_watermark(cmd, v)?, + "id" => { + int(k, v, 0, i64::MAX)?; + } + "ids" => { + let ok = v.as_array().is_some_and(|a| a.iter().all(|x| x.is_u64())); + if !ok { + return Err(bad(format!("`{k}` must be an array of photo ids"))); + } + } + _ => return Err(bad(unknown_key("parameter", k, &known))), + } + } + // issue #1952: a depth the format doesn't have would be written at the format's own depth + if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) { + let mut depths: Vec = Self::bit_depths(format).iter().map(|d| d.0).collect(); + if !depths.iter().any(|d| i64::from(*d) == depth) { + depths.sort_unstable(); + let mut list: Vec = depths.iter().map(u8::to_string).collect(); + let last = list.pop().unwrap_or_default(); + let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) }; + let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + return Err(bad(format!("`bitDepth` must be {list} for {name}"))); + } + } + Ok(()) + } + + /// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183). + pub(crate) fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { + use serde_json::Value; + let bad = |msg: String| crate::cmd::bad(cmd, msg); + let obj = match v { + Value::String(_) => return Ok(()), + Value::Object(o) => o, + _ => { + return Err(bad( + "`watermark` must be text or an object {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}".into(), + )); + } + }; + let frac = |k: &str, v: &Value, (lo, hi): (f32, f32), unit: &str| -> crate::Result<()> { + match v.as_f64() { + Some(f) if f.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&f) => Ok(()), + _ => Err(bad(format!("`watermark.{k}` must be a number {lo}..{hi} ({unit})"))), + } + }; + for (k, v) in obj { + if v.is_null() { + continue; + } + match k.as_str() { + "text" | "image" => { + v.as_str().ok_or_else(|| bad(format!("`watermark.{k}` must be a string")))?; + } + "vertical" | "shadow" => { + v.as_bool().ok_or_else(|| bad(format!("`watermark.{k}` must be true or false")))?; + } + "size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?, + "opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?, + "insetX" | "insetY" | "inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?, + "imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?, + "rotation" => { + if !matches!(whole(v), Some(0 | 90 | 180 | 270)) { + return Err(bad("`watermark.rotation` must be 0, 90, 180 or 270 (degrees clockwise)".into())); + } + } + "anchor" => { + serde_json::from_value::(v.clone()).map_err(|_| { + bad("`watermark.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight".into()) + })?; + } + "color" => { + let ok = v.as_array().is_some_and(|a| a.len() == 3 && a.iter().all(|c| matches!(whole(c), Some(0..=255)))); + if !ok { + return Err(bad("`watermark.color` must be [r, g, b] with 0..255 each (sRGB)".into())); + } + } + _ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))), + } + } + watermark_from_json(v).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}"))) + } + + /// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`, + /// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`, + /// `addToPhotos`, `photosAlbum`) and the + /// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H), + /// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus + /// `dontEnlarge` (default true). + /// + /// Lenient: anything unknown or unreadable falls back to the default, which suits stored + /// prefs and dialog prefill. A command that is about to export uses + /// [`ExportOptions::from_params`], which validates first. + pub fn from_json(p: &serde_json::Value) -> Self { + use serde_json::Value; + let d = Self::default(); + let s = |k: &str| p.get(k).and_then(Value::as_str); + let u = |k: &str| p.get(k).and_then(Value::as_u64); + fn enm(p: &Value, k: &str) -> Option { + p.get(k).cloned().and_then(|v| serde_json::from_value(v).ok()) + } + Self { + format: s("format").and_then(ExportFormat::parse).unwrap_or(d.format), + quality: u("quality").map_or(d.quality, |q| q.clamp(1, 100) as u8), + resize: Self::resize_from_json(p), + ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16), + limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), + sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen), + sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount), + naming: s("naming").map_or(d.naming, str::to_string), + start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), + destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode), + subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), + conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict), + tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { + Some("none") => TiffCompression::None, + Some("lzw") => TiffCompression::Lzw, + Some("zip" | "deflate") => TiffCompression::Deflate, + _ => d.tiff_compression, + }, + dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression), + metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata), + remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), + watermark: match p.get("watermark") { + Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), + Some(v @ Value::Object(_)) => watermark_from_json(v).ok(), + _ => None, + } + .filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()), + color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), + bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8), + hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr), + jxl_lossless: p.get("jxlLossless").and_then(Value::as_bool).unwrap_or(d.jxl_lossless), + jxl_effort: s("jxlEffort").and_then(JxlEffort::parse).unwrap_or(d.jxl_effort), + jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive), + add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos), + photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()), + hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer), + } + } + + fn resize_from_json(p: &serde_json::Value) -> Option { + use serde_json::Value; + let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| *v > 0.0); + let dont_enlarge = p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(true); + if let Some(r) = p.get("resize").filter(|v| v.is_object()) { + let r: Resize = serde_json::from_value(r.clone()).ok()?; + return (r.value > 0.0).then_some(Resize { dont_enlarge: p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(r.dont_enlarge), ..r }); + } + let r = |mode, value: f64| Resize { mode, value: value.min(65_535.0) as f32, height: 0, dont_enlarge }; + // `fit: widthHeight` binds each edge to its own number; the default fits either orientation + let bound = p.get("fit").and_then(Value::as_str).is_some_and(|fit| fit.eq_ignore_ascii_case("widthHeight")); + Some(match (f("width"), f("height")) { + (Some(w), Some(h)) => { + Resize { height: h.min(65_535.0) as u32, ..r(if bound { ResizeMode::WidthHeight } else { ResizeMode::Dimensions }, w) } + } + (Some(w), None) => r(ResizeMode::Width, w), + (None, Some(h)) => r(ResizeMode::Height, h), + (None, None) => { + if let Some(v) = f("longEdge") { + r(ResizeMode::LongEdge, v) + } else if let Some(v) = f("shortEdge") { + r(ResizeMode::ShortEdge, v) + } else if let Some(v) = f("megapixels") { + r(ResizeMode::Megapixels, v) + } else { + let v = f("percent")?; + r(ResizeMode::Percent, v) + } + } + }) + } + + /// These options as `app.export` params ([`ExportOptions::from_json`] reads them back + /// unchanged; full size is written as `longEdge: 0`). + pub fn to_json(&self) -> serde_json::Value { + let mut v = serde_json::to_value(self).unwrap_or_default(); + if let Some(o) = v.as_object_mut() { + o.remove("resize"); + if let Some(sz) = Self::resize_json(self.resize.as_ref()).as_object() { + o.extend(sz.clone()); + } + if self.resize.is_none() { + o.insert("longEdge".into(), 0.into()); + } + o.retain(|_, v| !v.is_null()); + } + v + } + + /// Whether `p` names an output size (any of the size params, even 0 = full size). + pub fn has_size_param(p: &serde_json::Value) -> bool { + ["resize", "longEdge", "shortEdge", "width", "height", "megapixels", "percent"].iter().any(|k| p.get(k).is_some()) + } + + /// The size params of [`ExportOptions::from_json`] for `resize` (`{}` = full size). + pub fn resize_json(resize: Option<&Resize>) -> serde_json::Value { + match resize { + None => serde_json::json!({}), + Some(r) => serde_json::json!({"resize": r, "dontEnlarge": r.dont_enlarge}), + } + } + + /// The sample format the file is written with (unsupported requests fall back to the closest). + pub fn effective_depth(&self) -> OutputDepth { + match (self.format, self.bit_depth) { + (ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng, _) => OutputDepth::U8, + (ExportFormat::Png, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, + (ExportFormat::Png, _) => OutputDepth::U8, + (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, + (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, + (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::RenderedDng, _) => OutputDepth::F32Linear, + (ExportFormat::Psd, Some(8)) => OutputDepth::U8, + (ExportFormat::Psd, _) => OutputDepth::U16, + (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, + (ExportFormat::Avif, _) => OutputDepth::U8, + (ExportFormat::Jxl, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::Jxl, Some(10 | 16 | 32)) => OutputDepth::U16, + (ExportFormat::Jxl, _) => OutputDepth::U8, + } + } + + /// The bit depths `format` offers (value, label); the first is its default. + pub fn bit_depths(format: ExportFormat) -> &'static [(u8, &'static str)] { + match format { + ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng => &[(8, "8-bit")], + ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], + ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], + ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")], + ExportFormat::RenderedDng => &[(16, "16-bit float")], + ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], + ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")], + } + } + + /// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF, + /// PNG or JPEG XL (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`]. + pub fn hdr_output(&self) -> bool { + self.hdr + && matches!( + (self.format, self.bit_depth), + (ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl, _) | (ExportFormat::Tiff, Some(32)) + ) + } + + /// The colour space the file is actually written in (AVIF: sRGB; AVIF, PNG and JPEG XL HDR: + /// Rec. 2020; lossy JPEG XL: Adobe RGB and ProPhoto as Rec. 2020, the widest space its encoder + /// can declare without a profile). + pub fn effective_space(&self) -> OutputSpace { + match self.format { + ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl if self.hdr => OutputSpace::Rec2020, + ExportFormat::Avif => OutputSpace::Srgb, + ExportFormat::Jxl if !self.jxl_lossless && matches!(self.color_space, OutputSpace::AdobeRgb | OutputSpace::ProPhoto) => { + OutputSpace::Rec2020 + } + _ => self.color_space, + } + } + + /// The JPEG XL encoder settings for these options, writing samples in `space`. + pub(super) fn jxl(&self, space: OutputSpace, hdr: bool) -> JxlOptions { + let colour = match space { + _ if hdr => JxlColour::Rec2020Pq, + OutputSpace::DisplayP3 => JxlColour::DisplayP3, + OutputSpace::Rec2020 => JxlColour::Rec2020, + // lossless files carry the ICC profile; lossy ones never get here (`effective_space`) + OutputSpace::Srgb | OutputSpace::AdobeRgb | OutputSpace::ProPhoto => JxlColour::Srgb, + }; + JxlOptions { quality: self.quality, lossless: self.jxl_lossless, effort: self.jxl_effort, progressive: self.jxl_progressive, colour } + } + + /// Output file name for photo `p` at 1-based position `seq` in a batch (the original's + /// extension is kept for [`ExportFormat::Original`]). + pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String { + let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() }; + let n = seq + self.start_number.max(1) as usize - 1; + let name = crate::rename::expand_tokens(base, p, n, 3); + let mut name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect(); + let (stem, ext) = p.file_name.rsplit_once('.').unwrap_or((&p.file_name, "")); + if name.trim().is_empty() { + // every token came out empty (no title, no capture time…): keep the original name + name = stem.to_string(); + } + if self.format == ExportFormat::Original { + let base = name.trim_end_matches('.'); + if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") } + } else { + format!("{name}.{}", self.format.extension()) + } + } +} diff --git a/crates/engine/src/export/place.rs b/crates/engine/src/export/place.rs new file mode 100644 index 000000000..f0b1ace61 --- /dev/null +++ b/crates/engine/src/export/place.rs @@ -0,0 +1,174 @@ +use super::options::{Conflict, DestinationMode}; +use super::prepare::{Destination, Exported}; +use super::*; + +/// Write an exported or rendered file on disk: its folder is created if needed, and the file is +/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic_nosync`]: a temp file +/// renamed into place), so a failure part-way leaves any previous file intact and no truncated +/// one. Not synced to disk (issue #134): an export can always be made again from the original. +/// The native writer behind exports, renders and screenshots (app, CLI, MCP). +pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { + write_with(path, bytes, false) +} + +/// [`write_file`], synced to disk: for a written file that becomes a library photo (Edit a Copy) +/// and so can't simply be exported again once the catalog points at it. +pub fn write_file_durable(path: &str, bytes: &[u8]) -> Result<(), String> { + write_with(path, bytes, true) +} + +/// Write `path` only when nothing exists there: the destination is prepared as a complete temp +/// file and published with create-new semantics, so a file that appears after a caller's check is +/// never replaced. Screenshots of an initially absent path use this instead of the overwriting +/// writer. +pub fn write_file_new(path: &str, bytes: &[u8]) -> Result<(), String> { + let p = std::path::Path::new(path); + if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + lightcraft_catalog::safe_file::write_new(p, bytes).map_err(|e| format!("{path}: {e}")) +} + +fn write_with(path: &str, bytes: &[u8], durable: bool) -> Result<(), String> { + use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync}; + let p = std::path::Path::new(path); + if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + if durable { write_atomic(p, bytes) } else { write_atomic_nosync(p, bytes) }.map_err(|e| format!("{path}: {e}")) +} + +/// The path of the sidecar with extension `ext` of the exported file `main`. +fn sidecar_path(main: &str, ext: &str) -> String { + std::path::Path::new(main).with_extension(ext).to_string_lossy().to_string() +} + +/// Where a batch's files go: each photo's path (destination folder, subfolder, conflict policy, +/// sidecars, never an original) and its write, in batch order. +pub(super) struct Placer<'a> { + o: &'a ExportOptions, + to: &'a Destination, + pub(super) single: bool, + taken: std::collections::HashSet, + pub(super) out: Vec, + write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &'a dyn Fn(&str) -> bool, + stop_on_error: bool, +} + +fn join_path(a: &str, b: &str) -> String { + if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) } +} + +impl<'a> Placer<'a> { + pub(super) fn new( + o: &'a ExportOptions, + to: &'a Destination, + write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &'a dyn Fn(&str) -> bool, + stop_on_error: bool, + ) -> Placer<'a> { + Placer { o, to, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } + } + + /// The folder photo `name` (its original in `source_dir`) is written to: the batch's folder or + /// the photo's own ([`DestinationMode`]), plus the subfolder. An error for a photo with no + /// folder of its own, or whose folder is gone: never a file written somewhere else instead. + fn folder_for(&self, name: &str, source_dir: Option<&str>) -> Result { + let base = match self.o.destination_mode { + DestinationMode::SpecificFolder => self.to.dir.clone(), + DestinationMode::SameAsOriginal => { + let Some(d) = source_dir else { + return Err(format!( + "{name}: the photo has no folder on disk (a demo photo or one in the browser's storage), so it can't be exported to the same folder as its original; choose a specific folder" + )); + }; + if !(self.exists)(d) { + return Err(format!("{name}: the original's folder is missing ({d}); reconnect it or choose a specific folder")); + } + d.to_string() + } + }; + Ok(if self.o.subfolder.is_empty() { base } else { join_path(&base, &self.o.subfolder) }) + } + + /// Place photo `photo`'s export result (file `name`, its original in `source_dir`): write it + /// and record the outcome. `Err` only when it failed and `stop_on_error` is set. + pub(super) fn place( + &mut self, + photo: lightcraft_catalog::PhotoId, + name: String, + source_dir: Option<&str>, + guard: &crate::originals::OriginalGuard, + e: Result, + ) -> Result<(), String> { + use serde_json::json; + // an exact file path needs no folder + let dir = if self.to.exact.is_some() && self.single { Ok(String::new()) } else { self.folder_for(&name, source_dir) }; + let (o, exists) = (self.o, self.exists); + let (dir, e) = match dir.and_then(|dir| e.map(|e| (dir, e))) { + Ok(v) => v, + Err(err) if self.stop_on_error => return Err(err), + Err(err) => { + self.out.push(json!({"photo": photo.0, "file": name, "error": err})); + return Ok(()); + } + }; + let dir = dir.as_str(); + // the exported file and its sidecars + let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::>(); + let path = match self.to.exact.as_deref().filter(|_| self.single) { + Some(p) => Ok(p.to_string()), + None => { + let path = join_path(dir, &e.file_name); + let taken = &self.taken; + let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p)); + if !busy(&path) { + Ok(path) + } else { + match o.conflict { + Conflict::Overwrite => Ok(path), + Conflict::Skip => { + self.out.push(json!({"skipped": path})); + return Ok(()); + } + Conflict::Unique => { + let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b))); + let name = |n: usize| join_path(dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}"))); + (2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name")) + } + } + } + } + }; + let file = path.clone().unwrap_or_else(|_| name.clone()); + let write = &mut *self.write; + let written = path.and_then(|path| { + let files = group(&path); + // never over an original, whatever the conflict policy or the exact path said + for f in &files { + guard.check(std::path::Path::new(f))?; + } + write(&path, &e.bytes)?; + let mut sidecars = Vec::new(); + for ((_, bytes), sc) in e.sidecars.iter().zip(files.iter().skip(1)) { + write(sc, bytes)?; + sidecars.push(sc.clone()); + } + Ok((path, files, sidecars)) + }); + match written { + Ok((path, files, sidecars)) => { + self.taken.extend(files); + let mut entry = json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}); + if let Some(why) = &e.gpu_fallback { + entry["gpuFallback"] = json!(why); + } + self.out.push(entry); + } + Err(err) if self.stop_on_error => return Err(err), + Err(err) => self.out.push(json!({"photo": photo.0, "file": file, "error": err})), + } + Ok(()) + } +} diff --git a/crates/engine/src/export/prepare.rs b/crates/engine/src/export/prepare.rs new file mode 100644 index 000000000..1fe988832 --- /dev/null +++ b/crates/engine/src/export/prepare.rs @@ -0,0 +1,402 @@ +use super::batch::run_batch; +use super::encoding::{encode_gain_map_jpeg, encode_rendered, export_metadata, removes_location}; +use super::options::ExportFormat; +use super::params::apple_photos_unsupported; +use super::*; + +/// One exported file. +pub struct Exported { + pub file_name: String, + pub bytes: Vec, + pub width: usize, + pub height: usize, + /// Files written next to it, named like it with another extension: `(extension, bytes)` (the + /// XMP sidecar of an `Original` export). + pub sidecars: Vec<(&'static str, Vec)>, + /// Set when the render was tried on the GPU and fell back to the CPU: why (see + /// `docs/gpu-pipeline.md`). The file holds the CPU render either way. + pub gpu_fallback: Option, +} + +/// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`). +pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) { + let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); + // whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie + let (w, h) = (w.round().max(1.0), h.round().max(1.0)); + match &o.resize { + Some(r) => r.apply(w, h), + None => (w as usize, h as usize), + } +} + +/// Render photo `id` at the requested size and encode it (or copy / convert its original for +/// [`ExportFormat::Original`] / [`ExportFormat::Dng`]). +pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result { + prepare_export(session, id, o, seq)?.run() +} + +/// One photo's export, set up from the session ([`prepare_export`]). [`PreparedExport::run`] does +/// the heavy part (read, decode, render, encode) without the session, so it can run on another +/// thread. +pub struct PreparedExport { + pub photo: lightcraft_catalog::PhotoId, + pub file_name: String, + /// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None` + /// for a demo photo or one in the browser's storage. + pub source_dir: Option, + work: Work, + /// The library's originals, which [`run_batch`] never writes over (shared by a batch). + pub(super) guard: std::sync::Arc, + /// Estimated working memory of [`Self::run`] (bytes), held from [`crate::memory::export_gate`] + /// while it runs beside other photos of a batch. + pub(super) weight: usize, +} + +struct RenderWork { + job: crate::media::RenderJob, + /// The HDR rendition of a gain map JPEG (`job` renders its SDR base). + hdr_job: Option, + meta: Option, + opts: ExportOptions, +} + +enum Work { + Render(Box), + /// [`ExportFormat::Original`] (the file's bytes + an XMP sidecar) or [`ExportFormat::Dng`] (the + /// raw data re-encoded as a lossless DNG with the edits in its XMP). + File { + path: String, + read: Option, + packet: String, + dng: Option, + /// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size). + quality: u8, + lossy_edge: Option, + /// The original must not carry a location ([`removes_location`]). + remove_location: bool, + label: String, + size: (usize, usize), + }, +} + +/// Set up the export of photo `id` at 1-based position `seq` of a batch. (For a whole batch use +/// [`prepare_batch`]: it looks at the library's originals once.) +pub fn prepare_export( + session: &mut crate::Session, + id: lightcraft_catalog::PhotoId, + o: &ExportOptions, + seq: usize, +) -> Result { + let guard = std::sync::Arc::new(session.original_guard()); + prepare_guarded(session, id, o, seq, guard) +} + +/// [`prepare_export`] for each of `ids` in order (`{seq}` = position, from 1). +pub fn prepare_batch(session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], o: &ExportOptions) -> Result, String> { + let guard = std::sync::Arc::new(session.original_guard()); + ids.iter().enumerate().map(|(i, id)| prepare_guarded(session, *id, o, i + 1, guard.clone())).collect() +} + +fn prepare_guarded( + session: &mut crate::Session, + id: lightcraft_catalog::PhotoId, + o: &ExportOptions, + seq: usize, + guard: std::sync::Arc, +) -> Result { + let p = session.catalog.photo(id).ok_or("no such photo")?; + let file_name = o.file_name_for(p, seq); + let source_dir = original_folder(&p.source); + // HDR output only for photos edited in HDR; the rest of the batch exports as usual + let sdr_opts; + let o = if o.hdr && !p.develop.hdr.enabled { + sdr_opts = ExportOptions { hdr: false, ..o.clone() }; + &sdr_opts + } else { + o + }; + let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize); + let (work, weight) = if o.format.is_rendered() { + let (w, h) = output_size(p, o); + let (nw, nh) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); + let native_px = (nw.round().max(1.0) * nh.round().max(1.0)) as usize; + let label = p.file_name.clone(); + let meta = export_metadata(p, &session.catalog, o); + let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg; + let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?; + let weight = render_weight(full_px, long, job.level, w.saturating_mul(h)); + if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) { + return Err(format!("{label}: {why}")); + } + let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None }; + (Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight) + } else { + let lightcraft_catalog::Source::File { path } = &p.source else { + return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name)); + }; + let dng = (o.format == ExportFormat::Dng).then_some(o.dng_compression); + // the file's bytes; a DNG also decodes the raw data (16-bit samples) and writes it again + let weight = (p.file_size as usize).saturating_add(if dng.is_some() { full_px.saturating_mul(6) } else { 0 }); + let work = Work::File { + path: path.clone(), + read: session.media.file_bytes.clone(), + packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)), + dng, + quality: o.quality, + lossy_edge: o.resize.is_some().then(|| { + let (w, h) = output_size(p, o); + u32::try_from(w.max(h)).unwrap_or(u32::MAX) + }), + remove_location: removes_location(p, &session.catalog, o), + label: p.file_name.clone(), + size: (p.width as usize, p.height as usize), + }; + (work, weight) + }; + Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight }) +} + +/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo +/// photo, and on the web, where an original lives in the browser's storage, not in a folder. +fn original_folder(source: &lightcraft_catalog::Source) -> Option { + let lightcraft_catalog::Source::File { path } = source else { return None }; + if cfg!(target_arch = "wasm32") { + return None; + } + let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string(); + (!dir.is_empty()).then_some(dir) +} + +/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size, +/// can't be made on a computer with `ram` bytes, given its working memory `weight`: an export +/// enlarged past the photo's size that needs more memory than the computer has would fail to +/// allocate, which ends the process rather than the export. Full-size and smaller exports, and +/// computers whose memory isn't known, are never refused. +pub(super) fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: usize, ram: Option) -> Option { + let ram = ram?; + if out.0.saturating_mul(out.1) <= native_px || weight <= ram { + return None; + } + let gb = |b: usize| b as f64 / (1u64 << 30) as f64; + Some(format!( + "exporting at {} × {} needs about {:.0} GB of memory, more than this computer has ({:.0} GB); choose a smaller size", + out.0, + out.1, + gb(weight).ceil(), + gb(ram) + )) +} + +/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level +/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the +/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export +/// peaks at ~1.2 GB, one at 2048 px from the 2560 px preview at ~150 MB. +pub(super) fn render_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize { + let edge = level.max_edge().min(long.max(1)); + let scale = edge as f64 / long.max(1) as f64; + let src_px = (full_px as f64 * scale * scale).min(usize::MAX as f64 / 64.0) as usize; + src_px.saturating_mul(12).saturating_add(out_px.saturating_mul(36)) +} + +impl PreparedExport { + pub fn run(self) -> Result { + let file_name = self.file_name; + match self.work { + Work::Render(w) => { + let RenderWork { job, hdr_job, meta, opts } = *w; + let result = job.run(); + let r = result.rendered?; + let gpu_fallback = result.gpu_fallback; + let bytes = match hdr_job { + Some(hj) => { + let hr = hj.run().rendered?; + let hdr = hr.deep.ok_or("the HDR render has no float samples")?; + encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())? + } + None => encode_rendered(&r, &opts, meta.as_ref())?, + }; + Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback }) + } + Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => { + let bytes = match &read { + Some(r) => r(&path)?, + None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, + }; + let Some(dng) = dng else { + // the file is copied byte for byte: refuse rather than leak a location it embeds + if remove_location { + let metadata = lightcraft_meta::try_extract(&bytes) + .map_err(|e| format!("{label}: can't verify location metadata in this original file: {e}"))?; + if metadata.gps.is_some() { + return Err(format!( + "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" + )); + } + } + return Ok(Exported { + file_name, + bytes, + width: size.0, + height: size.1, + sidecars: vec![("xmp", packet.into_bytes())], + gpu_fallback: None, + }); + }; + if lightcraft_raw::probe(&bytes).is_none() { + return Err(format!("{label}: DNG export needs a raw photo")); + } + let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; + crate::camera_preview::with_starting_colour(&mut raw, &bytes); + drop(bytes); + let compression = match dng { + DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, + DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false }, + DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed, + DngCompression::Lossy => { + lightcraft_raw::DngCompression::LossyJpeg { tile: 256, quality: quality.clamp(1, 100), long_edge: lossy_edge } + } + }; + let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) + .map_err(|e| e.to_string())?; + Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new(), gpu_fallback: None }) + } + } + } +} + +/// How an exported DNG stores its raw data. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DngCompression { + /// Lossless JPEG (the usual DNG compression). + #[default] + Lossless, + /// Deflate (zip) tiles with prediction. + Deflate, + Uncompressed, + /// Lossy DNG: demosaiced linear data as JPEG tiles at the export quality, scaled to the resize + /// setting when there is one (Lightroom's "Use Lossy Compression"). + Lossy, +} + +/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy +/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each +/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file +/// already there is replaced). Either way a catalogued original (or its sidecar) +/// is never written over: see [`crate::originals`]. +#[derive(Clone, Debug, Default)] +pub struct Destination { + pub dir: String, + pub exact: Option, +} + +/// What happens after an export has written its files (Lightroom's Post-Processing): nothing, or +/// adding them to Apple Photos ([`ExportOptions::add_to_photos`]). Made by +/// [`crate::Session::after_export`] before the export starts, so an export asking for something +/// this session can't do fails before writing anything; for Photos it holds the import slot +/// (a [`crate::apple_photos::Reservation`]) until the files are handed over, and frees it when +/// dropped unused (the export failed or never ran). `Send`: a background export takes it to its +/// worker. +#[derive(Default)] +pub struct AfterExport { + #[cfg(not(target_arch = "wasm32"))] + apple_photos: Option, +} + +impl AfterExport { + /// Add the files to Apple Photos with `runner`, into `album` when it isn't blank, reserving + /// the import slot of `imports` now. (Front ends get this from + /// [`crate::Session::after_export`], which checks the platform first.) + #[cfg(not(target_arch = "wasm32"))] + pub fn apple_photos(runner: crate::apple_photos::Runner, album: &str, imports: &crate::apple_photos::Imports) -> Result { + Ok(Self { apple_photos: Some(imports.reserve(runner, album)?) }) + } + + /// Nothing to do. + #[cfg(not(target_arch = "wasm32"))] + pub fn is_none(&self) -> bool { + self.apple_photos.is_none() + } + + /// Nothing to do. + #[cfg(target_arch = "wasm32")] + pub fn is_none(&self) -> bool { + true + } + + /// Do it now for an export's per-photo results ([`run_batch`]'s: only the files written + /// count), waiting for Photos: for a worker thread (the app's background export) or a caller + /// without a window (MCP, lightcraft-cli). `None` when there was nothing to do, else the + /// outcome to report as the export's `applePhotos`: the finished job (`{job, running: false, + /// requested, album}` with `{imported, ids, warning?}`, `{error}`, or `{skipped}` when the + /// export was cancelled or wrote nothing). Never fails the export: its files are written + /// either way. + pub fn run(self, files: &[serde_json::Value], cancelled: bool) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = self.apple_photos { + return Some(r.run(files, cancelled)); + } + let _ = (files, cancelled); + None + } + + /// Like [`AfterExport::run`], without waiting: Photos works on a worker thread and the + /// outcome is the running job (`{job, running: true, requested, album}`; its result comes + /// from `export.photosImports`, and the desktop app announces it). For a caller on the UI + /// thread, which must not wait minutes for Photos. + pub fn start(self, files: &[serde_json::Value]) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = self.apple_photos { + return Some(r.start(files)); + } + let _ = files; + None + } + + /// [`AfterExport::start`] where the session answers on a UI thread + /// ([`crate::Session::apple_photos_background`]), else [`AfterExport::run`]. + pub fn finish(self, s: &crate::Session, files: &[serde_json::Value]) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if s.apple_photos_background { + return self.start(files); + } + let _ = s; + self.run(files, false) + } +} + +impl crate::Session { + /// What an export with `o` does after writing its files; an error, before anything is written, + /// when it asks for Apple Photos and this platform or session has no way to reach it, or + /// another import is reserved or running. For Photos the import slot is reserved from now on. + pub fn after_export(&self, o: &ExportOptions) -> Result { + if !o.add_to_photos { + return Ok(AfterExport::default()); + } + if let Some(why) = apple_photos_unsupported() { + return Err(why.into()); + } + #[cfg(not(target_arch = "wasm32"))] + { + let runner = self.apple_photos.clone().ok_or_else(|| crate::apple_photos::unavailable().to_string())?; + let mut after = AfterExport::apple_photos(runner, &o.photos_album, &self.apple_photos_imports)?; + after.apple_photos = after.apple_photos.map(|r| r.shown_in(self.activity.clone())); + Ok(after) + } + #[cfg(target_arch = "wasm32")] + Err("Apple Photos is only available on macOS".into()) + } +} + +/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error. +pub fn export_batch( + session: &mut crate::Session, + ids: &[lightcraft_catalog::PhotoId], + o: &ExportOptions, + to: &Destination, + write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &dyn Fn(&str) -> bool, +) -> Result, String> { + let items = prepare_batch(session, ids, o)?; + run_batch(items, o, to, write, exists, true, &mut |_, _| true) +} diff --git a/crates/engine/src/export/watermark.rs b/crates/engine/src/export/watermark.rs new file mode 100644 index 000000000..68633794b --- /dev/null +++ b/crates/engine/src/export/watermark.rs @@ -0,0 +1,306 @@ +use super::encoding::srgb8_in; +use super::*; + +/// Where a watermark sits. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Anchor { + TopLeft, + Top, + TopRight, + Left, + Center, + Right, + BottomLeft, + Bottom, + #[default] + BottomRight, +} + +/// A text watermark, sized relative to the image so every export size looks the same. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Watermark { + pub text: String, + /// Basic Japanese vertical lettering: upright glyphs in columns from right to left. + pub vertical: bool, + /// Text height as a fraction of the image's short edge. + pub size: f32, + /// 0..1. + pub opacity: f32, + pub anchor: Anchor, + /// Margin from the left or right edge, as a fraction of the short edge (unused when the anchor + /// is centred across: top, center, bottom). + pub inset_x: f32, + /// Margin from the top or bottom edge, as a fraction of the short edge (unused when the anchor + /// is centred down: left, center, right). + pub inset_y: f32, + /// Degrees clockwise: 0, 90, 180 or 270 (anything else draws unrotated). The watermark is + /// sized before it is turned, and the insets are measured to the turned watermark. + pub rotation: u16, + /// sRGB colour. + pub color: [u8; 3], + /// Soft dark drop shadow for legibility on bright areas. + pub shadow: bool, + /// A graphic (PNG with transparency, JPEG, …) drawn instead of the text (empty = text). + pub image: String, + /// The graphic's width as a fraction of the photo's width. + pub image_width: f32, + /// The colour space the photo is in (set by the encoder; the graphic is converted into it). + #[serde(skip)] + pub target: Option, +} + +impl Default for Watermark { + fn default() -> Self { + Self { + text: String::new(), + vertical: false, + size: 0.035, + opacity: 0.7, + anchor: Anchor::BottomRight, + inset_x: 0.025, + inset_y: 0.025, + rotation: 0, + color: [255; 3], + shadow: true, + image: String::new(), + image_width: 0.2, + target: None, + } + } +} + +impl Watermark { + /// `rotation` in clockwise quarter turns. + fn quarter_turns(&self) -> u8 { + match self.rotation { + 90 => 1, + 180 => 2, + 270 => 3, + _ => 0, + } + } +} + +/// Where the point `(x, y)` of a `w × h` box lands when the box is turned `turns` quarter turns +/// clockwise (for pixel coordinates: the turned box is `h × w` for odd turns). +fn turn(turns: u8, x: f32, y: f32, w: f32, h: f32) -> (f32, f32) { + match turns { + 1 => (h - 1.0 - y, x), + 2 => (w - 1.0 - x, h - 1.0 - y), + 3 => (y, w - 1.0 - x), + _ => (x, y), + } +} + +/// Draw `wm` onto `img` (straight alpha blending of the encoded values). +pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { + blend_watermark(img, wm, 1.0); +} + +/// Draw `wm` onto `img`, a preview of an export whose short edge is `export_short_edge` pixels. +/// The smallest text size and shadow offset scale with the preview, so it looks like the file. +pub fn draw_watermark_preview(img: &mut Rgba8, wm: &Watermark, export_short_edge: usize) { + let short = img.width.min(img.height); + let scale = if export_short_edge == 0 { 1.0 } else { short as f32 / export_short_edge as f32 }; + blend_watermark(img, wm, scale); +} + +fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) { + let width = img.width; + watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, floor_scale, |x, y, k, col| { + let p = &mut img.data[y * width + x]; + for c in 0..3 { + p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; + } + }); +} + +/// Draw `wm` into the `w × h` area of `img` whose top-left corner is (`x0`, `y0`), sized and +/// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`. +pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) { + let (width, height) = (img.width, img.height); + watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { + let (px, py) = (x0.saturating_add(x), y0.saturating_add(y)); + if px >= width || py >= height { + return; + } + if let Some(p) = img.data.get_mut(py * width + px) { + for c in 0..3 { + p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; + } + } + }); +} + +/// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float +/// images are linear and blend in linear light). +pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { + let (width, trc) = (img.width, img.space.trc()); + match &mut img.samples { + DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { + let i = (y * width + x) * 3; + for c in 0..3 { + let p = v[i + c] as f32; + v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16; + } + }), + DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { + let i = (y * width + x) * 3; + for c in 0..3 { + let target = trc.decode(col[c] as f32 / 255.0); + v[i + c] += (target - v[i + c]) * k; + } + }), + } +} + +/// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for +/// every covered pixel (shadow pass first). `craft` is [`crate::fonts::CRAFT_FONTS`] (a parameter +/// so tests can render without it). `floor_scale` is this image's size over the exported file's +/// (1 when drawing the file itself): the 6 px minimum text size is a minimum in the file. +pub(super) fn watermark_coverage( + width: usize, + height: usize, + wm: &Watermark, + craft: &'static [crate::fonts::CraftFont], + floor_scale: f32, + mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), +) { + if !wm.image.trim().is_empty() { + logo_coverage(width, height, wm, blend_px); + return; + } + let text = wm.text.trim(); + if text.is_empty() || width == 0 || height == 0 { + return; + } + let short = width.min(height) as f32; + let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0 * floor_scale); + let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return }; + let (tw, th) = (layout.width, layout.height); + // laid out unturned in a tw × th box, placed by the box it becomes + let turns = wm.quarter_turns(); + let (bw, bh) = if turns % 2 == 1 { (th, tw) } else { (tw, th) }; + let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); + let (w, h) = (width as f32, height as f32); + use Anchor::*; + let ox = match wm.anchor { + TopLeft | Left | BottomLeft => ix, + Top | Center | Bottom => (w - bw) / 2.0, + TopRight | Right | BottomRight => w - ix - bw, + }; + let oy = match wm.anchor { + TopLeft | Top | TopRight => iy, + Left | Center | Right => (h - bh) / 2.0, + BottomLeft | Bottom | BottomRight => h - iy - bh, + }; + let alpha = wm.opacity.clamp(0.0, 1.0); + let passes: &[(f32, [u8; 3], f32)] = + if wm.shadow { &[((px * 0.05).max(floor_scale), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; + for &(off, col, a) in passes { + let mut put = |gx: i32, gy: i32, cov: f32| { + if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { + return; + } + blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col); + }; + if turns == 0 { + layout.draw_in(ox + off, oy + off, w, h, &mut put); + } else { + // turned with the box; the shadow still falls down and to the right + layout.draw_points(|x, y, cov| { + let (x, y) = turn(turns, x, y, tw, th); + put((ox + off + x) as i32, (oy + off + y) as i32, cov); + }); + } + } +} + +/// A watermark graphic, decoded once per file (and again when the file changes). +pub(crate) fn watermark_logo(path: &str) -> Option> { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + static CACHE: Mutex)>>> = Mutex::new(None); + let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?; + let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let map = c.get_or_insert_with(HashMap::new); + if let Some((m, img)) = map.get(path) + && *m == modified + { + return Some(img.clone()); + } + let img = Arc::new(read_graphic(path).ok()?); + map.insert(path.to_string(), (modified, img.clone())); + Some(img) +} + +/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the +/// watermark logo, the loupe's layout image. Applies EXIF orientation before either consumer sizes it. +/// The error says why it couldn't be read or decoded. +pub fn read_graphic(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?; + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?; + Ok(decoded.to_srgb8().into_oriented(lightcraft_geom::Orientation::from_exif(decoded.orientation))) +} + +/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to +/// `image_width` of the photo, placed like the text, blended with its alpha × opacity. +fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { + let Some(logo) = watermark_logo(wm.image.trim()) else { + log::warn!("watermark: can't read {}", wm.image); + return; + }; + if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 { + return; + } + let turns = wm.quarter_turns(); + // Reserve the inset on anchored axes before fitting the turned box. Centered axes + // ignore insets, just as placement below does. One scale preserves the graphic's shape. + let short = width.min(height) as f32; + let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); + use Anchor::*; + let available_w = width as f64 - if matches!(wm.anchor, Top | Center | Bottom) { 0.0 } else { ix as f64 }; + let available_h = height as f64 - if matches!(wm.anchor, Left | Center | Right) { 0.0 } else { iy as f64 }; + let (max_w, max_h) = if turns % 2 == 1 { (available_h, available_w) } else { (available_w, available_h) }; + let scale = + (wm.image_width.clamp(0.01, 1.0) as f64 * width as f64 / logo.width as f64).min(max_w / logo.width as f64).min(max_h / logo.height as f64); + let lw = (logo.width as f64 * scale).round().max(1.0) as usize; + let lh = (logo.height as f64 * scale).round().max(1.0) as usize; + let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| { + let p = logo.get(x, y); + let a = p[3] as f32 / 255.0; + [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] + }); + let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell); + let (bw, bh) = if turns % 2 == 1 { (lh, lw) } else { (lw, lh) }; + let (w, h, lwf, lhf) = (width as f32, height as f32, bw as f32, bh as f32); + let ox = match wm.anchor { + TopLeft | Left | BottomLeft => ix, + Top | Center | Bottom => (w - lwf) / 2.0, + TopRight | Right | BottomRight => w - ix - lwf, + } + .max(0.0) as usize; + let oy = match wm.anchor { + TopLeft | Top | TopRight => iy, + Left | Center | Right => (h - lhf) / 2.0, + BottomLeft | Bottom | BottomRight => h - iy - lhf, + } + .max(0.0) as usize; + let space = wm.target.unwrap_or(OutputSpace::Srgb); + let op = wm.opacity.clamp(0.0, 1.0); + for y in 0..lh { + for x in 0..lw { + let [r, g, b, a] = scaled.get(x, y); + let a = a.clamp(0.0, 1.0); + let (tx, ty) = turn(turns, x as f32, y as f32, lw as f32, lh as f32); + let (px, py) = (ox + tx as usize, oy + ty as usize); + if a <= 1e-4 || px >= width || py >= height { + continue; + } + let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8); + blend_px(px, py, a * op, srgb8_in(space, c)); + } + } +} From 50509093b64cbd060d6958bd15f6cb2bdee679bf Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:08:55 +0000 Subject: [PATCH 110/191] refactor(lightcraft-engine): split camera_preview.rs into camera_preview/ --- crates/engine/src/camera_preview.rs | 2090 +----------------- crates/engine/src/camera_preview/colour.rs | 232 ++ crates/engine/src/camera_preview/hue_sat.rs | 271 +++ crates/engine/src/camera_preview/look_fit.rs | 273 +++ crates/engine/src/camera_preview/mono.rs | 212 ++ crates/engine/src/camera_preview/pairs.rs | 499 +++++ crates/engine/src/camera_preview/proxy.rs | 442 ++++ crates/engine/src/camera_preview/tone.rs | 186 ++ 8 files changed, 2155 insertions(+), 2050 deletions(-) create mode 100644 crates/engine/src/camera_preview/colour.rs create mode 100644 crates/engine/src/camera_preview/hue_sat.rs create mode 100644 crates/engine/src/camera_preview/look_fit.rs create mode 100644 crates/engine/src/camera_preview/mono.rs create mode 100644 crates/engine/src/camera_preview/pairs.rs create mode 100644 crates/engine/src/camera_preview/proxy.rs create mode 100644 crates/engine/src/camera_preview/tone.rs diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 65e3739a6..1fc172548 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -16,6 +16,46 @@ use lightcraft_raw::{ profile::{HsvTable, ProfileLook, ToneCurve}, }; +mod colour; +mod hue_sat; +mod look_fit; +mod mono; +mod pairs; +mod proxy; +mod tone; + +#[cfg(test)] +pub(crate) use colour::{PROFILE_PROXY, dng_colour, dng_tone_eligible}; +#[cfg(test)] +use colour::{PROXY, starting_colour_with}; +pub(crate) use colour::{file_local_look, starting_colour, starting_colour_tags, with_starting_colour}; +pub(crate) use hue_sat::HueSat; +#[cfg(test)] +use hue_sat::{MIN_LUMINANCE_KEPT, NO_LUMINANCE_KEPT, fit_hue_sat, hue_saturation, restore_weight}; +#[cfg(test)] +pub(crate) use look_fit::fit_preview; +#[cfg(test)] +use look_fit::{ + FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, at_most, clips_more, correct_vignetting, fit_look, fit_look_with, sony_dro_off_tone, with_dro_off_tone, + without_clipped, +}; +#[cfg(test)] +use mono::{fit_dark_tone, fit_tone_only, monochrome, monochrome_look, to_prophoto}; +#[cfg(test)] +use pairs::{ + Attempt, Pairs, TONE_FITS, collect_pairs, colour_pair, displayed, fit_attempt, fit_pairs, fit_pairs_ordered, fit_pairs_with, fit_partial, + fit_tone_with_shadows, luma, rank_correlation_ties, search_ordered, +}; +#[cfg(test)] +pub(crate) use proxy::LensGeometry; +#[cfg(test)] +use proxy::{ + Cr3Framing, MIN_PREVIEW_FRACTION, alignment, estimate_cr3_framing, into_lens_geometry, proxies, reference_at, sensor_proxy, structure_agrees, +}; +pub(crate) use proxy::{fit_profile, lens_geometry, profile_pairs}; +#[cfg(test)] +use tone::{HighlightCheck, ToneFit, fit_tone, fit_tone_conditional, highlights}; + /// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone /// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because /// they keep the curve and the colour the fit produced; one written by an older version is rebuilt @@ -49,2055 +89,5 @@ pub(crate) struct CameraLook { pub hue_sat: Option, } -/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates -/// below were set at this size). -const PROXY: usize = 96; -/// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples. -pub(crate) const PROFILE_PROXY: usize = 192; - -/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix: -/// their starting look is fitted to the file's own JPEG, and white balance is relative to the -/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the -/// same formats by file extension. -pub(crate) fn file_local_look(format: RawFormat) -> bool { - matches!( - format, - RawFormat::Arw - | RawFormat::Nef - | RawFormat::Nrw - | RawFormat::Rw2 - | RawFormat::Raf - | RawFormat::Cr3 - | RawFormat::Cr2 - | RawFormat::Pef - | RawFormat::Srw - | RawFormat::Orf - | RawFormat::Mrw - | RawFormat::Erf - ) -} - -/// The colour a raw starts from, first available first: the file's own colour matrices (DNG); a look fitted to the -/// file's JPEG ([`fit_preview`]: with the camera profile for its model when there is one, else with colour fitted to -/// this photo alone); the model's colour matrices from measured spectral sensitivities (`lightcraft_raw::spectral`); -/// the neutral fallback. The spectral matrices only stand in for the neutral fallback, so a photo whose JPEG fit is -/// accepted renders as without them; when used they are written into `raw.color`. A photo whose JPEG is black and -/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's -/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the -/// as-shot white, the camera transform at it and the fitted look. -/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from -/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing. -/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit. -pub(crate) fn starting_colour( - raw: &mut RawImage, - bytes: &[u8], - lens: Option<&LensGeometry>, - embedded: Option<&lightcraft_develop::EmbeddedLens>, -) -> (Xy, CameraTransform, Option) { - let (xy, t, look) = starting_colour_with( - raw, - |raw, t| fit_preview(raw, bytes, t, lens), - |raw, t| monochrome_look(raw, bytes, t, lens), - |raw, t| { - if !file_local_look(raw.format) { - return None; - } - let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?; - fit_dark_tone(&sensor, &reference, &clipped) - }, - ); - if look.is_none() && dng_tone_eligible(raw, &t) { - let look = fit_dng_tone(raw, bytes, &t, embedded); - return (xy, t, look); - } - (xy, t, look) -} - -/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). -fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { - curve.is_none_or(|c| { - (0..=32).all(|i| { - let x = i as f32 / 32.0; - (c.eval(x) - x).abs() < 0.005 - }) - }) -} - -/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no -/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an -/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on -/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables -/// to its proxy, so it fits only the tone around them. -pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { - let p = &raw.color.profile; - raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none() -} - -/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white -/// JPEG's tone fit and the near-black tone fallback passed in (tests pin the order with them). -fn starting_colour_with( - raw: &mut RawImage, - fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option, bool), - monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option, - dark: impl FnOnce(&RawImage, &CameraTransform) -> Option, -) -> (Xy, CameraTransform, Option) { - let start = |raw: &RawImage| { - let xy = lightcraft_raw::color::as_shot_white_xy(raw); - (xy, lightcraft_raw::color::camera_transform(raw, xy)) - }; - let (xy, t) = start(raw); - if !t.matrix_is_fallback { - return (xy, t, None); - } - let (look, black_and_white) = fit(raw, &t); - if let Some(look) = look { - return (xy, t, Some(look)); - } - let (xy, t) = match raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) { - Some(camera) => { - camera.fill(&mut raw.color); - if lightcraft_pipeline::profiling() { - eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model); - } - start(raw) - } - None => (xy, t), - }; - // A black-and-white JPEG first tries its existing tone fit on the chosen colour. - let look = if black_and_white { monochrome(raw, &t) } else { None }; - (xy, t, look.or_else(|| dark(raw, &t))) -} - -/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo -/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered -/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them). -pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option { - if lightcraft_raw::color::has_matrix(&raw.color) { - return None; - } - // the white the source shows, decided before `starting_colour` may fill in measured matrices - let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); - let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw)); - // (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling) - let (_, t, look) = starting_colour(raw, bytes, None, None); - dng_colour(&raw.color, &white, &t, look.as_ref()) -} - -/// Give a raw about to be written as a DNG of its own (Export as DNG, Convert / Copy as DNG) the colour it opens -/// with, when the file has no colour matrices (issue #1745: a Z 9 NEF's DNG had no `ColorMatrix1`, and other raw -/// converters refused it). The lens corrections stay in the DNG; only the fit sees the raw without them, as when -/// the photo opens. -pub(crate) fn with_starting_colour(raw: &mut RawImage, bytes: &[u8]) { - if lightcraft_raw::color::has_matrix(&raw.color) { - return; - } - let list3 = raw.opcodes.list3.clone(); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let tags = starting_colour_tags(raw, bytes); - raw.opcodes.list3 = list3; - if let Some(tags) = tags { - raw.color = tags; - } -} - -/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward -/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation -/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table -/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two -/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo -/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all). -/// The colour matrices declare `declared`, the white balance the source shows ([`crate::files::WbScaleOf::dng_white`]), -/// so the DNG opens at the source's As Shot temperature and tint and a white-balance edit starts from it. Matrices -/// derived from `forward` put the declared white wherever the look and the generic camera model took it: ARW merges -/// opened at about 7000 K / +115 where the ARWs show 4800 K / +8, and any white-balance edit turned them green. -/// `forward` is used only with the as-shot neutral, so the render at As Shot doesn't depend on them. -/// `None` when the white balance or the matrix can't give tags. -pub(crate) fn dng_colour(tags: &ColorData, declared: &crate::files::DngWhite, t: &CameraTransform, look: Option<&CameraLook>) -> Option { - let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); - // the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes - // into the baseline exposure - let white = m.apply([1.0; 3]); - let k = (white[0] + white[1] + white[2]) / 3.0; - if !(k.is_finite() && k > 1e-6 && t.wb.iter().all(|w| w.is_finite() && *w > 0.0)) { - return None; - } - let m = Mat3(m.0.map(|r| r.map(|v| v / k))); - let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m); - let wb = t.wb.map(f64::from); - let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w); - let (illuminant, color_matrix) = match declared { - crate::files::DngWhite::Matrices(c) if lightcraft_raw::color::has_matrix(c) => (c.illuminant, c.color_matrix), - crate::files::DngWhite::Matrices(_) => ([21, 0], [Some(declaring(t.white_xy, neutral)?), None]), - crate::files::DngWhite::At(xy) => ([21, 0], [Some(declaring(*xy, neutral)?), None]), - }; - if !color_matrix.iter().flatten().all(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - return None; - } - Some(ColorData { - illuminant, - color_matrix, - // a second colour matrix comes with a second forward matrix (DNG spec): the same one, fitted at one white - forward_matrix: [Some(forward), color_matrix[1].map(|_| forward)], - camera_calibration: [None, None], - analog_balance: None, - as_shot_neutral: Some(neutral), - as_shot_white_xy: None, - baseline_exposure: t.baseline_exposure + k.log2(), - baseline_sharpness: tags.baseline_sharpness, - kelvin_presets: None, - profile: ProfileLook { - hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None], - tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)), - ..Default::default() - }, - }) -} - -/// An XYZ → camera colour matrix under which camera `neutral` is white `xy`: the generic camera model, scaled per -/// channel. `None` when the model gives no positive neutral there. -fn declaring(xy: Xy, neutral: [f64; 3]) -> Option { - let g = lightcraft_raw::color::xyz_to_camera(&ColorData::default(), xy); - let n = g.apply(xy.to_xyz()); - if !n.iter().all(|v| v.is_finite() && *v > 1e-9) { - return None; - } - Some(Mat3::diag(neutral[0] / n[0], neutral[1] / n[1], neutral[2] / n[2]).mul(&g)) -} - -/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the -/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`). -fn dng_tone_curve(tone: &CameraTone) -> Option { - const N: usize = 256; - let samples = (0..=N).map(|i| { - let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32); - [x, tone.apply(x).min(1.0)] - }); - let flat: Vec = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect(); - ToneCurve::from_tag(&flat) -} - -/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). -pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option, bool) { - if !transform.matrix_is_fallback || !file_local_look(raw.format) { - return (None, false); - } - let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) }; - let black_and_white = monochrome(&reference); - // A camera profile pooled from many photos knows colours this photo shows too little of; - // try its colour first and fit tone/chroma per photo (picture styles vary). - let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); - let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); - // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions, - // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO. - let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); - let look = match fit_look_with(&sensor, &reference, &clipped, colour.clone(), dro) { - Some(look) => look, - // A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve - // (`monochrome_look` does it without a profile). - None if colour.is_some() && black_and_white => { - let unclipped = without_clipped(&sensor, &clipped); - let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; - let Some(look) = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, colour, &bright) else { return (None, true) }; - look - } - None => return (None, black_and_white), - }; - let look = if dro { with_dro_off_tone(look) } else { look }; - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}, DRO tone replaced {dro}", - raw.format, - look.matrix.0, - look.tone, - look.hue_sat.is_some(), - profile.is_some() - ); - } - (Some(look), black_and_white) -} - -/// Sensor level (normalised, white = 1) at or above which a raw channel counts as clipped, as in -/// the render's highlight reconstruction. -const SENSOR_CLIP: f32 = 0.99; - -/// [`fit_look_with`] for a photo without Sony's DRO. -#[cfg(test)] -fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option { - fit_look_with(sensor, reference, clipped, colour, false) -} - -/// The photo's look, refitted without the proxy pixels whose sensor values are clipped. -/// -/// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip -/// level while the others keep rising, a white overcast sky comes out magenta or blue through the -/// camera matrix, while the camera JPEG shows it white. As correspondences such pixels teach the -/// colour matrix to pull colours toward grey, and the chroma curve to remove colour at their -/// display luminance (on a Z 6II forest under a clipped sky the curve cut colourfulness above -/// display luminance 0.4 to 13–19%, and every one of its samples there was clipped). The render -/// rebuilds clipped highlights separately (`lightcraft_raw::highlight`), so the look is fitted on -/// what the sensor measured. -/// -/// The search on all pixels decides, as before, whether the photo gets a look and which attempt -/// of [`search_ordered`] gives it; only that attempt is refitted without the clipped pixels, and -/// when the refit misses the acceptance gates the look on all pixels is kept. So no photo loses -/// its look and none switches attempt (a refit that just clears the gate on all pixels must not -/// replace a look fitted away from edges). A photo that had no look gets one when the search -/// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly -/// closer to the camera JPEG than the neutral fallback. -fn fit_look_with(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>, dro: bool) -> Option { - let unclipped = without_clipped(sensor, clipped); - // from the proxies as they are: the retries' filters (clipped pixels, edges) don't remove them - let bright = HighlightCheck { pairs: highlights(sensor, reference), dro, grey: false }; - let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS, &bright) else { - return unclipped - .as_ref() - .and_then(|u| search_ordered(u, reference, colour, TONE_FITS, &bright)) - .map(|(look, _)| look) - .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference, &bright)); - }; - let Some(unclipped) = unclipped else { return Some(look) }; - let refit = fit_attempt(&unclipped, reference, &colour, attempt, &bright); - if refit.is_none() && lightcraft_pipeline::profiling() { - eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels"); - } - Some(refit.unwrap_or(look)) -} - -/// `sensor` with its clipped pixels set to NaN (left out of every training pair), or `None` when -/// none is clipped. -fn without_clipped(sensor: &Rgb32f, clipped: &[bool]) -> Option { - if clipped.len() != sensor.data.len() || !clipped.iter().any(|c| *c) { - return None; - } - let mut out = sensor.clone(); - for (p, c) in out.data.iter_mut().zip(clipped) { - if *c { - *p = [f32::NAN; 3]; - } - } - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] camera look: {} of {} proxy pixels have a clipped sensor channel", - clipped.iter().filter(|c| **c).count(), - clipped.len() - ); - } - Some(out) -} - -/// 1 where any channel of a camera-RGB pixel is at or above [`SENSOR_CLIP`], else 0. -fn clip_mask(camera: &Rgb32f) -> Rgb32f { - let mut mask = camera.clone(); - mask.map_in_place(|p| if p.iter().any(|v| *v >= SENSOR_CLIP) { [1.0; 3] } else { [0.0; 3] }); - mask -} - -/// Sony's global tone curve with the Dynamic Range Optimizer off (Standard creative style), pooled -/// from 12 CC0 raw.pixls.us ARWs of 10 interchangeable-lens bodies and their camera JPEGs; the file -/// lists them and how the curve was derived (`assets/ATTRIBUTION.md`). Pooled the same way from 55 -/// private ILCE-7CR photos with DRO off, the curve differs by at most 1.2 L* between scene luminance -/// 0.01 and 0.5. Issue #244, `docs/camera-preview-colour.md`. -const SONY_DRO_OFF_TONE: &str = include_str!("../../../assets/camera-tone/sony-dro-off.json"); - -/// [`SONY_DRO_OFF_TONE`]'s curve (`None` only if the built-in file were invalid; a test checks it). -fn sony_dro_off_tone() -> Option { - #[derive(serde::Deserialize)] - struct File { - knots: [[f32; 2]; 32], - } - static TONE: std::sync::OnceLock> = std::sync::OnceLock::new(); - *TONE.get_or_init(|| serde_json::from_str::(SONY_DRO_OFF_TONE).ok().and_then(|f| CameraTone::new(f.knots))) -} - -/// `look`, fitted to a camera JPEG brightened by Sony's Dynamic Range Optimizer, with its tone -/// curve lowered to Sony's without DRO ([`SONY_DRO_OFF_TONE`]) wherever that is darker. DRO only -/// brightens, so the fitted curve bounds the curve without it from above, everywhere: where Sony's -/// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps -/// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted. -fn with_dro_off_tone(look: CameraLook) -> CameraLook { - CameraLook { tone: dro_off(&look.tone), ..look } -} - -/// The curve of [`with_dro_off_tone`]: `own` lowered to Sony's without DRO wherever that is darker, -/// or `own` unchanged when it can't be. -fn dro_off(own_tone: &CameraTone) -> CameraTone { - let Some(dro_off) = sony_dro_off_tone() else { return *own_tone }; - let own = own_tone.knots(); - let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]); - // Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots - // (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the - // extended highlights would end up above the fitted curve. Raise the second-last knot (never - // above the fitted curve) until the rate is at most the fitted curve's. - let ([a, b], last) = ([own[30], own[31]], knots[31]); - let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01); - let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]); - knots[30][1] = knots[30][1].max(floor).min(last[1]); - let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*own_tone.chroma())) else { return *own_tone }; - if !at_most(&tone, own_tone) { - return *own_tone; - } - tone -} - -/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone -/// table covers ([`ToneMap::camera`]). -fn at_most(a: &CameraTone, b: &CameraTone) -> bool { - use lightcraft_pipeline::tone::{GREY, LUT_MAX_EV, LUT_MIN_EV, LUT_N}; - (0..LUT_N).all(|i| { - let x = GREY * 2f32.powf(LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32); - a.apply(x) <= b.apply(x) + 1e-5 - }) -} - -/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and -/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px); -/// 128 MP also admits 100 MP medium-format previews. -const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; - -/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model -/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given -/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a -/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not -/// clip more highlights than the plain render ([`clips_more`]). -fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option { - // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. - let refuse = |why: &str| { - if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { - eprintln!("DNGTONE refused {why}"); - } - None - }; - let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") }; - // As the loader renders it: the profile's tables around the baseline exposure. - let xy = lightcraft_raw::color::as_shot_white_xy(raw); - if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { - let gain = 2f32.powf(transform.baseline_exposure as f32); - sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain)); - } - correct_vignetting(&mut sensor, lens, raw.orientation); - if (sensor.width, sensor.height) != (reference.width, reference.height) { - return refuse("preview"); - } - if !structure_agrees(&sensor, &reference) { - return refuse("structure"); - } - let unclipped = without_clipped(&sensor, &clipped); - let fit_on = unclipped.as_ref().unwrap_or(&sensor); - let colour = Some((Mat3::IDENTITY, None)); - let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; - let look = TONE_FITS.iter().find_map(|&tone| { - [false, true].into_iter().find_map(|away_from_edges| { - fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }, &bright) - }) - }); - let Some(look) = look else { return refuse("fit") }; - let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { - Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), - None => ToneMap::new(0.0, 0.0, 0.0), - }; - let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - if clips_more(&sensor, &reference, &fitted, &plain) { - return refuse("clip"); - } - if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { - eprintln!("DNGTONE accepted"); - } - Some(look) -} - -/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain -/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the -/// camera's look, one that clips what neither does loses highlight detail. -fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { - let near_white = |tone: &ToneMap| { - let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); - n as f64 / sensor.data.len().max(1) as f64 - }; - let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64; - near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE -} - -/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the -/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the -/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice. -fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) { - let Some(vignette) = lens.and_then(|l| l.vignette) else { return }; - let (w, h) = (sensor.width as f64, sensor.height as f64); - let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; - let width = sensor.width.max(1); - for (i, p) in sensor.data.iter_mut().enumerate() { - let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h); - let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32; - *p = p.map(|v| v * gain); - } -} - -/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a -/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview -/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty. -const MIN_PREVIEW_FRACTION: f64 = 0.1; - -/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark -/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. -const DARK_FLOOR: (f32, f32) = (0.004, 0.001); - -/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's -/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits -/// that clip about 1% more than the camera does and passes the ones that clip like the camera). -const CLIP_TOLERANCE: f64 = 0.001; - -/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. -const MIN_STRUCTURE: f64 = 0.85; - -/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance -/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3) -/// correlates better. -fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool { - let (w, h) = (sensor.width, sensor.height); - if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h { - return false; - } - let log = |img: &Rgb32f| -> Vec { - let l: Vec = - img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect(); - (0..w * h) - .map(|i| { - let (x, y) = (i % w, i / w); - let (mut sum, mut n) = (0.0, 0.0); - for yy in y.saturating_sub(2)..(y + 3).min(h) { - for xx in x.saturating_sub(2)..(x + 3).min(w) { - if let Some(v) = l.get(yy * w + xx) { - sum += v; - n += 1.0; - } - } - } - sum / f64::max(n, 1.0) - }) - .collect() - }; - let (a, b) = (log(sensor), log(reference)); - const M: usize = 3; - let corr = |dx: isize, dy: isize| -> f64 { - let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); - for y in M..h - M { - for x in M..w - M { - let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy)); - let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue }; - n += 1.0; - sa += u; - sb += v; - saa += u * u; - sbb += v * v; - sab += u * v; - } - } - let var = (saa - sa * sa / n) * (sbb - sb * sb / n); - if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() } - }; - let at_zero = corr(0, 0); - let (mut best, mut shift) = (at_zero, (0isize, 0isize)); - for dy in -(M as isize)..=M as isize { - for dx in -(M as isize)..=M as isize { - let c = corr(dx, dy); - if c > best + 1e-9 { - (best, shift) = (c, (dx, dy)); - } - } - } - at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1 -} - -/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s -/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), -/// and per proxy pixel whether any sensor sample it covers is clipped. -fn proxies( - raw: &RawImage, - bytes: &[u8], - transform: &CameraTransform, - size: usize, - min_fraction: f64, - lens: Option<&LensGeometry>, -) -> Option<(Rgb32f, Rgb32f, Vec)> { - let edge = (2 * size).max(384) as u32; - let decoded = crate::files::decode_raw_preview( - bytes, - lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS }, - None, - )?; - let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); - // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any - if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) - { - return None; - } - let mut reference = decoded.to_working(); - if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { - return None; - } - let matches = |w: usize, h: usize, rw: usize, rh: usize| (rw as f64 / rh as f64 / (w as f64 / h as f64) - 1.0).abs() <= 0.02; - if !matches(crop.width, crop.height, reference.width, reference.height) { - // Panasonic previews show the whole active area while the default crop is the in-camera aspect ratio - if !matches(a.width, a.height, reference.width, reference.height) { - return None; - } - let (sx, sy) = (reference.width as f64 / a.width as f64, reference.height as f64 / a.height as f64); - let (x, y) = ((crop.x as f64 * sx).round() as usize, (crop.y as f64 * sy).round() as usize); - let (w, h) = ((crop.width as f64 * sx).round() as usize, (crop.height as f64 * sy).round() as usize); - if w < 16 || h < 16 || x + w > reference.width || y + h > reference.height { - return None; - } - reference = reference.into_crop(x, y, w, h); - } - // Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution. - let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; - let mut sensor = sensor_proxy(raw, k, edge as usize)?; - let mut clipped = clip_mask(&sensor); - // The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 % - // zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate, - // kept below when it lines up with the JPEG better than the uncorrected one. - let warped = lens - .filter(|_| raw.format != RawFormat::Cr3) - .and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?))); - let gain = 2f32.powf(transform.baseline_exposure as f32); - let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)); - if raw.format == RawFormat::Cr3 { - // Orient the camera JPEG and the sensor identically before collecting pixel correspondences. - sensor = sensor.into_oriented(raw.orientation); - clipped = clipped.into_oriented(raw.orientation); - reference = reference.into_oriented(raw.orientation); - sensor.map_in_place(to_working); - // Canon's JPEG can be cropped differently from the sensor. Estimate only that common - // framing from edge directions, independently of the subsequent colour fit. - (sensor, clipped) = align_cr3_framing(sensor, clipped, &reference); - } - let mut sensor = fit(&sensor, size, size, Filter::Box); - let mut reference_small = reference_at(&reference, &sensor); - if let Some((warped, warped_clipped)) = warped { - let candidate = fit(&warped, size, size, Filter::Box); - let candidate_reference = reference_at(&reference, &candidate); - if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) { - (sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped); - } - } - let reference = reference_small; - // A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is - // NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like - // the reference, so the mask pairs pixel for pixel. - let clipped: Vec = resize(&clipped, sensor.width, sensor.height, Filter::Box).data.iter().map(|p| p[0] > 0.0).collect(); - if raw.format != RawFormat::Cr3 { - sensor.map_in_place(to_working); - } - Some((sensor, reference, clipped)) -} - -/// The camera JPEG at exactly the sensor proxy's size, so the two pair pixel for pixel. Fitting it -/// into the proxy's box instead keeps its own aspect, which can round a pixel short: an ILCE-7RM4's -/// 1616×1080 preview, decoded at 384×257, fits a 192×128 proxy as 191×128, and `collect_pairs` -/// then rejected every file `lightcraft-cli calibrate` was given. `proxies` has already checked -/// that the aspects agree within 2 %. -fn reference_at(reference: &Rgb32f, sensor: &Rgb32f) -> Rgb32f { - resize(reference, sensor.width, sensor.height, Filter::Box) -} - -#[derive(Clone, Copy, Debug)] -struct Cr3Framing { - scale: f32, - /// Translation as a fraction of the oriented frame's width and height. - offset: [f32; 2], -} - -impl Cr3Framing { - const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] }; - - fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) { - let (w, h) = (w as f32, h as f32); - ((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1])) - } -} - -/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different -/// white balance and monotone camera tone; no target RGB values train this three-parameter fit. -/// A separate third of the edges must confirm a substantial improvement and a close match. -/// A weak, flat or unrelated preview leaves the sensor framing unchanged. -fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option { - if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() { - return None; - } - let sensor = fit(sensor, PROXY, PROXY, Filter::Box); - let (w, h) = (sensor.width, sensor.height); - if w < 16 || h < 16 { - return None; - } - let reference = fit(reference, w, h, Filter::Box); - if (reference.width, reference.height) != (w, h) { - return None; - } - let mut gradients = Rgb32f::new(w, h); - let mut edges = Vec::new(); - for y in 1..h - 1 { - for x in 1..w - 1 { - let derivative = |image: &Rgb32f| { - [ - luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]), - luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]), - ] - }; - let d = derivative(&sensor); - gradients.data[y * w + x] = [d[0], d[1], 0.0]; - let d = derivative(&reference); - let magnitude = d[0].hypot(d[1]); - let value = luminance_2020(reference.data[y * w + x]); - if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 { - edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0)); - } - } - } - if edges.len() < 192 { - return None; - } - let score = |framing: Cr3Framing, held_out: bool| -> Option { - let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize); - for &(x, y, target, magnitude, holdout) in &edges { - if holdout != held_out { - continue; - } - total += 1; - let (sx, sy) = framing.source(x, y, w, h); - if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 { - continue; - } - let sample = gradients.sample_bilinear(sx, sy); - let length = sample[0].hypot(sample[1]); - if !length.is_finite() || length <= 0.01 { - continue; - } - let k = f64::from(magnitude.min(0.25)); - agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude)); - weight += k; - valid += 1; - } - (valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight) - }; - let before = score(Cr3Framing::IDENTITY, false)?; - let mut best = (before, Cr3Framing::IDENTITY); - // At most 10% zoom and two proxy pixels of translation; a coarse search followed by one - // local refinement prevents unbounded optimisation on the photograph's contents. - for s in -10..=10 { - for tx in -4..=4 { - for ty in -4..=4 { - let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] }; - if let Some(value) = score(framing, false) - && value > best.0 - { - best = (value, framing); - } - } - } - } - let coarse = best.1; - for s in -5..=5 { - for tx in -4..=4 { - for ty in -4..=4 { - let framing = Cr3Framing { - scale: coarse.scale + s as f32 * 0.002, - offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32], - }; - if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 { - continue; - } - if let Some(value) = score(framing, false) - && value > best.0 - { - best = (value, framing); - } - } - } - } - let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?); - if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 { - return None; - } - if lightcraft_pipeline::profiling() { - eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1); - } - Some(best.1) -} - -/// The sensor proxy and its clip mask (same size), both moved to the camera JPEG's framing. -fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rgb32f, Rgb32f) { - let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return (sensor, clipped) }; - let apply = |image: &Rgb32f| { - let (w, h) = (image.width, image.height); - let mut aligned = Rgb32f::new(w, h); - for (i, pixel) in aligned.data.iter_mut().enumerate() { - let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); - *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { image.sample_bilinear(x, y) } else { [f32::NAN; 3] }; - } - aligned - }; - (apply(&sensor), apply(&clipped)) -} - -/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the -/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own -/// colour matrices, other files or unusable previews. -pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option> { - if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { - return None; - } - let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); - let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?; - let to_camera = transform.matrix.inverse()?; - // clipped sensor pixels don't record the scene's colour (see `fit_look`) - let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor); - let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None, false)?; - Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect()) -} - -/// A camera profile's colour model (matrix from white-balanced camera RGB, hue/saturation table) -/// fitted to pairs pooled from many photos. -pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Option)> { - let matrix = fit_matrix(pairs)?; - Some((matrix, fit_hue_sat(pairs, &matrix))) -} - -/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of -/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without -/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted). -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct LensGeometry { - /// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance. - k: [f64; 4], - /// The optical centre, relative to the active area (as the opcode has it). - center: [f64; 2], -} - -pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option { - use lightcraft_raw::Opcode; - let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect(); - if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) { - return None; - } - ops.into_iter().find_map(|op| match op { - Opcode::WarpRectilinear { planes, center } => { - let p = planes.get(1).or(planes.first())?; - let k = [p[0], p[1], p[2], p[3]]; - (k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(¢er[0]) && (0.0..=1.0).contains(¢er[1])) - .then_some(LensGeometry { k, center: *center }) - } - _ => None, - }) -} - -/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both -/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels. -fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 { - let (w, h) = (a.width, a.height); - if (b.width, b.height) != (w, h) || w < 3 || h < 3 { - return -1.0; - } - let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::>(); - let high = |l: &[f64]| -> Vec { - (0..w * h) - .map(|i| { - let (x, y) = (i % w, i / w); - if x == 0 || y == 0 || x + 1 == w || y + 1 == h { - return f64::NAN; - } - let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1]; - l[i] - around.iter().map(|&j| l[j]).sum::() / 8.0 - }) - .collect() - }; - let (ha, hb) = (high(&lum(a)), high(&lum(b))); - let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect(); - if pairs.len() < 64 { - return -1.0; - } - let n = pairs.len() as f64; - let (ma, mb) = (pairs.iter().map(|p| p.0).sum::() / n, pairs.iter().map(|p| p.1).sum::() / n); - let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum(); - let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum()); - let r = cov / (va * vb).sqrt().max(1e-12); - if r.is_finite() { r } else { -1.0 } -} - -/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is -/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens` -/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another -/// size (the demosaiced fallback of linear raws). -fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option { - let a = raw.active_area; - let c = raw.crop.clipped(a.width, a.height); - let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c }; - let (w, h) = (img.width, img.height); - if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 { - return None; - } - let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0); - let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah); - let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9); - let [k0, k1, k2, k3] = lens.k; - let kf = k as f64; - let (x0, y0) = (c.x as f64, c.y as f64); - let mut out = Rgb32f::new(w, h); - for (i, px) in out.data.iter_mut().enumerate() { - // the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor - let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5); - let (dx, dy) = ((ox - cx) / m, (oy - cy) / m); - let r2 = dx * dx + dy * dy; - let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); - let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf); - *px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 { - img.sample_bilinear(sx as f32, sy as f32) - } else { - [f32::NAN; 3] - }; - } - Some(out) -} - -fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { - Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { - Some(sensor) => sensor, - // Lens opcodes cannot be applied to a binned mosaic: they are defined at full resolution. - None if (raw.cpp == 3 && raw.cfa.is_none()) || (raw.format == RawFormat::Dng && !raw.opcodes.list3.is_empty()) => { - fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box) - } - None => return None, - }) -} - -/// A fit must cut the held-out squared error to below this share of the fallback's. -const MIN_IMPROVEMENT: f64 = 0.7; -/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public -/// raw.pixls.us samples (eight Sony bodies) good fits that still beat the fallback 1.5–3× landed -/// at 0.065–0.093 (camera local tone, vignetting and lens processing that a global matrix + curve -/// cannot follow): visibly better renders that 0.055 rejected. -const MAX_HOLDOUT_RMS: f64 = 0.10; - -fn luma(p: [f64; 3]) -> f64 { - p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593 -} - -/// The finish stage's tone map and chroma curve (`lightcraft_pipeline::finish`). -fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] { - let scene = scene.map(|v| v.max(0.0)); - let y = luma(scene); - if y <= 0.0 { - return [0.0; 3]; - } - let o = f64::from(tone.apply(y as f32)); - let k = f64::from(tone.chroma_scale(o as f32)); - scene.map(|v| o + (v * o / y - o) * k) -} - -#[cfg(test)] -fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { - fit_pairs_with(sensor, reference, None) -} - -/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges). -fn local_contrast(image: &Rgb32f, i: usize) -> f32 { - let (w, h) = (image.width, image.height); - if w == 0 { - return f32::INFINITY; - } - let (x, y) = (i % w, i / w); - let (mut lo, mut hi) = (f32::INFINITY, 0f32); - for row in y.saturating_sub(1)..(y + 2).min(h) { - for col in x.saturating_sub(1)..(x + 2).min(w) { - let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p)); - lo = lo.min(l); - hi = hi.max(l); - } - } - if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY } -} - -/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights -/// (for the chroma curve); `None` when too few, or the photo has too little colour. With -/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio -/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated -/// colours (see [`EDGE_CONTRAST`]). -type Pair = ([f64; 3], [f64; 3]); -type Pairs = Vec; -fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option, dark: bool) -> Option<(Pairs, Pairs)> { - if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { - return None; - } - let mut pairs = Vec::new(); - // Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only. - let mut bright = Vec::new(); - let mut colour = 0; - for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { - if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { - continue; - } - let y = luminance_2020(*output); - if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) { - continue; - } - if (0.015..=1.0).contains(&y) { - bright.push((input.map(f64::from), output.map(f64::from))); - } - let floors = if dark { DARK_FLOOR } else { (0.015, 0.004) }; - if !colour_pair_above(*input, *output, floors) { - continue; - } - let min = output.iter().copied().fold(f32::INFINITY, f32::min); - let max = output.iter().copied().fold(0.0, f32::max); - colour += usize::from(max - min > min_chroma); - pairs.push((input.map(f64::from), output.map(f64::from))); - } - (pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright)) -} - -/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting. -fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool { - colour_pair_above(input, output, (0.015, 0.004)) -} - -/// [`colour_pair`] with its lowest preview luminance and channel value given (`floors`: the usual 0.015 and 0.004, or -/// [`DARK_FLOOR`] for the tone-only fit of a DNG). -fn colour_pair_above(input: [f32; 3], output: [f32; 3], (ylo, clo): (f32, f32)) -> bool { - input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) - && output.iter().all(|v| *v > clo && *v < 0.98) - && (ylo..0.85).contains(&luminance_2020(output)) -} - -/// Fit tone independently of colour eligibility when enough dark JPEG signal is available. -/// The original pairs and their split still own the colour fit and its acceptance gates. -/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and -/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise. -fn fit_tone_with_shadows( - fit: ToneFit, - pairs: &[([f64; 3], [f64; 3])], - sensor: &Rgb32f, - reference: &Rgb32f, - edge_limit: Option, - colour: impl Fn([f64; 3]) -> [f64; 3], -) -> Option { - let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y)); - let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect()); - let mut eligible = Vec::new(); - let (mut colour_index, mut extra_index) = (0, 0); - for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { - if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { - continue; - } - // About one 8-bit sRGB code above black: don't learn a flat black segment from - // JPEG compression. The sensor proxy averages many samples before this test. - if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5) - || !output.iter().all(|v| v.is_finite() && *v >= 0.0) - || !(0.0003..0.85).contains(&luminance_2020(*output)) - { - continue; - } - // A colour holdout must never enter tone training just because the wider sample set - // changes its index. Split the extra samples separately, keeping every colour index. - let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index }; - let held = *index % 3 == 0; - *index += 1; - let (x, y) = luminances(input.map(f64::from), output.map(f64::from)); - if x.is_finite() && x > 0.0 { - eligible.push((x, y, held)); - } - } - let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count(); - let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect(); - // Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank, - // so a single quantized grey also keeps the original curve. - if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION { - return original; - } - let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect(); - let Some(candidate) = fit.fit(training) else { return original }; - let error = |tone: &CameraTone| -> f64 { - held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum() - }; - // A wider histogram must also help independent dark pixels, not just redistribute the - // knots. The usual held-out gates still check the completed look on the colour pairs. - if let Some(old) = original { - let (before, after) = (error(&old), error(&candidate)); - if lightcraft_pipeline::profiling() { - eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}"); - } - if !after.is_finite() || after >= before * 0.99 { - return original; - } - } - Some(candidate) -} - -/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs. -fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option { - let mut gram = [[0.0; 3]; 3]; - let mut cross = [[0.0; 3]; 3]; - for (i, (x, y)) in pairs.iter().enumerate() { - if i % 3 == 0 { - continue; - } - let (lx, ly) = (luma(*x), luma(*y)); - if lx <= 0.0 || ly <= 0.0 { - continue; - } - for row in 0..3 { - for col in 0..3 { - // Normalising by luminance prevents a camera S-curve from corrupting colour. - gram[row][col] += x[row] * x[col] / (lx * lx); - cross[row][col] += y[row] * x[col] / (ly * lx); - } - } - } - let trace: f64 = (0..3).map(|i| gram[i][i]).sum(); - let inverse = Mat3(gram).inverse()?; - let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::(); - if trace <= 0.0 || !condition.is_finite() || condition > 1e6 { - return None; - } - let regularization = trace * 1e-4; - for i in 0..3 { - gram[i][i] += regularization; - cross[i][i] += regularization; - } - let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?); - matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix) -} - -/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt -/// of [`search_ordered`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto", -/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px -/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched -/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction -/// off). Away from edges: 0.049. Issue #232. -const EDGE_CONTRAST: f32 = 3.0; - -/// The look on all pixels (the search of [`search_ordered`] with [`TONE_FITS`]), as before -/// clipped pixels were left out. -#[cfg(test)] -fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - fit_pairs_ordered(sensor, reference, colour, TONE_FITS) -} - -/// The look from [`search_ordered`] without its attempt. -#[cfg(test)] -fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option { - search_ordered(sensor, reference, colour, order, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: false }) - .map(|(look, _)| look) -} - -/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is -/// used. Quantile matching comes first because it cannot produce flat steps; the -/// conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds) its -/// slightly lower held-out error is what clears the gates, and a photo must not lose its look to -/// the change of fit. -const TONE_FITS: &[ToneFit] = &[ToneFit::Quantile, ToneFit::ConditionalMedian]; - -/// One attempt of the search: tone fit, the camera profile's colour or the photo's own, all pixels -/// or only those away from edges. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -struct Attempt { - tone: ToneFit, - profile: bool, - away_from_edges: bool, - /// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG. - dark: bool, -} - -/// The photo's look and the attempt that produced it: its own matrix (with and without a -/// hue/saturation table), or the given colour model (a camera profile's, in the sensor proxy's -/// space), each completed with a tone and chroma curve fitted to this photo. Per tone fit in -/// `order`: the camera profile's colour (when there is one), then the photo's own, as a different -/// picture style can make a camera profile fail the gates; each fitted on all pixels and, when that -/// fails the gates, once more on the pixels away from edges (same gates): there colour pairs stay -/// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole -/// search runs with one tone fit before the next is tried, so the conditional-median fallback -/// accepts exactly what the previous releases accepted. -fn search_ordered( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: Option<(Mat3, Option)>, - order: &[ToneFit], - bright: &HighlightCheck, -) -> Option<(CameraLook, Attempt)> { - let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] }; - order.iter().find_map(|&tone| { - profiles.iter().find_map(|&profile| { - [false, true].into_iter().find_map(|away_from_edges| { - let attempt = Attempt { tone, profile, away_from_edges, dark: false }; - fit_attempt(sensor, reference, &colour, attempt, bright).map(|look| (look, attempt)) - }) - }) - }) -} - -fn fit_attempt( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: &Option<(Mat3, Option)>, - attempt: Attempt, - bright: &HighlightCheck, -) -> Option { - let colour = if attempt.profile { colour.clone() } else { None }; - fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark, bright) -} - -fn fit_pairs_on( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: Option<(Mat3, Option)>, - edge_limit: Option, - tone_fit: ToneFit, - dark: bool, - highlights: &HighlightCheck, -) -> Option { - // A known colour model needs enough signal for tone fitting, not a scene rich enough to - // learn a new colour matrix. Still reject monochrome references. - let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?; - let candidates = match colour { - Some(given) => vec![given], - None => { - let matrix = fit_matrix(&pairs)?; - vec![(matrix, fit_hue_sat(&pairs, &matrix)), (matrix, None)] - } - }; - // Matrix + table when the table helps the held-out pixels, else the matrix alone. Chosen by - // error in gamma-encoded display values (closer to what is seen: in linear values a slightly - // missed bright rock outweighs a clearly wrong dark shirt); the acceptance gates below stay linear. - let mut best: Option<(f64, f64, usize, CameraLook)> = None; - for (matrix, hue_sat) in candidates { - let correction = hue_sat.as_ref().and_then(HueSat::new); - let colour = |x: [f64; 3]| { - let p = matrix.apply(x); - correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) - }; - let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue }; - let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() }; - if let Some(tone) = fit_chroma(&bright, &look) { - look.tone = tone; - } - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - if !highlights_hold(&look.tone, highlights, colour) { - continue; - } - let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0); - for (x, target) in pairs.iter().step_by(3) { - let corrected = displayed(colour(*x), &tone); - for c in 0..3 { - linear += (corrected[c] - target[c]).powi(2); - perceptual += (corrected[c].max(0.0).powf(1.0 / 2.2) - target[c].max(0.0).powf(1.0 / 2.2)).powi(2); - samples += 1; - } - } - if linear.is_finite() && perceptual.is_finite() && best.as_ref().is_none_or(|b| perceptual < b.0) { - best = Some((perceptual, linear, samples, look)); - } - } - let (_, after, samples, look) = best?; - let original_tone = ToneMap::new(0.0, 0.0, 0.0); - let before: f64 = pairs - .iter() - .step_by(3) - .map(|(x, target)| { - let original = displayed(*x, &original_tone); - (0..3).map(|c| (original[c] - target[c]).powi(2)).sum::() - }) - .sum(); - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{}{})", - (before / samples as f64).sqrt(), - (after / samples as f64).sqrt(), - if edge_limit.is_some() { ", away from edges" } else { "" }, - if tone_fit == ToneFit::ConditionalMedian { ", conditional-median tone" } else { "" } - ); - } - if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) { - return None; - } - Some(look) -} - -/// Weight of the photo's own colour matrix in a [`fit_partial`] look; the rest is the identity (the -/// neutral fallback's generic camera ≈ sRGB colour). On the CC0 archive's proxies, with the -/// matrix at full weight 2 of the 23 colourful photos without a look came out more than 2° further -/// from the camera's hues than the neutral fallback, at half weight none (median hue error 9.6° with -/// the generic colour, which the tone and chroma curves make visible, 5.1° at half weight). -const PARTIAL_MATRIX_WEIGHT: f64 = 0.5; - -/// Smallest rank correlation of scene and camera JPEG luminance over a [`fit_partial`] look's pairs: -/// the JPEG must show the same picture. A look that only matches the brightness distribution of an -/// unrelated picture can still beat the dark neutral fallback pixel for pixel. On the CC0 archive's -/// proxies the accepted full looks have 0.55 or more, lens-corrected compacts without one 0.53–0.77, -/// three old Coolpix files whose raw decodes wrongly 0.07–0.27. -const PARTIAL_MIN_RANK_CORRELATION: f64 = 0.5; - -/// Spearman rank correlation of the scene's and the JPEG's luminance over `pairs`. -fn rank_correlation(pairs: &[([f64; 3], [f64; 3])]) -> f64 { - let ranks = |values: Vec| { - let mut order: Vec = (0..values.len()).collect(); - order.sort_by(|a, b| values[*a].total_cmp(&values[*b])); - let mut ranks = vec![0.0; values.len()]; - for (rank, i) in order.into_iter().enumerate() { - ranks[i] = rank as f64; - } - ranks - }; - let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect()); - let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect()); - let mean = (pairs.len() as f64 - 1.0) / 2.0; - let (covariance, variance) = - scene.iter().zip(&jpeg).fold((0.0, 0.0), |(c, v), (a, b)| (c + (a - mean) * (b - mean), v + (a - mean) * (a - mean))); - if variance > 0.0 { covariance / variance } else { 0.0 } -} - -/// [`rank_correlation`] with tied luminances sharing their mean rank, and 0 when either side has no spread. Without -/// colour to require, a JPEG may be flat (one grey for every pixel): [`rank_correlation`] ranks ties in pixel order, -/// so a flat JPEG of a scene whose pixels are stored in brightness order looks perfectly related. -fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 { - let ranks = |values: Vec| { - let mut order: Vec = (0..values.len()).collect(); - order.sort_by(|a, b| values[*a].total_cmp(&values[*b])); - let mut ranks = vec![0.0; values.len()]; - let mut start = 0; - while start < order.len() { - let mut end = start + 1; - while end < order.len() && values[order[end]] == values[order[start]] { - end += 1; - } - let mean = (start + end - 1) as f64 / 2.0; - for &i in &order[start..end] { - ranks[i] = mean; - } - start = end; - } - ranks - }; - let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect()); - let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect()); - let mean = (pairs.len() as f64 - 1.0) / 2.0; - let (covariance, scene_variance, jpeg_variance) = scene - .iter() - .zip(&jpeg) - .fold((0.0, 0.0, 0.0), |(c, va, vb), (a, b)| (c + (a - mean) * (b - mean), va + (a - mean) * (a - mean), vb + (b - mean) * (b - mean))); - if scene_variance > 0.0 && jpeg_variance > 0.0 { covariance / (scene_variance * jpeg_variance).sqrt() } else { 0.0 } -} - -/// A partial look for a photo whose full look misses the acceptance gates (lens-corrected JPEGs of -/// compacts, dull or nearly colourless scenes): the tone curve by quantile matching, which needs -/// no pixel-exact registration and was the largest part of the neutral fallback's error (rendered -/// 9–22 L* too dark), the chroma curve (the fallback kept about a third of the camera's -/// colourfulness), and, when the scene has enough colour to learn one (the full look's condition), -/// the photo's own colour matrix at [`PARTIAL_MATRIX_WEIGHT`]. No hue/saturation table. Tried on -/// all pixels and then, like the full look, on the pixels away from edges; used only when it cuts -/// the held-out squared error to below [`MIN_IMPROVEMENT`] of the neutral fallback's, in linear -/// and in gamma-encoded display values, and when the JPEG shows the same picture -/// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly -/// closer to the camera JPEG than what the photo would get otherwise. -fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f, highlights: &HighlightCheck) -> Option { - [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit, highlights)) -} - -fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option, highlights: &HighlightCheck) -> Option { - // as for a known colour model: enough signal for the tone, but no monochrome reference - let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; - let related = rank_correlation(&pairs); - if related < PARTIAL_MIN_RANK_CORRELATION { - if lightcraft_pipeline::profiling() { - eprintln!("[profile] camera look partial: luminance rank correlation {related:.3}, not the same picture"); - } - return None; - } - let spread = |y: &[f64; 3]| y.iter().copied().fold(f64::NEG_INFINITY, f64::max) - y.iter().copied().fold(f64::INFINITY, f64::min); - let colourful = pairs.iter().filter(|(_, y)| spread(y) > 0.05).count() >= pairs.len() / 20; - let matrix = match colourful.then(|| fit_matrix(&pairs)).flatten() { - Some(own) => Mat3(std::array::from_fn(|r| { - std::array::from_fn(|c| Mat3::IDENTITY.0[r][c] + PARTIAL_MATRIX_WEIGHT * (own.0[r][c] - Mat3::IDENTITY.0[r][c])) - })), - None => Mat3::IDENTITY, - }; - let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?; - let mut look = CameraLook { matrix, tone, hue_sat: None }; - if let Some(tone) = fit_chroma(&bright, &look) { - look.tone = tone; - } - let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); - if !highlights_hold(&look.tone, highlights, |x| matrix.apply(x)) { - return None; - } - let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); - let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); - for (x, target) in pairs.iter().step_by(3) { - let (fallback, partial) = (displayed(*x, &neutral), displayed(matrix.apply(*x), &tone)); - for c in 0..3 { - before += (fallback[c] - target[c]).powi(2); - after += (partial[c] - target[c]).powi(2); - before_encoded += (encoded(fallback[c]) - encoded(target[c])).powi(2); - after_encoded += (encoded(partial[c]) - encoded(target[c])).powi(2); - samples += 1; - } - } - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] camera look partial (tone{}{}) holdout RMS {:.5} -> {:.5} ({samples} channels{})", - if look.tone.chroma().iter().any(|k| *k != 1.0) { ", chroma" } else { "" }, - if matrix != Mat3::IDENTITY { ", damped matrix" } else { "" }, - (before / samples.max(1) as f64).sqrt(), - (after / samples.max(1) as f64).sqrt(), - if edge_limit.is_some() { ", away from edges" } else { "" } - ); - } - let better = - after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT; - (samples > 0 && better).then_some(look) -} - -/// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey. -const GREY_SPREAD: f32 = 0.002; - -/// Whether the camera JPEG is black and white: a monochrome picture style (Canon Monochrome, Sony B/W, Nikon -/// Monochrome), shot from a full-colour raw. All but one proxy pixel in a thousand are grey within -/// [`GREY_SPREAD`]. A toned monochrome (sepia) has colour, so it isn't one. -fn monochrome(reference: &Rgb32f) -> bool { - let coloured = reference - .data - .iter() - .filter(|p| { - let (min, max) = p.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v))); - !p.iter().all(|v| v.is_finite()) || max - min > GREY_SPREAD - }) - .count(); - reference.data.len() >= 256 && coloured.saturating_mul(1000) <= reference.data.len() -} - -/// The look of a raw whose camera JPEG is black and white ([`monochrome`]; issue #577) and gave no look with a camera -/// profile's colour in [`fit_preview`] (or whose model has no profile). Such a JPEG has no colour to fit, so the photo -/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker -/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve. -/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and -/// adds the tone curve alone ([`fit_tone_only_with`]), as [`fit_look_with`] tries the photo's own colour after a profile's. The -/// chroma curve stays neutral: the JPEG has no colourfulness to learn. -fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option { - if !file_local_look(raw.format) { - return None; - } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?; - if !monochrome(&reference) { - return None; - } - // Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one - let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); - let unclipped = without_clipped(&sensor, &clipped); - let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; - let look = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, None, &bright)?; - let look = if dro { with_dro_off_tone(look) } else { look }; - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] {:?} camera look from a black-and-white JPEG: tone {:?}, {} colour, DRO tone replaced {dro}", - raw.format, - look.tone, - if transform.matrix_is_fallback { "fallback" } else { "spectral" } - ); - } - Some(look) -} - -/// The tone curve of a black-and-white camera JPEG, on top of `colour` (a camera profile's, in the sensor proxy's -/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of -/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture -/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below -/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. A curve narrower than -/// [`NARROW_TONE_RANGE`] must also keep the JPEG's bright areas ([`highlights_hold`], by luminance). -fn fit_tone_only_with( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: Option<(Mat3, Option)>, - highlights: &HighlightCheck, -) -> Option { - [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit, highlights)) -} - -/// [`fit_tone_only_with`] for a photo without Sony's DRO, checked against its own bright areas. -#[cfg(test)] -fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - fit_tone_only_with(sensor, reference, colour, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }) -} - -fn fit_tone_only_on( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: &Option<(Mat3, Option)>, - edge_limit: Option, - highlights: &HighlightCheck, -) -> Option { - // every pair counts: the JPEG has no colour to require - let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; - fit_tone_only_pairs(sensor, reference, colour, edge_limit, &pairs, false, highlights) -} - -/// A near-black frame may have enough luminance signal for tone but fewer than 256 colour pairs. -/// Try only after every existing look fails. Keep the colour of the source transform, -/// exclude clipped pixels from training, and keep their luminance at least as close to the JPEG -/// as the default curve does. Bright JPEGs need the existing fits to constrain their highlights. -fn fit_dark_tone(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool]) -> Option { - if (sensor.width, sensor.height) != (reference.width, reference.height) - || sensor.data.len() != reference.data.len() - || clipped.len() != sensor.data.len() - || sensor.data.is_empty() - || sensor.data.iter().any(|p| !p.iter().all(|v| v.is_finite())) - || reference.data.iter().any(|p| !p.iter().all(|v| v.is_finite() && *v >= 0.0) || luminance_2020(*p) >= 0.85) - { - return None; - } - let dark = reference.data.iter().filter(|p| luminance_2020(**p) < 0.015).count(); - if (dark as f64) < reference.data.len() as f64 * 0.9 { - return None; - } - // no JPEG pixel reaches 0.85 here, so the bright-area check has nothing to hold - let bright = HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }; - [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| { - let pairs: Pairs = sensor - .data - .iter() - .zip(&reference.data) - .enumerate() - .filter(|(i, (input, output))| { - // The same signal floors as the camera toe, without colour ratios or counts. - !clipped.get(*i).copied().unwrap_or(true) - && input.iter().all(|v| *v > 0.0001 && *v < 1.5) - && (0.0003..0.85).contains(&luminance_2020(**output)) - && edge_limit.is_none_or(|limit| local_contrast(sensor, *i) <= limit && local_contrast(reference, *i) <= limit) - }) - .map(|(_, (input, output))| (input.map(f64::from), output.map(f64::from))) - .collect(); - if pairs.len() < 256 { - return None; - } - let look = fit_tone_only_pairs(sensor, reference, &None, edge_limit, &pairs, true, &bright)?; - let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); - for ((input, target), clipped) in sensor.data.iter().zip(&reference.data).zip(clipped) { - if *clipped { - let target = luma(target.map(f64::from)); - let before = luma(displayed(input.map(f64::from), &neutral)); - let after = luma(displayed(input.map(f64::from), &tone)); - if !after.is_finite() || (after - target).abs() > (before - target).abs() { - return None; - } - } - } - Some(look) - }) -} - -fn fit_tone_only_pairs( - sensor: &Rgb32f, - reference: &Rgb32f, - colour: &Option<(Mat3, Option)>, - edge_limit: Option, - pairs: &Pairs, - dark: bool, - highlights: &HighlightCheck, -) -> Option { - let kind = if dark { "near-black scene" } else { "black-and-white JPEG" }; - let related = rank_correlation_ties(pairs); - if related < PARTIAL_MIN_RANK_CORRELATION { - if lightcraft_pipeline::profiling() { - eprintln!("[profile] camera look from a {kind}: luminance rank correlation {related:.3}, not the same picture"); - } - return None; - } - let (matrix, hue_sat) = colour.clone().unwrap_or((Mat3::IDENTITY, None)); - let correction = hue_sat.as_ref().and_then(HueSat::new); - let coloured = |x: [f64; 3]| { - let p = matrix.apply(x); - correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) - }; - let tone = if dark { - // Split this wider set once. The camera toe's separate colour/extra split belongs to - // the original colour pairs and would put some of these held-out pixels into training. - fit_tone( - pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(), - )? - } else { - fit_tone_with_shadows(ToneFit::Quantile, pairs, sensor, reference, edge_limit, coloured)? - }; - let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() }; - if !highlights_hold(&look.tone, highlights, coloured) { - return None; - } - let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); - let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); - let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); - for (x, target) in pairs.iter().step_by(3) { - let (fallback, toned, target) = (luma(displayed(*x, &neutral)), luma(displayed(coloured(*x), &tone)), luma(*target)); - before += (fallback - target).powi(2); - after += (toned - target).powi(2); - before_encoded += (encoded(fallback) - encoded(target)).powi(2); - after_encoded += (encoded(toned) - encoded(target)).powi(2); - samples += 1; - } - if lightcraft_pipeline::profiling() { - eprintln!( - "[profile] camera look from a {kind}: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})", - (before / samples.max(1) as f64).sqrt(), - (after / samples.max(1) as f64).sqrt(), - if edge_limit.is_some() { ", away from edges" } else { "" } - ); - } - let better = - after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT; - (samples > 0 && better).then_some(look) -} - -/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in. -fn to_prophoto() -> Mat3 { - PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()) -} - -/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour -/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps -/// its colour; in between the two are blended, so noisy shadows show no step. -const MIN_LUMINANCE_KEPT: f32 = 0.25; -const NO_LUMINANCE_KEPT: f32 = 0.15; - -/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to -/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between. -fn restore_weight(share: f32) -> f32 { - if !share.is_finite() { - return 0.0; - } - let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0); - t * t * (3.0 - 2.0 * t) -} - -/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes -/// hue and saturation only: luminance is restored, the camera tone curve owns it. -pub(crate) struct HueSat<'a> { - table: &'a HsvTable, - to: [[f32; 3]; 3], - from: [[f32; 3]; 3], -} - -impl<'a> HueSat<'a> { - pub fn new(table: &'a HsvTable) -> Option> { - let to = to_prophoto(); - Some(HueSat { table, to: to.to_f32(), from: to.inverse()?.to_f32() }) - } - - #[inline] - pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { - let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) }; - let out = mul(&self.from, self.table.apply(mul(&self.to, rgb))); - let (before, after) = (luminance_2020(rgb), luminance_2020(out)); - // restoring the luminance divides by the table's: near black, where noise makes some channels - // negative, the table can bring it close to 0, and the gain would turn the pixel white (#820) - if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) { - return rgb; - } - let w = restore_weight(after / before); - if w >= 1.0 { - return out.map(|v| v * before / after); - } - if w <= 0.0 { - return rgb; - } - std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w) - } - - /// [`Self::apply`] to `N` pixels at once (`rgb[channel][lane]`), bit for bit, through - /// [`HsvTable::apply_lanes`], with the guards as selects. - #[inline] - pub fn apply_lanes(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { - let mul = |m: &[[f32; 3]; 3], v: &[[f32; N]; 3]| { - let mut out = [[0.0f32; N]; 3]; - for (row, out) in m.iter().zip(&mut out) { - for l in 0..N { - out[l] = row[0] * v[0][l] + row[1] * v[1][l] + row[2] * v[2][l]; - } - } - out - }; - let out = mul(&self.from, &self.table.apply_lanes(mul(&self.to, &rgb))); - let mut restored = rgb; - for l in 0..N { - let (input, out) = ([rgb[0][l], rgb[1][l], rgb[2][l]], [out[0][l], out[1][l], out[2][l]]); - let (before, after) = (luminance_2020(input), luminance_2020(out)); - let untouched = (before <= 0.0) | (after <= 0.0) | !(out[0].is_finite() & out[1].is_finite() & out[2].is_finite()); - let w = restore_weight(after / before); - for (c, restored) in restored.iter_mut().enumerate() { - let full = out[c] * before / after; - let blend = input[c] + (full - input[c]) * w; - // (`w >= 1` and `w <= 0` exclude each other) - restored[l] = if untouched | (w <= 0.0) { - input[c] - } else if w >= 1.0 { - full - } else { - blend - }; - } - } - restored - } -} - -/// HSV hue (degrees) and saturation; `None` for black or non-finite colours. -fn hue_saturation(p: [f64; 3]) -> Option<(f64, f64)> { - let max = p[0].max(p[1]).max(p[2]); - let min = p[0].min(p[1]).min(p[2]); - if !(max.is_finite() && min.is_finite()) || max <= 0.0 { - return None; - } - let d = max - min; - if d <= 0.0 { - return Some((0.0, 0.0)); - } - let h = if max == p[0] { - ((p[1] - p[2]) / d).rem_euclid(6.0) - } else if max == p[1] { - (p[2] - p[0]) / d + 2.0 - } else { - (p[0] - p[1]) / d + 4.0 - }; - Some((h * 60.0, d / max)) -} - -/// Table resolution: 5° hue steps (a coarser grid blurred the lime shirt into neighbouring browns -/// that want the opposite shift), saturation 0, 0.25 … 1. Value is not an axis: tone is fitted separately. -const TABLE_HUES: usize = 72; -const TABLE_SATS: usize = 5; -/// Value levels (sRGB-encoded, as DNG tables with encoding 1): cameras turn dark yellows toward -/// orange but bright yellow-greens toward green, which one shift per hue averages away. -const TABLE_VALS: usize = 5; -/// Kernel widths around each table node (hue in degrees, saturation, encoded value). -const KERNEL_HUE: f64 = 2.5; -const KERNEL_SAT: f64 = 0.15; -const KERNEL_VAL: f64 = 0.12; -/// Kernel weight at which a node keeps half of its estimate; sparse nodes shrink to identity. -const SHRINK_WEIGHT: f64 = 5.0; - -/// Fit hue shifts and saturation scales of the training pairs left after `matrix`, per (hue, -/// saturation) node, kernel-weighted and shrunk toward identity where the photo has few samples. -/// Saturation 0 stays identity, so neutrals are never tinted. -fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option { - let to = to_prophoto(); - let samples: Vec<(f64, f64, f64, f64, f64)> = pairs - .iter() - .enumerate() - .filter(|(i, _)| i % 3 != 0) - .filter_map(|(_, (x, y))| { - let p = to.apply(matrix.apply(*x)); - let (hp, sp) = hue_saturation(p)?; - let value = f64::from(lightcraft_color::transfer::linear_to_srgb(p[0].max(p[1]).max(p[2]).min(1.0) as f32)); - let (ht, st) = hue_saturation(to.apply(*y))?; - // hue is meaningless near neutral - if sp < 0.08 || st < 0.02 { - return None; - } - let shift = ((ht - hp + 180.0).rem_euclid(360.0) - 180.0).clamp(-30.0, 30.0); - let log_scale = (st / sp).ln().clamp(-0.7, 0.7); - Some((hp, sp.min(1.0), value, shift, log_scale)) - }) - .collect(); - if samples.len() < 64 { - return None; - } - // Samples by table hue step: a node only looks at hues within its kernel's reach. - let step = 360.0 / TABLE_HUES as f64; - let mut by_hue: Vec> = vec![Vec::new(); TABLE_HUES]; - for sample in samples { - if let Some(bin) = by_hue.get_mut(((sample.0.rem_euclid(360.0) / step) as usize).min(TABLE_HUES - 1)) { - bin.push(sample); - } - } - let reach = (4.0 * KERNEL_HUE / step).ceil() as usize + 1; - use rayon::prelude::*; - let data: Vec<[f32; 3]> = (0..TABLE_VALS * TABLE_HUES * TABLE_SATS) - .into_par_iter() - .map(|index| { - let (v, h, s) = (index / (TABLE_HUES * TABLE_SATS), index / TABLE_SATS % TABLE_HUES, index % TABLE_SATS); - if s == 0 { - return [0.0, 1.0, 1.0]; - } - let (val, hue, sat) = (v as f64 / (TABLE_VALS - 1) as f64, h as f64 * step, s as f64 / (TABLE_SATS - 1) as f64); - let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0); - for offset in 0..=2 * reach { - let Some(bin) = by_hue.get((h + TABLE_HUES * 2 + offset - reach) % TABLE_HUES) else { continue }; - for &(hp, sp, vp, dh, ls) in bin { - let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0; - if dhue.abs() > 4.0 * KERNEL_HUE { - continue; - } - let d2 = (dhue / KERNEL_HUE).powi(2) + ((sp - sat) / KERNEL_SAT).powi(2) + ((vp - val) / KERNEL_VAL).powi(2); - let k = (-0.5 * d2).exp() * sp; - weight += k; - shift += k * dh; - log_scale += k * ls; - } - } - if weight <= 0.0 { - return [0.0, 1.0, 1.0]; - } - let shrink = 1.0 / (weight + SHRINK_WEIGHT); - [(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0] - }) - .collect(); - data.iter().all(|e| e.iter().all(|v| v.is_finite())).then_some(HsvTable { - hue_divisions: TABLE_HUES, - sat_divisions: TABLE_SATS, - val_divisions: TABLE_VALS, - data, - srgb_value: true, - }) -} - -/// Kernel width of the chroma curve's nodes (display luminance) and the weight at which a node -/// keeps half of its estimate. -const CHROMA_KERNEL: f64 = 0.08; -const CHROMA_SHRINK: f64 = 2.0; - -/// The look's tone curve with a chroma-by-display-luminance curve fitted to `pairs` (two thirds -/// train, one third held out), when that lowers the held-out error. Colourfulness is compared as -/// the distance from neutral of luminance-normalised RGB. -fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option { - let correction = look.hue_sat.as_ref().and_then(HueSat::new); - let scene = |x: &[f64; 3]| { - let p = look.matrix.apply(*x); - correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) - }; - let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); - let predict = |x: &[f64; 3]| displayed(scene(x), &tone); - let chroma = |p: [f64; 3]| { - let y = luma(p); - (y > 0.0).then(|| p.iter().map(|v| (v / y - 1.0).powi(2)).sum::().sqrt()) - }; - let samples: Vec<(f64, f64, f64)> = pairs - .iter() - .enumerate() - .filter(|(i, _)| i % 3 != 0) - .filter_map(|(_, (x, y))| { - let p = predict(x); - let (cp, cy) = (chroma(p)?, chroma(*y)?); - (cp > 0.05).then(|| (luma(p), cp, (cy.max(1e-3) / cp).ln().clamp(0.05f64.ln(), 2.5f64.ln()))) - }) - .collect(); - if samples.len() < 64 { - return None; - } - let mut curve = [1.0f32; lightcraft_pipeline::tone::CHROMA_N]; - for (j, node) in curve.iter_mut().enumerate() { - let at = j as f64 / (lightcraft_pipeline::tone::CHROMA_N - 1) as f64; - let (mut weight, mut sum) = (0.0, 0.0); - for &(o, cp, log_ratio) in &samples { - let k = (-0.5 * ((o - at) / CHROMA_KERNEL).powi(2)).exp() * cp; - weight += k; - sum += k * log_ratio; - } - *node = (sum / (weight + CHROMA_SHRINK)).exp() as f32; - } - let fitted = look.tone.with_chroma(curve)?; - let with = ToneMap::camera(&fitted, 0.0, 0.0, 0.0); - let error = |map: &ToneMap| -> f64 { - pairs - .iter() - .step_by(3) - .map(|(x, y)| { - let p = displayed(scene(x), map); - (0..3).map(|c| (p[c] - y[c]).powi(2)).sum::() - }) - .sum() - }; - let (before, after) = (error(&tone), error(&with)); - if lightcraft_pipeline::profiling() { - eprintln!("[profile] ARW chroma curve {curve:?}: held-out error {before:.4} -> {after:.4}"); - } - (after.is_finite() && after < before).then_some(fitted) -} - -/// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum ToneFit { - /// [`fit_tone`]: no flat steps, no pixel-exact registration needed. - Quantile, - /// [`fit_tone_conditional`]: the earlier fit, kept as the fallback. - ConditionalMedian, -} - -impl ToneFit { - fn fit(self, pairs: Vec<(f64, f64)>) -> Option { - match self { - ToneFit::Quantile => fit_tone(pairs), - ToneFit::ConditionalMedian => fit_tone_conditional(pairs), - } - } -} - -/// The bright areas a narrow tone curve is checked against ([`highlights_hold`]): every proxy pixel -/// whose camera JPEG luminance is 0.85 or more, with its sensor value as developed (for a clipped -/// pixel a lower bound of the scene). Taken from the proxies before any training filter (clipped -/// pixels, edges, the sensor value range of [`collect_pairs`]), so no retry of the search can -/// leave them out. -fn highlights(sensor: &Rgb32f, reference: &Rgb32f) -> Pairs { - if sensor.data.len() != reference.data.len() { - return Vec::new(); - } - sensor - .data - .iter() - .zip(&reference.data) - .filter(|(x, y)| x.iter().chain(y.iter()).all(|v| v.is_finite() && *v >= 0.0) && luminance_2020(**y) >= 0.85) - .map(|(x, y)| (x.map(f64::from), y.map(f64::from))) - .collect() -} - -/// Smallest ratio between the scene luminance of the brightest and of the darkest 1/32 of the -/// pairs a tone curve is fitted to (its last and first knot). Below it the training pairs are -/// nearly uniform (under 0.26 EV from their darker to their brighter parts): the curve would be -/// fitted to a sliver of the tone range and the rest of it extrapolated. Above it the acceptance -/// gates judge the curve. This was 1.5, which rejected low-contrast scenes the curve fits very -/// well: in a hazy ILCE-7RM4 burst the frames at 1.46–1.49 opened with the neutral fallback (no full or partial -/// look, 18 L* darker than the camera JPEG) while the frame at 1.51 fitted at a held-out RMS of -/// 0.013, as the others do once allowed (issue #631). Across 135 public Sony files the lowest is 4.0. -/// A curve under [`NARROW_TONE_RANGE`] must also pass [`highlights_hold`]. -const MIN_TONE_RANGE: f32 = 1.2; - -/// [`MIN_TONE_RANGE`] before issue #631. A curve fitted to a narrower range than this must also -/// render the photo's bright areas no further from the camera JPEG than the neutral fallback does -/// ([`highlights_hold`]). Neither the fit nor the held-out gates see those areas (camera JPEG -/// luminance 0.85 and up, left out of the training pairs by [`collect_pairs`]), and a curve fitted -/// to a sliver of the tone range extrapolates them: in review, a synthetic frame whose curve passed -/// the gates at a held-out RMS of 0.0004 rendered its bright sky at 0.29 where the camera JPEG has -/// 0.93. -const NARROW_TONE_RANGE: f32 = 1.5; - -/// What a narrow tone curve is checked against ([`highlights_hold`]). -struct HighlightCheck { - /// The frame's [`highlights`]. - pairs: Pairs, - /// The photo's curve will be lowered to Sony's without DRO ([`with_dro_off_tone`]) after the - /// fit, so the check looks at it lowered. - dro: bool, - /// The camera JPEG is black and white ([`fit_tone_only_with`]): the check compares luminance, as that - /// fit's gates do, since the JPEG has no colour to match. - grey: bool, -} - -/// Whether a look with this `tone` and `colour` (sensor value to the finish stage's input) keeps -/// the frame's [`highlights`] (camera JPEG luminance 0.85 and up, left out of the fit) no further -/// from the camera JPEG than the neutral fallback does, with the tone curve as the photo will get -/// it (lowered for DRO), in luminance alone for a black-and-white JPEG. Only asked of curves narrower than -/// [`NARROW_TONE_RANGE`]: true for wider ones, and for frames without such pixels. -fn highlights_hold(tone: &CameraTone, check: &HighlightCheck, colour: impl Fn([f64; 3]) -> [f64; 3]) -> bool { - let knots = tone.knots(); - if knots[31][0] >= knots[0][0] * NARROW_TONE_RANGE { - return true; - } - // the curve as the photo will get it - let used = if check.dro { dro_off(tone) } else { *tone }; - let (map, neutral) = (ToneMap::camera(&used, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); - let (mut look, mut fallback) = (0.0, 0.0); - for (x, target) in &check.pairs { - let (rendered, base) = (displayed(colour(*x), &map), displayed(*x, &neutral)); - if check.grey { - look += (luma(rendered) - luma(*target)).powi(2); - fallback += (luma(base) - luma(*target)).powi(2); - continue; - } - for c in 0..3 { - look += (rendered[c] - target[c]).powi(2); - fallback += (base[c] - target[c]).powi(2); - } - } - let hold = look.is_finite() && look <= fallback; - if !hold && lightcraft_pipeline::profiling() { - eprintln!("[profile] camera look: narrow tone curve renders the bright areas worse than the fallback ({look:.3} > {fallback:.3})"); - } - hold -} - -/// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs -/// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th -/// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the -/// scene to the same quantile of the JPEG; matching the two distributions needs no pixel-exact -/// registration and is increasing by construction. -/// -/// Binning the pairs by scene luminance and taking the JPEG's median per bin instead (followed by -/// isotonic pooling) fails on real files: camera JPEGs are lens-corrected and sharpened, so on a -/// 96 px proxy of a busy scene neighbouring pairs disagree by more than the gap between bins. -/// The conditional medians then zig-zag, pooling turns them into flat steps (13 of 31 segments -/// on a Z 6II forest, issue #475) that render as posterised patches, and regression toward the -/// mean flattens both ends of the curve (lifted blacks, dull highlights). -fn fit_tone(pairs: Vec<(f64, f64)>) -> Option { - if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) { - return None; - } - let mut xs: Vec = pairs.iter().map(|p| p.0).collect(); - let mut ys: Vec = pairs.iter().map(|p| p.1).collect(); - xs.sort_by(f64::total_cmp); - ys.sort_by(f64::total_cmp); - let n = xs.len(); - let mut knots = [[0.0; 2]; 32]; - for (i, knot) in knots.iter_mut().enumerate() { - let (a, b) = (i * n / 32, (i + 1) * n / 32); - let mid = a + (b - a) / 2; - *knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32]; - } - if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { - return None; - } - CameraTone::new(knots) -} - -fn median(values: &mut [f64]) -> Option { - values.sort_by(f64::total_cmp); - values.get(values.len() / 2).copied() -} - -/// The earlier tone fit, kept as the fallback of [`ToneFit`]: the JPEG's median per equal-population -/// bin of scene luminance, then pooled-adjacent-violators isotonic regression. It follows the -/// per-pixel pairs closely when the two images are registered, which is why it can clear the -/// acceptance gates where [`fit_tone`] just misses them, but on lens-corrected JPEGs of busy scenes -/// the pooling leaves flat steps (issue #475). -fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option { - if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) { - return None; - } - pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut knots = [[0.0; 2]; 32]; - for (i, knot) in knots.iter_mut().enumerate() { - let bin = pairs.get(i * pairs.len() / 32..(i + 1) * pairs.len() / 32)?; - let mut xs: Vec<_> = bin.iter().map(|p| p.0).collect(); - let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect(); - *knot = [median(&mut xs)? as f32, median(&mut ys)? as f32]; - } - if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { - return None; - } - // Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial. - let mut blocks: Vec<(f32, usize)> = Vec::new(); - for knot in knots { - blocks.push((knot[1], 1)); - while blocks.len() >= 2 { - let (a, an) = *blocks.get(blocks.len() - 2)?; - let (b, bn) = *blocks.last()?; - if a <= b { - break; - } - blocks.truncate(blocks.len() - 2); - blocks.push(((a * an as f32 + b * bn as f32) / (an + bn) as f32, an + bn)); - } - } - let mut i = 0; - for (y, n) in blocks { - for knot in knots.get_mut(i..i + n)? { - knot[1] = y; - } - i += n; - } - CameraTone::new(knots) -} - #[cfg(test)] mod tests; diff --git a/crates/engine/src/camera_preview/colour.rs b/crates/engine/src/camera_preview/colour.rs new file mode 100644 index 000000000..a33a5c7a5 --- /dev/null +++ b/crates/engine/src/camera_preview/colour.rs @@ -0,0 +1,232 @@ +use super::look_fit::{fit_dng_tone, fit_preview}; +use super::mono::{fit_dark_tone, monochrome_look}; +use super::proxy::{LensGeometry, proxies}; +use super::*; + +/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates +/// below were set at this size). +pub(super) const PROXY: usize = 96; + +/// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples. +pub(crate) const PROFILE_PROXY: usize = 192; + +/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix: +/// their starting look is fitted to the file's own JPEG, and white balance is relative to the +/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the +/// same formats by file extension. +pub(crate) fn file_local_look(format: RawFormat) -> bool { + matches!( + format, + RawFormat::Arw + | RawFormat::Nef + | RawFormat::Nrw + | RawFormat::Rw2 + | RawFormat::Raf + | RawFormat::Cr3 + | RawFormat::Cr2 + | RawFormat::Pef + | RawFormat::Srw + | RawFormat::Orf + | RawFormat::Mrw + | RawFormat::Erf + ) +} + +/// The colour a raw starts from, first available first: the file's own colour matrices (DNG); a look fitted to the +/// file's JPEG ([`fit_preview`]: with the camera profile for its model when there is one, else with colour fitted to +/// this photo alone); the model's colour matrices from measured spectral sensitivities (`lightcraft_raw::spectral`); +/// the neutral fallback. The spectral matrices only stand in for the neutral fallback, so a photo whose JPEG fit is +/// accepted renders as without them; when used they are written into `raw.color`. A photo whose JPEG is black and +/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's +/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the +/// as-shot white, the camera transform at it and the fitted look. +/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from +/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing. +/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit. +pub(crate) fn starting_colour( + raw: &mut RawImage, + bytes: &[u8], + lens: Option<&LensGeometry>, + embedded: Option<&lightcraft_develop::EmbeddedLens>, +) -> (Xy, CameraTransform, Option) { + let (xy, t, look) = starting_colour_with( + raw, + |raw, t| fit_preview(raw, bytes, t, lens), + |raw, t| monochrome_look(raw, bytes, t, lens), + |raw, t| { + if !file_local_look(raw.format) { + return None; + } + let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?; + fit_dark_tone(&sensor, &reference, &clipped) + }, + ); + if look.is_none() && dng_tone_eligible(raw, &t) { + let look = fit_dng_tone(raw, bytes, &t, embedded); + return (xy, t, look); + } + (xy, t, look) +} + +/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). +fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { + curve.is_none_or(|c| { + (0..=32).all(|i| { + let x = i as f32 / 32.0; + (c.eval(x) - x).abs() < 0.005 + }) + }) +} + +/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no +/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an +/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on +/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables +/// to its proxy, so it fits only the tone around them. +pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { + let p = &raw.color.profile; + raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none() +} + +/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white +/// JPEG's tone fit and the near-black tone fallback passed in (tests pin the order with them). +pub(super) fn starting_colour_with( + raw: &mut RawImage, + fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option, bool), + monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option, + dark: impl FnOnce(&RawImage, &CameraTransform) -> Option, +) -> (Xy, CameraTransform, Option) { + let start = |raw: &RawImage| { + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + (xy, lightcraft_raw::color::camera_transform(raw, xy)) + }; + let (xy, t) = start(raw); + if !t.matrix_is_fallback { + return (xy, t, None); + } + let (look, black_and_white) = fit(raw, &t); + if let Some(look) = look { + return (xy, t, Some(look)); + } + let (xy, t) = match raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) { + Some(camera) => { + camera.fill(&mut raw.color); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model); + } + start(raw) + } + None => (xy, t), + }; + // A black-and-white JPEG first tries its existing tone fit on the chosen colour. + let look = if black_and_white { monochrome(raw, &t) } else { None }; + (xy, t, look.or_else(|| dark(raw, &t))) +} + +/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo +/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered +/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them). +pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option { + if lightcraft_raw::color::has_matrix(&raw.color) { + return None; + } + // the white the source shows, decided before `starting_colour` may fill in measured matrices + let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); + let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw)); + // (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling) + let (_, t, look) = starting_colour(raw, bytes, None, None); + dng_colour(&raw.color, &white, &t, look.as_ref()) +} + +/// Give a raw about to be written as a DNG of its own (Export as DNG, Convert / Copy as DNG) the colour it opens +/// with, when the file has no colour matrices (issue #1745: a Z 9 NEF's DNG had no `ColorMatrix1`, and other raw +/// converters refused it). The lens corrections stay in the DNG; only the fit sees the raw without them, as when +/// the photo opens. +pub(crate) fn with_starting_colour(raw: &mut RawImage, bytes: &[u8]) { + if lightcraft_raw::color::has_matrix(&raw.color) { + return; + } + let list3 = raw.opcodes.list3.clone(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let tags = starting_colour_tags(raw, bytes); + raw.opcodes.list3 = list3; + if let Some(tags) = tags { + raw.color = tags; + } +} + +/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward +/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation +/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table +/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two +/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo +/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all). +/// The colour matrices declare `declared`, the white balance the source shows ([`crate::files::WbScaleOf::dng_white`]), +/// so the DNG opens at the source's As Shot temperature and tint and a white-balance edit starts from it. Matrices +/// derived from `forward` put the declared white wherever the look and the generic camera model took it: ARW merges +/// opened at about 7000 K / +115 where the ARWs show 4800 K / +8, and any white-balance edit turned them green. +/// `forward` is used only with the as-shot neutral, so the render at As Shot doesn't depend on them. +/// `None` when the white balance or the matrix can't give tags. +pub(crate) fn dng_colour(tags: &ColorData, declared: &crate::files::DngWhite, t: &CameraTransform, look: Option<&CameraLook>) -> Option { + let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); + // the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes + // into the baseline exposure + let white = m.apply([1.0; 3]); + let k = (white[0] + white[1] + white[2]) / 3.0; + if !(k.is_finite() && k > 1e-6 && t.wb.iter().all(|w| w.is_finite() && *w > 0.0)) { + return None; + } + let m = Mat3(m.0.map(|r| r.map(|v| v / k))); + let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m); + let wb = t.wb.map(f64::from); + let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w); + let (illuminant, color_matrix) = match declared { + crate::files::DngWhite::Matrices(c) if lightcraft_raw::color::has_matrix(c) => (c.illuminant, c.color_matrix), + crate::files::DngWhite::Matrices(_) => ([21, 0], [Some(declaring(t.white_xy, neutral)?), None]), + crate::files::DngWhite::At(xy) => ([21, 0], [Some(declaring(*xy, neutral)?), None]), + }; + if !color_matrix.iter().flatten().all(|m| m.0.iter().flatten().all(|v| v.is_finite())) { + return None; + } + Some(ColorData { + illuminant, + color_matrix, + // a second colour matrix comes with a second forward matrix (DNG spec): the same one, fitted at one white + forward_matrix: [Some(forward), color_matrix[1].map(|_| forward)], + camera_calibration: [None, None], + analog_balance: None, + as_shot_neutral: Some(neutral), + as_shot_white_xy: None, + baseline_exposure: t.baseline_exposure + k.log2(), + baseline_sharpness: tags.baseline_sharpness, + kelvin_presets: None, + profile: ProfileLook { + hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None], + tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)), + ..Default::default() + }, + }) +} + +/// An XYZ → camera colour matrix under which camera `neutral` is white `xy`: the generic camera model, scaled per +/// channel. `None` when the model gives no positive neutral there. +fn declaring(xy: Xy, neutral: [f64; 3]) -> Option { + let g = lightcraft_raw::color::xyz_to_camera(&ColorData::default(), xy); + let n = g.apply(xy.to_xyz()); + if !n.iter().all(|v| v.is_finite() && *v > 1e-9) { + return None; + } + Some(Mat3::diag(neutral[0] / n[0], neutral[1] / n[1], neutral[2] / n[2]).mul(&g)) +} + +/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the +/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`). +fn dng_tone_curve(tone: &CameraTone) -> Option { + const N: usize = 256; + let samples = (0..=N).map(|i| { + let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32); + [x, tone.apply(x).min(1.0)] + }); + let flat: Vec = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect(); + ToneCurve::from_tag(&flat) +} diff --git a/crates/engine/src/camera_preview/hue_sat.rs b/crates/engine/src/camera_preview/hue_sat.rs new file mode 100644 index 000000000..7e92ba519 --- /dev/null +++ b/crates/engine/src/camera_preview/hue_sat.rs @@ -0,0 +1,271 @@ +use super::mono::to_prophoto; +use super::pairs::{displayed, luma}; +use super::*; + +/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour +/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps +/// its colour; in between the two are blended, so noisy shadows show no step. +pub(super) const MIN_LUMINANCE_KEPT: f32 = 0.25; + +pub(super) const NO_LUMINANCE_KEPT: f32 = 0.15; + +/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to +/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between. +pub(super) fn restore_weight(share: f32) -> f32 { + if !share.is_finite() { + return 0.0; + } + let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes +/// hue and saturation only: luminance is restored, the camera tone curve owns it. +pub(crate) struct HueSat<'a> { + table: &'a HsvTable, + pub(super) to: [[f32; 3]; 3], + pub(super) from: [[f32; 3]; 3], +} + +impl<'a> HueSat<'a> { + pub fn new(table: &'a HsvTable) -> Option> { + let to = to_prophoto(); + Some(HueSat { table, to: to.to_f32(), from: to.inverse()?.to_f32() }) + } + + #[inline] + pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { + let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) }; + let out = mul(&self.from, self.table.apply(mul(&self.to, rgb))); + let (before, after) = (luminance_2020(rgb), luminance_2020(out)); + // restoring the luminance divides by the table's: near black, where noise makes some channels + // negative, the table can bring it close to 0, and the gain would turn the pixel white (#820) + if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) { + return rgb; + } + let w = restore_weight(after / before); + if w >= 1.0 { + return out.map(|v| v * before / after); + } + if w <= 0.0 { + return rgb; + } + std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w) + } + + /// [`Self::apply`] to `N` pixels at once (`rgb[channel][lane]`), bit for bit, through + /// [`HsvTable::apply_lanes`], with the guards as selects. + #[inline] + pub fn apply_lanes(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { + let mul = |m: &[[f32; 3]; 3], v: &[[f32; N]; 3]| { + let mut out = [[0.0f32; N]; 3]; + for (row, out) in m.iter().zip(&mut out) { + for l in 0..N { + out[l] = row[0] * v[0][l] + row[1] * v[1][l] + row[2] * v[2][l]; + } + } + out + }; + let out = mul(&self.from, &self.table.apply_lanes(mul(&self.to, &rgb))); + let mut restored = rgb; + for l in 0..N { + let (input, out) = ([rgb[0][l], rgb[1][l], rgb[2][l]], [out[0][l], out[1][l], out[2][l]]); + let (before, after) = (luminance_2020(input), luminance_2020(out)); + let untouched = (before <= 0.0) | (after <= 0.0) | !(out[0].is_finite() & out[1].is_finite() & out[2].is_finite()); + let w = restore_weight(after / before); + for (c, restored) in restored.iter_mut().enumerate() { + let full = out[c] * before / after; + let blend = input[c] + (full - input[c]) * w; + // (`w >= 1` and `w <= 0` exclude each other) + restored[l] = if untouched | (w <= 0.0) { + input[c] + } else if w >= 1.0 { + full + } else { + blend + }; + } + } + restored + } +} + +/// HSV hue (degrees) and saturation; `None` for black or non-finite colours. +pub(super) fn hue_saturation(p: [f64; 3]) -> Option<(f64, f64)> { + let max = p[0].max(p[1]).max(p[2]); + let min = p[0].min(p[1]).min(p[2]); + if !(max.is_finite() && min.is_finite()) || max <= 0.0 { + return None; + } + let d = max - min; + if d <= 0.0 { + return Some((0.0, 0.0)); + } + let h = if max == p[0] { + ((p[1] - p[2]) / d).rem_euclid(6.0) + } else if max == p[1] { + (p[2] - p[0]) / d + 2.0 + } else { + (p[0] - p[1]) / d + 4.0 + }; + Some((h * 60.0, d / max)) +} + +/// Table resolution: 5° hue steps (a coarser grid blurred the lime shirt into neighbouring browns +/// that want the opposite shift), saturation 0, 0.25 … 1. Value is not an axis: tone is fitted separately. +const TABLE_HUES: usize = 72; + +const TABLE_SATS: usize = 5; + +/// Value levels (sRGB-encoded, as DNG tables with encoding 1): cameras turn dark yellows toward +/// orange but bright yellow-greens toward green, which one shift per hue averages away. +const TABLE_VALS: usize = 5; + +/// Kernel widths around each table node (hue in degrees, saturation, encoded value). +const KERNEL_HUE: f64 = 2.5; + +const KERNEL_SAT: f64 = 0.15; + +const KERNEL_VAL: f64 = 0.12; + +/// Kernel weight at which a node keeps half of its estimate; sparse nodes shrink to identity. +const SHRINK_WEIGHT: f64 = 5.0; + +/// Fit hue shifts and saturation scales of the training pairs left after `matrix`, per (hue, +/// saturation) node, kernel-weighted and shrunk toward identity where the photo has few samples. +/// Saturation 0 stays identity, so neutrals are never tinted. +pub(super) fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option { + let to = to_prophoto(); + let samples: Vec<(f64, f64, f64, f64, f64)> = pairs + .iter() + .enumerate() + .filter(|(i, _)| i % 3 != 0) + .filter_map(|(_, (x, y))| { + let p = to.apply(matrix.apply(*x)); + let (hp, sp) = hue_saturation(p)?; + let value = f64::from(lightcraft_color::transfer::linear_to_srgb(p[0].max(p[1]).max(p[2]).min(1.0) as f32)); + let (ht, st) = hue_saturation(to.apply(*y))?; + // hue is meaningless near neutral + if sp < 0.08 || st < 0.02 { + return None; + } + let shift = ((ht - hp + 180.0).rem_euclid(360.0) - 180.0).clamp(-30.0, 30.0); + let log_scale = (st / sp).ln().clamp(-0.7, 0.7); + Some((hp, sp.min(1.0), value, shift, log_scale)) + }) + .collect(); + if samples.len() < 64 { + return None; + } + // Samples by table hue step: a node only looks at hues within its kernel's reach. + let step = 360.0 / TABLE_HUES as f64; + let mut by_hue: Vec> = vec![Vec::new(); TABLE_HUES]; + for sample in samples { + if let Some(bin) = by_hue.get_mut(((sample.0.rem_euclid(360.0) / step) as usize).min(TABLE_HUES - 1)) { + bin.push(sample); + } + } + let reach = (4.0 * KERNEL_HUE / step).ceil() as usize + 1; + use rayon::prelude::*; + let data: Vec<[f32; 3]> = (0..TABLE_VALS * TABLE_HUES * TABLE_SATS) + .into_par_iter() + .map(|index| { + let (v, h, s) = (index / (TABLE_HUES * TABLE_SATS), index / TABLE_SATS % TABLE_HUES, index % TABLE_SATS); + if s == 0 { + return [0.0, 1.0, 1.0]; + } + let (val, hue, sat) = (v as f64 / (TABLE_VALS - 1) as f64, h as f64 * step, s as f64 / (TABLE_SATS - 1) as f64); + let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0); + for offset in 0..=2 * reach { + let Some(bin) = by_hue.get((h + TABLE_HUES * 2 + offset - reach) % TABLE_HUES) else { continue }; + for &(hp, sp, vp, dh, ls) in bin { + let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0; + if dhue.abs() > 4.0 * KERNEL_HUE { + continue; + } + let d2 = (dhue / KERNEL_HUE).powi(2) + ((sp - sat) / KERNEL_SAT).powi(2) + ((vp - val) / KERNEL_VAL).powi(2); + let k = (-0.5 * d2).exp() * sp; + weight += k; + shift += k * dh; + log_scale += k * ls; + } + } + if weight <= 0.0 { + return [0.0, 1.0, 1.0]; + } + let shrink = 1.0 / (weight + SHRINK_WEIGHT); + [(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0] + }) + .collect(); + data.iter().all(|e| e.iter().all(|v| v.is_finite())).then_some(HsvTable { + hue_divisions: TABLE_HUES, + sat_divisions: TABLE_SATS, + val_divisions: TABLE_VALS, + data, + srgb_value: true, + }) +} + +/// Kernel width of the chroma curve's nodes (display luminance) and the weight at which a node +/// keeps half of its estimate. +const CHROMA_KERNEL: f64 = 0.08; + +const CHROMA_SHRINK: f64 = 2.0; + +/// The look's tone curve with a chroma-by-display-luminance curve fitted to `pairs` (two thirds +/// train, one third held out), when that lowers the held-out error. Colourfulness is compared as +/// the distance from neutral of luminance-normalised RGB. +pub(super) fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option { + let correction = look.hue_sat.as_ref().and_then(HueSat::new); + let scene = |x: &[f64; 3]| { + let p = look.matrix.apply(*x); + correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) + }; + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let predict = |x: &[f64; 3]| displayed(scene(x), &tone); + let chroma = |p: [f64; 3]| { + let y = luma(p); + (y > 0.0).then(|| p.iter().map(|v| (v / y - 1.0).powi(2)).sum::().sqrt()) + }; + let samples: Vec<(f64, f64, f64)> = pairs + .iter() + .enumerate() + .filter(|(i, _)| i % 3 != 0) + .filter_map(|(_, (x, y))| { + let p = predict(x); + let (cp, cy) = (chroma(p)?, chroma(*y)?); + (cp > 0.05).then(|| (luma(p), cp, (cy.max(1e-3) / cp).ln().clamp(0.05f64.ln(), 2.5f64.ln()))) + }) + .collect(); + if samples.len() < 64 { + return None; + } + let mut curve = [1.0f32; lightcraft_pipeline::tone::CHROMA_N]; + for (j, node) in curve.iter_mut().enumerate() { + let at = j as f64 / (lightcraft_pipeline::tone::CHROMA_N - 1) as f64; + let (mut weight, mut sum) = (0.0, 0.0); + for &(o, cp, log_ratio) in &samples { + let k = (-0.5 * ((o - at) / CHROMA_KERNEL).powi(2)).exp() * cp; + weight += k; + sum += k * log_ratio; + } + *node = (sum / (weight + CHROMA_SHRINK)).exp() as f32; + } + let fitted = look.tone.with_chroma(curve)?; + let with = ToneMap::camera(&fitted, 0.0, 0.0, 0.0); + let error = |map: &ToneMap| -> f64 { + pairs + .iter() + .step_by(3) + .map(|(x, y)| { + let p = displayed(scene(x), map); + (0..3).map(|c| (p[c] - y[c]).powi(2)).sum::() + }) + .sum() + }; + let (before, after) = (error(&tone), error(&with)); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] ARW chroma curve {curve:?}: held-out error {before:.4} -> {after:.4}"); + } + (after.is_finite() && after < before).then_some(fitted) +} diff --git a/crates/engine/src/camera_preview/look_fit.rs b/crates/engine/src/camera_preview/look_fit.rs new file mode 100644 index 000000000..c1588093b --- /dev/null +++ b/crates/engine/src/camera_preview/look_fit.rs @@ -0,0 +1,273 @@ +use super::colour::{PROXY, file_local_look}; +use super::mono::{fit_tone_only_with, monochrome}; +use super::pairs::{Attempt, TONE_FITS, fit_attempt, fit_partial, search_ordered}; +use super::proxy::{CLIP_TOLERANCE, LensGeometry, MIN_PREVIEW_FRACTION, proxies, structure_agrees}; +use super::tone::{HighlightCheck, highlights}; +use super::*; + +/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). +pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option, bool) { + if !transform.matrix_is_fallback || !file_local_look(raw.format) { + return (None, false); + } + let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) }; + let black_and_white = monochrome(&reference); + // A camera profile pooled from many photos knows colours this photo shows too little of; + // try its colour first and fit tone/chroma per photo (picture styles vary). + let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); + let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); + // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions, + // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO. + let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); + let look = match fit_look_with(&sensor, &reference, &clipped, colour.clone(), dro) { + Some(look) => look, + // A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve + // (`monochrome_look` does it without a profile). + None if colour.is_some() && black_and_white => { + let unclipped = without_clipped(&sensor, &clipped); + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; + let Some(look) = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, colour, &bright) else { return (None, true) }; + look + } + None => return (None, black_and_white), + }; + let look = if dro { with_dro_off_tone(look) } else { look }; + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}, DRO tone replaced {dro}", + raw.format, + look.matrix.0, + look.tone, + look.hue_sat.is_some(), + profile.is_some() + ); + } + (Some(look), black_and_white) +} + +/// Sensor level (normalised, white = 1) at or above which a raw channel counts as clipped, as in +/// the render's highlight reconstruction. +pub(super) const SENSOR_CLIP: f32 = 0.99; + +/// [`fit_look_with`] for a photo without Sony's DRO. +#[cfg(test)] +pub(super) fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option { + fit_look_with(sensor, reference, clipped, colour, false) +} + +/// The photo's look, refitted without the proxy pixels whose sensor values are clipped. +/// +/// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip +/// level while the others keep rising, a white overcast sky comes out magenta or blue through the +/// camera matrix, while the camera JPEG shows it white. As correspondences such pixels teach the +/// colour matrix to pull colours toward grey, and the chroma curve to remove colour at their +/// display luminance (on a Z 6II forest under a clipped sky the curve cut colourfulness above +/// display luminance 0.4 to 13–19%, and every one of its samples there was clipped). The render +/// rebuilds clipped highlights separately (`lightcraft_raw::highlight`), so the look is fitted on +/// what the sensor measured. +/// +/// The search on all pixels decides, as before, whether the photo gets a look and which attempt +/// of [`search_ordered`] gives it; only that attempt is refitted without the clipped pixels, and +/// when the refit misses the acceptance gates the look on all pixels is kept. So no photo loses +/// its look and none switches attempt (a refit that just clears the gate on all pixels must not +/// replace a look fitted away from edges). A photo that had no look gets one when the search +/// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly +/// closer to the camera JPEG than the neutral fallback. +pub(super) fn fit_look_with( + sensor: &Rgb32f, + reference: &Rgb32f, + clipped: &[bool], + colour: Option<(Mat3, Option)>, + dro: bool, +) -> Option { + let unclipped = without_clipped(sensor, clipped); + // from the proxies as they are: the retries' filters (clipped pixels, edges) don't remove them + let bright = HighlightCheck { pairs: highlights(sensor, reference), dro, grey: false }; + let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS, &bright) else { + return unclipped + .as_ref() + .and_then(|u| search_ordered(u, reference, colour, TONE_FITS, &bright)) + .map(|(look, _)| look) + .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference, &bright)); + }; + let Some(unclipped) = unclipped else { return Some(look) }; + let refit = fit_attempt(&unclipped, reference, &colour, attempt, &bright); + if refit.is_none() && lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels"); + } + Some(refit.unwrap_or(look)) +} + +/// `sensor` with its clipped pixels set to NaN (left out of every training pair), or `None` when +/// none is clipped. +pub(super) fn without_clipped(sensor: &Rgb32f, clipped: &[bool]) -> Option { + if clipped.len() != sensor.data.len() || !clipped.iter().any(|c| *c) { + return None; + } + let mut out = sensor.clone(); + for (p, c) in out.data.iter_mut().zip(clipped) { + if *c { + *p = [f32::NAN; 3]; + } + } + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look: {} of {} proxy pixels have a clipped sensor channel", + clipped.iter().filter(|c| **c).count(), + clipped.len() + ); + } + Some(out) +} + +/// 1 where any channel of a camera-RGB pixel is at or above [`SENSOR_CLIP`], else 0. +pub(super) fn clip_mask(camera: &Rgb32f) -> Rgb32f { + let mut mask = camera.clone(); + mask.map_in_place(|p| if p.iter().any(|v| *v >= SENSOR_CLIP) { [1.0; 3] } else { [0.0; 3] }); + mask +} + +/// Sony's global tone curve with the Dynamic Range Optimizer off (Standard creative style), pooled +/// from 12 CC0 raw.pixls.us ARWs of 10 interchangeable-lens bodies and their camera JPEGs; the file +/// lists them and how the curve was derived (`assets/ATTRIBUTION.md`). Pooled the same way from 55 +/// private ILCE-7CR photos with DRO off, the curve differs by at most 1.2 L* between scene luminance +/// 0.01 and 0.5. Issue #244, `docs/camera-preview-colour.md`. +const SONY_DRO_OFF_TONE: &str = include_str!("../../../../assets/camera-tone/sony-dro-off.json"); + +/// [`SONY_DRO_OFF_TONE`]'s curve (`None` only if the built-in file were invalid; a test checks it). +pub(super) fn sony_dro_off_tone() -> Option { + #[derive(serde::Deserialize)] + struct File { + knots: [[f32; 2]; 32], + } + static TONE: std::sync::OnceLock> = std::sync::OnceLock::new(); + *TONE.get_or_init(|| serde_json::from_str::(SONY_DRO_OFF_TONE).ok().and_then(|f| CameraTone::new(f.knots))) +} + +/// `look`, fitted to a camera JPEG brightened by Sony's Dynamic Range Optimizer, with its tone +/// curve lowered to Sony's without DRO ([`SONY_DRO_OFF_TONE`]) wherever that is darker. DRO only +/// brightens, so the fitted curve bounds the curve without it from above, everywhere: where Sony's +/// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps +/// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted. +pub(super) fn with_dro_off_tone(look: CameraLook) -> CameraLook { + CameraLook { tone: dro_off(&look.tone), ..look } +} + +/// The curve of [`with_dro_off_tone`]: `own` lowered to Sony's without DRO wherever that is darker, +/// or `own` unchanged when it can't be. +pub(super) fn dro_off(own_tone: &CameraTone) -> CameraTone { + let Some(dro_off) = sony_dro_off_tone() else { return *own_tone }; + let own = own_tone.knots(); + let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]); + // Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots + // (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the + // extended highlights would end up above the fitted curve. Raise the second-last knot (never + // above the fitted curve) until the rate is at most the fitted curve's. + let ([a, b], last) = ([own[30], own[31]], knots[31]); + let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01); + let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]); + knots[30][1] = knots[30][1].max(floor).min(last[1]); + let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*own_tone.chroma())) else { return *own_tone }; + if !at_most(&tone, own_tone) { + return *own_tone; + } + tone +} + +/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone +/// table covers ([`ToneMap::camera`]). +pub(super) fn at_most(a: &CameraTone, b: &CameraTone) -> bool { + use lightcraft_pipeline::tone::{GREY, LUT_MAX_EV, LUT_MIN_EV, LUT_N}; + (0..LUT_N).all(|i| { + let x = GREY * 2f32.powf(LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32); + a.apply(x) <= b.apply(x) + 1e-5 + }) +} + +/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and +/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px); +/// 128 MP also admits 100 MP medium-format previews. +pub(super) const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; + +/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model +/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given +/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a +/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not +/// clip more highlights than the plain render ([`clips_more`]). +pub(super) fn fit_dng_tone( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + lens: Option<&lightcraft_develop::EmbeddedLens>, +) -> Option { + // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. + let refuse = |why: &str| { + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE refused {why}"); + } + None + }; + let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") }; + // As the loader renders it: the profile's tables around the baseline exposure. + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { + let gain = 2f32.powf(transform.baseline_exposure as f32); + sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain)); + } + correct_vignetting(&mut sensor, lens, raw.orientation); + if (sensor.width, sensor.height) != (reference.width, reference.height) { + return refuse("preview"); + } + if !structure_agrees(&sensor, &reference) { + return refuse("structure"); + } + let unclipped = without_clipped(&sensor, &clipped); + let fit_on = unclipped.as_ref().unwrap_or(&sensor); + let colour = Some((Mat3::IDENTITY, None)); + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + let look = TONE_FITS.iter().find_map(|&tone| { + [false, true].into_iter().find_map(|away_from_edges| { + fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }, &bright) + }) + }); + let Some(look) = look else { return refuse("fit") }; + let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { + Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), + None => ToneMap::new(0.0, 0.0, 0.0), + }; + let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if clips_more(&sensor, &reference, &fitted, &plain) { + return refuse("clip"); + } + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE accepted"); + } + Some(look) +} + +/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain +/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the +/// camera's look, one that clips what neither does loses highlight detail. +pub(super) fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { + let near_white = |tone: &ToneMap| { + let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); + n as f64 / sensor.data.len().max(1) as f64 + }; + let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64; + near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE +} + +/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the +/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the +/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice. +pub(super) fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) { + let Some(vignette) = lens.and_then(|l| l.vignette) else { return }; + let (w, h) = (sensor.width as f64, sensor.height as f64); + let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; + let width = sensor.width.max(1); + for (i, p) in sensor.data.iter_mut().enumerate() { + let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h); + let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32; + *p = p.map(|v| v * gain); + } +} diff --git a/crates/engine/src/camera_preview/mono.rs b/crates/engine/src/camera_preview/mono.rs new file mode 100644 index 000000000..cfac90c34 --- /dev/null +++ b/crates/engine/src/camera_preview/mono.rs @@ -0,0 +1,212 @@ +use super::colour::{PROXY, file_local_look}; +use super::hue_sat::HueSat; +use super::look_fit::{with_dro_off_tone, without_clipped}; +use super::pairs::{ + EDGE_CONTRAST, MIN_IMPROVEMENT, PARTIAL_MIN_RANK_CORRELATION, Pairs, collect_pairs, displayed, fit_tone_with_shadows, local_contrast, luma, + rank_correlation_ties, +}; +use super::proxy::{LensGeometry, proxies}; +use super::tone::{HighlightCheck, ToneFit, fit_tone, highlights, highlights_hold}; +use super::*; + +/// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey. +const GREY_SPREAD: f32 = 0.002; + +/// Whether the camera JPEG is black and white: a monochrome picture style (Canon Monochrome, Sony B/W, Nikon +/// Monochrome), shot from a full-colour raw. All but one proxy pixel in a thousand are grey within +/// [`GREY_SPREAD`]. A toned monochrome (sepia) has colour, so it isn't one. +pub(super) fn monochrome(reference: &Rgb32f) -> bool { + let coloured = reference + .data + .iter() + .filter(|p| { + let (min, max) = p.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + !p.iter().all(|v| v.is_finite()) || max - min > GREY_SPREAD + }) + .count(); + reference.data.len() >= 256 && coloured.saturating_mul(1000) <= reference.data.len() +} + +/// The look of a raw whose camera JPEG is black and white ([`monochrome`]; issue #577) and gave no look with a camera +/// profile's colour in [`fit_preview`] (or whose model has no profile). Such a JPEG has no colour to fit, so the photo +/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker +/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve. +/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and +/// adds the tone curve alone ([`fit_tone_only_with`]), as [`fit_look_with`] tries the photo's own colour after a profile's. The +/// chroma curve stays neutral: the JPEG has no colourfulness to learn. +pub(super) fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option { + if !file_local_look(raw.format) { + return None; + } + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?; + if !monochrome(&reference) { + return None; + } + // Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one + let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); + let unclipped = without_clipped(&sensor, &clipped); + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; + let look = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, None, &bright)?; + let look = if dro { with_dro_off_tone(look) } else { look }; + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] {:?} camera look from a black-and-white JPEG: tone {:?}, {} colour, DRO tone replaced {dro}", + raw.format, + look.tone, + if transform.matrix_is_fallback { "fallback" } else { "spectral" } + ); + } + Some(look) +} + +/// The tone curve of a black-and-white camera JPEG, on top of `colour` (a camera profile's, in the sensor proxy's +/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of +/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture +/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below +/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. A curve narrower than +/// [`NARROW_TONE_RANGE`] must also keep the JPEG's bright areas ([`highlights_hold`], by luminance). +pub(super) fn fit_tone_only_with( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + highlights: &HighlightCheck, +) -> Option { + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit, highlights)) +} + +/// [`fit_tone_only_with`] for a photo without Sony's DRO, checked against its own bright areas. +#[cfg(test)] +pub(super) fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + fit_tone_only_with(sensor, reference, colour, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }) +} + +fn fit_tone_only_on( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + edge_limit: Option, + highlights: &HighlightCheck, +) -> Option { + // every pair counts: the JPEG has no colour to require + let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; + fit_tone_only_pairs(sensor, reference, colour, edge_limit, &pairs, false, highlights) +} + +/// A near-black frame may have enough luminance signal for tone but fewer than 256 colour pairs. +/// Try only after every existing look fails. Keep the colour of the source transform, +/// exclude clipped pixels from training, and keep their luminance at least as close to the JPEG +/// as the default curve does. Bright JPEGs need the existing fits to constrain their highlights. +pub(super) fn fit_dark_tone(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool]) -> Option { + if (sensor.width, sensor.height) != (reference.width, reference.height) + || sensor.data.len() != reference.data.len() + || clipped.len() != sensor.data.len() + || sensor.data.is_empty() + || sensor.data.iter().any(|p| !p.iter().all(|v| v.is_finite())) + || reference.data.iter().any(|p| !p.iter().all(|v| v.is_finite() && *v >= 0.0) || luminance_2020(*p) >= 0.85) + { + return None; + } + let dark = reference.data.iter().filter(|p| luminance_2020(**p) < 0.015).count(); + if (dark as f64) < reference.data.len() as f64 * 0.9 { + return None; + } + // no JPEG pixel reaches 0.85 here, so the bright-area check has nothing to hold + let bright = HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }; + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| { + let pairs: Pairs = sensor + .data + .iter() + .zip(&reference.data) + .enumerate() + .filter(|(i, (input, output))| { + // The same signal floors as the camera toe, without colour ratios or counts. + !clipped.get(*i).copied().unwrap_or(true) + && input.iter().all(|v| *v > 0.0001 && *v < 1.5) + && (0.0003..0.85).contains(&luminance_2020(**output)) + && edge_limit.is_none_or(|limit| local_contrast(sensor, *i) <= limit && local_contrast(reference, *i) <= limit) + }) + .map(|(_, (input, output))| (input.map(f64::from), output.map(f64::from))) + .collect(); + if pairs.len() < 256 { + return None; + } + let look = fit_tone_only_pairs(sensor, reference, &None, edge_limit, &pairs, true, &bright)?; + let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + for ((input, target), clipped) in sensor.data.iter().zip(&reference.data).zip(clipped) { + if *clipped { + let target = luma(target.map(f64::from)); + let before = luma(displayed(input.map(f64::from), &neutral)); + let after = luma(displayed(input.map(f64::from), &tone)); + if !after.is_finite() || (after - target).abs() > (before - target).abs() { + return None; + } + } + } + Some(look) + }) +} + +fn fit_tone_only_pairs( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + edge_limit: Option, + pairs: &Pairs, + dark: bool, + highlights: &HighlightCheck, +) -> Option { + let kind = if dark { "near-black scene" } else { "black-and-white JPEG" }; + let related = rank_correlation_ties(pairs); + if related < PARTIAL_MIN_RANK_CORRELATION { + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look from a {kind}: luminance rank correlation {related:.3}, not the same picture"); + } + return None; + } + let (matrix, hue_sat) = colour.clone().unwrap_or((Mat3::IDENTITY, None)); + let correction = hue_sat.as_ref().and_then(HueSat::new); + let coloured = |x: [f64; 3]| { + let p = matrix.apply(x); + correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) + }; + let tone = if dark { + // Split this wider set once. The camera toe's separate colour/extra split belongs to + // the original colour pairs and would put some of these held-out pixels into training. + fit_tone( + pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(), + )? + } else { + fit_tone_with_shadows(ToneFit::Quantile, pairs, sensor, reference, edge_limit, coloured)? + }; + let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() }; + if !highlights_hold(&look.tone, highlights, coloured) { + return None; + } + let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); + let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); + for (x, target) in pairs.iter().step_by(3) { + let (fallback, toned, target) = (luma(displayed(*x, &neutral)), luma(displayed(coloured(*x), &tone)), luma(*target)); + before += (fallback - target).powi(2); + after += (toned - target).powi(2); + before_encoded += (encoded(fallback) - encoded(target)).powi(2); + after_encoded += (encoded(toned) - encoded(target)).powi(2); + samples += 1; + } + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look from a {kind}: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})", + (before / samples.max(1) as f64).sqrt(), + (after / samples.max(1) as f64).sqrt(), + if edge_limit.is_some() { ", away from edges" } else { "" } + ); + } + let better = + after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT; + (samples > 0 && better).then_some(look) +} + +/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in. +pub(super) fn to_prophoto() -> Mat3 { + PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()) +} diff --git a/crates/engine/src/camera_preview/pairs.rs b/crates/engine/src/camera_preview/pairs.rs new file mode 100644 index 000000000..2fce1a871 --- /dev/null +++ b/crates/engine/src/camera_preview/pairs.rs @@ -0,0 +1,499 @@ +use super::hue_sat::{HueSat, fit_chroma, fit_hue_sat}; +use super::proxy::DARK_FLOOR; +#[cfg(test)] +use super::tone::highlights; +use super::tone::{HighlightCheck, ToneFit, highlights_hold}; +use super::*; + +/// A fit must cut the held-out squared error to below this share of the fallback's. +pub(super) const MIN_IMPROVEMENT: f64 = 0.7; + +/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public +/// raw.pixls.us samples (eight Sony bodies) good fits that still beat the fallback 1.5–3× landed +/// at 0.065–0.093 (camera local tone, vignetting and lens processing that a global matrix + curve +/// cannot follow): visibly better renders that 0.055 rejected. +const MAX_HOLDOUT_RMS: f64 = 0.10; + +pub(super) fn luma(p: [f64; 3]) -> f64 { + p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593 +} + +/// The finish stage's tone map and chroma curve (`lightcraft_pipeline::finish`). +pub(super) fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] { + let scene = scene.map(|v| v.max(0.0)); + let y = luma(scene); + if y <= 0.0 { + return [0.0; 3]; + } + let o = f64::from(tone.apply(y as f32)); + let k = f64::from(tone.chroma_scale(o as f32)); + scene.map(|v| o + (v * o / y - o) * k) +} + +#[cfg(test)] +pub(super) fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { + fit_pairs_with(sensor, reference, None) +} + +/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges). +pub(super) fn local_contrast(image: &Rgb32f, i: usize) -> f32 { + let (w, h) = (image.width, image.height); + if w == 0 { + return f32::INFINITY; + } + let (x, y) = (i % w, i / w); + let (mut lo, mut hi) = (f32::INFINITY, 0f32); + for row in y.saturating_sub(1)..(y + 2).min(h) { + for col in x.saturating_sub(1)..(x + 2).min(w) { + let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p)); + lo = lo.min(l); + hi = hi.max(l); + } + } + if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY } +} + +/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights +/// (for the chroma curve); `None` when too few, or the photo has too little colour. With +/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio +/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated +/// colours (see [`EDGE_CONTRAST`]). +pub(super) type Pair = ([f64; 3], [f64; 3]); + +pub(super) type Pairs = Vec; + +pub(super) fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option, dark: bool) -> Option<(Pairs, Pairs)> { + if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { + return None; + } + let mut pairs = Vec::new(); + // Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only. + let mut bright = Vec::new(); + let mut colour = 0; + for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { + if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { + continue; + } + let y = luminance_2020(*output); + if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) { + continue; + } + if (0.015..=1.0).contains(&y) { + bright.push((input.map(f64::from), output.map(f64::from))); + } + let floors = if dark { DARK_FLOOR } else { (0.015, 0.004) }; + if !colour_pair_above(*input, *output, floors) { + continue; + } + let min = output.iter().copied().fold(f32::INFINITY, f32::min); + let max = output.iter().copied().fold(0.0, f32::max); + colour += usize::from(max - min > min_chroma); + pairs.push((input.map(f64::from), output.map(f64::from))); + } + (pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright)) +} + +/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting. +pub(super) fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool { + colour_pair_above(input, output, (0.015, 0.004)) +} + +/// [`colour_pair`] with its lowest preview luminance and channel value given (`floors`: the usual 0.015 and 0.004, or +/// [`DARK_FLOOR`] for the tone-only fit of a DNG). +fn colour_pair_above(input: [f32; 3], output: [f32; 3], (ylo, clo): (f32, f32)) -> bool { + input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) + && output.iter().all(|v| *v > clo && *v < 0.98) + && (ylo..0.85).contains(&luminance_2020(output)) +} + +/// Fit tone independently of colour eligibility when enough dark JPEG signal is available. +/// The original pairs and their split still own the colour fit and its acceptance gates. +/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and +/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise. +pub(super) fn fit_tone_with_shadows( + fit: ToneFit, + pairs: &[([f64; 3], [f64; 3])], + sensor: &Rgb32f, + reference: &Rgb32f, + edge_limit: Option, + colour: impl Fn([f64; 3]) -> [f64; 3], +) -> Option { + let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y)); + let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect()); + let mut eligible = Vec::new(); + let (mut colour_index, mut extra_index) = (0, 0); + for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { + if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { + continue; + } + // About one 8-bit sRGB code above black: don't learn a flat black segment from + // JPEG compression. The sensor proxy averages many samples before this test. + if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5) + || !output.iter().all(|v| v.is_finite() && *v >= 0.0) + || !(0.0003..0.85).contains(&luminance_2020(*output)) + { + continue; + } + // A colour holdout must never enter tone training just because the wider sample set + // changes its index. Split the extra samples separately, keeping every colour index. + let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index }; + let held = *index % 3 == 0; + *index += 1; + let (x, y) = luminances(input.map(f64::from), output.map(f64::from)); + if x.is_finite() && x > 0.0 { + eligible.push((x, y, held)); + } + } + let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count(); + let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect(); + // Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank, + // so a single quantized grey also keeps the original curve. + if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION { + return original; + } + let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect(); + let Some(candidate) = fit.fit(training) else { return original }; + let error = |tone: &CameraTone| -> f64 { + held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum() + }; + // A wider histogram must also help independent dark pixels, not just redistribute the + // knots. The usual held-out gates still check the completed look on the colour pairs. + if let Some(old) = original { + let (before, after) = (error(&old), error(&candidate)); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}"); + } + if !after.is_finite() || after >= before * 0.99 { + return original; + } + } + Some(candidate) +} + +/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs. +pub(super) fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option { + let mut gram = [[0.0; 3]; 3]; + let mut cross = [[0.0; 3]; 3]; + for (i, (x, y)) in pairs.iter().enumerate() { + if i % 3 == 0 { + continue; + } + let (lx, ly) = (luma(*x), luma(*y)); + if lx <= 0.0 || ly <= 0.0 { + continue; + } + for row in 0..3 { + for col in 0..3 { + // Normalising by luminance prevents a camera S-curve from corrupting colour. + gram[row][col] += x[row] * x[col] / (lx * lx); + cross[row][col] += y[row] * x[col] / (ly * lx); + } + } + } + let trace: f64 = (0..3).map(|i| gram[i][i]).sum(); + let inverse = Mat3(gram).inverse()?; + let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::(); + if trace <= 0.0 || !condition.is_finite() || condition > 1e6 { + return None; + } + let regularization = trace * 1e-4; + for i in 0..3 { + gram[i][i] += regularization; + cross[i][i] += regularization; + } + let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?); + matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix) +} + +/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt +/// of [`search_ordered`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto", +/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px +/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched +/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction +/// off). Away from edges: 0.049. Issue #232. +pub(super) const EDGE_CONTRAST: f32 = 3.0; + +/// The look on all pixels (the search of [`search_ordered`] with [`TONE_FITS`]), as before +/// clipped pixels were left out. +#[cfg(test)] +pub(super) fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + fit_pairs_ordered(sensor, reference, colour, TONE_FITS) +} + +/// The look from [`search_ordered`] without its attempt. +#[cfg(test)] +pub(super) fn fit_pairs_ordered( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + order: &[ToneFit], +) -> Option { + search_ordered(sensor, reference, colour, order, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: false }) + .map(|(look, _)| look) +} + +/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is +/// used. Quantile matching comes first because it cannot produce flat steps; the +/// conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds) its +/// slightly lower held-out error is what clears the gates, and a photo must not lose its look to +/// the change of fit. +pub(super) const TONE_FITS: &[ToneFit] = &[ToneFit::Quantile, ToneFit::ConditionalMedian]; + +/// One attempt of the search: tone fit, the camera profile's colour or the photo's own, all pixels +/// or only those away from edges. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) struct Attempt { + pub(super) tone: ToneFit, + pub(super) profile: bool, + pub(super) away_from_edges: bool, + /// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG. + pub(super) dark: bool, +} + +/// The photo's look and the attempt that produced it: its own matrix (with and without a +/// hue/saturation table), or the given colour model (a camera profile's, in the sensor proxy's +/// space), each completed with a tone and chroma curve fitted to this photo. Per tone fit in +/// `order`: the camera profile's colour (when there is one), then the photo's own, as a different +/// picture style can make a camera profile fail the gates; each fitted on all pixels and, when that +/// fails the gates, once more on the pixels away from edges (same gates): there colour pairs stay +/// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole +/// search runs with one tone fit before the next is tried, so the conditional-median fallback +/// accepts exactly what the previous releases accepted. +pub(super) fn search_ordered( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + order: &[ToneFit], + bright: &HighlightCheck, +) -> Option<(CameraLook, Attempt)> { + let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] }; + order.iter().find_map(|&tone| { + profiles.iter().find_map(|&profile| { + [false, true].into_iter().find_map(|away_from_edges| { + let attempt = Attempt { tone, profile, away_from_edges, dark: false }; + fit_attempt(sensor, reference, &colour, attempt, bright).map(|look| (look, attempt)) + }) + }) + }) +} + +pub(super) fn fit_attempt( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + attempt: Attempt, + bright: &HighlightCheck, +) -> Option { + let colour = if attempt.profile { colour.clone() } else { None }; + fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark, bright) +} + +fn fit_pairs_on( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + edge_limit: Option, + tone_fit: ToneFit, + dark: bool, + highlights: &HighlightCheck, +) -> Option { + // A known colour model needs enough signal for tone fitting, not a scene rich enough to + // learn a new colour matrix. Still reject monochrome references. + let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?; + let candidates = match colour { + Some(given) => vec![given], + None => { + let matrix = fit_matrix(&pairs)?; + vec![(matrix, fit_hue_sat(&pairs, &matrix)), (matrix, None)] + } + }; + // Matrix + table when the table helps the held-out pixels, else the matrix alone. Chosen by + // error in gamma-encoded display values (closer to what is seen: in linear values a slightly + // missed bright rock outweighs a clearly wrong dark shirt); the acceptance gates below stay linear. + let mut best: Option<(f64, f64, usize, CameraLook)> = None; + for (matrix, hue_sat) in candidates { + let correction = hue_sat.as_ref().and_then(HueSat::new); + let colour = |x: [f64; 3]| { + let p = matrix.apply(x); + correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) + }; + let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue }; + let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() }; + if let Some(tone) = fit_chroma(&bright, &look) { + look.tone = tone; + } + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if !highlights_hold(&look.tone, highlights, colour) { + continue; + } + let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0); + for (x, target) in pairs.iter().step_by(3) { + let corrected = displayed(colour(*x), &tone); + for c in 0..3 { + linear += (corrected[c] - target[c]).powi(2); + perceptual += (corrected[c].max(0.0).powf(1.0 / 2.2) - target[c].max(0.0).powf(1.0 / 2.2)).powi(2); + samples += 1; + } + } + if linear.is_finite() && perceptual.is_finite() && best.as_ref().is_none_or(|b| perceptual < b.0) { + best = Some((perceptual, linear, samples, look)); + } + } + let (_, after, samples, look) = best?; + let original_tone = ToneMap::new(0.0, 0.0, 0.0); + let before: f64 = pairs + .iter() + .step_by(3) + .map(|(x, target)| { + let original = displayed(*x, &original_tone); + (0..3).map(|c| (original[c] - target[c]).powi(2)).sum::() + }) + .sum(); + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{}{})", + (before / samples as f64).sqrt(), + (after / samples as f64).sqrt(), + if edge_limit.is_some() { ", away from edges" } else { "" }, + if tone_fit == ToneFit::ConditionalMedian { ", conditional-median tone" } else { "" } + ); + } + if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) { + return None; + } + Some(look) +} + +/// Weight of the photo's own colour matrix in a [`fit_partial`] look; the rest is the identity (the +/// neutral fallback's generic camera ≈ sRGB colour). On the CC0 archive's proxies, with the +/// matrix at full weight 2 of the 23 colourful photos without a look came out more than 2° further +/// from the camera's hues than the neutral fallback, at half weight none (median hue error 9.6° with +/// the generic colour, which the tone and chroma curves make visible, 5.1° at half weight). +const PARTIAL_MATRIX_WEIGHT: f64 = 0.5; + +/// Smallest rank correlation of scene and camera JPEG luminance over a [`fit_partial`] look's pairs: +/// the JPEG must show the same picture. A look that only matches the brightness distribution of an +/// unrelated picture can still beat the dark neutral fallback pixel for pixel. On the CC0 archive's +/// proxies the accepted full looks have 0.55 or more, lens-corrected compacts without one 0.53–0.77, +/// three old Coolpix files whose raw decodes wrongly 0.07–0.27. +pub(super) const PARTIAL_MIN_RANK_CORRELATION: f64 = 0.5; + +/// Spearman rank correlation of the scene's and the JPEG's luminance over `pairs`. +fn rank_correlation(pairs: &[([f64; 3], [f64; 3])]) -> f64 { + let ranks = |values: Vec| { + let mut order: Vec = (0..values.len()).collect(); + order.sort_by(|a, b| values[*a].total_cmp(&values[*b])); + let mut ranks = vec![0.0; values.len()]; + for (rank, i) in order.into_iter().enumerate() { + ranks[i] = rank as f64; + } + ranks + }; + let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect()); + let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect()); + let mean = (pairs.len() as f64 - 1.0) / 2.0; + let (covariance, variance) = + scene.iter().zip(&jpeg).fold((0.0, 0.0), |(c, v), (a, b)| (c + (a - mean) * (b - mean), v + (a - mean) * (a - mean))); + if variance > 0.0 { covariance / variance } else { 0.0 } +} + +/// [`rank_correlation`] with tied luminances sharing their mean rank, and 0 when either side has no spread. Without +/// colour to require, a JPEG may be flat (one grey for every pixel): [`rank_correlation`] ranks ties in pixel order, +/// so a flat JPEG of a scene whose pixels are stored in brightness order looks perfectly related. +pub(super) fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 { + let ranks = |values: Vec| { + let mut order: Vec = (0..values.len()).collect(); + order.sort_by(|a, b| values[*a].total_cmp(&values[*b])); + let mut ranks = vec![0.0; values.len()]; + let mut start = 0; + while start < order.len() { + let mut end = start + 1; + while end < order.len() && values[order[end]] == values[order[start]] { + end += 1; + } + let mean = (start + end - 1) as f64 / 2.0; + for &i in &order[start..end] { + ranks[i] = mean; + } + start = end; + } + ranks + }; + let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect()); + let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect()); + let mean = (pairs.len() as f64 - 1.0) / 2.0; + let (covariance, scene_variance, jpeg_variance) = scene + .iter() + .zip(&jpeg) + .fold((0.0, 0.0, 0.0), |(c, va, vb), (a, b)| (c + (a - mean) * (b - mean), va + (a - mean) * (a - mean), vb + (b - mean) * (b - mean))); + if scene_variance > 0.0 && jpeg_variance > 0.0 { covariance / (scene_variance * jpeg_variance).sqrt() } else { 0.0 } +} + +/// A partial look for a photo whose full look misses the acceptance gates (lens-corrected JPEGs of +/// compacts, dull or nearly colourless scenes): the tone curve by quantile matching, which needs +/// no pixel-exact registration and was the largest part of the neutral fallback's error (rendered +/// 9–22 L* too dark), the chroma curve (the fallback kept about a third of the camera's +/// colourfulness), and, when the scene has enough colour to learn one (the full look's condition), +/// the photo's own colour matrix at [`PARTIAL_MATRIX_WEIGHT`]. No hue/saturation table. Tried on +/// all pixels and then, like the full look, on the pixels away from edges; used only when it cuts +/// the held-out squared error to below [`MIN_IMPROVEMENT`] of the neutral fallback's, in linear +/// and in gamma-encoded display values, and when the JPEG shows the same picture +/// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly +/// closer to the camera JPEG than what the photo would get otherwise. +pub(super) fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f, highlights: &HighlightCheck) -> Option { + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit, highlights)) +} + +fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option, highlights: &HighlightCheck) -> Option { + // as for a known colour model: enough signal for the tone, but no monochrome reference + let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; + let related = rank_correlation(&pairs); + if related < PARTIAL_MIN_RANK_CORRELATION { + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look partial: luminance rank correlation {related:.3}, not the same picture"); + } + return None; + } + let spread = |y: &[f64; 3]| y.iter().copied().fold(f64::NEG_INFINITY, f64::max) - y.iter().copied().fold(f64::INFINITY, f64::min); + let colourful = pairs.iter().filter(|(_, y)| spread(y) > 0.05).count() >= pairs.len() / 20; + let matrix = match colourful.then(|| fit_matrix(&pairs)).flatten() { + Some(own) => Mat3(std::array::from_fn(|r| { + std::array::from_fn(|c| Mat3::IDENTITY.0[r][c] + PARTIAL_MATRIX_WEIGHT * (own.0[r][c] - Mat3::IDENTITY.0[r][c])) + })), + None => Mat3::IDENTITY, + }; + let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?; + let mut look = CameraLook { matrix, tone, hue_sat: None }; + if let Some(tone) = fit_chroma(&bright, &look) { + look.tone = tone; + } + let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + if !highlights_hold(&look.tone, highlights, |x| matrix.apply(x)) { + return None; + } + let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); + let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); + for (x, target) in pairs.iter().step_by(3) { + let (fallback, partial) = (displayed(*x, &neutral), displayed(matrix.apply(*x), &tone)); + for c in 0..3 { + before += (fallback[c] - target[c]).powi(2); + after += (partial[c] - target[c]).powi(2); + before_encoded += (encoded(fallback[c]) - encoded(target[c])).powi(2); + after_encoded += (encoded(partial[c]) - encoded(target[c])).powi(2); + samples += 1; + } + } + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look partial (tone{}{}) holdout RMS {:.5} -> {:.5} ({samples} channels{})", + if look.tone.chroma().iter().any(|k| *k != 1.0) { ", chroma" } else { "" }, + if matrix != Mat3::IDENTITY { ", damped matrix" } else { "" }, + (before / samples.max(1) as f64).sqrt(), + (after / samples.max(1) as f64).sqrt(), + if edge_limit.is_some() { ", away from edges" } else { "" } + ); + } + let better = + after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT; + (samples > 0 && better).then_some(look) +} diff --git a/crates/engine/src/camera_preview/proxy.rs b/crates/engine/src/camera_preview/proxy.rs new file mode 100644 index 000000000..81f170a87 --- /dev/null +++ b/crates/engine/src/camera_preview/proxy.rs @@ -0,0 +1,442 @@ +use super::colour::{PROFILE_PROXY, PROXY, file_local_look}; +use super::hue_sat::fit_hue_sat; +use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, without_clipped}; +use super::pairs::{collect_pairs, fit_matrix}; +use super::*; + +/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a +/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview +/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty. +pub(super) const MIN_PREVIEW_FRACTION: f64 = 0.1; + +/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark +/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. +pub(super) const DARK_FLOOR: (f32, f32) = (0.004, 0.001); + +/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's +/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits +/// that clip about 1% more than the camera does and passes the ones that clip like the camera). +pub(super) const CLIP_TOLERANCE: f64 = 0.001; + +/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. +const MIN_STRUCTURE: f64 = 0.85; + +/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance +/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3) +/// correlates better. +pub(super) fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool { + let (w, h) = (sensor.width, sensor.height); + if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h { + return false; + } + let log = |img: &Rgb32f| -> Vec { + let l: Vec = + img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect(); + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let (mut sum, mut n) = (0.0, 0.0); + for yy in y.saturating_sub(2)..(y + 3).min(h) { + for xx in x.saturating_sub(2)..(x + 3).min(w) { + if let Some(v) = l.get(yy * w + xx) { + sum += v; + n += 1.0; + } + } + } + sum / f64::max(n, 1.0) + }) + .collect() + }; + let (a, b) = (log(sensor), log(reference)); + const M: usize = 3; + let corr = |dx: isize, dy: isize| -> f64 { + let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + for y in M..h - M { + for x in M..w - M { + let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy)); + let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue }; + n += 1.0; + sa += u; + sb += v; + saa += u * u; + sbb += v * v; + sab += u * v; + } + } + let var = (saa - sa * sa / n) * (sbb - sb * sb / n); + if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() } + }; + let at_zero = corr(0, 0); + let (mut best, mut shift) = (at_zero, (0isize, 0isize)); + for dy in -(M as isize)..=M as isize { + for dx in -(M as isize)..=M as isize { + let c = corr(dx, dy); + if c > best + 1e-9 { + (best, shift) = (c, (dx, dy)); + } + } + } + at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1 +} + +/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s +/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), +/// and per proxy pixel whether any sensor sample it covers is clipped. +pub(super) fn proxies( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + size: usize, + min_fraction: f64, + lens: Option<&LensGeometry>, +) -> Option<(Rgb32f, Rgb32f, Vec)> { + let edge = (2 * size).max(384) as u32; + let decoded = crate::files::decode_raw_preview( + bytes, + lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS }, + None, + )?; + let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); + // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any + if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) + { + return None; + } + let mut reference = decoded.to_working(); + if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { + return None; + } + let matches = |w: usize, h: usize, rw: usize, rh: usize| (rw as f64 / rh as f64 / (w as f64 / h as f64) - 1.0).abs() <= 0.02; + if !matches(crop.width, crop.height, reference.width, reference.height) { + // Panasonic previews show the whole active area while the default crop is the in-camera aspect ratio + if !matches(a.width, a.height, reference.width, reference.height) { + return None; + } + let (sx, sy) = (reference.width as f64 / a.width as f64, reference.height as f64 / a.height as f64); + let (x, y) = ((crop.x as f64 * sx).round() as usize, (crop.y as f64 * sy).round() as usize); + let (w, h) = ((crop.width as f64 * sx).round() as usize, (crop.height as f64 * sy).round() as usize); + if w < 16 || h < 16 || x + w > reference.width || y + h > reference.height { + return None; + } + reference = reference.into_crop(x, y, w, h); + } + // Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution. + let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; + let mut sensor = sensor_proxy(raw, k, edge as usize)?; + let mut clipped = clip_mask(&sensor); + // The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 % + // zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate, + // kept below when it lines up with the JPEG better than the uncorrected one. + let warped = lens + .filter(|_| raw.format != RawFormat::Cr3) + .and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?))); + let gain = 2f32.powf(transform.baseline_exposure as f32); + let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)); + if raw.format == RawFormat::Cr3 { + // Orient the camera JPEG and the sensor identically before collecting pixel correspondences. + sensor = sensor.into_oriented(raw.orientation); + clipped = clipped.into_oriented(raw.orientation); + reference = reference.into_oriented(raw.orientation); + sensor.map_in_place(to_working); + // Canon's JPEG can be cropped differently from the sensor. Estimate only that common + // framing from edge directions, independently of the subsequent colour fit. + (sensor, clipped) = align_cr3_framing(sensor, clipped, &reference); + } + let mut sensor = fit(&sensor, size, size, Filter::Box); + let mut reference_small = reference_at(&reference, &sensor); + if let Some((warped, warped_clipped)) = warped { + let candidate = fit(&warped, size, size, Filter::Box); + let candidate_reference = reference_at(&reference, &candidate); + if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) { + (sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped); + } + } + let reference = reference_small; + // A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is + // NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like + // the reference, so the mask pairs pixel for pixel. + let clipped: Vec = resize(&clipped, sensor.width, sensor.height, Filter::Box).data.iter().map(|p| p[0] > 0.0).collect(); + if raw.format != RawFormat::Cr3 { + sensor.map_in_place(to_working); + } + Some((sensor, reference, clipped)) +} + +/// The camera JPEG at exactly the sensor proxy's size, so the two pair pixel for pixel. Fitting it +/// into the proxy's box instead keeps its own aspect, which can round a pixel short: an ILCE-7RM4's +/// 1616×1080 preview, decoded at 384×257, fits a 192×128 proxy as 191×128, and `collect_pairs` +/// then rejected every file `lightcraft-cli calibrate` was given. `proxies` has already checked +/// that the aspects agree within 2 %. +pub(super) fn reference_at(reference: &Rgb32f, sensor: &Rgb32f) -> Rgb32f { + resize(reference, sensor.width, sensor.height, Filter::Box) +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct Cr3Framing { + pub(super) scale: f32, + /// Translation as a fraction of the oriented frame's width and height. + pub(super) offset: [f32; 2], +} + +impl Cr3Framing { + const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] }; + + pub(super) fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) { + let (w, h) = (w as f32, h as f32); + ((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1])) + } +} + +/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different +/// white balance and monotone camera tone; no target RGB values train this three-parameter fit. +/// A separate third of the edges must confirm a substantial improvement and a close match. +/// A weak, flat or unrelated preview leaves the sensor framing unchanged. +pub(super) fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option { + if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() { + return None; + } + let sensor = fit(sensor, PROXY, PROXY, Filter::Box); + let (w, h) = (sensor.width, sensor.height); + if w < 16 || h < 16 { + return None; + } + let reference = fit(reference, w, h, Filter::Box); + if (reference.width, reference.height) != (w, h) { + return None; + } + let mut gradients = Rgb32f::new(w, h); + let mut edges = Vec::new(); + for y in 1..h - 1 { + for x in 1..w - 1 { + let derivative = |image: &Rgb32f| { + [ + luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]), + luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]), + ] + }; + let d = derivative(&sensor); + gradients.data[y * w + x] = [d[0], d[1], 0.0]; + let d = derivative(&reference); + let magnitude = d[0].hypot(d[1]); + let value = luminance_2020(reference.data[y * w + x]); + if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 { + edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0)); + } + } + } + if edges.len() < 192 { + return None; + } + let score = |framing: Cr3Framing, held_out: bool| -> Option { + let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize); + for &(x, y, target, magnitude, holdout) in &edges { + if holdout != held_out { + continue; + } + total += 1; + let (sx, sy) = framing.source(x, y, w, h); + if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 { + continue; + } + let sample = gradients.sample_bilinear(sx, sy); + let length = sample[0].hypot(sample[1]); + if !length.is_finite() || length <= 0.01 { + continue; + } + let k = f64::from(magnitude.min(0.25)); + agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude)); + weight += k; + valid += 1; + } + (valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight) + }; + let before = score(Cr3Framing::IDENTITY, false)?; + let mut best = (before, Cr3Framing::IDENTITY); + // At most 10% zoom and two proxy pixels of translation; a coarse search followed by one + // local refinement prevents unbounded optimisation on the photograph's contents. + for s in -10..=10 { + for tx in -4..=4 { + for ty in -4..=4 { + let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] }; + if let Some(value) = score(framing, false) + && value > best.0 + { + best = (value, framing); + } + } + } + } + let coarse = best.1; + for s in -5..=5 { + for tx in -4..=4 { + for ty in -4..=4 { + let framing = Cr3Framing { + scale: coarse.scale + s as f32 * 0.002, + offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32], + }; + if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 { + continue; + } + if let Some(value) = score(framing, false) + && value > best.0 + { + best = (value, framing); + } + } + } + } + let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?); + if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 { + return None; + } + if lightcraft_pipeline::profiling() { + eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1); + } + Some(best.1) +} + +/// The sensor proxy and its clip mask (same size), both moved to the camera JPEG's framing. +fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rgb32f, Rgb32f) { + let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return (sensor, clipped) }; + let apply = |image: &Rgb32f| { + let (w, h) = (image.width, image.height); + let mut aligned = Rgb32f::new(w, h); + for (i, pixel) in aligned.data.iter_mut().enumerate() { + let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); + *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { image.sample_bilinear(x, y) } else { [f32::NAN; 3] }; + } + aligned + }; + (apply(&sensor), apply(&clipped)) +} + +/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the +/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own +/// colour matrices, other files or unusable previews. +pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option> { + if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { + return None; + } + let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); + let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?; + let to_camera = transform.matrix.inverse()?; + // clipped sensor pixels don't record the scene's colour (see `fit_look`) + let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor); + let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None, false)?; + Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect()) +} + +/// A camera profile's colour model (matrix from white-balanced camera RGB, hue/saturation table) +/// fitted to pairs pooled from many photos. +pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Option)> { + let matrix = fit_matrix(pairs)?; + Some((matrix, fit_hue_sat(pairs, &matrix))) +} + +/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of +/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without +/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct LensGeometry { + /// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance. + pub(super) k: [f64; 4], + /// The optical centre, relative to the active area (as the opcode has it). + pub(super) center: [f64; 2], +} + +pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option { + use lightcraft_raw::Opcode; + let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect(); + if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) { + return None; + } + ops.into_iter().find_map(|op| match op { + Opcode::WarpRectilinear { planes, center } => { + let p = planes.get(1).or(planes.first())?; + let k = [p[0], p[1], p[2], p[3]]; + (k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(¢er[0]) && (0.0..=1.0).contains(¢er[1])) + .then_some(LensGeometry { k, center: *center }) + } + _ => None, + }) +} + +/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both +/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels. +pub(super) fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 { + let (w, h) = (a.width, a.height); + if (b.width, b.height) != (w, h) || w < 3 || h < 3 { + return -1.0; + } + let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::>(); + let high = |l: &[f64]| -> Vec { + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + if x == 0 || y == 0 || x + 1 == w || y + 1 == h { + return f64::NAN; + } + let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1]; + l[i] - around.iter().map(|&j| l[j]).sum::() / 8.0 + }) + .collect() + }; + let (ha, hb) = (high(&lum(a)), high(&lum(b))); + let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect(); + if pairs.len() < 64 { + return -1.0; + } + let n = pairs.len() as f64; + let (ma, mb) = (pairs.iter().map(|p| p.0).sum::() / n, pairs.iter().map(|p| p.1).sum::() / n); + let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum(); + let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum()); + let r = cov / (va * vb).sqrt().max(1e-12); + if r.is_finite() { r } else { -1.0 } +} + +/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is +/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens` +/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another +/// size (the demosaiced fallback of linear raws). +pub(super) fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option { + let a = raw.active_area; + let c = raw.crop.clipped(a.width, a.height); + let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c }; + let (w, h) = (img.width, img.height); + if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 { + return None; + } + let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0); + let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah); + let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9); + let [k0, k1, k2, k3] = lens.k; + let kf = k as f64; + let (x0, y0) = (c.x as f64, c.y as f64); + let mut out = Rgb32f::new(w, h); + for (i, px) in out.data.iter_mut().enumerate() { + // the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor + let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5); + let (dx, dy) = ((ox - cx) / m, (oy - cy) / m); + let r2 = dx * dx + dy * dy; + let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf); + *px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 { + img.sample_bilinear(sx as f32, sy as f32) + } else { + [f32::NAN; 3] + }; + } + Some(out) +} + +pub(super) fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { + Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { + Some(sensor) => sensor, + // Lens opcodes cannot be applied to a binned mosaic: they are defined at full resolution. + None if (raw.cpp == 3 && raw.cfa.is_none()) || (raw.format == RawFormat::Dng && !raw.opcodes.list3.is_empty()) => { + fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box) + } + None => return None, + }) +} diff --git a/crates/engine/src/camera_preview/tone.rs b/crates/engine/src/camera_preview/tone.rs new file mode 100644 index 000000000..19b66948c --- /dev/null +++ b/crates/engine/src/camera_preview/tone.rs @@ -0,0 +1,186 @@ +use super::look_fit::dro_off; +use super::pairs::{Pairs, displayed, luma}; +use super::*; + +/// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum ToneFit { + /// [`fit_tone`]: no flat steps, no pixel-exact registration needed. + Quantile, + /// [`fit_tone_conditional`]: the earlier fit, kept as the fallback. + ConditionalMedian, +} + +impl ToneFit { + pub(super) fn fit(self, pairs: Vec<(f64, f64)>) -> Option { + match self { + ToneFit::Quantile => fit_tone(pairs), + ToneFit::ConditionalMedian => fit_tone_conditional(pairs), + } + } +} + +/// The bright areas a narrow tone curve is checked against ([`highlights_hold`]): every proxy pixel +/// whose camera JPEG luminance is 0.85 or more, with its sensor value as developed (for a clipped +/// pixel a lower bound of the scene). Taken from the proxies before any training filter (clipped +/// pixels, edges, the sensor value range of [`collect_pairs`]), so no retry of the search can +/// leave them out. +pub(super) fn highlights(sensor: &Rgb32f, reference: &Rgb32f) -> Pairs { + if sensor.data.len() != reference.data.len() { + return Vec::new(); + } + sensor + .data + .iter() + .zip(&reference.data) + .filter(|(x, y)| x.iter().chain(y.iter()).all(|v| v.is_finite() && *v >= 0.0) && luminance_2020(**y) >= 0.85) + .map(|(x, y)| (x.map(f64::from), y.map(f64::from))) + .collect() +} + +/// Smallest ratio between the scene luminance of the brightest and of the darkest 1/32 of the +/// pairs a tone curve is fitted to (its last and first knot). Below it the training pairs are +/// nearly uniform (under 0.26 EV from their darker to their brighter parts): the curve would be +/// fitted to a sliver of the tone range and the rest of it extrapolated. Above it the acceptance +/// gates judge the curve. This was 1.5, which rejected low-contrast scenes the curve fits very +/// well: in a hazy ILCE-7RM4 burst the frames at 1.46–1.49 opened with the neutral fallback (no full or partial +/// look, 18 L* darker than the camera JPEG) while the frame at 1.51 fitted at a held-out RMS of +/// 0.013, as the others do once allowed (issue #631). Across 135 public Sony files the lowest is 4.0. +/// A curve under [`NARROW_TONE_RANGE`] must also pass [`highlights_hold`]. +const MIN_TONE_RANGE: f32 = 1.2; + +/// [`MIN_TONE_RANGE`] before issue #631. A curve fitted to a narrower range than this must also +/// render the photo's bright areas no further from the camera JPEG than the neutral fallback does +/// ([`highlights_hold`]). Neither the fit nor the held-out gates see those areas (camera JPEG +/// luminance 0.85 and up, left out of the training pairs by [`collect_pairs`]), and a curve fitted +/// to a sliver of the tone range extrapolates them: in review, a synthetic frame whose curve passed +/// the gates at a held-out RMS of 0.0004 rendered its bright sky at 0.29 where the camera JPEG has +/// 0.93. +const NARROW_TONE_RANGE: f32 = 1.5; + +/// What a narrow tone curve is checked against ([`highlights_hold`]). +pub(super) struct HighlightCheck { + /// The frame's [`highlights`]. + pub(super) pairs: Pairs, + /// The photo's curve will be lowered to Sony's without DRO ([`with_dro_off_tone`]) after the + /// fit, so the check looks at it lowered. + pub(super) dro: bool, + /// The camera JPEG is black and white ([`fit_tone_only_with`]): the check compares luminance, as that + /// fit's gates do, since the JPEG has no colour to match. + pub(super) grey: bool, +} + +/// Whether a look with this `tone` and `colour` (sensor value to the finish stage's input) keeps +/// the frame's [`highlights`] (camera JPEG luminance 0.85 and up, left out of the fit) no further +/// from the camera JPEG than the neutral fallback does, with the tone curve as the photo will get +/// it (lowered for DRO), in luminance alone for a black-and-white JPEG. Only asked of curves narrower than +/// [`NARROW_TONE_RANGE`]: true for wider ones, and for frames without such pixels. +pub(super) fn highlights_hold(tone: &CameraTone, check: &HighlightCheck, colour: impl Fn([f64; 3]) -> [f64; 3]) -> bool { + let knots = tone.knots(); + if knots[31][0] >= knots[0][0] * NARROW_TONE_RANGE { + return true; + } + // the curve as the photo will get it + let used = if check.dro { dro_off(tone) } else { *tone }; + let (map, neutral) = (ToneMap::camera(&used, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let (mut look, mut fallback) = (0.0, 0.0); + for (x, target) in &check.pairs { + let (rendered, base) = (displayed(colour(*x), &map), displayed(*x, &neutral)); + if check.grey { + look += (luma(rendered) - luma(*target)).powi(2); + fallback += (luma(base) - luma(*target)).powi(2); + continue; + } + for c in 0..3 { + look += (rendered[c] - target[c]).powi(2); + fallback += (base[c] - target[c]).powi(2); + } + } + let hold = look.is_finite() && look <= fallback; + if !hold && lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look: narrow tone curve renders the bright areas worse than the fallback ({look:.3} > {fallback:.3})"); + } + hold +} + +/// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs +/// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th +/// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the +/// scene to the same quantile of the JPEG; matching the two distributions needs no pixel-exact +/// registration and is increasing by construction. +/// +/// Binning the pairs by scene luminance and taking the JPEG's median per bin instead (followed by +/// isotonic pooling) fails on real files: camera JPEGs are lens-corrected and sharpened, so on a +/// 96 px proxy of a busy scene neighbouring pairs disagree by more than the gap between bins. +/// The conditional medians then zig-zag, pooling turns them into flat steps (13 of 31 segments +/// on a Z 6II forest, issue #475) that render as posterised patches, and regression toward the +/// mean flattens both ends of the curve (lifted blacks, dull highlights). +pub(super) fn fit_tone(pairs: Vec<(f64, f64)>) -> Option { + if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) { + return None; + } + let mut xs: Vec = pairs.iter().map(|p| p.0).collect(); + let mut ys: Vec = pairs.iter().map(|p| p.1).collect(); + xs.sort_by(f64::total_cmp); + ys.sort_by(f64::total_cmp); + let n = xs.len(); + let mut knots = [[0.0; 2]; 32]; + for (i, knot) in knots.iter_mut().enumerate() { + let (a, b) = (i * n / 32, (i + 1) * n / 32); + let mid = a + (b - a) / 2; + *knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32]; + } + if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { + return None; + } + CameraTone::new(knots) +} + +fn median(values: &mut [f64]) -> Option { + values.sort_by(f64::total_cmp); + values.get(values.len() / 2).copied() +} + +/// The earlier tone fit, kept as the fallback of [`ToneFit`]: the JPEG's median per equal-population +/// bin of scene luminance, then pooled-adjacent-violators isotonic regression. It follows the +/// per-pixel pairs closely when the two images are registered, which is why it can clear the +/// acceptance gates where [`fit_tone`] just misses them, but on lens-corrected JPEGs of busy scenes +/// the pooling leaves flat steps (issue #475). +pub(super) fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option { + if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) { + return None; + } + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut knots = [[0.0; 2]; 32]; + for (i, knot) in knots.iter_mut().enumerate() { + let bin = pairs.get(i * pairs.len() / 32..(i + 1) * pairs.len() / 32)?; + let mut xs: Vec<_> = bin.iter().map(|p| p.0).collect(); + let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect(); + *knot = [median(&mut xs)? as f32, median(&mut ys)? as f32]; + } + if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { + return None; + } + // Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial. + let mut blocks: Vec<(f32, usize)> = Vec::new(); + for knot in knots { + blocks.push((knot[1], 1)); + while blocks.len() >= 2 { + let (a, an) = *blocks.get(blocks.len() - 2)?; + let (b, bn) = *blocks.last()?; + if a <= b { + break; + } + blocks.truncate(blocks.len() - 2); + blocks.push(((a * an as f32 + b * bn as f32) / (an + bn) as f32, an + bn)); + } + } + let mut i = 0; + for (y, n) in blocks { + for knot in knots.get_mut(i..i + n)? { + knot[1] = y; + } + i += n; + } + CameraTone::new(knots) +} From 21304db29b279ebf4fff69b934cd08d8af3f9f47 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:08:55 +0000 Subject: [PATCH 111/191] refactor(lightcraft-engine): split media.rs into media/ --- crates/engine/src/media.rs | 1702 +--------------------- crates/engine/src/media/cache.rs | 365 +++++ crates/engine/src/media/quick.rs | 127 ++ crates/engine/src/media/render.rs | 377 +++++ crates/engine/src/media/session.rs | 476 ++++++ crates/engine/src/media/settings_hash.rs | 36 + crates/engine/src/media/source.rs | 324 ++++ 7 files changed, 1721 insertions(+), 1686 deletions(-) create mode 100644 crates/engine/src/media/cache.rs create mode 100644 crates/engine/src/media/quick.rs create mode 100644 crates/engine/src/media/render.rs create mode 100644 crates/engine/src/media/session.rs create mode 100644 crates/engine/src/media/settings_hash.rs create mode 100644 crates/engine/src/media/source.rs diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 353825465..7377e23f1 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -26,331 +26,23 @@ use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache}; use lightcraft_raster::{Histogram, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize}; -const SETTINGS_HASH_ENTRIES: usize = 1024; +mod cache; +mod quick; +mod render; +mod session; +mod settings_hash; +mod source; -#[derive(Default)] -struct SettingsHashes { - entries: HashMap, u64)>, - order: VecDeque, - #[cfg(test)] - computations: usize, -} - -impl SettingsHashes { - fn get(&mut self, id: PhotoId, settings: &Arc) -> u64 { - if let Some((old, hash)) = self.entries.get(&id) - && old.as_ptr() == Arc::as_ptr(settings) - { - return *hash; - } - let hash = settings.hash64(); - #[cfg(test)] - { - self.computations += 1; - } - if !self.entries.contains_key(&id) { - if self.entries.len() >= SETTINGS_HASH_ENTRIES - && let Some(oldest) = self.order.pop_front() - { - self.entries.remove(&oldest); - } - self.order.push_back(id); - } - self.entries.insert(id, (Arc::downgrade(settings), hash)); - hash - } -} - -/// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 70; - -/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). -pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; - -/// Face tiles are rendered at a multiple of this many pixels ([`crate::Session::face_job`]): the -/// tile's size follows the thumbnail slider and the screen's scale, and without steps every size -/// in between would render (and cache) every face again. -pub const FACE_EDGE_STEP: usize = 16; - -/// The edge a face tile asked to be `edge` pixels across is rendered at: the next -/// [`FACE_EDGE_STEP`], 16..=512. -pub fn face_edge(edge: usize) -> usize { - edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge()) -} - -pub fn thumb_bucket(long_edge: usize) -> usize { - THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512) -} - -/// Memory budgets. -const THUMB_SOURCE_BYTES: usize = 384 << 20; -const RENDERED_MEM_BYTES: usize = 128 << 20; -/// Disk budget for the thumbnail cache. -pub const DISK_CACHE_BYTES: u64 = 2 << 30; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SourceLevel { - Thumb, - Preview, - /// The original at its native resolution (exports larger than a preview). - Full, -} - -impl SourceLevel { - pub fn max_edge(self) -> usize { - match self { - SourceLevel::Thumb => 512, - SourceLevel::Preview => 2560, - SourceLevel::Full => usize::MAX, - } - } - /// Smallest level that can serve an output of `long_edge` pixels. - pub fn for_size(long_edge: usize) -> SourceLevel { - match long_edge { - 0..=520 => SourceLevel::Thumb, - 521..=2560 => SourceLevel::Preview, - _ => SourceLevel::Full, - } - } -} - -/// Pixels along the source's long edge that an output `out_long` pixels long needs: a crop shows -/// only part of the photo, so its pixels come from a source that many times larger (the source -/// level is chosen from this, or a tight crop shown at its own pixels would be a stretched preview). -fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, out_long: usize) -> usize { - let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); - let r = settings.crop.geometry.rect; - let shown = if apply_crop { (r.width() * w).max(r.height() * h) } else { w.max(h) }; - if !shown.is_finite() || shown < 1.0 { - return out_long; - } - let needed = out_long as f64 * w.max(h) / shown; - if needed.is_finite() { needed.min(usize::MAX as f64 / 2.0) as usize } else { out_long } -} - -/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). -pub type FileLoader = Arc Result<(Rgb32f, SourceInfo), String> + Send + Sync>; - -/// [`FileLoader`] for a thumbnail: a raw file that keeps smaller copies of its raw image (Lightroom's merged DNGs) -/// develops the smallest that covers `max_edge` (set by the app); the flag says it did. Never used for an export, -/// which must render the full image. -pub type ReducedLoader = Arc Result<(Rgb32f, SourceInfo, bool), String> + Send + Sync>; - -/// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than -/// `max_edge` (set by the app). `None`: no usable preview. -pub type PreviewLoader = Arc Option + Send + Sync>; - -/// Makes a photo's denoised picture when it is not cached yet (an export does not wait for the background queue): -/// `Ok(true)` once it is there, `Ok(false)` when denoise does not apply to this photo (render it plain), `Err` when it -/// should have worked and did not. -pub type MakeProduct = Arc Result + Send + Sync>; - -/// A raw photo's cached AI-denoised picture (see [`crate::denoise`]): where the file is and the key it must have been -/// made with. A file that is missing, damaged or made for something else is a cache miss, never an error. -#[derive(Clone)] -pub struct DenoiseSpec { - pub product: std::path::PathBuf, - pub key: String, - /// Run before the picture is read, by a job that must not render without it (an export). - pub make: Option, -} - -/// Decodes a raw file once and develops it twice: as usual, and from the denoised picture in `spec` when that can be -/// read (`None` when it cannot). Both are at most `max_edge` long and the same size. -pub type PairLoader = Arc Result<(Rgb32f, Option, SourceInfo), String> + Send + Sync>; - -/// A photo's denoised picture next to its plain one, and the last mix of the two that was asked for. -pub struct Twin { - pub image: Arc, - /// The Amount (in thousandths) and the picture mixed for it, so dragging other sliders keeps one source picture. - mix: std::sync::Mutex)>>, -} - -impl Twin { - pub fn new(image: Arc) -> Twin { - Twin { image, mix: std::sync::Mutex::new(None) } - } - - /// Bytes reserved for the twin and its lazy mix, before either cache cost can grow. - fn bytes(&self) -> usize { - self.image.data.len().saturating_mul(24) - } -} - -/// Pixels and the source interpretation learned while decoding; they must be cached together. -#[derive(Clone)] -pub struct DecodedSource { - pub image: Arc, - pub info: Option, - /// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the - /// catalog's header facts lack (used when `info` is `None`). - pub camera_tone: Option, - /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. - pub denoised: Option>, - /// These pixels came from the photo's smart preview (an offline original), not from its file. - /// A refresh must never rebuild a proxy from itself: that would stamp its older look as current. - pub from_proxy: bool, - /// Developed from a smaller copy of the raw image that the file keeps ([`ReducedLoader`]), not from the image - /// itself: good for a thumbnail, never for an export. - pub reduced: bool, -} - -impl DecodedSource { - pub fn new(image: Arc, info: Option) -> Self { - DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false, reduced: false } - } - - /// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one, - /// the denoised one, or a mix of the two. A mix is made once per amount and kept, so the stages after it are - /// reused until the amount changes. - pub fn image_for(&self, amount: f32) -> Arc { - let Some(twin) = &self.denoised else { return self.image.clone() }; - if amount.is_nan() || amount <= 0.0 || twin.image.width != self.image.width || twin.image.height != self.image.height { - return self.image.clone(); - } - if amount >= 1.0 { - return twin.image.clone(); - } - let thousandths = (amount * 1000.0).round().clamp(1.0, 999.0) as u32; - let mut memo = twin.mix.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some((t, img)) = memo.as_ref() - && *t == thousandths - { - return img.clone(); - } - let mut mixed = (*self.image).clone(); - if !lightcraft_denoise::product::blend(&mut mixed, &twin.image, thousandths as f32 / 1000.0) { - return self.image.clone(); - } - let mixed = Arc::new(mixed); - *memo = Some((thousandths, mixed.clone())); - mixed - } - - /// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix. - pub fn bytes(&self) -> usize { - let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes()); - let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4); - self.image.data.len() * 12 + 64 + std::mem::size_of::() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes()) - } - - /// What to render these pixels against: the decoder's facts, else `header` (the catalog's) - /// with any stored camera tone curve. The original's size comes from `header` either way: the - /// decoder only sees the level it decoded. - pub fn info_or(&self, header: SourceInfo) -> SourceInfo { - match &self.info { - Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() }, - None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }, - } - } -} - -#[derive(Clone)] -pub enum SourceRef { - Loaded(Box), - /// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of - /// the photo open in the loupe is cut from its preview-level source instead of decoding the - /// file again (a second or more for a 64 MP raw). - Fit { - from: Box, - max_edge: usize, - }, - Demo { - scene: Box, - max_edge: usize, - }, - File { - path: String, - max_edge: usize, - loader: Option, - /// The photo's smart preview, used when the original can't be read (offline drive). The - /// UI thread never checks whether the original is there; the render worker finds out. - fallback: Option, - /// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]). - denoise: Option<(DenoiseSpec, PairLoader)>, - /// A thumbnail's loader, used instead of `loader` (see [`ReducedLoader`]); `None` for every other job. - reduced: Option, - }, - /// A smart preview standing in for a missing original. - Smart { - path: std::path::PathBuf, - }, -} - -impl SourceRef { - pub fn load(&self) -> Result, String> { - self.load_source().map(|s| s.image) - } - - pub fn load_source(&self) -> Result { - let image = match self { - SourceRef::Loaded(a) => return Ok((**a).clone()), - SourceRef::Fit { from, max_edge } => { - if from.image.width.max(from.image.height) <= *max_edge { - return Ok((**from).clone()); - } - // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near - // the output's resolution limit and aliases what is finer - let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3); - return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); - } - SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, - SourceRef::File { path, max_edge, loader, fallback, denoise, reduced } => { - let plain = || match (reduced, loader) { - (Some(l), _) => { - l(path, *max_edge).map(|(image, info, reduced)| DecodedSource { reduced, ..DecodedSource::new(Arc::new(image), Some(info)) }) - } - (None, Some(l)) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), - (None, None) => Err(format!("no decoder available for {path}")), - }; - // the denoised picture comes with the plain one from a single decode; when it cannot be read the - // pair loader gives the plain picture alone (the cached file is never a reason to fail a render) - let r = match denoise { - Some((spec, pair)) => { - // a job that needs the denoised picture has it made first; if that fails the job fails (no smart - // preview stands in: an export must not quietly come out without the denoise it was asked for) - let made = match &spec.make { - Some(make) => make()?, - None => true, - }; - if made { - pair(path, *max_edge, spec).and_then(|(image, twin, info)| { - if spec.make.is_some() && twin.is_none() { - return Err("the cached AI Denoise picture could not be read; clear the denoise cache and retry".into()); - } - Ok(DecodedSource { - image: Arc::new(image), - info: Some(info), - camera_tone: None, - denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))), - from_proxy: false, - reduced: false, - }) - }) - } else { - plain() - } - } - None => plain(), - }; - // An offline original renders from its smart preview (no decoder facts: header ones). - return match (r, fallback) { - #[cfg(not(target_arch = "wasm32"))] - (Err(e), Some(sp)) if denoise.as_ref().is_none_or(|(spec, _)| spec.make.is_none()) && crate::smart::is_valid(sp) => { - crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")) - } - (r, _) => r, - }; - } - #[cfg(not(target_arch = "wasm32"))] - SourceRef::Smart { path } => return crate::smart::load(path), - #[cfg(target_arch = "wasm32")] - SourceRef::Smart { .. } => return Err("smart previews are not available here".into()), - }; - Ok(DecodedSource::new(image, None)) - } -} +pub use cache::DenoiseIndex; +pub use quick::{QuickJob, QuickSource, content_key, source_info}; +pub use render::{RenderJob, RenderResult, develop, develop_reporting, fit_for_display}; +#[cfg(test)] +use settings_hash::SETTINGS_HASH_ENTRIES; +use settings_hash::SettingsHashes; +pub use source::{ + DISK_CACHE_BYTES, DecodedSource, DenoiseSpec, FACE_EDGE_STEP, FileLoader, FileProbe, MakeProduct, PairLoader, PreviewLoader, ProbeInfo, + RENDER_CACHE_VERSION, ReducedLoader, SourceLevel, SourceRef, THUMB_SIZES, Twin, face_edge, thumb_bucket, +}; pub struct MediaCache { settings_hashes: SettingsHashes, @@ -389,1368 +81,6 @@ pub struct MediaCache { pub denoise: DenoiseIndex, } -/// The photos that have a cached denoised picture for the denoise model in use. Only memory: nothing here touches the -/// file system, so it can be asked on the UI thread. -#[derive(Default)] -pub struct DenoiseIndex { - ready: std::collections::HashMap, - /// Reads the pictures (`None`: this build cannot, and nothing is ever ready). - pub loader: Option, -} - -impl DenoiseIndex { - /// What to load for `id` besides its plain picture, and how. - pub fn get(&self, id: PhotoId) -> Option<(&DenoiseSpec, &PairLoader)> { - self.ready.get(&id).zip(self.loader.as_ref()) - } - - pub fn spec(&self, id: PhotoId) -> Option<&DenoiseSpec> { - self.ready.get(&id) - } - - pub fn set(&mut self, id: PhotoId, spec: DenoiseSpec) { - self.ready.insert(id, spec); - } - - pub fn remove(&mut self, id: PhotoId) -> bool { - self.ready.remove(&id).is_some() - } - - pub fn clear(&mut self) { - self.ready.clear(); - } - - pub fn len(&self) -> usize { - self.ready.len() - } - - pub fn is_empty(&self) -> bool { - self.ready.is_empty() - } - - pub fn ids(&self) -> Vec { - let mut v: Vec = self.ready.keys().copied().collect(); - v.sort(); - v - } -} - -impl Default for MediaCache { - fn default() -> Self { - let budget = crate::memory::cache_share(crate::memory::budget()); - MediaCache { - settings_hashes: SettingsHashes::default(), - thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)), - previews: Vec::new(), - full: Vec::new(), - full_capacity: 1, - on_screen: None, - preview_capacity: 0, - budget, - file_loader: None, - reduced_loader: None, - file_probe: None, - preview_loader: None, - file_bytes: None, - smart_dir: None, - availability: Default::default(), - scenes: Vec::new(), - rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))), - denoise: DenoiseIndex::default(), - } - } -} - -/// Memory for rendered previews out of the cache share. -fn rendered_budget(share: usize) -> usize { - RENDERED_MEM_BYTES.min(share / 4) -} - -impl MediaCache { - /// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired - /// (its files stay): render jobs still holding it can't write through it any more, even - /// into a directory a later clear emptied (`dir` may be the same one). - pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) { - self.rendered.retire(); - self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes)); - } - - /// Change the disk budget of the cache in `dir`: in place when that cache is attached (its - /// thumbnails are keyed by content, so they and the textures shown from them stay valid, - /// and the same cache object keeps its generation), else attach it. - pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) { - match self.rendered.disk().filter(|d| d.dir() == dir) { - Some(d) => d.set_budget(disk_bytes), - None => self.attach_disk_cache(dir, disk_bytes), - } - } - - /// Forget every decoded source (photo ids changed meaning, e.g. another library was opened). - pub fn clear_sources(&mut self) { - self.settings_hashes = SettingsHashes::default(); - self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget)); - self.previews.clear(); - self.full.clear(); - } - - /// How many full-resolution originals to keep at once (at least one). Compare and Reference - /// views zoomed to 1:1 cut windows from two photos' originals: with room for one, every window - /// render of one side would evict the other side's original and decode its own again. Lowering - /// it drops the least recently used originals at once. - pub fn set_full_capacity(&mut self, n: usize) { - self.full_capacity = n.max(1); - self.trim_full(); - } - - /// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays - /// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it - /// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once. - pub fn set_on_screen(&mut self, id: Option) { - if self.on_screen != id { - self.on_screen = id; - self.enforce_budget(); - } - } - - fn trim_full(&mut self) { - while self.full.len() > self.full_capacity { - let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); - self.full.remove(oldest); - } - } - - /// Bytes the caches may hold together. - pub fn budget(&self) -> usize { - self.budget - } - - /// Change the bytes the caches may hold together, evicting at once if needed. - pub fn set_budget(&mut self, bytes: usize) { - self.budget = bytes; - self.thumbs.set_budget(THUMB_SOURCE_BYTES.min(bytes)); - self.rendered.set_mem_budget(rendered_budget(bytes)); - self.enforce_budget(); - } - - /// Bytes held by decoded sources and rendered previews in memory. - pub fn held(&self) -> usize { - let (t, p, f) = self.usage(); - t.bytes + p.bytes + f.bytes + self.rendered.mem_usage().1 - } - - /// Evict least recently used entries across the source caches and the rendered previews - /// until they fit the budget. The newest preview-level source (the photo on screen, or the - /// one just decoded) is never evicted here, nor is the original of the photo on screen - /// ([`Self::set_on_screen`]). - pub fn enforce_budget(&mut self) { - // The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB) - // nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and - // thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step. - let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum(); - let mut held = self.held().saturating_sub(pinned); - while held > self.budget { - let newest_preview = self.previews.iter().map(|e| e.2).max(); - let candidates = [ - self.thumbs.oldest_tick().map(|t| (t, 0)), - self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)), - self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)), - self.rendered.oldest_tick().map(|t| (t, 3)), - ]; - let Some((tick, which)) = candidates.into_iter().flatten().min() else { break }; - let freed = match which { - 0 => self.thumbs.pop_oldest(), - 1 => self.previews.iter().position(|e| e.2 == tick).map(|i| self.previews.remove(i).1.bytes()), - 2 => self.full.iter().position(|e| e.2 == tick).map(|i| self.full.remove(i).1.bytes()), - _ => self.rendered.evict_oldest(), - }; - match freed { - Some(b) if b > 0 => held = held.saturating_sub(b), - _ => break, - } - } - } - - pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option> { - self.get_source(id, level).map(|s| s.image) - } - - fn get_source(&mut self, id: PhotoId, level: SourceLevel) -> Option { - match level { - SourceLevel::Thumb => self.thumbs.get(&id).cloned(), - SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| { - e.2 = lightcraft_preview::next_tick(); - e.1.clone() - }), - SourceLevel::Full => self.full.iter_mut().find(|e| e.0 == id).map(|e| { - e.2 = lightcraft_preview::next_tick(); - e.1.clone() - }), - } - } - - /// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header` - /// (with a smart preview's stored camera tone curve). - fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo { - let cached = || { - self.thumbs - .peek(&id) - .into_iter() - .chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1)) - .chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1)) - }; - match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) { - Some(s) => s.info_or(header), - None => header, - } - } - - pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc) { - self.insert_source(id, level, DecodedSource::new(img, None)); - } - - fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) { - let tick = lightcraft_preview::next_tick(); - match level { - SourceLevel::Thumb => { - let cost = img.bytes(); - self.thumbs.insert(id, img, cost); - } - SourceLevel::Preview => { - self.previews.retain(|e| e.0 != id); - self.previews.push((id, img, tick)); - let cap = if self.preview_capacity == 0 { 4 } else { self.preview_capacity }; - while self.previews.len() > cap { - let oldest = self.previews.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); - self.previews.remove(oldest); - } - } - SourceLevel::Full => { - self.full.retain(|e| e.0 != id); - self.full.push((id, img, tick)); - self.trim_full(); - } - } - self.enforce_budget(); - } - - /// Forget a photo's decoded sources (e.g. after its file changed). - pub fn forget(&mut self, id: PhotoId) { - self.thumbs.remove(&id); - self.previews.retain(|e| e.0 != id); - self.full.retain(|e| e.0 != id); - } - - /// (decoded thumbnail sources, bytes). - pub fn source_usage(&self) -> (usize, usize) { - (self.thumbs.len(), self.thumbs.cost()) - } - - /// Whether the decoded source of `id` at `level` is held. - pub fn has_source(&self, id: PhotoId, level: SourceLevel) -> bool { - match level { - SourceLevel::Thumb => self.thumbs.contains(&id), - SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id), - SourceLevel::Full => self.full.iter().any(|e| e.0 == id), - } - } - - /// Decoded sources held: (thumbnail level, preview level, full size). - pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) { - use crate::memory::Usage; - let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| e.1.bytes()).sum()); - let full = Usage::new(self.full.len(), self.full.iter().map(|e| e.1.bytes()).sum()); - (Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full) - } - - /// What a render of photo `id` with settings `s` adds to its identity for the denoised picture it will use: 0 unless - /// it uses one (the Denoise amount is above 0 and the photo has a cached denoised picture). - pub fn denoise_salt(&self, id: PhotoId, s: &DevelopSettings) -> u64 { - match self.denoise.spec(id) { - Some(spec) if s.denoise_amount() > 0.0 => Hasher128::new().str(&spec.key).finish().0 as u64 | 1, - _ => 0, - } - } - - /// How to load photo `p` at `level`: from the cache when its source is there, else from its file (with its denoised - /// picture beside it when it has one), its smart preview or the demo scene. - pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { - self.source_ref_with(p, level, true) - } - - /// [`Self::source_ref`] that never takes a decoded source from memory: for a job that needs something the cached - /// one lacks (an export, whose denoised picture is made when it runs). - pub fn source_ref_uncached(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { - self.source_ref_with(p, level, false) - } - - fn source_ref_with(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef { - let mut r = self.source_ref_plain(p, level, cached); - if let (SourceRef::File { denoise, .. }, Some((spec, loader))) = (&mut r, self.denoise.get(p.id)) { - *denoise = Some((spec.clone(), loader.clone())); - } - r - } - - fn source_ref_plain(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef { - if cached && let Some(a) = self.get_source(p.id, level) { - return SourceRef::Loaded(Box::new(a)); - } - // a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not - // with a denoised picture, which the thumbnail would lose - if cached - && level == SourceLevel::Thumb - && self.denoise.spec(p.id).is_none() - && let Some(big) = self.get_source(p.id, SourceLevel::Preview) - { - return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() }; - } - // Procedural scenes have a nominal size: "full" is that size, not unbounded. - let max_edge = level.max_edge().min(match (&p.source, level) { - (Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize, - _ => usize::MAX, - }); - // the original is offline: its smart preview, when there is one. Only cached answers are - // used here (no file-system call on the UI thread in the app); an original not known to - // be offline is tried first and its smart preview is the render worker's fallback. - #[cfg(not(target_arch = "wasm32"))] - if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) { - let sharded = crate::smart::sharded_file_path(dir, p); - let legacy = dir.join(crate::smart::file_name(p)); - let sp = if self.availability.exists(&sharded.to_string_lossy()) == Some(true) { sharded } else { legacy }; - // (a proxy cut short fails to load, like the offline original; the worker's fallback - // in `SourceRef::load` only uses a proxy that passes `smart::is_valid`) - if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) { - return SourceRef::Smart { path: sp }; - } - let mut r = self.origin_ref(&p.source, max_edge); - if let SourceRef::File { fallback, .. } = &mut r { - *fallback = Some(sp); - } - return r; - } - self.origin_ref(&p.source, max_edge) - } - - /// How to load `origin` at most `max_edge` pixels long, ignoring decoded sources in memory (a - /// render worker in another wasm instance builds its sources from this). - pub fn origin_ref(&mut self, origin: &Source, max_edge: usize) -> SourceRef { - match origin { - Source::Demo { scene } => { - if self.scenes.is_empty() { - self.scenes = lightcraft_scenes::demo_library(); - } - match self.scenes.iter().find(|s| s.id == *scene) { - Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge }, - None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None, reduced: None }, - } - } - Source::File { path } => { - SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None, reduced: None } - } - } - } -} - -/// Everything needed to render one photo, detached from the session. -#[derive(Clone)] -pub struct RenderJob { - /// Assigned by a frontend to distinguish late completions, even for an identical render key. - pub request_id: u64, - pub cache_generation: u64, - /// Content identity for validating a decoded source returned after a reload. - pub source_key: Option, - pub photo: PhotoId, - pub level: SourceLevel, - pub source: SourceRef, - /// Where the photo's pixels come from (for executors that can't share `source`, e.g. a web - /// worker). - pub origin: Source, - pub info: SourceInfo, - pub settings: Arc, - pub request: RenderRequest, - /// Identifies the result for caching: hash of settings + request. - pub key: u64, - /// Rendered-thumbnail cache and this job's key in it. - pub cache: Option<(Arc, Hash128)>, - /// Intermediate results of this view's previous renders (set by the frontend for the loupe): - /// slider drags then only redo the stages the changed setting feeds. - pub stages: Option>, - /// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]). - pub view_cache: Option<(Arc, Hash128)>, - /// Render for this monitor profile ([`Self::with_display`]). - pub display: Option>, - /// Compose the render into a slideshow slide ([`Self::with_slide`]). - pub slide: Option>, - /// A face tile that can be cut from the raw's embedded preview, tried before `source` - /// ([`crate::Session::face_job`]; `None` for every other job). - pub preview_cut: Option, - /// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once - /// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up - /// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at - /// small ratios and aliases it at large ones. The caches keep the full render. - pub display_fit: Option<(usize, usize)>, -} - -/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]). -pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 { - use lightcraft_raster::resample::{Filter, fit}; - let f = lightcraft_raster::Image { - width: img.width, - height: img.height, - data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(), - }; - let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3); - let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; - Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() } -} - -pub struct RenderResult { - pub request_id: u64, - pub source_key: Option, - pub photo: PhotoId, - pub level: SourceLevel, - pub key: u64, - pub rendered: Result, - /// Set when this job's render was tried on the GPU and fell back to the CPU: why - /// ([`lightcraft_gpu::Fallback::Failed`]). The result is the CPU render either way. - pub gpu_fallback: Option, - /// A source that was loaded by this job (to be inserted into the cache). - pub loaded: Option, - /// Set for a [`QuickJob`]'s stand-in: where the image came from. - pub quick: Option, - /// The display profile (id) the image is encoded for; `None`: sRGB (see - /// [`crate::display::present`]). - pub display: Option, -} - -impl RenderJob { - /// Render at draft quality (interactive drags). Gets its own result key. - /// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see - /// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again. - pub fn with_display_fit(mut self, w: usize, h: usize) -> Self { - self.display_fit = Some((w, h)); - self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); - self - } - - pub fn draft(mut self) -> Self { - if self.request.quality != Quality::Draft { - self.request.quality = Quality::Draft; - self.key ^= 0x9e37_79b9_7f4a_7c15; - } - self - } - - /// Draw a diagnostic overlay over the result (e.g. Point Color's visualized range). Gets its - /// own result key. - pub fn with_overlay(mut self, overlay: lightcraft_pipeline::Overlay) -> Self { - if self.request.overlay != overlay { - self.key ^= self.request.overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); - self.request.overlay = overlay; - self.key ^= overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); - } - // a diagnostic view (visualized range / spots) must never become the photo's cached preview - if overlay != lightcraft_pipeline::Overlay::None { - self.view_cache = None; - } - self - } - - /// Soft-proof the result (see [`lightcraft_pipeline::Proof`]). Gets its own result key and is - /// never kept as the photo's cached preview. - pub fn with_proof(mut self, proof: Option) -> Self { - if self.request.proof != proof { - let k = |p: Option| p.map_or(0, |p| p.key()).wrapping_mul(0xc2b2_ae3d_27d4_eb4f); - self.key ^= k(self.request.proof) ^ k(proof); - self.request.proof = proof; - } - if proof.is_some() { - self.view_cache = None; - } - self - } - - /// Render with some colour grading wheels (shadows, midtones, highlights) switched off for - /// viewing (the wheel eyes in the Edit panel). The photo's settings are untouched; the result - /// gets its own key and is never cached as the photo's preview or thumbnail. - pub fn with_hidden_grading(mut self, hidden: [bool; 3]) -> Self { - if !hidden.iter().any(|h| *h) { - return self; - } - let mut s = (*self.settings).clone(); - let g = &mut s.grading; - for (off, wheel) in hidden.into_iter().zip([&mut g.shadows, &mut g.midtones, &mut g.highlights]) { - if off { - *wheel = lightcraft_develop::Wheel::default(); - } - } - let bits = hidden.iter().enumerate().fold(0u64, |b, (i, h)| b | ((*h as u64) << i)); - self.key ^= s.hash64() ^ self.settings.hash64() ^ bits.wrapping_mul(0xd6e8_feb8_6659_fd93); - self.settings = Arc::new(s); - self.cache = None; - self.view_cache = None; - self.preview_cut = None; - self - } - - /// Reuse `stages` across this view's renders. - pub fn with_stages(mut self, stages: Arc) -> Self { - self.stages = Some(stages); - self - } - - /// Render for a monitor profile: into the display's primaries (its gamut, not sRGB's), ready - /// to show ([`RenderResult::display`]). The histogram and the cached view preview stay sRGB. - /// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are: - /// [`crate::display::present`] converts their sRGB results. - pub fn with_display(mut self, display: Option>) -> Self { - // (a slide is composed in sRGB, backdrop colours included; the frontend presents it) - if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { - return self; - } - let k = |d: &Option>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); - self.key ^= k(&self.display) ^ k(&display); - self.request.display = display.as_ref().map(|d| d.space); - self.display = display; - self - } - - /// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting - /// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace - /// it, so a few such panics used to stop every render). - pub fn run(self) -> RenderResult { - let failed = self.failed(String::new()); - crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed }) - } - - /// This job's result when it fails with `e`. - fn failed(&self, e: String) -> RenderResult { - RenderResult { - request_id: self.request_id, - source_key: self.source_key, - photo: self.photo, - level: self.level, - key: self.key, - rendered: Err(e), - gpu_fallback: None, - loaded: None, - quick: None, - display: None, - } - } - - /// Compose the render into a slideshow slide (layout, options, backdrop): the result is the - /// slide, not the photo. Gets its own result key; the photo's cached view preview stays the - /// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone). - pub fn with_slide(mut self, slide: Arc) -> Self { - let k = |s: &Option>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407); - self.key ^= k(&self.slide); - self.key ^= k(&Some(slide.clone())); - self.slide = Some(slide); - self - } - - /// [`Self::run_photo`], composed into its slide when this is a slide job (inside [`Self::run`]'s guard). - fn run_unguarded(self) -> RenderResult { - let slide = self.slide.clone(); - let mut r = self.run_photo(); - if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) { - rendered.image = slide.compose(&rendered.image); - rendered.deep = None; - } - r - } - - /// The photo's render (without slide composition). - fn run_photo(self) -> RenderResult { - if let Some((cache, key)) = &self.cache - && let Some(img) = cache.get_at(self.cache_generation, *key) - { - let image = Arc::unwrap_or_clone(img); - let histogram = Histogram::of_srgb8(&image); - return RenderResult { - request_id: self.request_id, - source_key: self.source_key, - photo: self.photo, - level: self.level, - key: self.key, - rendered: Ok(Rendered { image, histogram, deep: None }), - gpu_fallback: None, - loaded: None, - quick: None, - display: None, - }; - } - // a face tile of an unedited raw: cut from the camera's embedded preview when it is big enough - // (the sensor data is never read for it), else rendered from `source` below - if let Some(image) = self.preview_cut.as_ref().and_then(|c| c.cut()) { - if let Some((cache, key)) = &self.cache { - cache.put_at(self.cache_generation, *key, Arc::new(image.clone())); - } - let histogram = Histogram::of_srgb8(&image); - return RenderResult { - request_id: self.request_id, - source_key: self.source_key, - photo: self.photo, - level: self.level, - key: self.key, - rendered: Ok(Rendered { image, histogram, deep: None }), - gpu_fallback: None, - loaded: None, - quick: None, - display: None, - }; - } - let was_loaded = matches!(self.source, SourceRef::Loaded(_)); - // The app is closing (issue #620): nobody is left to show this, and quitting waits for the - // jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU. - let closing = || lightcraft_gpu::shutting_down().then(|| "LightCraft is closing".to_string()); - let source = match closing() { - Some(e) => Err(e), - None => self.source.load_source().and_then(|s| closing().map_or(Ok(s), Err)), - }; - match source { - Ok(source) => { - // the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for - let src = &source.image_for(self.settings.denoise_amount()); - let info = source.info_or(self.info); - // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use - // the GPU when there is one. - let gpu = self.cache.is_none(); - let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); - // for a display profile: the image goes to the display's values, the histogram - // and the cached view preview stay sRGB - let mut srgb = None; - let display = match (&self.display, self.request.display) { - (Some(d), Some(_)) => { - let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; - let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); - // (an HDR edit's HDR histogram stays) - if let Some(h) = h.filter(|_| rendered.histogram.hdr.is_none()) { - rendered.histogram = h; - } - srgb = s; - Some(d.id()) - } - _ => None, - }; - if let Some((cache, key)) = &self.cache { - cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); - } - if let Some((cache, key)) = &self.view_cache - && self.request.quality == Quality::Full - && (display.is_none() || srgb.is_some()) - { - cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); - } - // the display fit, after the caches have the full render - if let Some((fw, fh)) = self.display_fit - && self.request.quality == Quality::Full - { - let (w, h) = (rendered.image.width, rendered.image.height); - if w > fw.max(1) || h > fh.max(1) { - rendered.image = fit_for_display(&rendered.image, fw, fh); - } - } - RenderResult { - request_id: self.request_id, - source_key: self.source_key, - photo: self.photo, - level: self.level, - key: self.key, - rendered: Ok(rendered), - gpu_fallback, - loaded: (!was_loaded).then_some(source), - quick: None, - display, - } - } - Err(e) => self.failed(e), - } - } -} - -/// Render `src`: on the GPU when `gpu` is set and a GPU is available (`lightcraft_gpu`), else on -/// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see -/// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU. -pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered { - develop_reporting(src, info, s, req, stages, gpu).0 -} - -/// [`develop`], also saying why the render fell back from the GPU to the CPU when it did (`None`: -/// it rendered on the GPU, or the GPU was not tried). The reason is this render's own, not the -/// process-wide [`lightcraft_gpu::last_fallback`]. -pub fn develop_reporting( - src: &Arc, - info: &SourceInfo, - s: &DevelopSettings, - req: &RenderRequest, - stages: Option<&StageCache>, - gpu: bool, -) -> (Rendered, Option) { - let (mut rendered, fallback) = develop_image(src, info, s, req, stages, gpu); - // an HDR edit's preview is its SDR rendition: its histogram shows the HDR range instead - if let Some(h) = lightcraft_pipeline::hdr_histogram(src, info, s, req) { - rendered.histogram = h; - } - (rendered, fallback) -} - -fn develop_image( - src: &Arc, - info: &SourceInfo, - s: &DevelopSettings, - req: &RenderRequest, - stages: Option<&StageCache>, - gpu: bool, -) -> (Rendered, Option) { - // LUT profiles have no GPU stage: they render on the CPU - let fallback = if gpu - && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id) - && !s.masks.iter().any(|m| m.visible && m.refine > 0.0) - && req.proof.is_none() - { - match lightcraft_gpu::try_render(src, info, s, req, stages) { - Ok(r) => return (r, None), - Err(lightcraft_gpu::Fallback::Failed(why)) => Some(why), - Err(lightcraft_gpu::Fallback::NotTried) => None, - } - } else { - None - }; - let rendered = match stages { - Some(st) => lightcraft_pipeline::render_cached(src, info, s, req, st), - None => lightcraft_pipeline::render(src, info, s, req), - }; - (rendered, fallback) -} - -/// Where a [`QuickJob`]'s stand-in image came from. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum QuickSource { - /// The photo's own develop render for its current settings (cached view or thumbnail render). - Cached, - /// The camera's embedded JPEG (unedited raws). - Embedded, - /// A thumbnail-level develop render made for the occasion. - Small, -} - -/// Something to show *now* for a photo while its real render is prepared. Tried in order: cached -/// renders, the embedded preview of an unedited raw, a thumbnail-level render. -#[derive(Clone)] -pub struct QuickJob { - pub request_id: u64, - pub photo: PhotoId, - pub key: u64, - /// Cached renders to try first, in order: (cache, key). - pub cached: Vec<(Arc, Hash128)>, - /// Embedded preview of an unedited raw: (path, loader, max edge). - pub embedded: Option<(String, PreviewLoader, usize)>, - /// Last resort: run this thumbnail job. - pub small: Option>, -} - -impl QuickJob { - /// The stand-in, from the first source that has one. A panic inside is this job's failure - /// (see [`RenderJob::run`]). - pub fn run(self) -> RenderResult { - let (request_id, photo, key) = (self.request_id, self.photo, self.key); - crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { - request_id, - source_key: None, - photo, - level: SourceLevel::Thumb, - key, - rendered: Err(e), - gpu_fallback: None, - loaded: None, - quick: None, - display: None, - }) - } - - fn run_unguarded(self) -> RenderResult { - let (photo, key) = (self.photo, self.key); - let request_id = self.request_id; - let done = |rendered: Result, quick| RenderResult { - request_id, - source_key: None, - photo, - level: SourceLevel::Thumb, - key, - rendered, - gpu_fallback: None, - loaded: None, - quick, - display: None, - }; - let rendered = |image: Rgba8| { - let histogram = Histogram::of_srgb8(&image); - Ok(Rendered { image, histogram, deep: None }) - }; - for (cache, k) in &self.cached { - if let Some(img) = cache.get(*k) { - return done(rendered(Arc::unwrap_or_clone(img)), Some(QuickSource::Cached)); - } - } - if let Some((path, loader, edge)) = &self.embedded - && let Some(image) = loader(path, *edge) - { - return done(rendered(image), Some(QuickSource::Embedded)); - } - if let Some(job) = self.small { - let r = job.run(); - return RenderResult { request_id, key, quick: Some(QuickSource::Small), ..r }; - } - done(Err("no quick preview".into()), None) - } -} - -/// What identifies a photo's pixels for caching: its content hash, else its source. -pub fn content_key(p: &Photo) -> String { - match (&p.content_hash, &p.source) { - (Some(h), _) => h.clone(), - (None, Source::Demo { scene }) => format!("demo:{scene}"), - (None, Source::File { path }) => format!("file:{path}:{}", p.file_size), - } -} - -pub fn source_info(p: &Photo) -> SourceInfo { - // Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map. - // (They are drawn afresh at every size, so they have no original size.) - if matches!(p.source, Source::Demo { .. }) { - return SourceInfo { raw: true, ..Default::default() }; - } - // the thumbnail and preview levels are smaller copies: noise reduction works in the original's pixels (#960) - let native_long = Some(p.width.max(p.height) as usize).filter(|&n| n > 0); - SourceInfo { native_long, ..header_info(p) } -} - -fn header_info(p: &Photo) -> SourceInfo { - // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white - // balance and the display tone curve, like any other rendered file. - if p.develops_raw() { - let relative = p.relative_wb(); - let (temp, tint) = if relative { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) }; - SourceInfo { - raw: true, - as_shot_temp: temp, - as_shot_tint: tint, - lens: p.embedded_lens, - relative_wb: relative, - // (a fitted-look raw edited in Kelvin: its camera's matrices give the scale, see `files::WbScaleOf`) - legacy_relative_wb: p.fitted_look() && !relative, - ..Default::default() - } - } else { - SourceInfo::default() - } -} - -impl crate::Session { - /// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look. - pub fn render_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, before: bool, apply_crop: bool) -> Option { - self.build_job(id, max_w, max_h, before, apply_crop, None) - } - - /// A grid/filmstrip thumbnail job: the long edge is rounded up to one of [`THUMB_SIZES`] - /// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk). - pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option { - let b = thumb_bucket(long_edge); - let mut job = self.build_job(id, b, b, false, true, Some(b))?; - // (a thumbnail may develop a smaller copy of the raw image that the file keeps) - if let SourceRef::File { reduced, .. } = &mut job.source { - reduced.clone_from(&self.media.reduced_loader); - } - Some(job) - } - - fn build_job( - &mut self, - id: PhotoId, - max_w: usize, - max_h: usize, - before: bool, - apply_crop: bool, - thumb_bucket: Option, - ) -> Option { - let p = self.catalog.photo(id)?.clone(); - let settings = if before { Arc::new(self.before_settings(&p)) } else { p.develop.clone() }; - let level = SourceLevel::for_size(if thumb_bucket.is_some() { - max_w.max(max_h) // (grid thumbnails of tight crops stay cheap) - } else { - source_edge_needed(&p, &settings, apply_crop, max_w.max(max_h)) - }); - let source = self.media.source_ref(&p, level); - let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) }; - // the photo id is part of the key: two photos with the same settings and size must not - // share a result (a view slot showing photo A would otherwise look current for photo B) - // …and so is the file's content: a file changed on disk (Reload) renders afresh - let content_key = content_key(&p); - let source_key = Hasher128::new().str(&content_key).finish(); - let content = source_key.0 as u64; - // Temporary "before" settings must not replace the photo's cached develop hash. - let settings_hash = if before { settings.hash64() } else { self.media.settings_hashes.get(id, &settings) }; - let salt = self.media.denoise_salt(id, &settings); - let key = settings_hash - ^ ((max_w as u64) << 40) - ^ ((max_h as u64) << 20) - ^ (apply_crop as u64) - ^ (level as u64) << 60 - ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) - ^ content.rotate_left(17) - ^ salt; - let cache = thumb_bucket.map(|b| { - let mut k = Hasher128::new(); - k.str(&content_key).u64(settings_hash).u64(b as u64).u64(RENDER_CACHE_VERSION).u64(crate::camera_profiles::cache_key()); - // (only a render that uses a denoised picture has it in its key, so every other thumbnail stays valid) - if salt != 0 { - k.u64(salt); - } - (self.media.rendered.clone(), k.finish()) - }); - Some(RenderJob { - request_id: 0, - cache_generation: self.media.rendered.generation(), - source_key: Some(source_key), - photo: id, - level, - source, - origin: p.source.clone(), - info: source_info(&p), - settings, - request, - key, - cache, - stages: None, - view_cache: None, - display: None, - slide: None, - preview_cut: None, - display_fit: None, - }) - } - - /// A render of `id` with `settings` in place of its own, fitting `max_w × max_h` (the loupe's - /// temporary preview while hovering a preset or profile; nothing is committed or cached). - pub fn preview_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool, settings: &DevelopSettings) -> Option { - let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; - job.settings = Arc::new(settings.clone()); - job.key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (job.level as u64) << 60; - Some(job) - } - - /// Cache key of a variant thumbnail ([`Self::variant_job`]). - pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 { - Hasher128::new() - .str(&content_key(p)) - .str("variant") - .u64(settings.hash64()) - .u64(edge as u64) - .u64(RENDER_CACHE_VERSION) - .u64(crate::camera_profiles::cache_key()) - .finish() - } - - /// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset - /// browsers): from the thumbnail-level source, long edge `edge` (≤ 512), cropped. The result - /// is cached (memory + the library's disk cache) under the photo's content and the settings - /// hash, so a variant renders once; `key` is derived from the same hash, so a frontend can - /// keep one texture per variant. - pub fn variant_job(&mut self, id: PhotoId, settings: &DevelopSettings, edge: usize) -> Option { - let p = self.catalog.photo(id)?.clone(); - let edge = edge.clamp(16, SourceLevel::Thumb.max_edge()); - let level = SourceLevel::Thumb; - let source = self.media.source_ref(&p, level); - let ck = Self::variant_key(&p, settings, edge); - Some(RenderJob { - request_id: 0, - cache_generation: self.media.rendered.generation(), - source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), - photo: id, - level, - source, - origin: p.source.clone(), - info: source_info(&p), - settings: Arc::new(settings.clone()), - request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, - key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), - cache: Some((self.media.rendered.clone(), ck)), - stages: None, - view_cache: None, - display: None, - slide: None, - preview_cut: None, - display_fit: None, - }) - } - - /// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the - /// photo's own look with its crop replaced by a square around the face (room for hair and chin), - /// at least `edge` pixels across (rounded up to [`FACE_EDGE_STEP`], ≤ 512, so a tile size - /// between two steps reuses the cached one). An unedited raw's tile is cut from the camera's - /// embedded preview when that holds the square at [`crate::face_tiles::SOFT_LIMIT`] of the - /// size asked or more ([`crate::face_tiles`]); else, and for every other photo, the source - /// level is the smallest that keeps the face sharp, up to the preview. Cached (memory and the - /// library's thumbnail cache on disk) like [`Self::variant_job`], and `key` likewise follows - /// the content: a later launch shows the tile without reading the original. - pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option { - let p = self.catalog.photo(id)?.clone(); - // `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the - // user's Rotate Left/Right, so carry the box (and the photo's size) over to it. - let orient = p.develop.orientation; - let face = orient.map_norm_rect(face); - let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); - let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) }; - let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7; - // finite, within the photo, and never wider than its short side (so the clamps below are valid) - let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) }; - let (cx, cy) = ((face.x0 + face.x1) / 2.0 * w, (face.y0 + face.y1) / 2.0 * h); - let (cx, cy) = (if cx.is_finite() { cx } else { w / 2.0 }, if cy.is_finite() { cy } else { h / 2.0 }); - let (x0, y0) = (cx.clamp(side / 2.0, w - side / 2.0) - side / 2.0, cy.clamp(side / 2.0, h - side / 2.0) - side / 2.0); - let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h }; - let mut settings = (*p.develop).clone(); - settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() }; - let edge = face_edge(edge); - // pixels along the long edge that leave `edge` across the crop - let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64); - let level = SourceLevel::for_size(needed as usize); - let source = self.media.source_ref(&p, level); - let ck = Self::variant_key(&p, &settings, edge); - let preview_cut = self.face_preview_cut(&p, rect, edge, needed as usize); - Some(RenderJob { - request_id: 0, - cache_generation: self.media.rendered.generation(), - source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), - photo: id, - level, - source, - origin: p.source.clone(), - info: source_info(&p), - settings: Arc::new(settings), - request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, - key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), - cache: Some((self.media.rendered.clone(), ck)), - stages: None, - view_cache: None, - display: None, - slide: None, - preview_cut, - display_fit: None, - }) - } - - /// How a face tile of `p` can come from its embedded preview ([`crate::face_tiles`]): only an - /// unedited raw (whose grid thumbnail is that preview too) not known to be offline, upright as - /// imported, with a preview loader installed. `rect` is the square around the face, `edge` the - /// tile, `needed` the source's long edge the develop render would ask for. - fn face_preview_cut(&self, p: &Photo, rect: lightcraft_geom::Rect, edge: usize, needed: usize) -> Option { - let (path, loader) = self.embedded_of(p)?; - if p.develop.orientation != lightcraft_geom::Orientation::Normal || self.media.availability.is_offline(&path) || p.width == 0 || p.height == 0 - { - return None; - } - let want = needed.max(edge).max(1); - let min = (want as f64 * crate::face_tiles::SOFT_LIMIT).ceil() as usize; - let aspect = f64::from(p.width) / f64::from(p.height); - Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge }) - } - - /// Size-independent cache key of a photo's view render (loupe) for its current settings. - fn view_key(p: &Photo, apply_crop: bool) -> Hash128 { - Hasher128::new() - .str(&content_key(p)) - .str("view") - .u64(p.develop.hash64()) - .u64(apply_crop as u64) - .u64(RENDER_CACHE_VERSION) - .u64(crate::camera_profiles::cache_key()) - .finish() - } - - /// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the - /// photo's view preview (memory, and disk with a library) for [`Self::quick_view_job`]. - pub fn loupe_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool) -> Option { - let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; - let p = self.catalog.photo(id)?; - job.view_cache = Some((self.media.rendered.clone(), Self::view_key(p, apply_crop))); - Some(job) - } - - /// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame - /// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and - /// the result is `window`'s pixels of it, at that scale, from the source level that size needs. - /// Nothing is cached here (a window is only worth keeping while it is on screen). `None`: no - /// such photo, or the window reads (spots, Auto Mask strokes) more than one render can hold. - pub fn region_job( - &mut self, - id: PhotoId, - full_w: usize, - full_h: usize, - window: lightcraft_pipeline::PixelWindow, - apply_crop: bool, - ) -> Option { - self.region_job_of(id, full_w, full_h, window, apply_crop, false) - } - - /// [`Self::region_job`] of the photo without its edits (its crop kept): the Before side. - pub fn region_job_before( - &mut self, - id: PhotoId, - full_w: usize, - full_h: usize, - window: lightcraft_pipeline::PixelWindow, - apply_crop: bool, - ) -> Option { - self.region_job_of(id, full_w, full_h, window, apply_crop, true) - } - - fn region_job_of( - &mut self, - id: PhotoId, - full_w: usize, - full_h: usize, - window: lightcraft_pipeline::PixelWindow, - apply_crop: bool, - before: bool, - ) -> Option { - let mut job = self.render_job(id, full_w, full_h, before, apply_crop)?; - // what the window's spots and Auto Mask strokes read must fit in one render: else the - // caller keeps the whole-frame render (a window alone would come out wrong) - let p = self.catalog.photo(id)?; - let frame = lightcraft_pipeline::geometry::Frame::with_lens( - p.width.max(1) as usize, - p.height.max(1) as usize, - &job.settings, - apply_crop, - p.embedded_lens.as_ref(), - ); - let ppl = frame.px_per_long(full_w); - lightcraft_pipeline::spots::window_for_reads_checked(&job.settings, &frame, full_w, full_h, ppl, window.clamped(full_w, full_h))?; - job.request.window = Some(window); - job.key = Hasher128::new() - .u64(job.key) - .str("window") - .u64(window.x as u64) - .u64(window.y as u64) - .u64(window.w as u64) - .u64(window.h as u64) - .finish() - .0 as u64; - Some(job) - } - - /// The embedded preview of an unedited raw (path, loader), when the app installed a loader. - fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { - match (&p.source, &self.media.preview_loader) { - (Source::File { path }, Some(l)) if p.kind == MediaKind::Raw && crate::import::has_import_look(p) => Some((path.clone(), l.clone())), - _ => None, - } - } - - /// Whether `p`'s thumbnail can be the camera's own JPEG, so a frontend can skip the sensor - /// decode behind it. - /// - /// The grid shows this JPEG first in any case ([`QuickJob`]: "grid thumbnails of unedited raws - /// likewise start from the embedded preview"), and then paid for a render of the raw to replace - /// it. That render decodes the whole sensor — 50 of a 56 MB Fuji RAF's bytes — for a 512 px - /// thumbnail the 4 416 × 2 944 embedded JPEG already covers, and on a share at 1.2 MB/s it is - /// 45 s a photo: a 149-photo folder spent 8.3 GB of traffic on thumbnails, and the queue behind - /// it delayed the render the user was waiting for. - /// - /// Only unedited raws answer `true`: any edit must be rendered, and `has_import_look` is what - /// says the photo still looks the way it did at import. A photo the user edits re-renders - /// through the ordinary path, because its thumbnail key (settings hash) changes. - pub fn embedded_thumb_is_final(&self, p: &Photo) -> bool { - self.embedded_of(p).is_some() - } - - /// The camera's embedded preview of a raw file, for the face scan. Faces are read from the unedited picture, so the - /// photo's edits do not matter, and the camera's own JPEG (oriented, display-ready) costs a fraction of decoding the - /// raw. `None` for anything else, and where the host installed no loader. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn scan_preview_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { - match (&p.source, &self.media.preview_loader) { - (Source::File { path }, Some(l)) if p.kind == MediaKind::Raw => Some((path.clone(), l.clone())), - _ => None, - } - } - - /// Something to show in the loupe right away for `id` (see [`QuickJob`]): its cached view - /// render, else the embedded preview of an unedited raw, else a cached or fresh thumbnail. - pub fn quick_view_job(&mut self, id: PhotoId, max_edge: usize, apply_crop: bool) -> Option { - let p = self.catalog.photo(id)?.clone(); - let small = self.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])?; - // (the thumbnail job itself starts with the cached thumbnail, after the sharper embedded preview) - let cached = vec![(self.media.rendered.clone(), Self::view_key(&p, apply_crop))]; - let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish(); - let key = h.0 as u64; - let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge()))); - Some(QuickJob { request_id: 0, photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) }) - } - - /// A first grid thumbnail for an unedited raw: the cached render for `job` (the real - /// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries - /// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview, else `job` - /// itself: the grid queues no render behind an unedited raw's quick thumbnail - /// ([`Self::embedded_thumb_is_final`]), so a preview that can't be read must not leave the - /// cell empty (issue #1448). `None` for other photos (render them directly). - pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option { - let p = self.catalog.photo(job.photo)?.clone(); - let (path, l) = self.embedded_of(&p)?; - let edge = job.request.max_w.max(job.request.max_h); - Some(QuickJob { - request_id: 0, - photo: job.photo, - key: job.key, - cached: job.cache.clone().into_iter().collect(), - embedded: Some((path, l, edge)), - small: Some(Box::new(job.clone())), - }) - } - - /// Accept a finished job's loaded source into the cache. What the decode learnt about the file that the catalog - /// has wrong is corrected too ([`Self::learn_as_shot_wb`]). - pub fn accept(&mut self, r: &RenderResult) { - if let Some(src) = &r.loaded - && r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key)) - { - if let Some(info) = &src.info { - self.learn_as_shot_wb(r.photo, info); - } - self.media.insert_source(r.photo, r.level, src.clone()); - } - } - - /// Record the as-shot white balance a decode of photo `id`'s file read (`info`) when the catalog has another: - /// a raw imported before its camera's matrices gave it a Kelvin scale keeps 6500 / 0 (the relative scale's - /// reference) until its file is next decoded (issue #730). Bookkeeping, not an undo step; the look does not - /// change (the pipeline reads the stored settings against the real as-shot white either way), only what As Shot - /// says and what a smart preview renders with. Only for raws edited in Kelvin. - fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) { - let Some(p) = self.catalog.photo(id) else { return }; - if !info.raw || info.relative_wb || !p.develops_raw() || p.relative_wb() { - return; - } - let read = (info.as_shot_temp, info.as_shot_tint); - if !(read.0.is_finite() && read.1.is_finite()) || p.as_shot_wb == Some(read) { - return; - } - if let Err(e) = self.apply_system(Op::SetAsShotWb { id, wb: Some(read) }) { - log::warn!("photo {}: as-shot white balance not recorded: {e}", id.0); - } - } - - /// Synchronous render (CLI, MCP, tests). - pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result { - let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; - let r = job.run(); - self.accept(&r); - r.rendered - } - - /// Synchronous render for export: like [`Self::render_now`], into colour space `space` with - /// sample format `depth`. - pub fn render_export( - &mut self, - id: PhotoId, - max_w: usize, - max_h: usize, - space: lightcraft_pipeline::OutputSpace, - depth: lightcraft_pipeline::OutputDepth, - ) -> Result { - let r = self.export_job(id, max_w, max_h, space, depth)?.run(); - self.accept(&r); - r.rendered - } - - /// The job [`Self::render_export`] runs, to run elsewhere (e.g. a background export thread). - pub fn export_job( - &mut self, - id: PhotoId, - max_w: usize, - max_h: usize, - space: lightcraft_pipeline::OutputSpace, - depth: lightcraft_pipeline::OutputDepth, - ) -> Result { - let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; - // An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw - // image that the file keeps (Lightroom's merged DNGs) - let reduced = match &job.source { - SourceRef::Loaded(s) => s.reduced, - SourceRef::Fit { from, .. } => from.reduced, - _ => false, - }; - if reduced { - let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); - job.source = self.media.source_ref_uncached(&p, job.level); - } - // An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the - // background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.) - if let Some(wanted) = self.denoise_for_export(id, &job.settings)? { - let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); - job.source = self.media.source_ref_uncached(&p, job.level); - if let SourceRef::File { denoise, .. } = &mut job.source { - *denoise = Some(wanted); - } - } - job.request.space = space; - job.request.depth = depth; - job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db); - Ok(job) - } - - /// Prefer decoder facts to header-only metadata for pixel-statistics commands. - pub fn source_info(&self, id: PhotoId) -> SourceInfo { - let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default(); - self.media.source_facts(id, header) - } - - /// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously. - pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result, String> { - let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); - let r = self.media.source_ref(&p, level).load_source()?; - let image = r.image.clone(); - if let Some(info) = &r.info { - self.learn_as_shot_wb(id, info); - } - self.media.insert_source(id, level, r); - Ok(image) - } -} - -/// What an import learns from a file header (set by the app from `lightcraft-codecs`/`-raw`). -#[derive(Clone, Debug, Default)] -pub struct ProbeInfo { - pub width: u32, - pub height: u32, - pub format: String, - pub kind: MediaKind, - pub file_size: u64, - pub captured: Option, - /// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the - /// capture date of a file whose metadata has none, as Lightroom does (exports saved without - /// metadata would otherwise all land under "Unknown Date"). - pub file_modified: Option, - pub meta: lightcraft_catalog::Meta, - pub as_shot_wb: Option<(f64, f64)>, - /// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit - /// [`lightcraft_preview::hash_bytes`] of the whole file (as the native probe computes it), - /// Import → Copy verifies each copy against it instead of reading the source again. - pub content_hash: Option, - /// Lens corrections embedded in the file (DNG opcodes). - pub embedded_lens: Option, - /// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file. - pub xmp: Option, - /// A raw variant that can't be decoded yet: why. The file is described (and will be shown and - /// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`]. - pub preview_only: Option, -} - -pub type FileProbe = Arc Result + Send + Sync>; - #[cfg(test)] mod thumbnail_hash_tests; diff --git a/crates/engine/src/media/cache.rs b/crates/engine/src/media/cache.rs new file mode 100644 index 000000000..c3d323e38 --- /dev/null +++ b/crates/engine/src/media/cache.rs @@ -0,0 +1,365 @@ +use super::settings_hash::SettingsHashes; +use super::source::{DecodedSource, DenoiseSpec, PairLoader, RENDERED_MEM_BYTES, SourceLevel, SourceRef, THUMB_SOURCE_BYTES}; +use super::*; + +/// The photos that have a cached denoised picture for the denoise model in use. Only memory: nothing here touches the +/// file system, so it can be asked on the UI thread. +#[derive(Default)] +pub struct DenoiseIndex { + ready: std::collections::HashMap, + /// Reads the pictures (`None`: this build cannot, and nothing is ever ready). + pub loader: Option, +} + +impl DenoiseIndex { + /// What to load for `id` besides its plain picture, and how. + pub fn get(&self, id: PhotoId) -> Option<(&DenoiseSpec, &PairLoader)> { + self.ready.get(&id).zip(self.loader.as_ref()) + } + + pub fn spec(&self, id: PhotoId) -> Option<&DenoiseSpec> { + self.ready.get(&id) + } + + pub fn set(&mut self, id: PhotoId, spec: DenoiseSpec) { + self.ready.insert(id, spec); + } + + pub fn remove(&mut self, id: PhotoId) -> bool { + self.ready.remove(&id).is_some() + } + + pub fn clear(&mut self) { + self.ready.clear(); + } + + pub fn len(&self) -> usize { + self.ready.len() + } + + pub fn is_empty(&self) -> bool { + self.ready.is_empty() + } + + pub fn ids(&self) -> Vec { + let mut v: Vec = self.ready.keys().copied().collect(); + v.sort(); + v + } +} + +impl Default for MediaCache { + fn default() -> Self { + let budget = crate::memory::cache_share(crate::memory::budget()); + MediaCache { + settings_hashes: SettingsHashes::default(), + thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)), + previews: Vec::new(), + full: Vec::new(), + full_capacity: 1, + on_screen: None, + preview_capacity: 0, + budget, + file_loader: None, + reduced_loader: None, + file_probe: None, + preview_loader: None, + file_bytes: None, + smart_dir: None, + availability: Default::default(), + scenes: Vec::new(), + rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))), + denoise: DenoiseIndex::default(), + } + } +} + +/// Memory for rendered previews out of the cache share. +fn rendered_budget(share: usize) -> usize { + RENDERED_MEM_BYTES.min(share / 4) +} + +impl MediaCache { + /// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired + /// (its files stay): render jobs still holding it can't write through it any more, even + /// into a directory a later clear emptied (`dir` may be the same one). + pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) { + self.rendered.retire(); + self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes)); + } + + /// Change the disk budget of the cache in `dir`: in place when that cache is attached (its + /// thumbnails are keyed by content, so they and the textures shown from them stay valid, + /// and the same cache object keeps its generation), else attach it. + pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) { + match self.rendered.disk().filter(|d| d.dir() == dir) { + Some(d) => d.set_budget(disk_bytes), + None => self.attach_disk_cache(dir, disk_bytes), + } + } + + /// Forget every decoded source (photo ids changed meaning, e.g. another library was opened). + pub fn clear_sources(&mut self) { + self.settings_hashes = SettingsHashes::default(); + self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget)); + self.previews.clear(); + self.full.clear(); + } + + /// How many full-resolution originals to keep at once (at least one). Compare and Reference + /// views zoomed to 1:1 cut windows from two photos' originals: with room for one, every window + /// render of one side would evict the other side's original and decode its own again. Lowering + /// it drops the least recently used originals at once. + pub fn set_full_capacity(&mut self, n: usize) { + self.full_capacity = n.max(1); + self.trim_full(); + } + + /// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays + /// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it + /// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once. + pub fn set_on_screen(&mut self, id: Option) { + if self.on_screen != id { + self.on_screen = id; + self.enforce_budget(); + } + } + + fn trim_full(&mut self) { + while self.full.len() > self.full_capacity { + let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); + self.full.remove(oldest); + } + } + + /// Bytes the caches may hold together. + pub fn budget(&self) -> usize { + self.budget + } + + /// Change the bytes the caches may hold together, evicting at once if needed. + pub fn set_budget(&mut self, bytes: usize) { + self.budget = bytes; + self.thumbs.set_budget(THUMB_SOURCE_BYTES.min(bytes)); + self.rendered.set_mem_budget(rendered_budget(bytes)); + self.enforce_budget(); + } + + /// Bytes held by decoded sources and rendered previews in memory. + pub fn held(&self) -> usize { + let (t, p, f) = self.usage(); + t.bytes + p.bytes + f.bytes + self.rendered.mem_usage().1 + } + + /// Evict least recently used entries across the source caches and the rendered previews + /// until they fit the budget. The newest preview-level source (the photo on screen, or the + /// one just decoded) is never evicted here, nor is the original of the photo on screen + /// ([`Self::set_on_screen`]). + pub fn enforce_budget(&mut self) { + // The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB) + // nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and + // thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step. + let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum(); + let mut held = self.held().saturating_sub(pinned); + while held > self.budget { + let newest_preview = self.previews.iter().map(|e| e.2).max(); + let candidates = [ + self.thumbs.oldest_tick().map(|t| (t, 0)), + self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)), + self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)), + self.rendered.oldest_tick().map(|t| (t, 3)), + ]; + let Some((tick, which)) = candidates.into_iter().flatten().min() else { break }; + let freed = match which { + 0 => self.thumbs.pop_oldest(), + 1 => self.previews.iter().position(|e| e.2 == tick).map(|i| self.previews.remove(i).1.bytes()), + 2 => self.full.iter().position(|e| e.2 == tick).map(|i| self.full.remove(i).1.bytes()), + _ => self.rendered.evict_oldest(), + }; + match freed { + Some(b) if b > 0 => held = held.saturating_sub(b), + _ => break, + } + } + } + + pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option> { + self.get_source(id, level).map(|s| s.image) + } + + pub(super) fn get_source(&mut self, id: PhotoId, level: SourceLevel) -> Option { + match level { + SourceLevel::Thumb => self.thumbs.get(&id).cloned(), + SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| { + e.2 = lightcraft_preview::next_tick(); + e.1.clone() + }), + SourceLevel::Full => self.full.iter_mut().find(|e| e.0 == id).map(|e| { + e.2 = lightcraft_preview::next_tick(); + e.1.clone() + }), + } + } + + /// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header` + /// (with a smart preview's stored camera tone curve). + pub(super) fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo { + let cached = || { + self.thumbs + .peek(&id) + .into_iter() + .chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1)) + .chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1)) + }; + match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) { + Some(s) => s.info_or(header), + None => header, + } + } + + pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc) { + self.insert_source(id, level, DecodedSource::new(img, None)); + } + + pub(super) fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) { + let tick = lightcraft_preview::next_tick(); + match level { + SourceLevel::Thumb => { + let cost = img.bytes(); + self.thumbs.insert(id, img, cost); + } + SourceLevel::Preview => { + self.previews.retain(|e| e.0 != id); + self.previews.push((id, img, tick)); + let cap = if self.preview_capacity == 0 { 4 } else { self.preview_capacity }; + while self.previews.len() > cap { + let oldest = self.previews.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); + self.previews.remove(oldest); + } + } + SourceLevel::Full => { + self.full.retain(|e| e.0 != id); + self.full.push((id, img, tick)); + self.trim_full(); + } + } + self.enforce_budget(); + } + + /// Forget a photo's decoded sources (e.g. after its file changed). + pub fn forget(&mut self, id: PhotoId) { + self.thumbs.remove(&id); + self.previews.retain(|e| e.0 != id); + self.full.retain(|e| e.0 != id); + } + + /// (decoded thumbnail sources, bytes). + pub fn source_usage(&self) -> (usize, usize) { + (self.thumbs.len(), self.thumbs.cost()) + } + + /// Whether the decoded source of `id` at `level` is held. + pub fn has_source(&self, id: PhotoId, level: SourceLevel) -> bool { + match level { + SourceLevel::Thumb => self.thumbs.contains(&id), + SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id), + SourceLevel::Full => self.full.iter().any(|e| e.0 == id), + } + } + + /// Decoded sources held: (thumbnail level, preview level, full size). + pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) { + use crate::memory::Usage; + let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| e.1.bytes()).sum()); + let full = Usage::new(self.full.len(), self.full.iter().map(|e| e.1.bytes()).sum()); + (Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full) + } + + /// What a render of photo `id` with settings `s` adds to its identity for the denoised picture it will use: 0 unless + /// it uses one (the Denoise amount is above 0 and the photo has a cached denoised picture). + pub fn denoise_salt(&self, id: PhotoId, s: &DevelopSettings) -> u64 { + match self.denoise.spec(id) { + Some(spec) if s.denoise_amount() > 0.0 => Hasher128::new().str(&spec.key).finish().0 as u64 | 1, + _ => 0, + } + } + + /// How to load photo `p` at `level`: from the cache when its source is there, else from its file (with its denoised + /// picture beside it when it has one), its smart preview or the demo scene. + pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { + self.source_ref_with(p, level, true) + } + + /// [`Self::source_ref`] that never takes a decoded source from memory: for a job that needs something the cached + /// one lacks (an export, whose denoised picture is made when it runs). + pub fn source_ref_uncached(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { + self.source_ref_with(p, level, false) + } + + fn source_ref_with(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef { + let mut r = self.source_ref_plain(p, level, cached); + if let (SourceRef::File { denoise, .. }, Some((spec, loader))) = (&mut r, self.denoise.get(p.id)) { + *denoise = Some((spec.clone(), loader.clone())); + } + r + } + + fn source_ref_plain(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef { + if cached && let Some(a) = self.get_source(p.id, level) { + return SourceRef::Loaded(Box::new(a)); + } + // a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not + // with a denoised picture, which the thumbnail would lose + if cached + && level == SourceLevel::Thumb + && self.denoise.spec(p.id).is_none() + && let Some(big) = self.get_source(p.id, SourceLevel::Preview) + { + return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() }; + } + // Procedural scenes have a nominal size: "full" is that size, not unbounded. + let max_edge = level.max_edge().min(match (&p.source, level) { + (Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize, + _ => usize::MAX, + }); + // the original is offline: its smart preview, when there is one. Only cached answers are + // used here (no file-system call on the UI thread in the app); an original not known to + // be offline is tried first and its smart preview is the render worker's fallback. + #[cfg(not(target_arch = "wasm32"))] + if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) { + let sharded = crate::smart::sharded_file_path(dir, p); + let legacy = dir.join(crate::smart::file_name(p)); + let sp = if self.availability.exists(&sharded.to_string_lossy()) == Some(true) { sharded } else { legacy }; + // (a proxy cut short fails to load, like the offline original; the worker's fallback + // in `SourceRef::load` only uses a proxy that passes `smart::is_valid`) + if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) { + return SourceRef::Smart { path: sp }; + } + let mut r = self.origin_ref(&p.source, max_edge); + if let SourceRef::File { fallback, .. } = &mut r { + *fallback = Some(sp); + } + return r; + } + self.origin_ref(&p.source, max_edge) + } + + /// How to load `origin` at most `max_edge` pixels long, ignoring decoded sources in memory (a + /// render worker in another wasm instance builds its sources from this). + pub fn origin_ref(&mut self, origin: &Source, max_edge: usize) -> SourceRef { + match origin { + Source::Demo { scene } => { + if self.scenes.is_empty() { + self.scenes = lightcraft_scenes::demo_library(); + } + match self.scenes.iter().find(|s| s.id == *scene) { + Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge }, + None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None, reduced: None }, + } + } + Source::File { path } => { + SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None, reduced: None } + } + } + } +} diff --git a/crates/engine/src/media/quick.rs b/crates/engine/src/media/quick.rs new file mode 100644 index 000000000..6098ed4dd --- /dev/null +++ b/crates/engine/src/media/quick.rs @@ -0,0 +1,127 @@ +use super::render::{RenderJob, RenderResult}; +use super::source::{PreviewLoader, SourceLevel}; +use super::*; + +/// Where a [`QuickJob`]'s stand-in image came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum QuickSource { + /// The photo's own develop render for its current settings (cached view or thumbnail render). + Cached, + /// The camera's embedded JPEG (unedited raws). + Embedded, + /// A thumbnail-level develop render made for the occasion. + Small, +} + +/// Something to show *now* for a photo while its real render is prepared. Tried in order: cached +/// renders, the embedded preview of an unedited raw, a thumbnail-level render. +#[derive(Clone)] +pub struct QuickJob { + pub request_id: u64, + pub photo: PhotoId, + pub key: u64, + /// Cached renders to try first, in order: (cache, key). + pub cached: Vec<(Arc, Hash128)>, + /// Embedded preview of an unedited raw: (path, loader, max edge). + pub embedded: Option<(String, PreviewLoader, usize)>, + /// Last resort: run this thumbnail job. + pub small: Option>, +} + +impl QuickJob { + /// The stand-in, from the first source that has one. A panic inside is this job's failure + /// (see [`RenderJob::run`]). + pub fn run(self) -> RenderResult { + let (request_id, photo, key) = (self.request_id, self.photo, self.key); + crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { + request_id, + source_key: None, + photo, + level: SourceLevel::Thumb, + key, + rendered: Err(e), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }) + } + + fn run_unguarded(self) -> RenderResult { + let (photo, key) = (self.photo, self.key); + let request_id = self.request_id; + let done = |rendered: Result, quick| RenderResult { + request_id, + source_key: None, + photo, + level: SourceLevel::Thumb, + key, + rendered, + gpu_fallback: None, + loaded: None, + quick, + display: None, + }; + let rendered = |image: Rgba8| { + let histogram = Histogram::of_srgb8(&image); + Ok(Rendered { image, histogram, deep: None }) + }; + for (cache, k) in &self.cached { + if let Some(img) = cache.get(*k) { + return done(rendered(Arc::unwrap_or_clone(img)), Some(QuickSource::Cached)); + } + } + if let Some((path, loader, edge)) = &self.embedded + && let Some(image) = loader(path, *edge) + { + return done(rendered(image), Some(QuickSource::Embedded)); + } + if let Some(job) = self.small { + let r = job.run(); + return RenderResult { request_id, key, quick: Some(QuickSource::Small), ..r }; + } + done(Err("no quick preview".into()), None) + } +} + +/// What identifies a photo's pixels for caching: its content hash, else its source. +pub fn content_key(p: &Photo) -> String { + match (&p.content_hash, &p.source) { + (Some(h), _) => h.clone(), + (None, Source::Demo { scene }) => format!("demo:{scene}"), + (None, Source::File { path }) => format!("file:{path}:{}", p.file_size), + } +} + +pub fn source_info(p: &Photo) -> SourceInfo { + // Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map. + // (They are drawn afresh at every size, so they have no original size.) + if matches!(p.source, Source::Demo { .. }) { + return SourceInfo { raw: true, ..Default::default() }; + } + // the thumbnail and preview levels are smaller copies: noise reduction works in the original's pixels (#960) + let native_long = Some(p.width.max(p.height) as usize).filter(|&n| n > 0); + SourceInfo { native_long, ..header_info(p) } +} + +fn header_info(p: &Photo) -> SourceInfo { + // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white + // balance and the display tone curve, like any other rendered file. + if p.develops_raw() { + let relative = p.relative_wb(); + let (temp, tint) = if relative { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) }; + SourceInfo { + raw: true, + as_shot_temp: temp, + as_shot_tint: tint, + lens: p.embedded_lens, + relative_wb: relative, + // (a fitted-look raw edited in Kelvin: its camera's matrices give the scale, see `files::WbScaleOf`) + legacy_relative_wb: p.fitted_look() && !relative, + ..Default::default() + } + } else { + SourceInfo::default() + } +} diff --git a/crates/engine/src/media/render.rs b/crates/engine/src/media/render.rs new file mode 100644 index 000000000..1192a98e0 --- /dev/null +++ b/crates/engine/src/media/render.rs @@ -0,0 +1,377 @@ +use super::quick::QuickSource; +use super::source::{DecodedSource, SourceLevel, SourceRef}; +use super::*; + +/// Everything needed to render one photo, detached from the session. +#[derive(Clone)] +pub struct RenderJob { + /// Assigned by a frontend to distinguish late completions, even for an identical render key. + pub request_id: u64, + pub cache_generation: u64, + /// Content identity for validating a decoded source returned after a reload. + pub source_key: Option, + pub photo: PhotoId, + pub level: SourceLevel, + pub source: SourceRef, + /// Where the photo's pixels come from (for executors that can't share `source`, e.g. a web + /// worker). + pub origin: Source, + pub info: SourceInfo, + pub settings: Arc, + pub request: RenderRequest, + /// Identifies the result for caching: hash of settings + request. + pub key: u64, + /// Rendered-thumbnail cache and this job's key in it. + pub cache: Option<(Arc, Hash128)>, + /// Intermediate results of this view's previous renders (set by the frontend for the loupe): + /// slider drags then only redo the stages the changed setting feeds. + pub stages: Option>, + /// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]). + pub view_cache: Option<(Arc, Hash128)>, + /// Render for this monitor profile ([`Self::with_display`]). + pub display: Option>, + /// Compose the render into a slideshow slide ([`Self::with_slide`]). + pub slide: Option>, + /// A face tile that can be cut from the raw's embedded preview, tried before `source` + /// ([`crate::Session::face_job`]; `None` for every other job). + pub preview_cut: Option, + /// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once + /// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up + /// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at + /// small ratios and aliases it at large ones. The caches keep the full render. + pub display_fit: Option<(usize, usize)>, +} + +/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]). +pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 { + use lightcraft_raster::resample::{Filter, fit}; + let f = lightcraft_raster::Image { + width: img.width, + height: img.height, + data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(), + }; + let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3); + let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; + Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() } +} + +pub struct RenderResult { + pub request_id: u64, + pub source_key: Option, + pub photo: PhotoId, + pub level: SourceLevel, + pub key: u64, + pub rendered: Result, + /// Set when this job's render was tried on the GPU and fell back to the CPU: why + /// ([`lightcraft_gpu::Fallback::Failed`]). The result is the CPU render either way. + pub gpu_fallback: Option, + /// A source that was loaded by this job (to be inserted into the cache). + pub loaded: Option, + /// Set for a [`QuickJob`]'s stand-in: where the image came from. + pub quick: Option, + /// The display profile (id) the image is encoded for; `None`: sRGB (see + /// [`crate::display::present`]). + pub display: Option, +} + +impl RenderJob { + /// Render at draft quality (interactive drags). Gets its own result key. + /// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see + /// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again. + pub fn with_display_fit(mut self, w: usize, h: usize) -> Self { + self.display_fit = Some((w, h)); + self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + self + } + + pub fn draft(mut self) -> Self { + if self.request.quality != Quality::Draft { + self.request.quality = Quality::Draft; + self.key ^= 0x9e37_79b9_7f4a_7c15; + } + self + } + + /// Draw a diagnostic overlay over the result (e.g. Point Color's visualized range). Gets its + /// own result key. + pub fn with_overlay(mut self, overlay: lightcraft_pipeline::Overlay) -> Self { + if self.request.overlay != overlay { + self.key ^= self.request.overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); + self.request.overlay = overlay; + self.key ^= overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); + } + // a diagnostic view (visualized range / spots) must never become the photo's cached preview + if overlay != lightcraft_pipeline::Overlay::None { + self.view_cache = None; + } + self + } + + /// Soft-proof the result (see [`lightcraft_pipeline::Proof`]). Gets its own result key and is + /// never kept as the photo's cached preview. + pub fn with_proof(mut self, proof: Option) -> Self { + if self.request.proof != proof { + let k = |p: Option| p.map_or(0, |p| p.key()).wrapping_mul(0xc2b2_ae3d_27d4_eb4f); + self.key ^= k(self.request.proof) ^ k(proof); + self.request.proof = proof; + } + if proof.is_some() { + self.view_cache = None; + } + self + } + + /// Render with some colour grading wheels (shadows, midtones, highlights) switched off for + /// viewing (the wheel eyes in the Edit panel). The photo's settings are untouched; the result + /// gets its own key and is never cached as the photo's preview or thumbnail. + pub fn with_hidden_grading(mut self, hidden: [bool; 3]) -> Self { + if !hidden.iter().any(|h| *h) { + return self; + } + let mut s = (*self.settings).clone(); + let g = &mut s.grading; + for (off, wheel) in hidden.into_iter().zip([&mut g.shadows, &mut g.midtones, &mut g.highlights]) { + if off { + *wheel = lightcraft_develop::Wheel::default(); + } + } + let bits = hidden.iter().enumerate().fold(0u64, |b, (i, h)| b | ((*h as u64) << i)); + self.key ^= s.hash64() ^ self.settings.hash64() ^ bits.wrapping_mul(0xd6e8_feb8_6659_fd93); + self.settings = Arc::new(s); + self.cache = None; + self.view_cache = None; + self.preview_cut = None; + self + } + + /// Reuse `stages` across this view's renders. + pub fn with_stages(mut self, stages: Arc) -> Self { + self.stages = Some(stages); + self + } + + /// Render for a monitor profile: into the display's primaries (its gamut, not sRGB's), ready + /// to show ([`RenderResult::display`]). The histogram and the cached view preview stay sRGB. + /// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are: + /// [`crate::display::present`] converts their sRGB results. + pub fn with_display(mut self, display: Option>) -> Self { + // (a slide is composed in sRGB, backdrop colours included; the frontend presents it) + if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { + return self; + } + let k = |d: &Option>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); + self.key ^= k(&self.display) ^ k(&display); + self.request.display = display.as_ref().map(|d| d.space); + self.display = display; + self + } + + /// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting + /// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace + /// it, so a few such panics used to stop every render). + pub fn run(self) -> RenderResult { + let failed = self.failed(String::new()); + crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed }) + } + + /// This job's result when it fails with `e`. + fn failed(&self, e: String) -> RenderResult { + RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Err(e), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + } + } + + /// Compose the render into a slideshow slide (layout, options, backdrop): the result is the + /// slide, not the photo. Gets its own result key; the photo's cached view preview stays the + /// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone). + pub fn with_slide(mut self, slide: Arc) -> Self { + let k = |s: &Option>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407); + self.key ^= k(&self.slide); + self.key ^= k(&Some(slide.clone())); + self.slide = Some(slide); + self + } + + /// [`Self::run_photo`], composed into its slide when this is a slide job (inside [`Self::run`]'s guard). + fn run_unguarded(self) -> RenderResult { + let slide = self.slide.clone(); + let mut r = self.run_photo(); + if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) { + rendered.image = slide.compose(&rendered.image); + rendered.deep = None; + } + r + } + + /// The photo's render (without slide composition). + fn run_photo(self) -> RenderResult { + if let Some((cache, key)) = &self.cache + && let Some(img) = cache.get_at(self.cache_generation, *key) + { + let image = Arc::unwrap_or_clone(img); + let histogram = Histogram::of_srgb8(&image); + return RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Ok(Rendered { image, histogram, deep: None }), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }; + } + // a face tile of an unedited raw: cut from the camera's embedded preview when it is big enough + // (the sensor data is never read for it), else rendered from `source` below + if let Some(image) = self.preview_cut.as_ref().and_then(|c| c.cut()) { + if let Some((cache, key)) = &self.cache { + cache.put_at(self.cache_generation, *key, Arc::new(image.clone())); + } + let histogram = Histogram::of_srgb8(&image); + return RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Ok(Rendered { image, histogram, deep: None }), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }; + } + let was_loaded = matches!(self.source, SourceRef::Loaded(_)); + // The app is closing (issue #620): nobody is left to show this, and quitting waits for the + // jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU. + let closing = || lightcraft_gpu::shutting_down().then(|| "LightCraft is closing".to_string()); + let source = match closing() { + Some(e) => Err(e), + None => self.source.load_source().and_then(|s| closing().map_or(Ok(s), Err)), + }; + match source { + Ok(source) => { + // the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for + let src = &source.image_for(self.settings.denoise_amount()); + let info = source.info_or(self.info); + // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use + // the GPU when there is one. + let gpu = self.cache.is_none(); + let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + // for a display profile: the image goes to the display's values, the histogram + // and the cached view preview stay sRGB + let mut srgb = None; + let display = match (&self.display, self.request.display) { + (Some(d), Some(_)) => { + let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; + let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); + // (an HDR edit's HDR histogram stays) + if let Some(h) = h.filter(|_| rendered.histogram.hdr.is_none()) { + rendered.histogram = h; + } + srgb = s; + Some(d.id()) + } + _ => None, + }; + if let Some((cache, key)) = &self.cache { + cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); + } + if let Some((cache, key)) = &self.view_cache + && self.request.quality == Quality::Full + && (display.is_none() || srgb.is_some()) + { + cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); + } + // the display fit, after the caches have the full render + if let Some((fw, fh)) = self.display_fit + && self.request.quality == Quality::Full + { + let (w, h) = (rendered.image.width, rendered.image.height); + if w > fw.max(1) || h > fh.max(1) { + rendered.image = fit_for_display(&rendered.image, fw, fh); + } + } + RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Ok(rendered), + gpu_fallback, + loaded: (!was_loaded).then_some(source), + quick: None, + display, + } + } + Err(e) => self.failed(e), + } + } +} + +/// Render `src`: on the GPU when `gpu` is set and a GPU is available (`lightcraft_gpu`), else on +/// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see +/// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU. +pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered { + develop_reporting(src, info, s, req, stages, gpu).0 +} + +/// [`develop`], also saying why the render fell back from the GPU to the CPU when it did (`None`: +/// it rendered on the GPU, or the GPU was not tried). The reason is this render's own, not the +/// process-wide [`lightcraft_gpu::last_fallback`]. +pub fn develop_reporting( + src: &Arc, + info: &SourceInfo, + s: &DevelopSettings, + req: &RenderRequest, + stages: Option<&StageCache>, + gpu: bool, +) -> (Rendered, Option) { + let (mut rendered, fallback) = develop_image(src, info, s, req, stages, gpu); + // an HDR edit's preview is its SDR rendition: its histogram shows the HDR range instead + if let Some(h) = lightcraft_pipeline::hdr_histogram(src, info, s, req) { + rendered.histogram = h; + } + (rendered, fallback) +} + +fn develop_image( + src: &Arc, + info: &SourceInfo, + s: &DevelopSettings, + req: &RenderRequest, + stages: Option<&StageCache>, + gpu: bool, +) -> (Rendered, Option) { + // LUT profiles have no GPU stage: they render on the CPU + let fallback = if gpu + && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id) + && !s.masks.iter().any(|m| m.visible && m.refine > 0.0) + && req.proof.is_none() + { + match lightcraft_gpu::try_render(src, info, s, req, stages) { + Ok(r) => return (r, None), + Err(lightcraft_gpu::Fallback::Failed(why)) => Some(why), + Err(lightcraft_gpu::Fallback::NotTried) => None, + } + } else { + None + }; + let rendered = match stages { + Some(st) => lightcraft_pipeline::render_cached(src, info, s, req, st), + None => lightcraft_pipeline::render(src, info, s, req), + }; + (rendered, fallback) +} diff --git a/crates/engine/src/media/session.rs b/crates/engine/src/media/session.rs new file mode 100644 index 000000000..d160b216d --- /dev/null +++ b/crates/engine/src/media/session.rs @@ -0,0 +1,476 @@ +use super::quick::{QuickJob, content_key, source_info}; +use super::render::{RenderJob, RenderResult}; +use super::source::{PreviewLoader, RENDER_CACHE_VERSION, SourceLevel, SourceRef, THUMB_SIZES, face_edge, source_edge_needed, thumb_bucket}; +use super::*; + +impl crate::Session { + /// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look. + pub fn render_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, before: bool, apply_crop: bool) -> Option { + self.build_job(id, max_w, max_h, before, apply_crop, None) + } + + /// A grid/filmstrip thumbnail job: the long edge is rounded up to one of [`THUMB_SIZES`] + /// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk). + pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option { + let b = thumb_bucket(long_edge); + let mut job = self.build_job(id, b, b, false, true, Some(b))?; + // (a thumbnail may develop a smaller copy of the raw image that the file keeps) + if let SourceRef::File { reduced, .. } = &mut job.source { + reduced.clone_from(&self.media.reduced_loader); + } + Some(job) + } + + fn build_job( + &mut self, + id: PhotoId, + max_w: usize, + max_h: usize, + before: bool, + apply_crop: bool, + thumb_bucket: Option, + ) -> Option { + let p = self.catalog.photo(id)?.clone(); + let settings = if before { Arc::new(self.before_settings(&p)) } else { p.develop.clone() }; + let level = SourceLevel::for_size(if thumb_bucket.is_some() { + max_w.max(max_h) // (grid thumbnails of tight crops stay cheap) + } else { + source_edge_needed(&p, &settings, apply_crop, max_w.max(max_h)) + }); + let source = self.media.source_ref(&p, level); + let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) }; + // the photo id is part of the key: two photos with the same settings and size must not + // share a result (a view slot showing photo A would otherwise look current for photo B) + // …and so is the file's content: a file changed on disk (Reload) renders afresh + let content_key = content_key(&p); + let source_key = Hasher128::new().str(&content_key).finish(); + let content = source_key.0 as u64; + // Temporary "before" settings must not replace the photo's cached develop hash. + let settings_hash = if before { settings.hash64() } else { self.media.settings_hashes.get(id, &settings) }; + let salt = self.media.denoise_salt(id, &settings); + let key = settings_hash + ^ ((max_w as u64) << 40) + ^ ((max_h as u64) << 20) + ^ (apply_crop as u64) + ^ (level as u64) << 60 + ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) + ^ content.rotate_left(17) + ^ salt; + let cache = thumb_bucket.map(|b| { + let mut k = Hasher128::new(); + k.str(&content_key).u64(settings_hash).u64(b as u64).u64(RENDER_CACHE_VERSION).u64(crate::camera_profiles::cache_key()); + // (only a render that uses a denoised picture has it in its key, so every other thumbnail stays valid) + if salt != 0 { + k.u64(salt); + } + (self.media.rendered.clone(), k.finish()) + }); + Some(RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: Some(source_key), + photo: id, + level, + source, + origin: p.source.clone(), + info: source_info(&p), + settings, + request, + key, + cache, + stages: None, + view_cache: None, + display: None, + slide: None, + preview_cut: None, + display_fit: None, + }) + } + + /// A render of `id` with `settings` in place of its own, fitting `max_w × max_h` (the loupe's + /// temporary preview while hovering a preset or profile; nothing is committed or cached). + pub fn preview_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool, settings: &DevelopSettings) -> Option { + let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; + job.settings = Arc::new(settings.clone()); + job.key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (job.level as u64) << 60; + Some(job) + } + + /// Cache key of a variant thumbnail ([`Self::variant_job`]). + pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 { + Hasher128::new() + .str(&content_key(p)) + .str("variant") + .u64(settings.hash64()) + .u64(edge as u64) + .u64(RENDER_CACHE_VERSION) + .u64(crate::camera_profiles::cache_key()) + .finish() + } + + /// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset + /// browsers): from the thumbnail-level source, long edge `edge` (≤ 512), cropped. The result + /// is cached (memory + the library's disk cache) under the photo's content and the settings + /// hash, so a variant renders once; `key` is derived from the same hash, so a frontend can + /// keep one texture per variant. + pub fn variant_job(&mut self, id: PhotoId, settings: &DevelopSettings, edge: usize) -> Option { + let p = self.catalog.photo(id)?.clone(); + let edge = edge.clamp(16, SourceLevel::Thumb.max_edge()); + let level = SourceLevel::Thumb; + let source = self.media.source_ref(&p, level); + let ck = Self::variant_key(&p, settings, edge); + Some(RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), + photo: id, + level, + source, + origin: p.source.clone(), + info: source_info(&p), + settings: Arc::new(settings.clone()), + request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, + key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), + cache: Some((self.media.rendered.clone(), ck)), + stages: None, + view_cache: None, + display: None, + slide: None, + preview_cut: None, + display_fit: None, + }) + } + + /// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the + /// photo's own look with its crop replaced by a square around the face (room for hair and chin), + /// at least `edge` pixels across (rounded up to [`FACE_EDGE_STEP`], ≤ 512, so a tile size + /// between two steps reuses the cached one). An unedited raw's tile is cut from the camera's + /// embedded preview when that holds the square at [`crate::face_tiles::SOFT_LIMIT`] of the + /// size asked or more ([`crate::face_tiles`]); else, and for every other photo, the source + /// level is the smallest that keeps the face sharp, up to the preview. Cached (memory and the + /// library's thumbnail cache on disk) like [`Self::variant_job`], and `key` likewise follows + /// the content: a later launch shows the tile without reading the original. + pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option { + let p = self.catalog.photo(id)?.clone(); + // `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the + // user's Rotate Left/Right, so carry the box (and the photo's size) over to it. + let orient = p.develop.orientation; + let face = orient.map_norm_rect(face); + let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) }; + let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7; + // finite, within the photo, and never wider than its short side (so the clamps below are valid) + let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) }; + let (cx, cy) = ((face.x0 + face.x1) / 2.0 * w, (face.y0 + face.y1) / 2.0 * h); + let (cx, cy) = (if cx.is_finite() { cx } else { w / 2.0 }, if cy.is_finite() { cy } else { h / 2.0 }); + let (x0, y0) = (cx.clamp(side / 2.0, w - side / 2.0) - side / 2.0, cy.clamp(side / 2.0, h - side / 2.0) - side / 2.0); + let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h }; + let mut settings = (*p.develop).clone(); + settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() }; + let edge = face_edge(edge); + // pixels along the long edge that leave `edge` across the crop + let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64); + let level = SourceLevel::for_size(needed as usize); + let source = self.media.source_ref(&p, level); + let ck = Self::variant_key(&p, &settings, edge); + let preview_cut = self.face_preview_cut(&p, rect, edge, needed as usize); + Some(RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), + photo: id, + level, + source, + origin: p.source.clone(), + info: source_info(&p), + settings: Arc::new(settings), + request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, + key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), + cache: Some((self.media.rendered.clone(), ck)), + stages: None, + view_cache: None, + display: None, + slide: None, + preview_cut, + display_fit: None, + }) + } + + /// How a face tile of `p` can come from its embedded preview ([`crate::face_tiles`]): only an + /// unedited raw (whose grid thumbnail is that preview too) not known to be offline, upright as + /// imported, with a preview loader installed. `rect` is the square around the face, `edge` the + /// tile, `needed` the source's long edge the develop render would ask for. + fn face_preview_cut(&self, p: &Photo, rect: lightcraft_geom::Rect, edge: usize, needed: usize) -> Option { + let (path, loader) = self.embedded_of(p)?; + if p.develop.orientation != lightcraft_geom::Orientation::Normal || self.media.availability.is_offline(&path) || p.width == 0 || p.height == 0 + { + return None; + } + let want = needed.max(edge).max(1); + let min = (want as f64 * crate::face_tiles::SOFT_LIMIT).ceil() as usize; + let aspect = f64::from(p.width) / f64::from(p.height); + Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge }) + } + + /// Size-independent cache key of a photo's view render (loupe) for its current settings. + fn view_key(p: &Photo, apply_crop: bool) -> Hash128 { + Hasher128::new() + .str(&content_key(p)) + .str("view") + .u64(p.develop.hash64()) + .u64(apply_crop as u64) + .u64(RENDER_CACHE_VERSION) + .u64(crate::camera_profiles::cache_key()) + .finish() + } + + /// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the + /// photo's view preview (memory, and disk with a library) for [`Self::quick_view_job`]. + pub fn loupe_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool) -> Option { + let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; + let p = self.catalog.photo(id)?; + job.view_cache = Some((self.media.rendered.clone(), Self::view_key(p, apply_crop))); + Some(job) + } + + /// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame + /// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and + /// the result is `window`'s pixels of it, at that scale, from the source level that size needs. + /// Nothing is cached here (a window is only worth keeping while it is on screen). `None`: no + /// such photo, or the window reads (spots, Auto Mask strokes) more than one render can hold. + pub fn region_job( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + ) -> Option { + self.region_job_of(id, full_w, full_h, window, apply_crop, false) + } + + /// [`Self::region_job`] of the photo without its edits (its crop kept): the Before side. + pub fn region_job_before( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + ) -> Option { + self.region_job_of(id, full_w, full_h, window, apply_crop, true) + } + + fn region_job_of( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + before: bool, + ) -> Option { + let mut job = self.render_job(id, full_w, full_h, before, apply_crop)?; + // what the window's spots and Auto Mask strokes read must fit in one render: else the + // caller keeps the whole-frame render (a window alone would come out wrong) + let p = self.catalog.photo(id)?; + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + p.width.max(1) as usize, + p.height.max(1) as usize, + &job.settings, + apply_crop, + p.embedded_lens.as_ref(), + ); + let ppl = frame.px_per_long(full_w); + lightcraft_pipeline::spots::window_for_reads_checked(&job.settings, &frame, full_w, full_h, ppl, window.clamped(full_w, full_h))?; + job.request.window = Some(window); + job.key = Hasher128::new() + .u64(job.key) + .str("window") + .u64(window.x as u64) + .u64(window.y as u64) + .u64(window.w as u64) + .u64(window.h as u64) + .finish() + .0 as u64; + Some(job) + } + + /// The embedded preview of an unedited raw (path, loader), when the app installed a loader. + fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { + match (&p.source, &self.media.preview_loader) { + (Source::File { path }, Some(l)) if p.kind == MediaKind::Raw && crate::import::has_import_look(p) => Some((path.clone(), l.clone())), + _ => None, + } + } + + /// Whether `p`'s thumbnail can be the camera's own JPEG, so a frontend can skip the sensor + /// decode behind it. + /// + /// The grid shows this JPEG first in any case ([`QuickJob`]: "grid thumbnails of unedited raws + /// likewise start from the embedded preview"), and then paid for a render of the raw to replace + /// it. That render decodes the whole sensor — 50 of a 56 MB Fuji RAF's bytes — for a 512 px + /// thumbnail the 4 416 × 2 944 embedded JPEG already covers, and on a share at 1.2 MB/s it is + /// 45 s a photo: a 149-photo folder spent 8.3 GB of traffic on thumbnails, and the queue behind + /// it delayed the render the user was waiting for. + /// + /// Only unedited raws answer `true`: any edit must be rendered, and `has_import_look` is what + /// says the photo still looks the way it did at import. A photo the user edits re-renders + /// through the ordinary path, because its thumbnail key (settings hash) changes. + pub fn embedded_thumb_is_final(&self, p: &Photo) -> bool { + self.embedded_of(p).is_some() + } + + /// The camera's embedded preview of a raw file, for the face scan. Faces are read from the unedited picture, so the + /// photo's edits do not matter, and the camera's own JPEG (oriented, display-ready) costs a fraction of decoding the + /// raw. `None` for anything else, and where the host installed no loader. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn scan_preview_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { + match (&p.source, &self.media.preview_loader) { + (Source::File { path }, Some(l)) if p.kind == MediaKind::Raw => Some((path.clone(), l.clone())), + _ => None, + } + } + + /// Something to show in the loupe right away for `id` (see [`QuickJob`]): its cached view + /// render, else the embedded preview of an unedited raw, else a cached or fresh thumbnail. + pub fn quick_view_job(&mut self, id: PhotoId, max_edge: usize, apply_crop: bool) -> Option { + let p = self.catalog.photo(id)?.clone(); + let small = self.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])?; + // (the thumbnail job itself starts with the cached thumbnail, after the sharper embedded preview) + let cached = vec![(self.media.rendered.clone(), Self::view_key(&p, apply_crop))]; + let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish(); + let key = h.0 as u64; + let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge()))); + Some(QuickJob { request_id: 0, photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) }) + } + + /// A first grid thumbnail for an unedited raw: the cached render for `job` (the real + /// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries + /// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview, else `job` + /// itself: the grid queues no render behind an unedited raw's quick thumbnail + /// ([`Self::embedded_thumb_is_final`]), so a preview that can't be read must not leave the + /// cell empty (issue #1448). `None` for other photos (render them directly). + pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option { + let p = self.catalog.photo(job.photo)?.clone(); + let (path, l) = self.embedded_of(&p)?; + let edge = job.request.max_w.max(job.request.max_h); + Some(QuickJob { + request_id: 0, + photo: job.photo, + key: job.key, + cached: job.cache.clone().into_iter().collect(), + embedded: Some((path, l, edge)), + small: Some(Box::new(job.clone())), + }) + } + + /// Accept a finished job's loaded source into the cache. What the decode learnt about the file that the catalog + /// has wrong is corrected too ([`Self::learn_as_shot_wb`]). + pub fn accept(&mut self, r: &RenderResult) { + if let Some(src) = &r.loaded + && r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key)) + { + if let Some(info) = &src.info { + self.learn_as_shot_wb(r.photo, info); + } + self.media.insert_source(r.photo, r.level, src.clone()); + } + } + + /// Record the as-shot white balance a decode of photo `id`'s file read (`info`) when the catalog has another: + /// a raw imported before its camera's matrices gave it a Kelvin scale keeps 6500 / 0 (the relative scale's + /// reference) until its file is next decoded (issue #730). Bookkeeping, not an undo step; the look does not + /// change (the pipeline reads the stored settings against the real as-shot white either way), only what As Shot + /// says and what a smart preview renders with. Only for raws edited in Kelvin. + fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) { + let Some(p) = self.catalog.photo(id) else { return }; + if !info.raw || info.relative_wb || !p.develops_raw() || p.relative_wb() { + return; + } + let read = (info.as_shot_temp, info.as_shot_tint); + if !(read.0.is_finite() && read.1.is_finite()) || p.as_shot_wb == Some(read) { + return; + } + if let Err(e) = self.apply_system(Op::SetAsShotWb { id, wb: Some(read) }) { + log::warn!("photo {}: as-shot white balance not recorded: {e}", id.0); + } + } + + /// Synchronous render (CLI, MCP, tests). + pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result { + let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; + let r = job.run(); + self.accept(&r); + r.rendered + } + + /// Synchronous render for export: like [`Self::render_now`], into colour space `space` with + /// sample format `depth`. + pub fn render_export( + &mut self, + id: PhotoId, + max_w: usize, + max_h: usize, + space: lightcraft_pipeline::OutputSpace, + depth: lightcraft_pipeline::OutputDepth, + ) -> Result { + let r = self.export_job(id, max_w, max_h, space, depth)?.run(); + self.accept(&r); + r.rendered + } + + /// The job [`Self::render_export`] runs, to run elsewhere (e.g. a background export thread). + pub fn export_job( + &mut self, + id: PhotoId, + max_w: usize, + max_h: usize, + space: lightcraft_pipeline::OutputSpace, + depth: lightcraft_pipeline::OutputDepth, + ) -> Result { + let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; + // An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw + // image that the file keeps (Lightroom's merged DNGs) + let reduced = match &job.source { + SourceRef::Loaded(s) => s.reduced, + SourceRef::Fit { from, .. } => from.reduced, + _ => false, + }; + if reduced { + let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); + job.source = self.media.source_ref_uncached(&p, job.level); + } + // An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the + // background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.) + if let Some(wanted) = self.denoise_for_export(id, &job.settings)? { + let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); + job.source = self.media.source_ref_uncached(&p, job.level); + if let SourceRef::File { denoise, .. } = &mut job.source { + *denoise = Some(wanted); + } + } + job.request.space = space; + job.request.depth = depth; + job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db); + Ok(job) + } + + /// Prefer decoder facts to header-only metadata for pixel-statistics commands. + pub fn source_info(&self, id: PhotoId) -> SourceInfo { + let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default(); + self.media.source_facts(id, header) + } + + /// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously. + pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result, String> { + let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); + let r = self.media.source_ref(&p, level).load_source()?; + let image = r.image.clone(); + if let Some(info) = &r.info { + self.learn_as_shot_wb(id, info); + } + self.media.insert_source(id, level, r); + Ok(image) + } +} diff --git a/crates/engine/src/media/settings_hash.rs b/crates/engine/src/media/settings_hash.rs new file mode 100644 index 000000000..5d9c08b93 --- /dev/null +++ b/crates/engine/src/media/settings_hash.rs @@ -0,0 +1,36 @@ +use super::*; + +pub(super) const SETTINGS_HASH_ENTRIES: usize = 1024; + +#[derive(Default)] +pub(super) struct SettingsHashes { + pub(super) entries: HashMap, u64)>, + pub(super) order: VecDeque, + #[cfg(test)] + pub(super) computations: usize, +} + +impl SettingsHashes { + pub(super) fn get(&mut self, id: PhotoId, settings: &Arc) -> u64 { + if let Some((old, hash)) = self.entries.get(&id) + && old.as_ptr() == Arc::as_ptr(settings) + { + return *hash; + } + let hash = settings.hash64(); + #[cfg(test)] + { + self.computations += 1; + } + if !self.entries.contains_key(&id) { + if self.entries.len() >= SETTINGS_HASH_ENTRIES + && let Some(oldest) = self.order.pop_front() + { + self.entries.remove(&oldest); + } + self.order.push_back(id); + } + self.entries.insert(id, (Arc::downgrade(settings), hash)); + hash + } +} diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs new file mode 100644 index 000000000..51c54c60a --- /dev/null +++ b/crates/engine/src/media/source.rs @@ -0,0 +1,324 @@ +use super::*; + +/// Bump when the pipeline's output changes, to invalidate cached thumbnails. +pub const RENDER_CACHE_VERSION: u64 = 70; + +/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). +pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; + +/// Face tiles are rendered at a multiple of this many pixels ([`crate::Session::face_job`]): the +/// tile's size follows the thumbnail slider and the screen's scale, and without steps every size +/// in between would render (and cache) every face again. +pub const FACE_EDGE_STEP: usize = 16; + +/// The edge a face tile asked to be `edge` pixels across is rendered at: the next +/// [`FACE_EDGE_STEP`], 16..=512. +pub fn face_edge(edge: usize) -> usize { + edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge()) +} + +pub fn thumb_bucket(long_edge: usize) -> usize { + THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512) +} + +/// Memory budgets. +pub(super) const THUMB_SOURCE_BYTES: usize = 384 << 20; + +pub(super) const RENDERED_MEM_BYTES: usize = 128 << 20; + +/// Disk budget for the thumbnail cache. +pub const DISK_CACHE_BYTES: u64 = 2 << 30; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SourceLevel { + Thumb, + Preview, + /// The original at its native resolution (exports larger than a preview). + Full, +} + +impl SourceLevel { + pub fn max_edge(self) -> usize { + match self { + SourceLevel::Thumb => 512, + SourceLevel::Preview => 2560, + SourceLevel::Full => usize::MAX, + } + } + /// Smallest level that can serve an output of `long_edge` pixels. + pub fn for_size(long_edge: usize) -> SourceLevel { + match long_edge { + 0..=520 => SourceLevel::Thumb, + 521..=2560 => SourceLevel::Preview, + _ => SourceLevel::Full, + } + } +} + +/// Pixels along the source's long edge that an output `out_long` pixels long needs: a crop shows +/// only part of the photo, so its pixels come from a source that many times larger (the source +/// level is chosen from this, or a tight crop shown at its own pixels would be a stretched preview). +pub(super) fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, out_long: usize) -> usize { + let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let r = settings.crop.geometry.rect; + let shown = if apply_crop { (r.width() * w).max(r.height() * h) } else { w.max(h) }; + if !shown.is_finite() || shown < 1.0 { + return out_long; + } + let needed = out_long as f64 * w.max(h) / shown; + if needed.is_finite() { needed.min(usize::MAX as f64 / 2.0) as usize } else { out_long } +} + +/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). +pub type FileLoader = Arc Result<(Rgb32f, SourceInfo), String> + Send + Sync>; + +/// [`FileLoader`] for a thumbnail: a raw file that keeps smaller copies of its raw image (Lightroom's merged DNGs) +/// develops the smallest that covers `max_edge` (set by the app); the flag says it did. Never used for an export, +/// which must render the full image. +pub type ReducedLoader = Arc Result<(Rgb32f, SourceInfo, bool), String> + Send + Sync>; + +/// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than +/// `max_edge` (set by the app). `None`: no usable preview. +pub type PreviewLoader = Arc Option + Send + Sync>; + +/// Makes a photo's denoised picture when it is not cached yet (an export does not wait for the background queue): +/// `Ok(true)` once it is there, `Ok(false)` when denoise does not apply to this photo (render it plain), `Err` when it +/// should have worked and did not. +pub type MakeProduct = Arc Result + Send + Sync>; + +/// A raw photo's cached AI-denoised picture (see [`crate::denoise`]): where the file is and the key it must have been +/// made with. A file that is missing, damaged or made for something else is a cache miss, never an error. +#[derive(Clone)] +pub struct DenoiseSpec { + pub product: std::path::PathBuf, + pub key: String, + /// Run before the picture is read, by a job that must not render without it (an export). + pub make: Option, +} + +/// Decodes a raw file once and develops it twice: as usual, and from the denoised picture in `spec` when that can be +/// read (`None` when it cannot). Both are at most `max_edge` long and the same size. +pub type PairLoader = Arc Result<(Rgb32f, Option, SourceInfo), String> + Send + Sync>; + +/// A photo's denoised picture next to its plain one, and the last mix of the two that was asked for. +pub struct Twin { + pub image: Arc, + /// The Amount (in thousandths) and the picture mixed for it, so dragging other sliders keeps one source picture. + mix: std::sync::Mutex)>>, +} + +impl Twin { + pub fn new(image: Arc) -> Twin { + Twin { image, mix: std::sync::Mutex::new(None) } + } + + /// Bytes reserved for the twin and its lazy mix, before either cache cost can grow. + fn bytes(&self) -> usize { + self.image.data.len().saturating_mul(24) + } +} + +/// Pixels and the source interpretation learned while decoding; they must be cached together. +#[derive(Clone)] +pub struct DecodedSource { + pub image: Arc, + pub info: Option, + /// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the + /// catalog's header facts lack (used when `info` is `None`). + pub camera_tone: Option, + /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. + pub denoised: Option>, + /// These pixels came from the photo's smart preview (an offline original), not from its file. + /// A refresh must never rebuild a proxy from itself: that would stamp its older look as current. + pub from_proxy: bool, + /// Developed from a smaller copy of the raw image that the file keeps ([`ReducedLoader`]), not from the image + /// itself: good for a thumbnail, never for an export. + pub reduced: bool, +} + +impl DecodedSource { + pub fn new(image: Arc, info: Option) -> Self { + DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false, reduced: false } + } + + /// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one, + /// the denoised one, or a mix of the two. A mix is made once per amount and kept, so the stages after it are + /// reused until the amount changes. + pub fn image_for(&self, amount: f32) -> Arc { + let Some(twin) = &self.denoised else { return self.image.clone() }; + if amount.is_nan() || amount <= 0.0 || twin.image.width != self.image.width || twin.image.height != self.image.height { + return self.image.clone(); + } + if amount >= 1.0 { + return twin.image.clone(); + } + let thousandths = (amount * 1000.0).round().clamp(1.0, 999.0) as u32; + let mut memo = twin.mix.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((t, img)) = memo.as_ref() + && *t == thousandths + { + return img.clone(); + } + let mut mixed = (*self.image).clone(); + if !lightcraft_denoise::product::blend(&mut mixed, &twin.image, thousandths as f32 / 1000.0) { + return self.image.clone(); + } + let mixed = Arc::new(mixed); + *memo = Some((thousandths, mixed.clone())); + mixed + } + + /// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix. + pub fn bytes(&self) -> usize { + let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes()); + let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4); + self.image.data.len() * 12 + 64 + std::mem::size_of::() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes()) + } + + /// What to render these pixels against: the decoder's facts, else `header` (the catalog's) + /// with any stored camera tone curve. The original's size comes from `header` either way: the + /// decoder only sees the level it decoded. + pub fn info_or(&self, header: SourceInfo) -> SourceInfo { + match &self.info { + Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() }, + None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }, + } + } +} + +#[derive(Clone)] +pub enum SourceRef { + Loaded(Box), + /// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of + /// the photo open in the loupe is cut from its preview-level source instead of decoding the + /// file again (a second or more for a 64 MP raw). + Fit { + from: Box, + max_edge: usize, + }, + Demo { + scene: Box, + max_edge: usize, + }, + File { + path: String, + max_edge: usize, + loader: Option, + /// The photo's smart preview, used when the original can't be read (offline drive). The + /// UI thread never checks whether the original is there; the render worker finds out. + fallback: Option, + /// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]). + denoise: Option<(DenoiseSpec, PairLoader)>, + /// A thumbnail's loader, used instead of `loader` (see [`ReducedLoader`]); `None` for every other job. + reduced: Option, + }, + /// A smart preview standing in for a missing original. + Smart { + path: std::path::PathBuf, + }, +} + +impl SourceRef { + pub fn load(&self) -> Result, String> { + self.load_source().map(|s| s.image) + } + + pub fn load_source(&self) -> Result { + let image = match self { + SourceRef::Loaded(a) => return Ok((**a).clone()), + SourceRef::Fit { from, max_edge } => { + if from.image.width.max(from.image.height) <= *max_edge { + return Ok((**from).clone()); + } + // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near + // the output's resolution limit and aliases what is finer + let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3); + return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); + } + SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, + SourceRef::File { path, max_edge, loader, fallback, denoise, reduced } => { + let plain = || match (reduced, loader) { + (Some(l), _) => { + l(path, *max_edge).map(|(image, info, reduced)| DecodedSource { reduced, ..DecodedSource::new(Arc::new(image), Some(info)) }) + } + (None, Some(l)) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), + (None, None) => Err(format!("no decoder available for {path}")), + }; + // the denoised picture comes with the plain one from a single decode; when it cannot be read the + // pair loader gives the plain picture alone (the cached file is never a reason to fail a render) + let r = match denoise { + Some((spec, pair)) => { + // a job that needs the denoised picture has it made first; if that fails the job fails (no smart + // preview stands in: an export must not quietly come out without the denoise it was asked for) + let made = match &spec.make { + Some(make) => make()?, + None => true, + }; + if made { + pair(path, *max_edge, spec).and_then(|(image, twin, info)| { + if spec.make.is_some() && twin.is_none() { + return Err("the cached AI Denoise picture could not be read; clear the denoise cache and retry".into()); + } + Ok(DecodedSource { + image: Arc::new(image), + info: Some(info), + camera_tone: None, + denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))), + from_proxy: false, + reduced: false, + }) + }) + } else { + plain() + } + } + None => plain(), + }; + // An offline original renders from its smart preview (no decoder facts: header ones). + return match (r, fallback) { + #[cfg(not(target_arch = "wasm32"))] + (Err(e), Some(sp)) if denoise.as_ref().is_none_or(|(spec, _)| spec.make.is_none()) && crate::smart::is_valid(sp) => { + crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")) + } + (r, _) => r, + }; + } + #[cfg(not(target_arch = "wasm32"))] + SourceRef::Smart { path } => return crate::smart::load(path), + #[cfg(target_arch = "wasm32")] + SourceRef::Smart { .. } => return Err("smart previews are not available here".into()), + }; + Ok(DecodedSource::new(image, None)) + } +} + +/// What an import learns from a file header (set by the app from `lightcraft-codecs`/`-raw`). +#[derive(Clone, Debug, Default)] +pub struct ProbeInfo { + pub width: u32, + pub height: u32, + pub format: String, + pub kind: MediaKind, + pub file_size: u64, + pub captured: Option, + /// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the + /// capture date of a file whose metadata has none, as Lightroom does (exports saved without + /// metadata would otherwise all land under "Unknown Date"). + pub file_modified: Option, + pub meta: lightcraft_catalog::Meta, + pub as_shot_wb: Option<(f64, f64)>, + /// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit + /// [`lightcraft_preview::hash_bytes`] of the whole file (as the native probe computes it), + /// Import → Copy verifies each copy against it instead of reading the source again. + pub content_hash: Option, + /// Lens corrections embedded in the file (DNG opcodes). + pub embedded_lens: Option, + /// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file. + pub xmp: Option, + /// A raw variant that can't be decoded yet: why. The file is described (and will be shown and + /// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`]. + pub preview_only: Option, +} + +pub type FileProbe = Arc Result + Send + Sync>; From 8942984c353281314c4ac62d5ee4305a831ef1fa Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:08:55 +0000 Subject: [PATCH 112/191] refactor(lightcraft-engine): split denoise.rs into denoise/ --- crates/engine/src/denoise.rs | 1627 +------------------------ crates/engine/src/denoise/gpu.rs | 152 +++ crates/engine/src/denoise/model.rs | 439 +++++++ crates/engine/src/denoise/products.rs | 234 ++++ crates/engine/src/denoise/session.rs | 509 ++++++++ crates/engine/src/denoise/settings.rs | 135 ++ crates/engine/src/denoise/state.rs | 179 +++ 7 files changed, 1672 insertions(+), 1603 deletions(-) create mode 100644 crates/engine/src/denoise/gpu.rs create mode 100644 crates/engine/src/denoise/model.rs create mode 100644 crates/engine/src/denoise/products.rs create mode 100644 crates/engine/src/denoise/session.rs create mode 100644 crates/engine/src/denoise/settings.rs create mode 100644 crates/engine/src/denoise/state.rs diff --git a/crates/engine/src/denoise.rs b/crates/engine/src/denoise.rs index 89af8463a..44ef3eef7 100644 --- a/crates/engine/src/denoise.rs +++ b/crates/engine/src/denoise.rs @@ -36,1611 +36,32 @@ use crate::merge::ByteReader; use crate::model_download::Downloads; use crate::{Selection, Session}; -/// Extension of a product file. -pub const EXT: &str = "lcdn"; -/// Where the products of a library are kept, inside the library's folder (safe to delete, like `thumbs/`). -pub const DIR: &str = "denoise"; -/// What the cache may take up unless the user says otherwise (GB). A product is about as large as the raw file's -/// decoded samples, compressed: ~100 MB for 24 megapixels. -pub const DEFAULT_CACHE_GB: u32 = 20; -/// Samples at or above this (white = 1) are clipped: the model leaves them as the camera recorded them. -const CLIP: f32 = 0.99; -/// Most photos waiting in the queue. -const MAX_QUEUE: usize = 5000; -/// Most selected photos the automatic queue looks at in one pump. -const AUTO_LOOK: usize = 64; -/// How long a look at the model settings is trusted. -const SETTINGS_TTL: Duration = Duration::from_secs(2); -/// Stack of the threads that run the model (the runtime recurses through its graph). -const STACK: usize = 16 << 20; +mod gpu; +mod model; +mod products; +mod session; +mod settings; +mod state; -// --------------------------------------------------------------------------------------------------------------------- -// the model - -/// Where the model runs (Settings ▸ AI Denoise ▸ Speed; `denoise.settings {runOn}`). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum RunOn { - /// The graphics card where it is faster than the processor at the pace the work goes at (both are timed on this - /// computer when the card is set up), else the processor. - #[default] - Auto, - /// The graphics card whenever it can run the model, even where the processor would be faster. - Gpu, - /// Always the processor. - Cpu, -} - -impl RunOn { - /// `auto`, `gpu` or `cpu`. - pub fn parse(name: &str) -> Option { - match name { - "auto" => Some(RunOn::Auto), - "gpu" => Some(RunOn::Gpu), - "cpu" => Some(RunOn::Cpu), - _ => None, - } - } - - pub fn name(self) -> &'static str { - match self { - RunOn::Auto => "auto", - RunOn::Gpu => "gpu", - RunOn::Cpu => "cpu", - } - } -} - -/// A loaded denoise model: what runs tiles, and a self-check. -pub(crate) trait Model: Send + Sync { - /// What runs a photo's tiles, and how many to run at once, when `threads` may be used and the setting is `run_on` - /// (a graphics card is kept busy by a few). The first time the card may be used it is set up and checked here. - fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize); - /// Check it does something sensible: the test's result, or why not. Unless `run_on` is the processor the graphics - /// card is set up and checked too, and the result says where the work will run. - fn self_test(&self, run_on: RunOn) -> Result; - /// Conservative scratch bound for an unknown runner. - fn bytes_per_tile(&self) -> usize { - 512 * 1024 * 1024 - } - /// Where the tiles run: `{kind: "gpu", adapter}`, `{kind: "cpu", reason?}` or `{kind: "pending"}` (the card is not - /// set up yet), with the card's and the processor's time for the check tile (`cardMs`, `cpuMs`) once both were - /// timed. Never starts anything. - fn device(&self, _run_on: RunOn) -> Value { - json!({"kind": "cpu"}) - } -} - -/// Loads the model at a path as its manifest describes it. -pub(crate) type Loader = Arc Result, String> + Send + Sync>; - -/// Tiles run at once on a graphics card: enough to keep it fed while others are packed and blended. +#[cfg(test)] +pub(crate) use gpu::GPU_SETUP_MARKER; +#[cfg(all(feature = "denoise", test))] +use gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, SETTING_UP, card_is_faster, set_up_card}; +pub use model::RunOn; +#[cfg(all(feature = "denoise", test))] +pub(crate) use model::default_loader; +#[cfg(all(feature = "denoise", test))] +use model::{GpuSide, Network, RemoteSide, network, worth_retrying}; +pub(crate) use model::{Loader, Model}; #[cfg(feature = "denoise")] -const GPU_PARALLEL: usize = 4; -/// How far the GPU's answer on the check tile may be from the CPU's (of the answer's largest value) before it is not used. -#[cfg(feature = "denoise")] -const GPU_AGREE: f32 = 1e-3; -/// Longest the card may take to set up (build its kernels, run the check tile, time it) before the processor does the -/// work instead. -#[cfg(feature = "denoise")] -const GPU_SETUP_LIMIT: Duration = Duration::from_secs(30); -/// Tiles the card may get wrong (an error, numbers that are not finite) before it stops being used for the model. -#[cfg(feature = "denoise")] -const GPU_MAX_FALLBACKS: usize = 3; -/// The file beside a model while the graphics card is set up for it (see [`set_up_card`]). -pub(crate) const GPU_SETUP_MARKER: &str = "gpu-setup.marker"; -/// Why the card is not tried when a set-up marker is found. -#[cfg(feature = "denoise")] -const GPU_CRASHED: &str = "LightCraft closed while it was setting up the graphics card for AI Denoise last time"; -/// Why the card is not used when its set-up ran out of time (the start of it). -#[cfg(feature = "denoise")] -const GPU_SLOW: &str = "setting up the graphics card took longer than"; -/// Why the card is not used while a set-up that ran out of time is still going. -#[cfg(feature = "denoise")] -const GPU_STUCK: &str = "the graphics card is still setting up from before (it took too long)"; - -/// A set-up that may work another time (a crash, the time limit), unlike one the card or the model cannot do. -#[cfg(feature = "denoise")] -fn worth_retrying(why: &str) -> bool { - why == GPU_CRASHED || why == GPU_STUCK || why.starts_with(GPU_SLOW) -} - -/// The model on our pure-Rust CPU runner, and on the graphics card when there is one that can run it: the card is set up for the -/// first photo that may use it, checked against the CPU's answer on a check tile and timed against it, and used while -/// it keeps working; a tile it gets wrong is run on the CPU, so a photo is always finished. -#[cfg(feature = "denoise")] -struct Network { - cpu: lightcraft_denoise::runtime::CpuRunner, - path: PathBuf, - tile: usize, - gpu: OnceLock>, - /// Tiles the card got wrong (an error, numbers that are not finite) and the CPU ran. - fallbacks: AtomicUsize, - /// Tiles at once of the photo started last (0: none yet), to say where the work runs. - last_threads: AtomicUsize, - /// Another computer's graphics card (`LIGHTCRAFT_DENOISE_URL` + `LIGHTCRAFT_DENOISE_TOKEN`; see - /// `docs/denoise.md` → Another computer's graphics card), greeted on first use: it takes tiles while it has a free - /// slot, on top of this computer's runner. - remote: Option, - /// Whether this computer's share runs on the card (else the CPU), as chosen for the photo started last. - local_card: AtomicBool, -} - -#[cfg(feature = "denoise")] -struct RemoteSide { - runner: lightcraft_denoise::remote::Remote, - /// The greeting, and the server's time for the check tile there and back (ms; the faster of two runs). - greeted: OnceLock>, - /// Whether the photo started last shares tiles with the server, and if not because it is slower, the two times. - share: AtomicBool, - slower: Mutex>, -} - -#[cfg(feature = "denoise")] -impl RemoteSide { - /// From the environment, for the model whose file has `sha256` and tiles of `tile` cells. - fn from_env(sha256: &str, tile: usize) -> Option { - let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); - let address = var("LIGHTCRAFT_DENOISE_URL")?; - let token = var("LIGHTCRAFT_DENOISE_TOKEN").unwrap_or_default(); - Some(RemoteSide::new(lightcraft_denoise::remote::Remote::new(&address, &token, sha256, tile))) - } - - fn new(runner: lightcraft_denoise::remote::Remote) -> RemoteSide { - RemoteSide { runner, greeted: OnceLock::new(), share: AtomicBool::new(false), slower: Mutex::new(None) } - } - - /// Greet the server and time a check tile there and back: a server behind a slow link can take seconds a tile, and - /// handing it tiles a fast local card would finish sooner only makes the photo wait for it. - fn greet(&self) -> Result<(lightcraft_denoise::remote::ServerInfo, f64), String> { - let info = self.runner.hello().map_err(|e| e.to_string())?; - let input = lightcraft_denoise::runtime::check_tile(info.tile); - let mut best = f64::INFINITY; - for _ in 0..2 { - let started = web_time::Instant::now(); - match self.runner.try_run(&input) { - Some(Ok(_)) => best = best.min(started.elapsed().as_secs_f64() * 1000.0), - Some(Err(e)) => return Err(format!("the check tile failed: {e}")), - None => return Err("no free slot for the check tile".into()), - } - } - Ok((info, best)) - } - - /// The server when it can take tiles now (greeting it the first time), else why not. - fn usable(&self, start: bool) -> Result<&lightcraft_denoise::remote::Remote, String> { - let greeted = if start { Some(self.greeted.get_or_init(|| self.greet())) } else { self.greeted.get() }; - match greeted { - None => Err("not contacted yet".into()), - Some(Err(why)) => Err(why.clone()), - Some(Ok(_)) if !self.runner.healthy() => { - Err(format!("it failed {} tiles ({})", self.runner.failures(), self.runner.last_error().unwrap_or_default())) - } - Some(Ok(_)) => Ok(&self.runner), - } - } - - /// The server's time for a tile, once greeted. - fn tile_ms(&self) -> Option { - self.greeted.get().and_then(|g| g.as_ref().ok()).map(|g| g.1) - } - - fn describe(&self) -> Value { - match self.greeted.get() { - None => json!({"address": self.runner.address(), "state": "pending"}), - Some(Err(why)) => json!({"address": self.runner.address(), "state": "unavailable", "reason": why}), - Some(Ok((info, ms))) => { - let slower = *self.slower.lock().unwrap_or_else(PoisonError::into_inner); - let state = match () { - _ if !self.runner.healthy() => "given up", - _ if slower.is_some() => "slower", - _ if self.share.load(Ordering::Relaxed) => "used", - _ => "idle", - }; - let mut v = - json!({"address": info.address, "device": info.device, "state": state, "remoteMs": ms, "failures": self.runner.failures()}); - if let (Some((_, local)), Some(o)) = (slower, v.as_object_mut()) { - o.insert("localMs".into(), json!(local)); - } - if let (Some(e), Some(o)) = (self.runner.last_error(), v.as_object_mut()) { - o.insert("lastError".into(), json!(e)); - } - v - } - } - } -} - -#[cfg(feature = "denoise")] -struct GpuSide { - runner: Box, - adapter: String, - /// The check tile on the card, the fastest of a few runs (ms). - card_ms: f64, - /// The check tile on the CPU, on one thread (ms). - cpu_ms: f64, -} - -/// Whether the card (`card_ms` a tile) is faster than the CPU running `threads` tiles at once, from the CPU's time for -/// one tile alone (`cpu_ms`). Tiles at once share the memory and the cores' second threads, so each one more counts as -/// half a tile's speed: on a 16-core laptop one tile alone took 1.4 s and sixteen at once finished one every 0.2 s. -#[cfg(feature = "denoise")] -fn card_is_faster(card_ms: f64, cpu_ms: f64, threads: usize) -> bool { - let at_once = 1.0 + 0.5 * threads.saturating_sub(1) as f64; - card_ms.is_finite() && card_ms <= cpu_ms / at_once -} - -/// Card set-ups of this process that have not finished, by marker (their markers are not a crash's). -#[cfg(feature = "denoise")] -static SETTING_UP: Mutex> = Mutex::new(Vec::new()); - -/// Removes a set-up's marker when the set-up ends, however it ends — but not when the process dies inside the driver. -#[cfg(feature = "denoise")] -struct SetUpEnded(PathBuf); - -#[cfg(feature = "denoise")] -impl Drop for SetUpEnded { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - SETTING_UP.lock().unwrap_or_else(PoisonError::into_inner).retain(|m| m != &self.0); - } -} - -/// Set the graphics card up with `card` on a thread of its own while `meanwhile` runs here, and give up on the card when -/// it takes longer than `limit` (a driver building kernels for minutes, or stuck). The `marker` file exists while `card` -/// runs: one found before it starts was left by a LightCraft that closed during a set-up (a driver crash takes the app -/// with it), so the card is not tried again until the user chooses where denoise runs (`denoise.settings {runOn}`). -#[cfg(feature = "denoise")] -fn set_up_card( - marker: &Path, - limit: Duration, - card: impl FnOnce() -> Result + Send + 'static, - meanwhile: impl FnOnce() -> R, -) -> Result<(T, R), String> { - let ended = { - let mut running = SETTING_UP.lock().unwrap_or_else(PoisonError::into_inner); - if running.iter().any(|m| m == marker) { - return Err(GPU_STUCK.into()); - } - if marker.exists() { - return Err(GPU_CRASHED.into()); - } - let secs = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_or(0, |d| d.as_secs()); - // a marker that cannot be written only loses the crash guard - let _ = std::fs::write(marker, format!("setting up the graphics card for AI Denoise (unix time {secs})\n")); - running.push(marker.to_path_buf()); - SetUpEnded(marker.to_path_buf()) - }; - let started = web_time::Instant::now(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::Builder::new() - .name("denoise-gpu-setup".into()) - .stack_size(STACK) - .spawn(move || { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(card)).unwrap_or_else(|_| Err("the GPU runner gave up".into())); - drop(ended); - let _ = tx.send(r); - }) - .map_err(|e| format!("could not start setting up the graphics card: {e}"))?; - let here = meanwhile(); - match rx.recv_timeout(limit.saturating_sub(started.elapsed())) { - Ok(r) => r.map(|t| (t, here)), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(format!("{GPU_SLOW} {} s, so it is not used", limit.as_secs_f64())), - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err("the GPU runner gave up".into()), - } -} - -/// The card's time for one tile once it is warm: the fastest of up to three runs, stopping after about two seconds (a -/// slow card is slow enough to tell at once). -#[cfg(feature = "denoise")] -fn card_tile_ms(runner: &dyn TileRunner, input: &[f32]) -> f64 { - if runner.run(input).is_err() { - return f64::INFINITY; - } - let began = web_time::Instant::now(); - let mut best = f64::INFINITY; - for _ in 0..3 { - let one = web_time::Instant::now(); - if runner.run(input).is_err() { - break; - } - best = best.min(one.elapsed().as_secs_f64() * 1000.0); - if began.elapsed() > Duration::from_secs(2) { - break; - } - } - best -} - -/// Set the card up for the model at `path`: kernels built, the check tile run on both and compared, both timed. -#[cfg(feature = "denoise")] -fn make_gpu(cpu: &lightcraft_denoise::runtime::CpuRunner, path: &Path, tile: usize) -> Result { - let input = Arc::new(lightcraft_denoise::runtime::check_tile(tile)); - let (on_card, net) = (input.clone(), cpu.net().clone()); - let card = move || -> Result<_, String> { - let runner = lightcraft_gpu::nn::runner(&net, tile)?; - let got = runner.run(&on_card).map_err(|e| format!("the GPU could not run the check tile: {e}"))?; - let card_ms = card_tile_ms(&runner, &on_card); - Ok((runner, got, card_ms)) - }; - let on_cpu = || { - let started = web_time::Instant::now(); - (cpu.run(&input), started.elapsed().as_secs_f64() * 1000.0) - }; - let ((runner, got, card_ms), (want, cpu_ms)) = set_up_card(&path.with_file_name(GPU_SETUP_MARKER), GPU_SETUP_LIMIT, card, on_cpu)?; - let want = want.map_err(|e| format!("the CPU could not check the GPU: {e}"))?; - let scale = want.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-12); - let worst = want.iter().zip(&got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale; - if got.len() != want.len() || !got.iter().all(|v| v.is_finite()) || worst > GPU_AGREE { - return Err(format!("its answer on the check tile is not the CPU's (off by {worst:.1e} of the largest value): not used")); - } - Ok(GpuSide { adapter: runner.adapter(), runner: Box::new(runner), card_ms, cpu_ms }) -} - -#[cfg(feature = "denoise")] -impl Network { - /// The card when it can be used now, else why not. `start` sets it up when that has not been tried yet; without it - /// nothing is started. - fn card(&self, start: bool) -> Result<&GpuSide, String> { - let side = if start { - Some(self.gpu.get_or_init(|| { - let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| make_gpu(&self.cpu, &self.path, self.tile))) - .unwrap_or_else(|_| Err("the GPU runner gave up".into())); - if let Err(why) = &made { - log::info!("denoise: running on the CPU ({why})"); - } - made - })) - } else { - self.gpu.get() - }; - let g = match side { - None => return Err("the graphics card is not set up yet".into()), - Some(Err(why)) => return Err(why.clone()), - Some(Ok(g)) => g, - }; - if let Some(why) = lightcraft_gpu::nn::broken_reason() { - return Err(format!("the graphics card stopped working ({why})")); - } - if self.fallbacks.load(Ordering::Relaxed) >= GPU_MAX_FALLBACKS { - return Err(format!("{} got {GPU_MAX_FALLBACKS} tiles wrong", g.adapter)); - } - Ok(g) - } - - /// The card, when a photo made `threads` tiles at a time under `run_on` runs on it; else why the CPU does. - fn choose(&self, run_on: RunOn, threads: usize, start: bool) -> Result<&GpuSide, String> { - if run_on == RunOn::Cpu { - return Err("the processor is chosen in Settings".into()); - } - let g = self.card(start)?; - if run_on == RunOn::Auto && !card_is_faster(g.card_ms, g.cpu_ms, threads) { - return Err(format!("it is faster than {} on this computer", g.adapter)); - } - Ok(g) - } - - fn fell_back(&self, why: &str) { - let n = self.fallbacks.fetch_add(1, Ordering::Relaxed) + 1; - if n == 1 { - log::warn!("denoise: a tile came back wrong from the GPU ({why}); the CPU runs it"); - } else if n == GPU_MAX_FALLBACKS { - log::warn!("denoise: the GPU got {n} tiles wrong; the CPU does the rest"); - } - } -} - -/// A remote server's free slot first (when there is one), then the card when this photo runs on it, with the CPU -/// running any tile either gets wrong. Handed out for photos that run on the card or have a server to share. -#[cfg(feature = "denoise")] -impl TileRunner for Network { - fn run(&self, input: &[f32]) -> Result, RunError> { - if let Some(Ok(remote)) = self.remote.as_ref().filter(|r| r.share.load(Ordering::Relaxed)).map(|r| r.usable(false)) - && let Some(Ok(out)) = remote.try_run(input) - { - return Ok(out); - } - if !self.local_card.load(Ordering::Relaxed) { - return self.cpu.run(input); - } - if let Ok(g) = self.card(false) { - match g.runner.run(input) { - Ok(out) if out.iter().all(|v| v.is_finite()) => return Ok(out), - Ok(_) => self.fell_back("numbers that are not finite"), - Err(e) => self.fell_back(&e.to_string()), - } - } - self.cpu.run(input) - } -} - -#[cfg(feature = "denoise")] -impl Model for Network { - fn bytes_per_tile(&self) -> usize { - self.cpu.bytes_per_tile() - } - fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize) { - let threads = threads.max(1); - self.last_threads.store(threads, Ordering::Relaxed); - let (card, local) = match self.choose(run_on, threads, true) { - Ok(_) => (true, threads.min(GPU_PARALLEL)), - Err(_) => (false, threads), - }; - self.local_card.store(card, Ordering::Relaxed); - if let Some(r) = &self.remote { - // this computer's time for a tile: the card's, or the CPU's with `threads` tiles sharing it (as - // `card_is_faster` counts them); unknown when the card was never set up (then the server is used) - let local_ms = match self.gpu.get() { - Some(Ok(g)) if card => g.card_ms, - Some(Ok(g)) => g.cpu_ms / (1.0 + 0.5 * threads.saturating_sub(1) as f64), - _ => f64::INFINITY, - }; - let share = match (r.usable(true), r.tile_ms()) { - (Ok(_), Some(ms)) if ms < local_ms => { - *r.slower.lock().unwrap_or_else(PoisonError::into_inner) = None; - true - } - (Ok(_), Some(ms)) => { - *r.slower.lock().unwrap_or_else(PoisonError::into_inner) = Some((ms, local_ms)); - false - } - _ => false, - }; - r.share.store(share, Ordering::Relaxed); - if let (true, Ok(remote)) = (share, r.usable(false)) { - // the server's slots come on top of this computer's own tiles at once - return (self, local + remote.slots()); - } - } - if card { (self, local) } else { (&self.cpu, local) } - } - - fn self_test(&self, run_on: RunOn) -> Result { - let t = self.cpu.self_test(); - if !t.ok { - let failed: Vec<&str> = t.checks.iter().filter(|(_, ok)| !*ok).map(|(c, _)| c.as_str()).collect(); - return Err(format!("the model failed its self-test: it does not {}", failed.join(", and does not "))); - } - let mut v = serde_json::to_value(&t).map_err(|e| e.to_string())?; - // the card is set up and checked here, so the test says where the work will run - if run_on != RunOn::Cpu { - let _ = self.card(true); - } - if let Some(o) = v.as_object_mut() { - o.insert("device".into(), self.device(run_on)); - } - Ok(v) - } - - fn device(&self, run_on: RunOn) -> Value { - if run_on != RunOn::Cpu && self.gpu.get().is_none() { - return json!({"kind": "pending"}); - } - let threads = match self.last_threads.load(Ordering::Relaxed) { - 0 => Pace::Full.parallel(std::thread::available_parallelism().map_or(1, |n| n.get()), None), - n => n, - }; - let mut v = match self.choose(run_on, threads, false) { - Ok(g) => json!({"kind": "gpu", "adapter": g.adapter}), - // a set-up that crashed or ran out of time can be tried again (`denoise.settings {runOn}`) - Err(why) => json!({"kind": "cpu", "retry": worth_retrying(&why), "reason": why}), - }; - if let (Some(Ok(g)), Some(o)) = (self.gpu.get(), v.as_object_mut()) { - // measured on this computer, on the check tile: the card's best run, and one tile alone on the CPU - o.insert("cardMs".into(), json!(g.card_ms)); - o.insert("cpuMs".into(), json!(g.cpu_ms)); - o.insert("fellBack".into(), json!(self.fallbacks.load(Ordering::Relaxed))); - } - if let (Some(r), Some(o)) = (&self.remote, v.as_object_mut()) { - o.insert("remote".into(), r.describe()); - } - v - } -} - -/// The loader of this build: our pure-Rust executor with the `denoise` feature, else one that says why nothing can run. -pub(crate) fn default_loader() -> Loader { - #[cfg(feature = "denoise")] - { - Arc::new(|path, manifest| { - // the server must run this very file: identified by the manifest's hash, else the file's - let sha256 = match &manifest.sha256 { - Some(h) => Some(h.clone()), - None if std::env::var_os("LIGHTCRAFT_DENOISE_URL").is_some() => lightcraft_denoise::hash::sha256_file(path).ok(), - None => None, - }; - let remote = sha256.and_then(|h| RemoteSide::from_env(&h, manifest.tile as usize)); - Ok(Arc::new(network(path, manifest, remote)?) as Arc) - }) - } - #[cfg(not(feature = "denoise"))] - { - Arc::new(|_, _| Err("this build cannot run denoise models".to_string())) - } -} - -/// The model at `path` on this computer, sharing tiles with `remote` when there is one. -#[cfg(feature = "denoise")] -fn network(path: &Path, manifest: &DenoiserManifest, remote: Option) -> Result { - let cpu = lightcraft_denoise::runtime::CpuRunner::load(path, manifest).map_err(|e| e.to_string())?; - Ok(Network { - remote, - local_card: AtomicBool::new(true), - cpu, - path: path.to_path_buf(), - tile: manifest.tile as usize, - gpu: OnceLock::new(), - fallbacks: AtomicUsize::new(0), - last_threads: AtomicUsize::new(0), - }) -} - -/// A model ready to be served to other computers (`lightcraft-cli denoise-serve`). -#[cfg(feature = "denoise")] -pub struct Serving { - pub model: lightcraft_denoise::remote::ModelId, - pub tile: usize, - /// Where its tiles run here: the graphics card's name, or why the CPU does. - pub device: String, - pub run: lightcraft_denoise::remote::RunTile, -} - -/// The installed model `id` (default: the one selected in Settings) in the models folder `dir` (default: this user's), -/// set up to run on `run_on` as the app would: the card checked against the CPU first, the CPU when the card can't. -#[cfg(feature = "denoise")] -pub fn serving(dir: Option<&Path>, id: Option<&str>, run_on: RunOn) -> Result { - let dir = match dir { - Some(d) => d.to_path_buf(), - None => crate::config::default_denoise_models_dir().ok_or("no denoise models folder (set LIGHTCRAFT_DENOISE_MODELS)")?, - }; - let want = match id { - Some(i) => i.to_string(), - None => read_settings(&dir).model.ok_or_else(|| { - format!("no denoise model is selected in {} (install one in Settings ▸ AI Denoise, or name it with --model)", dir.display()) - })?, - }; - let installed = installed_models(&dir) - .into_iter() - .find(|m| m.manifest.id == want) - .ok_or_else(|| format!("no installed denoise model `{want}` in {}", dir.display()))?; - let m = &installed.manifest; - // the same identity a client derives (see `default_loader`) - let sha256 = match &m.sha256 { - Some(h) => h.clone(), - None => lightcraft_denoise::hash::sha256_file(&installed.onnx).map_err(|e| format!("{}: {e}", installed.onnx.display()))?, - }; - let net = Arc::new(network(&installed.onnx, m, None)?); - let tile = m.tile as usize; - // set the card up now (and say where the tiles run) rather than with the first client's tile - let _ = net.runner(run_on, GPU_PARALLEL); - let d = net.device(run_on); - let device = match d["kind"].as_str() { - Some("gpu") => format!("GPU: {}", d["adapter"].as_str().unwrap_or("?")), - _ => format!("CPU ({})", d["reason"].as_str().unwrap_or("chosen")), - }; - let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| net.runner(run_on, GPU_PARALLEL).0.run(input)); - Ok(Serving { model: lightcraft_denoise::remote::ModelId { id: m.id.clone(), version: m.version.clone(), sha256 }, tile, device, run }) -} - -/// A model that is loaded on first use and kept, shared by the background job and the exports. -pub(crate) type ModelSlot = Arc>>>; - -// --------------------------------------------------------------------------------------------------------------------- -// settings and installed models - -/// `settings.json` in the models folder. -#[derive(Clone, Default, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub(crate) struct Settings { - /// The installed model in use; `None`: denoise is off. - pub model: Option, - /// Photos with a Denoise amount above 0 that are being looked at (active, selected) get their picture made without - /// being asked (`None`: yes). - pub auto: Option, - /// Cache size limit (GB; `None`: [`DEFAULT_CACHE_GB`]). - pub cache_gb: Option, - /// Most tiles run at once (`None`: by how hard the pump says to work). - pub threads: Option, - /// Where the model runs: `auto`, `gpu` or `cpu` (`None` or anything else: `auto`; see [`RunOn`]). - pub run_on: Option, - /// What came before `runOn`: `false` was the processor only. - pub gpu: Option, -} - -impl Settings { - pub fn auto(&self) -> bool { - self.auto.unwrap_or(true) - } - - pub fn run_on(&self) -> RunOn { - match self.run_on.as_deref().and_then(RunOn::parse) { - Some(r) => r, - None if self.gpu == Some(false) => RunOn::Cpu, - None => RunOn::Auto, - } - } - - pub fn cache_bytes(&self) -> u64 { - u64::from(self.cache_gb.unwrap_or(DEFAULT_CACHE_GB).clamp(1, 10_000)) << 30 - } -} - -pub(crate) fn read_capped(path: &Path, max: usize) -> Option> { - use std::io::Read; - let mut buf = Vec::new(); - std::fs::File::open(path).ok()?.take(max as u64 + 1).read_to_end(&mut buf).ok()?; - (buf.len() <= max).then_some(buf) -} - -pub(crate) fn read_settings(dir: &Path) -> Settings { - read_capped(&dir.join("settings.json"), 4096).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() -} - -/// Write a file next to its final name and rename it into place. -pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { - let part = path.with_extension("part"); - std::fs::write(&part, bytes).map_err(|e| format!("could not write {}: {e}", path.display()))?; - std::fs::rename(&part, path).map_err(|e| { - let _ = std::fs::remove_file(&part); - format!("could not write {}: {e}", path.display()) - }) -} - -pub(crate) fn write_settings(dir: &Path, st: &Settings) -> Result<(), String> { - std::fs::create_dir_all(dir).map_err(|e| format!("could not create the denoise models folder: {e}"))?; - write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| e.to_string())?) -} - -/// An installed model folder: `/model.onnx`, `denoise-model.json`, `installed.json`. -pub(crate) struct Installed { - pub manifest: DenoiserManifest, - pub onnx: PathBuf, - /// What was recorded when it was installed (terms accepted, hash of the file, self-test). - pub accepted: Value, -} - -impl Installed { - /// Names the model file the cache is made with: a hash of the file as installed, so another file with the same id - /// never finds this one's products. - pub fn fingerprint(&self) -> String { - let sha = self.accepted.get("sha256").and_then(Value::as_str).or(self.manifest.sha256.as_deref()).unwrap_or(""); - let len = std::fs::metadata(&self.onnx).map(|m| m.len()).unwrap_or(0); - format!("{}@{}:{len}", self.manifest.id, sha) - } -} - -/// The models in the folder. A folder that is not a valid model (wrong name, no model file, a manifest that does not -/// validate or does not match its folder) is ignored, never trusted. -pub(crate) fn installed_models(dir: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; - let mut out = Vec::new(); - for e in entries.flatten() { - let p = e.path(); - let Some(name) = p.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue }; - let onnx = p.join("model.onnx"); - if !lightcraft_denoise::licence::valid_id(&name) || !p.is_dir() || !onnx.is_file() { - continue; - } - let Some(m) = read_capped(&p.join("denoise-model.json"), lightcraft_denoise::manifest::MAX_MANIFEST_BYTES) - .and_then(|b| lightcraft_denoise::manifest::parse(&b).ok()) - else { - continue; - }; - if m.id != name { - continue; - } - let accepted = read_capped(&p.join("installed.json"), 8192).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null); - out.push(Installed { manifest: m, onnx, accepted }); - } - out.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id)); - out -} - -/// Forget that setting the graphics card up for a model closed LightCraft before (the markers [`set_up_card`] leaves): -/// the user chose where denoise runs, so the card may be tried again. -pub(crate) fn forget_gpu_set_ups(dir: &Path) { - for i in installed_models(dir) { - let _ = std::fs::remove_file(i.onnx.with_file_name(GPU_SETUP_MARKER)); - } -} - -/// The model in use. -pub(crate) struct Active { - pub id: String, - pub manifest: DenoiserManifest, - pub onnx: PathBuf, - pub fingerprint: String, - pub model: ModelSlot, -} - -// --------------------------------------------------------------------------------------------------------------------- -// products - -/// The photos whose files can be denoised at all: raw files developed from their sensor data and read from disk. -pub(crate) fn eligible(p: &Photo) -> bool { - p.develops_raw() && matches!(p.source, Source::File { .. }) && !p.deleted -} - -/// A product's name: what it was made from and with. -pub(crate) fn product_key(fingerprint: &str, p: &Photo) -> String { - let mut h = Hasher128::new(); - h.str("lcdn").u64(u64::from(product::ALGORITHM)).str(fingerprint).str(&crate::media::content_key(p)); - h.finish().to_string() -} - -pub(crate) fn product_path(dir: &Path, key: &str) -> PathBuf { - dir.join(format!("{key}.{EXT}")) -} - -/// The `.lcdn` files in `dir`: path, size, last change. -fn products_in(dir: &Path) -> Vec<(PathBuf, u64, SystemTime)> { - let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; - entries - .flatten() - .filter(|e| e.path().extension().is_some_and(|x| x == EXT)) - .filter_map(|e| { - let m = e.metadata().ok()?; - m.is_file().then(|| (e.path(), m.len(), m.modified().unwrap_or(SystemTime::UNIX_EPOCH))) - }) - .collect() -} - -/// Make sure only one thread writes a product at a time: a second one waits, then finds it made. -fn making() -> &'static (Mutex>, Condvar) { - static MAKING: OnceLock<(Mutex>, Condvar)> = OnceLock::new(); - MAKING.get_or_init(|| (Mutex::new(HashSet::new()), Condvar::new())) -} - -struct Slot(PathBuf); - -impl Slot { - fn enter(path: &Path) -> Slot { - let (set, cv) = making(); - let mut g = set.lock().unwrap_or_else(PoisonError::into_inner); - while g.contains(path) { - g = cv.wait(g).unwrap_or_else(PoisonError::into_inner); - } - g.insert(path.to_path_buf()); - Slot(path.to_path_buf()) - } -} - -impl Drop for Slot { - fn drop(&mut self) { - let (set, cv) = making(); - set.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.0); - cv.notify_all(); - } -} - -// --------------------------------------------------------------------------------------------------------------------- -// making a product - -/// Everything a thread needs to make one product, detached from the session. -#[derive(Clone)] -pub(crate) struct JobSpec { - /// The original file. - pub path: String, - pub read: Option, - pub product: PathBuf, - pub key: String, - pub manifest: DenoiserManifest, - pub onnx: PathBuf, - pub model: ModelSlot, - pub loader: Loader, - /// Tiles run at once. - pub parallel: usize, - /// Where the model runs (the setting). - pub run_on: RunOn, -} - -/// Why a product was not made. -#[derive(Debug, PartialEq)] -pub(crate) enum MakeError { - /// Denoise does not apply to this file (not a Bayer raw): nothing is wrong. - Unsupported(String), - Failed(String), - Cancelled, -} - -impl From for MakeError { - fn from(e: RunError) -> Self { - match e { - RunError::Cancelled => MakeError::Cancelled, - other => MakeError::Failed(other.to_string()), - } - } -} - -/// Progress and cancel of a running job. -#[derive(Default)] -pub(crate) struct Progress { - done: AtomicUsize, - total: AtomicUsize, - cancel: AtomicBool, -} - -impl Progress { - /// Ask the job to stop at the next tile. - pub(crate) fn cancel_now(&self) { - self.cancel.store(true, Ordering::Relaxed); - } -} - -/// The loaded model, loading it first when it is not (kept for the next photo). -fn loaded(spec: &JobSpec) -> Result, MakeError> { - if let Some(m) = spec.model.lock().unwrap_or_else(PoisonError::into_inner).clone() { - return Ok(m); - } - let m = (spec.loader)(&spec.onnx, &spec.manifest).map_err(|e| MakeError::Failed(format!("the denoise model could not be loaded: {e}")))?; - *spec.model.lock().unwrap_or_else(PoisonError::into_inner) = Some(m.clone()); - Ok(m) -} - -/// The cell `[(0,0), (1,0), (0,1), (1,1)]` colours of a Bayer mosaic and so its layout. -fn layout_of(cfa: &lightcraft_raw::Cfa) -> Option { - Layout::from_cell([cfa.color_at(0, 0), cfa.color_at(1, 0), cfa.color_at(0, 1), cfa.color_at(1, 1)]) -} - -/// Make the product of `spec` unless it is there: decode the photo, run the model over its mosaic, write the result. -/// `Ok(true)` when it was made now, `Ok(false)` when it already existed. -pub(crate) fn make_product(spec: &JobSpec, progress: Option<&Progress>) -> Result { - let _slot = Slot::enter(&spec.product); - if product::is_current(&spec.product, &spec.key) { - return Ok(false); - } - let began = web_time::Instant::now(); - let cancelled = || progress.is_some_and(|p| p.cancel.load(Ordering::Relaxed)); - if cancelled() { - return Err(MakeError::Cancelled); - } - let bytes = match &spec.read { - Some(r) => r(&spec.path).map_err(MakeError::Failed)?, - None => std::fs::read(&spec.path).map_err(|e| MakeError::Failed(format!("{}: {e}", spec.path)))?, - }; - // the decode and the normalised mosaic wait their turn at the process-wide memory gate, as every decode does - let (mosaic, layout) = { - let _held = crate::memory::work_gate().acquire(bytes.len().saturating_mul(4)); - if lightcraft_raw::probe(&bytes).is_none() { - return Err(MakeError::Unsupported("not a raw file".into())); - } - let raw = match lightcraft_raw::decode(&bytes) { - Ok(r) => r, - Err(lightcraft_raw::RawError::Unsupported(why)) => return Err(MakeError::Unsupported(why)), - Err(e) => return Err(MakeError::Failed(e.to_string())), - }; - drop(bytes); - if raw.cpp != 1 || !raw.cfa.as_ref().is_some_and(lightcraft_raw::Cfa::is_bayer) { - return Err(MakeError::Unsupported( - "only Bayer raw files can be denoised (this one has another sensor layout or is already demosaiced)".into(), - )); - } - let n = raw.normalized().map_err(|e| MakeError::Failed(e.to_string()))?; - drop(raw); - let layout = - n.cfa.as_ref().and_then(layout_of).ok_or_else(|| MakeError::Unsupported("the colour filter layout is not a Bayer pattern".into()))?; - (n, layout) - }; - if cancelled() { - return Err(MakeError::Cancelled); - } - let decoded = began.elapsed(); - let model = loaded(spec)?; - // Packed input, returned RGB and the preceding group's RGB overlap while tiles run. - let scratch = - model.bytes_per_tile().saturating_add(128usize.saturating_mul(spec.manifest.tile as usize).saturating_mul(spec.manifest.tile as usize)); - let (_, budget) = crate::memory::work_gate().usage(); - let parallel = spec.parallel.clamp(1, 4).min((budget / scratch.max(1)).max(1)); - let _held = crate::memory::work_gate().acquire(mosaic.data.len().saturating_mul(20).saturating_add(scratch.saturating_mul(parallel))); - let (runner, parallel) = model.runner(spec.run_on, parallel); - let params = Params { - tile: spec.manifest.tile as usize, - overlap: spec.manifest.overlap as usize, - gain: spec.manifest.gain, - clip: Some(CLIP), - parallel: parallel.max(1), - }; - let report = |done: usize, total: usize| { - if let Some(p) = progress { - p.total.store(total, Ordering::Relaxed); - p.done.store(done, Ordering::Relaxed); - } - }; - let ctl = Control { cancel: progress.map(|p| &p.cancel), progress: Some(&report) }; - // the tiles run on threads of their own, so a busy window's renders keep theirs - let pool = rayon::ThreadPoolBuilder::new() - .num_threads(params.parallel) - .stack_size(STACK) - .thread_name(|i| format!("denoise-{i}")) - .build() - .map_err(|e| MakeError::Failed(format!("could not start the worker threads: {e}")))?; - let rgb = pool.install(|| denoise_bayer(&mosaic.data, mosaic.width, mosaic.height, layout, runner, ¶ms, &ctl))?; - drop(mosaic); - if cancelled() { - return Err(MakeError::Cancelled); - } - let ran = began.elapsed(); - product::write(&spec.product, &rgb, &spec.key).map_err(|e| MakeError::Failed(format!("could not save the denoised picture: {e}")))?; - if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { - eprintln!( - "[denoise] read + decode {:.0} ms, model (set-up, tiles, blend) {:.0} ms, write {:.0} ms", - decoded.as_secs_f64() * 1e3, - (ran - decoded).as_secs_f64() * 1e3, - (began.elapsed() - ran).as_secs_f64() * 1e3 - ); - } - Ok(true) -} - -// --------------------------------------------------------------------------------------------------------------------- -// the session's state - -/// How hard the background work may go (the app picks it from what the user is doing, like the face scan). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Pace { - /// Start nothing new. - Pause, - /// Two tiles at once. - Light, - /// Half the machine. - Normal, - /// Most of the machine: the user is watching the progress. - Full, -} - -impl Pace { - /// `pause`, `light`, `normal` or `full`; anything else is `normal`. - pub fn parse(name: Option<&str>) -> Pace { - match name { - Some("pause") => Pace::Pause, - Some("light") => Pace::Light, - Some("full") => Pace::Full, - _ => Pace::Normal, - } - } - - /// Tiles to run at once on a machine with `cores` threads (`cap`: the user's own limit). - pub fn parallel(self, cores: usize, cap: Option) -> usize { - let n = match self { - Pace::Pause | Pace::Light => 2, - Pace::Normal => cores / 2, - Pace::Full => cores * 4 / 5, - } - .clamp(1, 16); - cap.map_or(n, |c| n.min(c.max(1) as usize)) - } -} - -/// How a photo stands, for the interface. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(tag = "state", rename_all = "camelCase")] -pub enum PhotoState { - /// Not a photo denoise applies to (not a raw, offline, a demo). - NotApplicable, - /// No model is chosen: nothing can be made. - NoModel, - /// Its picture is made: the Amount slider works. - Ready, - Queued { - /// Photos ahead of it (0 = next). - ahead: usize, - }, - Running { - done: usize, - total: usize, - }, - /// Making it failed (`why`), or denoise does not apply to this file (`unsupported`). - Failed { - why: String, - unsupported: bool, - }, - /// Nothing has asked for it yet. - Idle, -} - -#[derive(Clone)] -struct Failure { - key: String, - why: String, - unsupported: bool, -} - -enum Outcome { - Done, - Failed(String, bool), - Cancelled, -} - -struct Running { - photo: PhotoId, - key: String, - product: PathBuf, - progress: Arc, - outcome: Arc>>, - started: web_time::Instant, -} - -pub(crate) struct State { - /// Where the models are kept (`None` where there is no file system). - pub models_dir: Option, - /// Models being downloaded at the user's request. - pub downloads: Downloads, - pub(crate) install: Option, - /// How a model is loaded (tests put another in). - pub loader: Loader, - pub(crate) active: Option, - active_seen: Option, - /// `settings.json` as last read (with the model choice). - pub(crate) settings: Settings, - /// The index must be made again (the library, the model or the cache changed). - dirty: bool, - /// The catalog revision and time of the last index, to look again when photos changed. - reindexed: Option<(u64, web_time::Instant)>, - queue: VecDeque, - running: Option, - failed: HashMap, - /// Counts every change of what is ready, so the interface knows to draw again. - pub generation: u64, - /// Photos made since the session started. - made: u64, - /// AI Denoise's row in the activity stack, while a photo is being made or waits its turn. - task: Option, -} - -impl Default for State { - fn default() -> Self { - State { - models_dir: None, - downloads: Downloads::default(), - install: None, - loader: default_loader(), - active: None, - active_seen: None, - settings: Settings::default(), - dirty: true, - reindexed: None, - queue: VecDeque::new(), - running: None, - failed: HashMap::new(), - generation: 0, - made: 0, - task: None, - } - } -} - -impl Drop for State { - /// Quitting stops the job instead of leaving it to finish a photo nobody will see. - fn drop(&mut self) { - if let Some(r) = &self.running { - r.progress.cancel_now(); - } - } -} - -impl State { - /// The library was replaced: nothing carries over (photo ids are per library). - pub fn library_changed(&mut self) { - if let Some(r) = self.running.take() { - r.progress.cancel_now(); - } - self.queue.clear(); - self.failed.clear(); - self.dirty = true; - self.generation += 1; - } - - /// The model choice or the cache changed on disk: look again at the next pump. - pub fn touch(&mut self) { - self.active_seen = None; - self.dirty = true; - } - - pub fn queued(&self) -> usize { - self.queue.len() - } -} - -impl Session { - /// Where the active model's tiles run (see [`Model::device`]); `{kind: "none"}` while no model is loaded. - pub(crate) fn denoise_device(&self) -> Value { - let loaded = self.denoise.active.as_ref().and_then(|a| a.model.lock().unwrap_or_else(PoisonError::into_inner).clone()); - match loaded { - Some(m) => m.device(self.denoise.settings.run_on()), - None => json!({"kind": "none"}), - } - } - - /// The folder the products of the open library are kept in; `None` without a library on disk. - pub(crate) fn denoise_products_dir(&self) -> Option { - self.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(DIR)) - } - - /// Read which model is in use (at most every couple of seconds): the manifest and file of the model the settings - /// name, if it is installed. - pub(crate) fn denoise_refresh_active(&mut self, force: bool) { - let fresh = self.denoise.active_seen.is_some_and(|t| t.elapsed() < SETTINGS_TTL); - if fresh && !force { - return; - } - self.denoise.active_seen = Some(web_time::Instant::now()); - self.denoise.settings = self.denoise.models_dir.as_deref().map(read_settings).unwrap_or_default(); - let chosen = self.denoise.models_dir.as_deref().and_then(|dir| { - let id = self.denoise.settings.model.clone()?; - installed_models(dir).into_iter().find(|i| i.manifest.id == id) - }); - let found = chosen.map(|i| (i.fingerprint(), i)); - let same = match (&self.denoise.active, &found) { - (None, None) => true, - (Some(a), Some((fp, _))) => a.fingerprint == *fp, - _ => false, - }; - if same { - return; - } - // another model: what was made with the old one is not this one's, and what failed may work now - self.denoise.active = found.map(|(fingerprint, i)| Active { - id: i.manifest.id.clone(), - manifest: i.manifest, - onnx: i.onnx, - fingerprint, - model: Arc::new(Mutex::new(None)), - }); - if let Some(r) = self.denoise.running.take() { - r.progress.cancel_now(); - } - self.denoise.queue.clear(); - self.denoise.failed.clear(); - self.denoise.dirty = true; - } - - fn denoise_spec(&self, p: &Photo) -> Option<(DenoiseSpec, String)> { - let (dir, active) = (self.denoise_products_dir()?, self.denoise.active.as_ref()?); - let key = product_key(&active.fingerprint, p); - Some((DenoiseSpec { product: product_path(&dir, &key), key: key.clone(), make: None }, key)) - } - - /// Make the index of ready photos from the products on disk (a directory listing and a hash per photo: no file is - /// opened). Pictures that appeared or vanished drop the decoded sources they affect. The photo being made is its - /// job's to report (see [`Self::denoise_check_photo`]). - pub(crate) fn denoise_reindex(&mut self) { - self.denoise.dirty = false; - self.denoise.reindexed = Some((self.catalog.revision, web_time::Instant::now())); - let running = self.denoise.running.as_ref().map(|r| r.photo); - let mut now: HashMap = HashMap::new(); - if let (Some(dir), Some(active)) = (self.denoise_products_dir(), self.denoise.active.as_ref()) { - let names: HashSet = - products_in(&dir).into_iter().filter_map(|(p, _, _)| p.file_stem().and_then(|s| s.to_str()).map(str::to_string)).collect(); - if !names.is_empty() { - for p in self.catalog.photos().filter(|p| eligible(p) && running != Some(p.id)) { - let key = product_key(&active.fingerprint, p); - if names.contains(&key) { - now.insert(p.id, DenoiseSpec { product: product_path(&dir, &key), key, make: None }); - } - } - } - } - let mut changed = false; - for id in self.media.denoise.ids() { - if !now.contains_key(&id) { - self.media.denoise.remove(id); - self.media.forget(id); - changed = true; - } - } - for (id, spec) in now { - if self.media.denoise.spec(id).is_none_or(|old| old.key != spec.key) { - self.media.denoise.set(id, spec); - self.media.forget(id); - changed = true; - } - } - if changed { - self.denoise.generation += 1; - } - } - - /// A photo whose file changed (its content key is not the one its index entry was made for) has no picture. - /// - /// The photo whose picture is being made is left to its job: the worker saves the picture before it reports, so the - /// file is on disk while the job still counts as running. Finding it here would make the photo Ready while the session - /// is busy with it (#719); [`Self::denoise_poll`] makes it Ready in the pump that takes the job's result instead. - fn denoise_check_photo(&mut self, id: PhotoId) { - if self.denoise.running.as_ref().is_some_and(|r| r.photo == id) { - return; - } - let Some(p) = self.catalog.photo(id).cloned() else { return }; - let Some((spec, _)) = self.denoise_spec(&p) else { return }; - match self.media.denoise.spec(id) { - Some(have) if have.key != spec.key => { - self.media.denoise.remove(id); - self.media.forget(id); - self.denoise.generation += 1; - } - None if spec.product.is_file() && product::is_current(&spec.product, &spec.key) => { - // made by an export, or by another session - self.media.denoise.set(id, spec); - self.media.forget(id); - self.denoise.generation += 1; - } - _ => {} - } - } - - /// How photo `id` stands. - pub fn denoise_photo_state(&self, id: PhotoId) -> PhotoState { - let Some(p) = self.catalog.photo(id) else { return PhotoState::NotApplicable }; - if !eligible(p) { - return PhotoState::NotApplicable; - } - if self.denoise.active.is_none() { - // the model folder has not been looked at yet (just started): not "no model" until it has been - return if self.denoise.active_seen.is_none() { PhotoState::Idle } else { PhotoState::NoModel }; - } - if self.denoise_products_dir().is_none() { - return PhotoState::Failed { - why: "AI Denoise keeps its pictures in the library folder, and this session has none.".into(), - unsupported: true, - }; - } - if self.media.denoise.spec(id).is_some() { - return PhotoState::Ready; - } - if let Some(r) = self.denoise.running.as_ref().filter(|r| r.photo == id) { - return PhotoState::Running { done: r.progress.done.load(Ordering::Relaxed), total: r.progress.total.load(Ordering::Relaxed) }; - } - if let Some(ahead) = self.denoise.queue.iter().position(|q| *q == id) { - return PhotoState::Queued { ahead: ahead + usize::from(self.denoise.running.is_some()) }; - } - let key = self.denoise_spec(p).map(|s| s.1); - match self.denoise.failed.get(&id) { - Some(f) if Some(&f.key) == key.as_ref() => PhotoState::Failed { why: f.why.clone(), unsupported: f.unsupported }, - _ => PhotoState::Idle, - } - } - - /// Ask for photos' pictures to be made (the ones that are not made, queued or known to be impossible). With `retry` - /// a photo that failed before is tried again. A photo that is not raw is skipped. Returns how many were queued. - pub(crate) fn denoise_enqueue(&mut self, ids: &[PhotoId], front: bool, retry: bool) -> usize { - self.denoise_refresh_active(false); - if self.denoise.active.is_none() || self.denoise_products_dir().is_none() { - return 0; - } - let mut added = 0; - for id in ids { - let Some(p) = self.catalog.photo(*id).cloned() else { continue }; - if !eligible(&p) || self.media.denoise.spec(*id).is_some() || self.denoise.queue.contains(id) { - continue; - } - if self.denoise.running.as_ref().is_some_and(|r| r.photo == *id) || self.denoise.queue.len() >= MAX_QUEUE { - continue; - } - let Some((_, key)) = self.denoise_spec(&p) else { continue }; - if retry { - self.denoise.failed.remove(id); - } else if self.denoise.failed.get(id).is_some_and(|f| f.key == key) { - continue; - } - if front { - self.denoise.queue.push_front(*id); - } else { - self.denoise.queue.push_back(*id); - } - added += 1; - } - added - } - - /// Stop the job that is running, and with `all` forget everything that waits. - pub(crate) fn denoise_cancel(&mut self, photo: Option, all: bool) -> usize { - let mut n = 0; - if let Some(r) = &self.denoise.running - && photo.is_none_or(|p| p == r.photo) - { - r.progress.cancel_now(); - n += 1; - } - let before = self.denoise.queue.len(); - match photo { - Some(p) => self.denoise.queue.retain(|q| *q != p), - None if all => self.denoise.queue.clear(), - None => {} - } - n + before - self.denoise.queue.len() - } - - /// The photos the automatic queue looks at: the one open and the ones selected, those with a Denoise amount. - fn denoise_wanted(&self) -> Vec { - let Selection { active, ids, .. } = &self.selection; - active - .iter() - .chain(ids.iter().take(AUTO_LOOK)) - .copied() - .filter(|id| self.catalog.photo(*id).is_some_and(|p| p.develop.denoise_amount() > 0.0)) - .collect() - } - - fn denoise_job_spec(&self, p: &Photo, parallel: usize) -> Option { - let Source::File { path } = &p.source else { return None }; - let (spec, _) = self.denoise_spec(p)?; - let active = self.denoise.active.as_ref()?; - Some(JobSpec { - path: path.clone(), - read: self.media.file_bytes.clone(), - product: spec.product, - key: spec.key, - manifest: active.manifest.clone(), - onnx: active.onnx.clone(), - model: active.model.clone(), - loader: self.denoise.loader.clone(), - parallel, - run_on: self.denoise.settings.run_on(), - }) - } - - /// Start the next queued photo on its own thread. - fn denoise_start_next(&mut self, parallel: usize) { - while let Some(id) = self.denoise.queue.pop_front() { - let Some(p) = self.catalog.photo(id).cloned() else { continue }; - if !eligible(&p) || self.media.denoise.spec(id).is_some() { - continue; - } - let Some(spec) = self.denoise_job_spec(&p, parallel) else { continue }; - // another session or an export may have made it since it was queued - if product::is_current(&spec.product, &spec.key) { - self.denoise_check_photo(id); - continue; - } - let progress = Arc::new(Progress::default()); - let outcome: Arc>> = Arc::new(Mutex::new(None)); - let (key, product) = (spec.key.clone(), spec.product.clone()); - let (worker_progress, worker_outcome) = (progress.clone(), outcome.clone()); - let started = std::thread::Builder::new().name("denoise".into()).stack_size(STACK).spawn(move || { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| make_product(&spec, Some(&worker_progress)))); - let o = match result { - Ok(Ok(_)) => Outcome::Done, - Ok(Err(MakeError::Cancelled)) => Outcome::Cancelled, - Ok(Err(MakeError::Unsupported(why))) => Outcome::Failed(why, true), - Ok(Err(MakeError::Failed(why))) => Outcome::Failed(why, false), - Err(_) => Outcome::Failed("the denoiser stopped unexpectedly".into(), false), - }; - *worker_outcome.lock().unwrap_or_else(PoisonError::into_inner) = Some(o); - }); - match started { - Ok(_) => { - self.denoise.running = Some(Running { photo: id, key, product, progress, outcome, started: web_time::Instant::now() }); - } - Err(e) => { - self.denoise.failed.insert(id, Failure { key, why: format!("could not start the denoiser: {e}"), unsupported: false }); - continue; - } - } - return; - } - } - - /// Take in the running job's result. - fn denoise_poll(&mut self) { - let Some(r) = &self.denoise.running else { return }; - let Some(outcome) = r.outcome.lock().unwrap_or_else(PoisonError::into_inner).take() else { return }; - let Some(r) = self.denoise.running.take() else { return }; - match outcome { - Outcome::Done => { - self.denoise.made += 1; - self.denoise_check_photo(r.photo); - self.denoise_evict(Some(&r.product)); - } - Outcome::Failed(why, unsupported) => { - self.denoise.failed.insert(r.photo, Failure { key: r.key, why, unsupported }); - } - Outcome::Cancelled => {} - } - // the picture's state changed: the interface draws it again - self.denoise.generation += 1; - } - - /// Keep the cache within its limit: the pictures nobody asks for go first, the oldest first; `keep` (the one just - /// made) never goes. The index forgets what was deleted. - pub(crate) fn denoise_evict(&mut self, keep: Option<&Path>) { - let Some(models) = self.denoise.models_dir.clone() else { return }; - self.denoise_evict_to(read_settings(&models).cache_bytes(), keep); - } - - /// [`Self::denoise_evict`] for a limit of `limit` bytes. - pub(crate) fn denoise_evict_to(&mut self, limit: u64, keep: Option<&Path>) { - let Some(dir) = self.denoise_products_dir() else { return }; - let mut files = products_in(&dir); - let mut total: u64 = files.iter().map(|f| f.1).sum(); - if total <= limit { - return; - } - // the pictures photos use now (a Denoise amount above 0) are the last to go - let wanted: HashSet = self - .media - .denoise - .ids() - .into_iter() - .filter(|id| self.catalog.photo(*id).is_some_and(|p| p.develop.denoise_amount() > 0.0)) - .filter_map(|id| self.media.denoise.spec(id).map(|s| s.product.clone())) - .collect(); - files.sort_by_key(|(path, _, modified)| (wanted.contains(path), *modified)); - let mut gone: HashSet = HashSet::new(); - for (path, size, _) in files { - if total <= limit { - break; - } - if keep == Some(path.as_path()) { - continue; - } - if std::fs::remove_file(&path).is_ok() { - total = total.saturating_sub(size); - gone.insert(path); - } - } - if gone.is_empty() { - return; - } - for id in self.media.denoise.ids() { - if self.media.denoise.spec(id).is_some_and(|s| gone.contains(&s.product)) { - self.media.denoise.remove(id); - self.media.forget(id); - } - } - self.denoise.generation += 1; - } - - /// Delete every product of this library (they are made again when wanted). - pub(crate) fn denoise_clear(&mut self) -> (usize, u64) { - self.denoise_cancel(None, true); - let (mut files, mut bytes) = (0, 0); - if let Some(dir) = self.denoise_products_dir() { - for (path, size, _) in products_in(&dir) { - if std::fs::remove_file(&path).is_ok() { - files += 1; - bytes += size; - } - } - } - self.denoise.failed.clear(); - self.denoise.touch(); - (files, bytes) - } - - /// Per frame: take in what finished, look at what the user is looking at, and keep one job going. Cheap when - /// nothing is happening (no file is opened unless a photo with a Denoise amount lacks its picture). - pub(crate) fn denoise_pump(&mut self, pace: Pace) { - crate::cmd::denoise::finish_downloads(self); - self.denoise_refresh_active(false); - // photos that changed (a replaced file has another key) must not keep the picture of what they were - let stale = !self.media.denoise.is_empty() - && self.denoise.reindexed.is_none_or(|(rev, at)| rev != self.catalog.revision && at.elapsed() > SETTINGS_TTL); - if self.denoise.dirty || stale { - self.denoise_reindex(); - } - self.denoise_poll(); - if self.denoise.active.is_none() || self.denoise_products_dir().is_none() { - return; - } - let (cap, auto) = (self.denoise.settings.threads, self.denoise.settings.auto()); - let wanted = self.denoise_wanted(); - for id in &wanted { - self.denoise_check_photo(*id); - } - if auto { - // the photo being edited goes first - let (open, others): (Vec, Vec) = wanted.into_iter().partition(|id| self.selection.active == Some(*id)); - self.denoise_enqueue(&others, false, false); - self.denoise_enqueue(&open, true, false); - } - if self.denoise.running.is_none() && pace != Pace::Pause { - let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); - self.denoise_start_next(pace.parallel(cores, cap)); - } - } - - /// The statistics `denoise.status` reports besides the queue (reads the cache folder). - pub(crate) fn denoise_cache_usage(&self) -> (usize, u64) { - match self.denoise_products_dir() { - Some(dir) => { - let files = products_in(&dir); - (files.len(), files.iter().map(|f| f.1).sum()) - } - None => (0, 0), - } - } - - /// Are pictures made without being asked for photos being looked at (Settings ▸ AI Denoise)? - pub fn denoise_auto(&self) -> bool { - self.denoise.settings.auto() - } - - /// Is a picture being made, or waiting for its turn with a model to make it? (Headless runs wait for this.) - pub fn denoise_busy(&self) -> bool { - self.denoise.running.is_some() || (!self.denoise.queue.is_empty() && self.denoise.active.is_some()) - } - - /// Keep AI Denoise's row in the activity stack up to date (each `denoise.pump`): there while a photo is being made or - /// waits its turn, with the running photo's tiles and its file name; gone when the queue is idle. It has no ✕: what is - /// made follows the photos' Denoise amounts and Settings ▸ AI Denoise. - pub(crate) fn denoise_track(&mut self) { - if !self.denoise_busy() { - self.denoise.task = None; - return; - } - let task = self.denoise.task.get_or_insert_with(|| { - let t = self.activity.start("denoise", "AI Denoise", crate::activity::Cancel::No); - t.set_unit(crate::activity::Unit::Percent); - t - }); - match &self.denoise.running { - Some(r) => { - task.progress(r.progress.done.load(Ordering::Relaxed) as u64, r.progress.total.load(Ordering::Relaxed) as u64); - task.detail(self.catalog.photo(r.photo).map_or("", |p| p.file_name.as_str())); - } - None => { - task.progress(0, 0); - task.detail(""); - } - } - } - - /// Seconds the running job has been going, and its progress. - pub(crate) fn denoise_running_json(&self) -> Value { - match &self.denoise.running { - Some(r) => json!({ - "photo": r.photo.0, - "done": r.progress.done.load(Ordering::Relaxed), - "total": r.progress.total.load(Ordering::Relaxed), - "seconds": r.started.elapsed().as_secs_f64(), - }), - None => Value::Null, - } - } - - pub(crate) fn denoise_made(&self) -> u64 { - self.denoise.made - } - - /// The running job's product path and key (tests stand in for the worker's save). - #[cfg(test)] - pub(crate) fn denoise_running_product(&self) -> Option<(PathBuf, String)> { - self.denoise.running.as_ref().map(|r| (r.product.clone(), r.key.clone())) - } - - /// What an export of photo `id` with `settings` needs for its Denoise amount: its denoised picture, made now when the - /// queue has not got to it. `Ok(None)` when denoise does not apply (amount 0, not a Bayer raw), `Err` when it should - /// have and cannot (no model chosen, nowhere to keep the picture, the model failing). - pub(crate) fn denoise_for_export(&mut self, id: PhotoId, settings: &DevelopSettings) -> Result, String> { - if settings.denoise_amount() <= 0.0 { - return Ok(None); - } - let Some(p) = self.catalog.photo(id).cloned() else { return Ok(None) }; - let Some(loader) = self.media.denoise.loader.clone() else { return Ok(None) }; - if !eligible(&p) { - return Ok(None); - } - self.denoise_refresh_active(false); - let name = &p.file_name; - if self.denoise.active.is_none() { - return Err(format!("{name}: Denoise is on for this photo but no denoise model is chosen; choose one in Settings, or set Denoise to 0")); - } - if self.denoise_products_dir().is_none() { - return Err(format!("{name}: Denoise needs a library on disk to keep its pictures in")); - } - self.denoise_check_photo(id); - let Some(job) = self.denoise_job_spec(&p, Pace::Full.parallel(std::thread::available_parallelism().map_or(1, |n| n.get()), None)) else { - return Ok(None); - }; - let label = name.clone(); - let spec = job.clone(); - let make: MakeProduct = Arc::new(move || match make_product(&spec, None) { - Ok(_) => Ok(true), - Err(MakeError::Unsupported(_)) => Ok(false), - Err(MakeError::Failed(why)) => Err(format!("{label}: could not denoise: {why}")), - Err(MakeError::Cancelled) => Err(format!("{label}: denoise was cancelled")), - }); - Ok(Some((DenoiseSpec { product: job.product, key: job.key, make: Some(make) }, loader))) - } -} +pub use model::{Serving, serving}; +pub(crate) use products::eligible; +#[cfg(test)] +pub(crate) use products::{JobSpec, MakeError, Progress, make_product}; +pub use settings::{DEFAULT_CACHE_GB, DIR, EXT}; +pub(crate) use settings::{Settings, forget_gpu_set_ups, installed_models, read_capped, read_settings, write_atomic, write_settings}; +pub(crate) use state::State; +pub use state::{Pace, PhotoState}; #[cfg(all(test, feature = "denoise"))] mod gpu_tests; diff --git a/crates/engine/src/denoise/gpu.rs b/crates/engine/src/denoise/gpu.rs new file mode 100644 index 000000000..1577b4edc --- /dev/null +++ b/crates/engine/src/denoise/gpu.rs @@ -0,0 +1,152 @@ +#[cfg(feature = "denoise")] +use super::model::{GpuSide, STACK}; +#[cfg(feature = "denoise")] +use super::*; + +/// Tiles run at once on a graphics card: enough to keep it fed while others are packed and blended. +#[cfg(feature = "denoise")] +pub(super) const GPU_PARALLEL: usize = 4; + +/// How far the GPU's answer on the check tile may be from the CPU's (of the answer's largest value) before it is not used. +#[cfg(feature = "denoise")] +const GPU_AGREE: f32 = 1e-3; + +/// Longest the card may take to set up (build its kernels, run the check tile, time it) before the processor does the +/// work instead. +#[cfg(feature = "denoise")] +const GPU_SETUP_LIMIT: Duration = Duration::from_secs(30); + +/// Tiles the card may get wrong (an error, numbers that are not finite) before it stops being used for the model. +#[cfg(feature = "denoise")] +pub(super) const GPU_MAX_FALLBACKS: usize = 3; + +/// The file beside a model while the graphics card is set up for it (see [`set_up_card`]). +pub(crate) const GPU_SETUP_MARKER: &str = "gpu-setup.marker"; + +/// Why the card is not tried when a set-up marker is found. +#[cfg(feature = "denoise")] +pub(super) const GPU_CRASHED: &str = "LightCraft closed while it was setting up the graphics card for AI Denoise last time"; + +/// Why the card is not used when its set-up ran out of time (the start of it). +#[cfg(feature = "denoise")] +pub(super) const GPU_SLOW: &str = "setting up the graphics card took longer than"; + +/// Why the card is not used while a set-up that ran out of time is still going. +#[cfg(feature = "denoise")] +pub(super) const GPU_STUCK: &str = "the graphics card is still setting up from before (it took too long)"; + +/// Whether the card (`card_ms` a tile) is faster than the CPU running `threads` tiles at once, from the CPU's time for +/// one tile alone (`cpu_ms`). Tiles at once share the memory and the cores' second threads, so each one more counts as +/// half a tile's speed: on a 16-core laptop one tile alone took 1.4 s and sixteen at once finished one every 0.2 s. +#[cfg(feature = "denoise")] +pub(super) fn card_is_faster(card_ms: f64, cpu_ms: f64, threads: usize) -> bool { + let at_once = 1.0 + 0.5 * threads.saturating_sub(1) as f64; + card_ms.is_finite() && card_ms <= cpu_ms / at_once +} + +/// Card set-ups of this process that have not finished, by marker (their markers are not a crash's). +#[cfg(feature = "denoise")] +pub(super) static SETTING_UP: Mutex> = Mutex::new(Vec::new()); + +/// Removes a set-up's marker when the set-up ends, however it ends — but not when the process dies inside the driver. +#[cfg(feature = "denoise")] +struct SetUpEnded(PathBuf); + +#[cfg(feature = "denoise")] +impl Drop for SetUpEnded { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + SETTING_UP.lock().unwrap_or_else(PoisonError::into_inner).retain(|m| m != &self.0); + } +} + +/// Set the graphics card up with `card` on a thread of its own while `meanwhile` runs here, and give up on the card when +/// it takes longer than `limit` (a driver building kernels for minutes, or stuck). The `marker` file exists while `card` +/// runs: one found before it starts was left by a LightCraft that closed during a set-up (a driver crash takes the app +/// with it), so the card is not tried again until the user chooses where denoise runs (`denoise.settings {runOn}`). +#[cfg(feature = "denoise")] +pub(super) fn set_up_card( + marker: &Path, + limit: Duration, + card: impl FnOnce() -> Result + Send + 'static, + meanwhile: impl FnOnce() -> R, +) -> Result<(T, R), String> { + let ended = { + let mut running = SETTING_UP.lock().unwrap_or_else(PoisonError::into_inner); + if running.iter().any(|m| m == marker) { + return Err(GPU_STUCK.into()); + } + if marker.exists() { + return Err(GPU_CRASHED.into()); + } + let secs = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + // a marker that cannot be written only loses the crash guard + let _ = std::fs::write(marker, format!("setting up the graphics card for AI Denoise (unix time {secs})\n")); + running.push(marker.to_path_buf()); + SetUpEnded(marker.to_path_buf()) + }; + let started = web_time::Instant::now(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("denoise-gpu-setup".into()) + .stack_size(STACK) + .spawn(move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(card)).unwrap_or_else(|_| Err("the GPU runner gave up".into())); + drop(ended); + let _ = tx.send(r); + }) + .map_err(|e| format!("could not start setting up the graphics card: {e}"))?; + let here = meanwhile(); + match rx.recv_timeout(limit.saturating_sub(started.elapsed())) { + Ok(r) => r.map(|t| (t, here)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(format!("{GPU_SLOW} {} s, so it is not used", limit.as_secs_f64())), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err("the GPU runner gave up".into()), + } +} + +/// The card's time for one tile once it is warm: the fastest of up to three runs, stopping after about two seconds (a +/// slow card is slow enough to tell at once). +#[cfg(feature = "denoise")] +fn card_tile_ms(runner: &dyn TileRunner, input: &[f32]) -> f64 { + if runner.run(input).is_err() { + return f64::INFINITY; + } + let began = web_time::Instant::now(); + let mut best = f64::INFINITY; + for _ in 0..3 { + let one = web_time::Instant::now(); + if runner.run(input).is_err() { + break; + } + best = best.min(one.elapsed().as_secs_f64() * 1000.0); + if began.elapsed() > Duration::from_secs(2) { + break; + } + } + best +} + +/// Set the card up for the model at `path`: kernels built, the check tile run on both and compared, both timed. +#[cfg(feature = "denoise")] +pub(super) fn make_gpu(cpu: &lightcraft_denoise::runtime::CpuRunner, path: &Path, tile: usize) -> Result { + let input = Arc::new(lightcraft_denoise::runtime::check_tile(tile)); + let (on_card, net) = (input.clone(), cpu.net().clone()); + let card = move || -> Result<_, String> { + let runner = lightcraft_gpu::nn::runner(&net, tile)?; + let got = runner.run(&on_card).map_err(|e| format!("the GPU could not run the check tile: {e}"))?; + let card_ms = card_tile_ms(&runner, &on_card); + Ok((runner, got, card_ms)) + }; + let on_cpu = || { + let started = web_time::Instant::now(); + (cpu.run(&input), started.elapsed().as_secs_f64() * 1000.0) + }; + let ((runner, got, card_ms), (want, cpu_ms)) = set_up_card(&path.with_file_name(GPU_SETUP_MARKER), GPU_SETUP_LIMIT, card, on_cpu)?; + let want = want.map_err(|e| format!("the CPU could not check the GPU: {e}"))?; + let scale = want.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-12); + let worst = want.iter().zip(&got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale; + if got.len() != want.len() || !got.iter().all(|v| v.is_finite()) || worst > GPU_AGREE { + return Err(format!("its answer on the check tile is not the CPU's (off by {worst:.1e} of the largest value): not used")); + } + Ok(GpuSide { adapter: runner.adapter(), runner: Box::new(runner), card_ms, cpu_ms }) +} diff --git a/crates/engine/src/denoise/model.rs b/crates/engine/src/denoise/model.rs new file mode 100644 index 000000000..28ff953bf --- /dev/null +++ b/crates/engine/src/denoise/model.rs @@ -0,0 +1,439 @@ +#[cfg(feature = "denoise")] +use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STUCK, card_is_faster, make_gpu}; +#[cfg(feature = "denoise")] +use super::settings::{installed_models, read_settings}; +#[cfg(feature = "denoise")] +use super::state::Pace; +use super::*; + +/// Stack of the threads that run the model (the runtime recurses through its graph). +pub(super) const STACK: usize = 16 << 20; + +// --------------------------------------------------------------------------------------------------------------------- +// the model + +/// Where the model runs (Settings ▸ AI Denoise ▸ Speed; `denoise.settings {runOn}`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RunOn { + /// The graphics card where it is faster than the processor at the pace the work goes at (both are timed on this + /// computer when the card is set up), else the processor. + #[default] + Auto, + /// The graphics card whenever it can run the model, even where the processor would be faster. + Gpu, + /// Always the processor. + Cpu, +} + +impl RunOn { + /// `auto`, `gpu` or `cpu`. + pub fn parse(name: &str) -> Option { + match name { + "auto" => Some(RunOn::Auto), + "gpu" => Some(RunOn::Gpu), + "cpu" => Some(RunOn::Cpu), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + RunOn::Auto => "auto", + RunOn::Gpu => "gpu", + RunOn::Cpu => "cpu", + } + } +} + +/// A loaded denoise model: what runs tiles, and a self-check. +pub(crate) trait Model: Send + Sync { + /// What runs a photo's tiles, and how many to run at once, when `threads` may be used and the setting is `run_on` + /// (a graphics card is kept busy by a few). The first time the card may be used it is set up and checked here. + fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize); + /// Check it does something sensible: the test's result, or why not. Unless `run_on` is the processor the graphics + /// card is set up and checked too, and the result says where the work will run. + fn self_test(&self, run_on: RunOn) -> Result; + /// Conservative scratch bound for an unknown runner. + fn bytes_per_tile(&self) -> usize { + 512 * 1024 * 1024 + } + /// Where the tiles run: `{kind: "gpu", adapter}`, `{kind: "cpu", reason?}` or `{kind: "pending"}` (the card is not + /// set up yet), with the card's and the processor's time for the check tile (`cardMs`, `cpuMs`) once both were + /// timed. Never starts anything. + fn device(&self, _run_on: RunOn) -> Value { + json!({"kind": "cpu"}) + } +} + +/// Loads the model at a path as its manifest describes it. +pub(crate) type Loader = Arc Result, String> + Send + Sync>; + +/// A set-up that may work another time (a crash, the time limit), unlike one the card or the model cannot do. +#[cfg(feature = "denoise")] +pub(super) fn worth_retrying(why: &str) -> bool { + why == GPU_CRASHED || why == GPU_STUCK || why.starts_with(GPU_SLOW) +} + +/// The model on our pure-Rust CPU runner, and on the graphics card when there is one that can run it: the card is set up for the +/// first photo that may use it, checked against the CPU's answer on a check tile and timed against it, and used while +/// it keeps working; a tile it gets wrong is run on the CPU, so a photo is always finished. +#[cfg(feature = "denoise")] +pub(super) struct Network { + pub(super) cpu: lightcraft_denoise::runtime::CpuRunner, + pub(super) path: PathBuf, + pub(super) tile: usize, + pub(super) gpu: OnceLock>, + /// Tiles the card got wrong (an error, numbers that are not finite) and the CPU ran. + pub(super) fallbacks: AtomicUsize, + /// Tiles at once of the photo started last (0: none yet), to say where the work runs. + pub(super) last_threads: AtomicUsize, + /// Another computer's graphics card (`LIGHTCRAFT_DENOISE_URL` + `LIGHTCRAFT_DENOISE_TOKEN`; see + /// `docs/denoise.md` → Another computer's graphics card), greeted on first use: it takes tiles while it has a free + /// slot, on top of this computer's runner. + pub(super) remote: Option, + /// Whether this computer's share runs on the card (else the CPU), as chosen for the photo started last. + pub(super) local_card: AtomicBool, +} + +#[cfg(feature = "denoise")] +pub(super) struct RemoteSide { + runner: lightcraft_denoise::remote::Remote, + /// The greeting, and the server's time for the check tile there and back (ms; the faster of two runs). + greeted: OnceLock>, + /// Whether the photo started last shares tiles with the server, and if not because it is slower, the two times. + share: AtomicBool, + slower: Mutex>, +} + +#[cfg(feature = "denoise")] +impl RemoteSide { + /// From the environment, for the model whose file has `sha256` and tiles of `tile` cells. + fn from_env(sha256: &str, tile: usize) -> Option { + let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); + let address = var("LIGHTCRAFT_DENOISE_URL")?; + let token = var("LIGHTCRAFT_DENOISE_TOKEN").unwrap_or_default(); + Some(RemoteSide::new(lightcraft_denoise::remote::Remote::new(&address, &token, sha256, tile))) + } + + pub(super) fn new(runner: lightcraft_denoise::remote::Remote) -> RemoteSide { + RemoteSide { runner, greeted: OnceLock::new(), share: AtomicBool::new(false), slower: Mutex::new(None) } + } + + /// Greet the server and time a check tile there and back: a server behind a slow link can take seconds a tile, and + /// handing it tiles a fast local card would finish sooner only makes the photo wait for it. + fn greet(&self) -> Result<(lightcraft_denoise::remote::ServerInfo, f64), String> { + let info = self.runner.hello().map_err(|e| e.to_string())?; + let input = lightcraft_denoise::runtime::check_tile(info.tile); + let mut best = f64::INFINITY; + for _ in 0..2 { + let started = web_time::Instant::now(); + match self.runner.try_run(&input) { + Some(Ok(_)) => best = best.min(started.elapsed().as_secs_f64() * 1000.0), + Some(Err(e)) => return Err(format!("the check tile failed: {e}")), + None => return Err("no free slot for the check tile".into()), + } + } + Ok((info, best)) + } + + /// The server when it can take tiles now (greeting it the first time), else why not. + fn usable(&self, start: bool) -> Result<&lightcraft_denoise::remote::Remote, String> { + let greeted = if start { Some(self.greeted.get_or_init(|| self.greet())) } else { self.greeted.get() }; + match greeted { + None => Err("not contacted yet".into()), + Some(Err(why)) => Err(why.clone()), + Some(Ok(_)) if !self.runner.healthy() => { + Err(format!("it failed {} tiles ({})", self.runner.failures(), self.runner.last_error().unwrap_or_default())) + } + Some(Ok(_)) => Ok(&self.runner), + } + } + + /// The server's time for a tile, once greeted. + fn tile_ms(&self) -> Option { + self.greeted.get().and_then(|g| g.as_ref().ok()).map(|g| g.1) + } + + fn describe(&self) -> Value { + match self.greeted.get() { + None => json!({"address": self.runner.address(), "state": "pending"}), + Some(Err(why)) => json!({"address": self.runner.address(), "state": "unavailable", "reason": why}), + Some(Ok((info, ms))) => { + let slower = *self.slower.lock().unwrap_or_else(PoisonError::into_inner); + let state = match () { + _ if !self.runner.healthy() => "given up", + _ if slower.is_some() => "slower", + _ if self.share.load(Ordering::Relaxed) => "used", + _ => "idle", + }; + let mut v = + json!({"address": info.address, "device": info.device, "state": state, "remoteMs": ms, "failures": self.runner.failures()}); + if let (Some((_, local)), Some(o)) = (slower, v.as_object_mut()) { + o.insert("localMs".into(), json!(local)); + } + if let (Some(e), Some(o)) = (self.runner.last_error(), v.as_object_mut()) { + o.insert("lastError".into(), json!(e)); + } + v + } + } + } +} + +#[cfg(feature = "denoise")] +pub(super) struct GpuSide { + pub(super) runner: Box, + pub(super) adapter: String, + /// The check tile on the card, the fastest of a few runs (ms). + pub(super) card_ms: f64, + /// The check tile on the CPU, on one thread (ms). + pub(super) cpu_ms: f64, +} + +#[cfg(feature = "denoise")] +impl Network { + /// The card when it can be used now, else why not. `start` sets it up when that has not been tried yet; without it + /// nothing is started. + fn card(&self, start: bool) -> Result<&GpuSide, String> { + let side = if start { + Some(self.gpu.get_or_init(|| { + let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| make_gpu(&self.cpu, &self.path, self.tile))) + .unwrap_or_else(|_| Err("the GPU runner gave up".into())); + if let Err(why) = &made { + log::info!("denoise: running on the CPU ({why})"); + } + made + })) + } else { + self.gpu.get() + }; + let g = match side { + None => return Err("the graphics card is not set up yet".into()), + Some(Err(why)) => return Err(why.clone()), + Some(Ok(g)) => g, + }; + if let Some(why) = lightcraft_gpu::nn::broken_reason() { + return Err(format!("the graphics card stopped working ({why})")); + } + if self.fallbacks.load(Ordering::Relaxed) >= GPU_MAX_FALLBACKS { + return Err(format!("{} got {GPU_MAX_FALLBACKS} tiles wrong", g.adapter)); + } + Ok(g) + } + + /// The card, when a photo made `threads` tiles at a time under `run_on` runs on it; else why the CPU does. + fn choose(&self, run_on: RunOn, threads: usize, start: bool) -> Result<&GpuSide, String> { + if run_on == RunOn::Cpu { + return Err("the processor is chosen in Settings".into()); + } + let g = self.card(start)?; + if run_on == RunOn::Auto && !card_is_faster(g.card_ms, g.cpu_ms, threads) { + return Err(format!("it is faster than {} on this computer", g.adapter)); + } + Ok(g) + } + + fn fell_back(&self, why: &str) { + let n = self.fallbacks.fetch_add(1, Ordering::Relaxed) + 1; + if n == 1 { + log::warn!("denoise: a tile came back wrong from the GPU ({why}); the CPU runs it"); + } else if n == GPU_MAX_FALLBACKS { + log::warn!("denoise: the GPU got {n} tiles wrong; the CPU does the rest"); + } + } +} + +/// A remote server's free slot first (when there is one), then the card when this photo runs on it, with the CPU +/// running any tile either gets wrong. Handed out for photos that run on the card or have a server to share. +#[cfg(feature = "denoise")] +impl TileRunner for Network { + fn run(&self, input: &[f32]) -> Result, RunError> { + if let Some(Ok(remote)) = self.remote.as_ref().filter(|r| r.share.load(Ordering::Relaxed)).map(|r| r.usable(false)) + && let Some(Ok(out)) = remote.try_run(input) + { + return Ok(out); + } + if !self.local_card.load(Ordering::Relaxed) { + return self.cpu.run(input); + } + if let Ok(g) = self.card(false) { + match g.runner.run(input) { + Ok(out) if out.iter().all(|v| v.is_finite()) => return Ok(out), + Ok(_) => self.fell_back("numbers that are not finite"), + Err(e) => self.fell_back(&e.to_string()), + } + } + self.cpu.run(input) + } +} + +#[cfg(feature = "denoise")] +impl Model for Network { + fn bytes_per_tile(&self) -> usize { + self.cpu.bytes_per_tile() + } + fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize) { + let threads = threads.max(1); + self.last_threads.store(threads, Ordering::Relaxed); + let (card, local) = match self.choose(run_on, threads, true) { + Ok(_) => (true, threads.min(GPU_PARALLEL)), + Err(_) => (false, threads), + }; + self.local_card.store(card, Ordering::Relaxed); + if let Some(r) = &self.remote { + // this computer's time for a tile: the card's, or the CPU's with `threads` tiles sharing it (as + // `card_is_faster` counts them); unknown when the card was never set up (then the server is used) + let local_ms = match self.gpu.get() { + Some(Ok(g)) if card => g.card_ms, + Some(Ok(g)) => g.cpu_ms / (1.0 + 0.5 * threads.saturating_sub(1) as f64), + _ => f64::INFINITY, + }; + let share = match (r.usable(true), r.tile_ms()) { + (Ok(_), Some(ms)) if ms < local_ms => { + *r.slower.lock().unwrap_or_else(PoisonError::into_inner) = None; + true + } + (Ok(_), Some(ms)) => { + *r.slower.lock().unwrap_or_else(PoisonError::into_inner) = Some((ms, local_ms)); + false + } + _ => false, + }; + r.share.store(share, Ordering::Relaxed); + if let (true, Ok(remote)) = (share, r.usable(false)) { + // the server's slots come on top of this computer's own tiles at once + return (self, local + remote.slots()); + } + } + if card { (self, local) } else { (&self.cpu, local) } + } + + fn self_test(&self, run_on: RunOn) -> Result { + let t = self.cpu.self_test(); + if !t.ok { + let failed: Vec<&str> = t.checks.iter().filter(|(_, ok)| !*ok).map(|(c, _)| c.as_str()).collect(); + return Err(format!("the model failed its self-test: it does not {}", failed.join(", and does not "))); + } + let mut v = serde_json::to_value(&t).map_err(|e| e.to_string())?; + // the card is set up and checked here, so the test says where the work will run + if run_on != RunOn::Cpu { + let _ = self.card(true); + } + if let Some(o) = v.as_object_mut() { + o.insert("device".into(), self.device(run_on)); + } + Ok(v) + } + + fn device(&self, run_on: RunOn) -> Value { + if run_on != RunOn::Cpu && self.gpu.get().is_none() { + return json!({"kind": "pending"}); + } + let threads = match self.last_threads.load(Ordering::Relaxed) { + 0 => Pace::Full.parallel(std::thread::available_parallelism().map_or(1, |n| n.get()), None), + n => n, + }; + let mut v = match self.choose(run_on, threads, false) { + Ok(g) => json!({"kind": "gpu", "adapter": g.adapter}), + // a set-up that crashed or ran out of time can be tried again (`denoise.settings {runOn}`) + Err(why) => json!({"kind": "cpu", "retry": worth_retrying(&why), "reason": why}), + }; + if let (Some(Ok(g)), Some(o)) = (self.gpu.get(), v.as_object_mut()) { + // measured on this computer, on the check tile: the card's best run, and one tile alone on the CPU + o.insert("cardMs".into(), json!(g.card_ms)); + o.insert("cpuMs".into(), json!(g.cpu_ms)); + o.insert("fellBack".into(), json!(self.fallbacks.load(Ordering::Relaxed))); + } + if let (Some(r), Some(o)) = (&self.remote, v.as_object_mut()) { + o.insert("remote".into(), r.describe()); + } + v + } +} + +/// The loader of this build: our pure-Rust executor with the `denoise` feature, else one that says why nothing can run. +pub(crate) fn default_loader() -> Loader { + #[cfg(feature = "denoise")] + { + Arc::new(|path, manifest| { + // the server must run this very file: identified by the manifest's hash, else the file's + let sha256 = match &manifest.sha256 { + Some(h) => Some(h.clone()), + None if std::env::var_os("LIGHTCRAFT_DENOISE_URL").is_some() => lightcraft_denoise::hash::sha256_file(path).ok(), + None => None, + }; + let remote = sha256.and_then(|h| RemoteSide::from_env(&h, manifest.tile as usize)); + Ok(Arc::new(network(path, manifest, remote)?) as Arc) + }) + } + #[cfg(not(feature = "denoise"))] + { + Arc::new(|_, _| Err("this build cannot run denoise models".to_string())) + } +} + +/// The model at `path` on this computer, sharing tiles with `remote` when there is one. +#[cfg(feature = "denoise")] +pub(super) fn network(path: &Path, manifest: &DenoiserManifest, remote: Option) -> Result { + let cpu = lightcraft_denoise::runtime::CpuRunner::load(path, manifest).map_err(|e| e.to_string())?; + Ok(Network { + remote, + local_card: AtomicBool::new(true), + cpu, + path: path.to_path_buf(), + tile: manifest.tile as usize, + gpu: OnceLock::new(), + fallbacks: AtomicUsize::new(0), + last_threads: AtomicUsize::new(0), + }) +} + +/// A model ready to be served to other computers (`lightcraft-cli denoise-serve`). +#[cfg(feature = "denoise")] +pub struct Serving { + pub model: lightcraft_denoise::remote::ModelId, + pub tile: usize, + /// Where its tiles run here: the graphics card's name, or why the CPU does. + pub device: String, + pub run: lightcraft_denoise::remote::RunTile, +} + +/// The installed model `id` (default: the one selected in Settings) in the models folder `dir` (default: this user's), +/// set up to run on `run_on` as the app would: the card checked against the CPU first, the CPU when the card can't. +#[cfg(feature = "denoise")] +pub fn serving(dir: Option<&Path>, id: Option<&str>, run_on: RunOn) -> Result { + let dir = match dir { + Some(d) => d.to_path_buf(), + None => crate::config::default_denoise_models_dir().ok_or("no denoise models folder (set LIGHTCRAFT_DENOISE_MODELS)")?, + }; + let want = match id { + Some(i) => i.to_string(), + None => read_settings(&dir).model.ok_or_else(|| { + format!("no denoise model is selected in {} (install one in Settings ▸ AI Denoise, or name it with --model)", dir.display()) + })?, + }; + let installed = installed_models(&dir) + .into_iter() + .find(|m| m.manifest.id == want) + .ok_or_else(|| format!("no installed denoise model `{want}` in {}", dir.display()))?; + let m = &installed.manifest; + // the same identity a client derives (see `default_loader`) + let sha256 = match &m.sha256 { + Some(h) => h.clone(), + None => lightcraft_denoise::hash::sha256_file(&installed.onnx).map_err(|e| format!("{}: {e}", installed.onnx.display()))?, + }; + let net = Arc::new(network(&installed.onnx, m, None)?); + let tile = m.tile as usize; + // set the card up now (and say where the tiles run) rather than with the first client's tile + let _ = net.runner(run_on, GPU_PARALLEL); + let d = net.device(run_on); + let device = match d["kind"].as_str() { + Some("gpu") => format!("GPU: {}", d["adapter"].as_str().unwrap_or("?")), + _ => format!("CPU ({})", d["reason"].as_str().unwrap_or("chosen")), + }; + let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| net.runner(run_on, GPU_PARALLEL).0.run(input)); + Ok(Serving { model: lightcraft_denoise::remote::ModelId { id: m.id.clone(), version: m.version.clone(), sha256 }, tile, device, run }) +} + +/// A model that is loaded on first use and kept, shared by the background job and the exports. +pub(crate) type ModelSlot = Arc>>>; diff --git a/crates/engine/src/denoise/products.rs b/crates/engine/src/denoise/products.rs new file mode 100644 index 000000000..499e8fd6e --- /dev/null +++ b/crates/engine/src/denoise/products.rs @@ -0,0 +1,234 @@ +use super::model::{Loader, Model, ModelSlot, RunOn, STACK}; +use super::settings::EXT; +use super::*; + +/// Samples at or above this (white = 1) are clipped: the model leaves them as the camera recorded them. +const CLIP: f32 = 0.99; + +/// The model in use. +pub(crate) struct Active { + pub id: String, + pub manifest: DenoiserManifest, + pub onnx: PathBuf, + pub fingerprint: String, + pub model: ModelSlot, +} + +// --------------------------------------------------------------------------------------------------------------------- +// products + +/// The photos whose files can be denoised at all: raw files developed from their sensor data and read from disk. +pub(crate) fn eligible(p: &Photo) -> bool { + p.develops_raw() && matches!(p.source, Source::File { .. }) && !p.deleted +} + +/// A product's name: what it was made from and with. +pub(crate) fn product_key(fingerprint: &str, p: &Photo) -> String { + let mut h = Hasher128::new(); + h.str("lcdn").u64(u64::from(product::ALGORITHM)).str(fingerprint).str(&crate::media::content_key(p)); + h.finish().to_string() +} + +pub(crate) fn product_path(dir: &Path, key: &str) -> PathBuf { + dir.join(format!("{key}.{EXT}")) +} + +/// The `.lcdn` files in `dir`: path, size, last change. +pub(super) fn products_in(dir: &Path) -> Vec<(PathBuf, u64, SystemTime)> { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + entries + .flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == EXT)) + .filter_map(|e| { + let m = e.metadata().ok()?; + m.is_file().then(|| (e.path(), m.len(), m.modified().unwrap_or(SystemTime::UNIX_EPOCH))) + }) + .collect() +} + +/// Make sure only one thread writes a product at a time: a second one waits, then finds it made. +fn making() -> &'static (Mutex>, Condvar) { + static MAKING: OnceLock<(Mutex>, Condvar)> = OnceLock::new(); + MAKING.get_or_init(|| (Mutex::new(HashSet::new()), Condvar::new())) +} + +struct Slot(PathBuf); + +impl Slot { + fn enter(path: &Path) -> Slot { + let (set, cv) = making(); + let mut g = set.lock().unwrap_or_else(PoisonError::into_inner); + while g.contains(path) { + g = cv.wait(g).unwrap_or_else(PoisonError::into_inner); + } + g.insert(path.to_path_buf()); + Slot(path.to_path_buf()) + } +} + +impl Drop for Slot { + fn drop(&mut self) { + let (set, cv) = making(); + set.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.0); + cv.notify_all(); + } +} + +// --------------------------------------------------------------------------------------------------------------------- +// making a product + +/// Everything a thread needs to make one product, detached from the session. +#[derive(Clone)] +pub(crate) struct JobSpec { + /// The original file. + pub path: String, + pub read: Option, + pub product: PathBuf, + pub key: String, + pub manifest: DenoiserManifest, + pub onnx: PathBuf, + pub model: ModelSlot, + pub loader: Loader, + /// Tiles run at once. + pub parallel: usize, + /// Where the model runs (the setting). + pub run_on: RunOn, +} + +/// Why a product was not made. +#[derive(Debug, PartialEq)] +pub(crate) enum MakeError { + /// Denoise does not apply to this file (not a Bayer raw): nothing is wrong. + Unsupported(String), + Failed(String), + Cancelled, +} + +impl From for MakeError { + fn from(e: RunError) -> Self { + match e { + RunError::Cancelled => MakeError::Cancelled, + other => MakeError::Failed(other.to_string()), + } + } +} + +/// Progress and cancel of a running job. +#[derive(Default)] +pub(crate) struct Progress { + pub(super) done: AtomicUsize, + pub(super) total: AtomicUsize, + cancel: AtomicBool, +} + +impl Progress { + /// Ask the job to stop at the next tile. + pub(crate) fn cancel_now(&self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +/// The loaded model, loading it first when it is not (kept for the next photo). +pub(super) fn loaded(spec: &JobSpec) -> Result, MakeError> { + if let Some(m) = spec.model.lock().unwrap_or_else(PoisonError::into_inner).clone() { + return Ok(m); + } + let m = (spec.loader)(&spec.onnx, &spec.manifest).map_err(|e| MakeError::Failed(format!("the denoise model could not be loaded: {e}")))?; + *spec.model.lock().unwrap_or_else(PoisonError::into_inner) = Some(m.clone()); + Ok(m) +} + +/// The cell `[(0,0), (1,0), (0,1), (1,1)]` colours of a Bayer mosaic and so its layout. +fn layout_of(cfa: &lightcraft_raw::Cfa) -> Option { + Layout::from_cell([cfa.color_at(0, 0), cfa.color_at(1, 0), cfa.color_at(0, 1), cfa.color_at(1, 1)]) +} + +/// Make the product of `spec` unless it is there: decode the photo, run the model over its mosaic, write the result. +/// `Ok(true)` when it was made now, `Ok(false)` when it already existed. +pub(crate) fn make_product(spec: &JobSpec, progress: Option<&Progress>) -> Result { + let _slot = Slot::enter(&spec.product); + if product::is_current(&spec.product, &spec.key) { + return Ok(false); + } + let began = web_time::Instant::now(); + let cancelled = || progress.is_some_and(|p| p.cancel.load(Ordering::Relaxed)); + if cancelled() { + return Err(MakeError::Cancelled); + } + let bytes = match &spec.read { + Some(r) => r(&spec.path).map_err(MakeError::Failed)?, + None => std::fs::read(&spec.path).map_err(|e| MakeError::Failed(format!("{}: {e}", spec.path)))?, + }; + // the decode and the normalised mosaic wait their turn at the process-wide memory gate, as every decode does + let (mosaic, layout) = { + let _held = crate::memory::work_gate().acquire(bytes.len().saturating_mul(4)); + if lightcraft_raw::probe(&bytes).is_none() { + return Err(MakeError::Unsupported("not a raw file".into())); + } + let raw = match lightcraft_raw::decode(&bytes) { + Ok(r) => r, + Err(lightcraft_raw::RawError::Unsupported(why)) => return Err(MakeError::Unsupported(why)), + Err(e) => return Err(MakeError::Failed(e.to_string())), + }; + drop(bytes); + if raw.cpp != 1 || !raw.cfa.as_ref().is_some_and(lightcraft_raw::Cfa::is_bayer) { + return Err(MakeError::Unsupported( + "only Bayer raw files can be denoised (this one has another sensor layout or is already demosaiced)".into(), + )); + } + let n = raw.normalized().map_err(|e| MakeError::Failed(e.to_string()))?; + drop(raw); + let layout = + n.cfa.as_ref().and_then(layout_of).ok_or_else(|| MakeError::Unsupported("the colour filter layout is not a Bayer pattern".into()))?; + (n, layout) + }; + if cancelled() { + return Err(MakeError::Cancelled); + } + let decoded = began.elapsed(); + let model = loaded(spec)?; + // Packed input, returned RGB and the preceding group's RGB overlap while tiles run. + let scratch = + model.bytes_per_tile().saturating_add(128usize.saturating_mul(spec.manifest.tile as usize).saturating_mul(spec.manifest.tile as usize)); + let (_, budget) = crate::memory::work_gate().usage(); + let parallel = spec.parallel.clamp(1, 4).min((budget / scratch.max(1)).max(1)); + let _held = crate::memory::work_gate().acquire(mosaic.data.len().saturating_mul(20).saturating_add(scratch.saturating_mul(parallel))); + let (runner, parallel) = model.runner(spec.run_on, parallel); + let params = Params { + tile: spec.manifest.tile as usize, + overlap: spec.manifest.overlap as usize, + gain: spec.manifest.gain, + clip: Some(CLIP), + parallel: parallel.max(1), + }; + let report = |done: usize, total: usize| { + if let Some(p) = progress { + p.total.store(total, Ordering::Relaxed); + p.done.store(done, Ordering::Relaxed); + } + }; + let ctl = Control { cancel: progress.map(|p| &p.cancel), progress: Some(&report) }; + // the tiles run on threads of their own, so a busy window's renders keep theirs + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(params.parallel) + .stack_size(STACK) + .thread_name(|i| format!("denoise-{i}")) + .build() + .map_err(|e| MakeError::Failed(format!("could not start the worker threads: {e}")))?; + let rgb = pool.install(|| denoise_bayer(&mosaic.data, mosaic.width, mosaic.height, layout, runner, ¶ms, &ctl))?; + drop(mosaic); + if cancelled() { + return Err(MakeError::Cancelled); + } + let ran = began.elapsed(); + product::write(&spec.product, &rgb, &spec.key).map_err(|e| MakeError::Failed(format!("could not save the denoised picture: {e}")))?; + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!( + "[denoise] read + decode {:.0} ms, model (set-up, tiles, blend) {:.0} ms, write {:.0} ms", + decoded.as_secs_f64() * 1e3, + (ran - decoded).as_secs_f64() * 1e3, + (began.elapsed() - ran).as_secs_f64() * 1e3 + ); + } + Ok(true) +} diff --git a/crates/engine/src/denoise/session.rs b/crates/engine/src/denoise/session.rs new file mode 100644 index 000000000..6a452f693 --- /dev/null +++ b/crates/engine/src/denoise/session.rs @@ -0,0 +1,509 @@ +#[cfg(doc)] +use super::model::Model; +use super::model::STACK; +use super::products::{Active, JobSpec, MakeError, Progress, eligible, make_product, product_key, product_path, products_in}; +use super::settings::{DIR, SETTINGS_TTL, installed_models, read_settings}; +use super::state::{AUTO_LOOK, Failure, MAX_QUEUE, Outcome, Pace, PhotoState, Running}; +use super::*; + +impl Session { + /// Where the active model's tiles run (see [`Model::device`]); `{kind: "none"}` while no model is loaded. + pub(crate) fn denoise_device(&self) -> Value { + let loaded = self.denoise.active.as_ref().and_then(|a| a.model.lock().unwrap_or_else(PoisonError::into_inner).clone()); + match loaded { + Some(m) => m.device(self.denoise.settings.run_on()), + None => json!({"kind": "none"}), + } + } + + /// The folder the products of the open library are kept in; `None` without a library on disk. + pub(crate) fn denoise_products_dir(&self) -> Option { + self.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(DIR)) + } + + /// Read which model is in use (at most every couple of seconds): the manifest and file of the model the settings + /// name, if it is installed. + pub(crate) fn denoise_refresh_active(&mut self, force: bool) { + let fresh = self.denoise.active_seen.is_some_and(|t| t.elapsed() < SETTINGS_TTL); + if fresh && !force { + return; + } + self.denoise.active_seen = Some(web_time::Instant::now()); + self.denoise.settings = self.denoise.models_dir.as_deref().map(read_settings).unwrap_or_default(); + let chosen = self.denoise.models_dir.as_deref().and_then(|dir| { + let id = self.denoise.settings.model.clone()?; + installed_models(dir).into_iter().find(|i| i.manifest.id == id) + }); + let found = chosen.map(|i| (i.fingerprint(), i)); + let same = match (&self.denoise.active, &found) { + (None, None) => true, + (Some(a), Some((fp, _))) => a.fingerprint == *fp, + _ => false, + }; + if same { + return; + } + // another model: what was made with the old one is not this one's, and what failed may work now + self.denoise.active = found.map(|(fingerprint, i)| Active { + id: i.manifest.id.clone(), + manifest: i.manifest, + onnx: i.onnx, + fingerprint, + model: Arc::new(Mutex::new(None)), + }); + if let Some(r) = self.denoise.running.take() { + r.progress.cancel_now(); + } + self.denoise.queue.clear(); + self.denoise.failed.clear(); + self.denoise.dirty = true; + } + + fn denoise_spec(&self, p: &Photo) -> Option<(DenoiseSpec, String)> { + let (dir, active) = (self.denoise_products_dir()?, self.denoise.active.as_ref()?); + let key = product_key(&active.fingerprint, p); + Some((DenoiseSpec { product: product_path(&dir, &key), key: key.clone(), make: None }, key)) + } + + /// Make the index of ready photos from the products on disk (a directory listing and a hash per photo: no file is + /// opened). Pictures that appeared or vanished drop the decoded sources they affect. The photo being made is its + /// job's to report (see [`Self::denoise_check_photo`]). + pub(crate) fn denoise_reindex(&mut self) { + self.denoise.dirty = false; + self.denoise.reindexed = Some((self.catalog.revision, web_time::Instant::now())); + let running = self.denoise.running.as_ref().map(|r| r.photo); + let mut now: HashMap = HashMap::new(); + if let (Some(dir), Some(active)) = (self.denoise_products_dir(), self.denoise.active.as_ref()) { + let names: HashSet = + products_in(&dir).into_iter().filter_map(|(p, _, _)| p.file_stem().and_then(|s| s.to_str()).map(str::to_string)).collect(); + if !names.is_empty() { + for p in self.catalog.photos().filter(|p| eligible(p) && running != Some(p.id)) { + let key = product_key(&active.fingerprint, p); + if names.contains(&key) { + now.insert(p.id, DenoiseSpec { product: product_path(&dir, &key), key, make: None }); + } + } + } + } + let mut changed = false; + for id in self.media.denoise.ids() { + if !now.contains_key(&id) { + self.media.denoise.remove(id); + self.media.forget(id); + changed = true; + } + } + for (id, spec) in now { + if self.media.denoise.spec(id).is_none_or(|old| old.key != spec.key) { + self.media.denoise.set(id, spec); + self.media.forget(id); + changed = true; + } + } + if changed { + self.denoise.generation += 1; + } + } + + /// A photo whose file changed (its content key is not the one its index entry was made for) has no picture. + /// + /// The photo whose picture is being made is left to its job: the worker saves the picture before it reports, so the + /// file is on disk while the job still counts as running. Finding it here would make the photo Ready while the session + /// is busy with it (#719); [`Self::denoise_poll`] makes it Ready in the pump that takes the job's result instead. + fn denoise_check_photo(&mut self, id: PhotoId) { + if self.denoise.running.as_ref().is_some_and(|r| r.photo == id) { + return; + } + let Some(p) = self.catalog.photo(id).cloned() else { return }; + let Some((spec, _)) = self.denoise_spec(&p) else { return }; + match self.media.denoise.spec(id) { + Some(have) if have.key != spec.key => { + self.media.denoise.remove(id); + self.media.forget(id); + self.denoise.generation += 1; + } + None if spec.product.is_file() && product::is_current(&spec.product, &spec.key) => { + // made by an export, or by another session + self.media.denoise.set(id, spec); + self.media.forget(id); + self.denoise.generation += 1; + } + _ => {} + } + } + + /// How photo `id` stands. + pub fn denoise_photo_state(&self, id: PhotoId) -> PhotoState { + let Some(p) = self.catalog.photo(id) else { return PhotoState::NotApplicable }; + if !eligible(p) { + return PhotoState::NotApplicable; + } + if self.denoise.active.is_none() { + // the model folder has not been looked at yet (just started): not "no model" until it has been + return if self.denoise.active_seen.is_none() { PhotoState::Idle } else { PhotoState::NoModel }; + } + if self.denoise_products_dir().is_none() { + return PhotoState::Failed { + why: "AI Denoise keeps its pictures in the library folder, and this session has none.".into(), + unsupported: true, + }; + } + if self.media.denoise.spec(id).is_some() { + return PhotoState::Ready; + } + if let Some(r) = self.denoise.running.as_ref().filter(|r| r.photo == id) { + return PhotoState::Running { done: r.progress.done.load(Ordering::Relaxed), total: r.progress.total.load(Ordering::Relaxed) }; + } + if let Some(ahead) = self.denoise.queue.iter().position(|q| *q == id) { + return PhotoState::Queued { ahead: ahead + usize::from(self.denoise.running.is_some()) }; + } + let key = self.denoise_spec(p).map(|s| s.1); + match self.denoise.failed.get(&id) { + Some(f) if Some(&f.key) == key.as_ref() => PhotoState::Failed { why: f.why.clone(), unsupported: f.unsupported }, + _ => PhotoState::Idle, + } + } + + /// Ask for photos' pictures to be made (the ones that are not made, queued or known to be impossible). With `retry` + /// a photo that failed before is tried again. A photo that is not raw is skipped. Returns how many were queued. + pub(crate) fn denoise_enqueue(&mut self, ids: &[PhotoId], front: bool, retry: bool) -> usize { + self.denoise_refresh_active(false); + if self.denoise.active.is_none() || self.denoise_products_dir().is_none() { + return 0; + } + let mut added = 0; + for id in ids { + let Some(p) = self.catalog.photo(*id).cloned() else { continue }; + if !eligible(&p) || self.media.denoise.spec(*id).is_some() || self.denoise.queue.contains(id) { + continue; + } + if self.denoise.running.as_ref().is_some_and(|r| r.photo == *id) || self.denoise.queue.len() >= MAX_QUEUE { + continue; + } + let Some((_, key)) = self.denoise_spec(&p) else { continue }; + if retry { + self.denoise.failed.remove(id); + } else if self.denoise.failed.get(id).is_some_and(|f| f.key == key) { + continue; + } + if front { + self.denoise.queue.push_front(*id); + } else { + self.denoise.queue.push_back(*id); + } + added += 1; + } + added + } + + /// Stop the job that is running, and with `all` forget everything that waits. + pub(crate) fn denoise_cancel(&mut self, photo: Option, all: bool) -> usize { + let mut n = 0; + if let Some(r) = &self.denoise.running + && photo.is_none_or(|p| p == r.photo) + { + r.progress.cancel_now(); + n += 1; + } + let before = self.denoise.queue.len(); + match photo { + Some(p) => self.denoise.queue.retain(|q| *q != p), + None if all => self.denoise.queue.clear(), + None => {} + } + n + before - self.denoise.queue.len() + } + + /// The photos the automatic queue looks at: the one open and the ones selected, those with a Denoise amount. + fn denoise_wanted(&self) -> Vec { + let Selection { active, ids, .. } = &self.selection; + active + .iter() + .chain(ids.iter().take(AUTO_LOOK)) + .copied() + .filter(|id| self.catalog.photo(*id).is_some_and(|p| p.develop.denoise_amount() > 0.0)) + .collect() + } + + fn denoise_job_spec(&self, p: &Photo, parallel: usize) -> Option { + let Source::File { path } = &p.source else { return None }; + let (spec, _) = self.denoise_spec(p)?; + let active = self.denoise.active.as_ref()?; + Some(JobSpec { + path: path.clone(), + read: self.media.file_bytes.clone(), + product: spec.product, + key: spec.key, + manifest: active.manifest.clone(), + onnx: active.onnx.clone(), + model: active.model.clone(), + loader: self.denoise.loader.clone(), + parallel, + run_on: self.denoise.settings.run_on(), + }) + } + + /// Start the next queued photo on its own thread. + fn denoise_start_next(&mut self, parallel: usize) { + while let Some(id) = self.denoise.queue.pop_front() { + let Some(p) = self.catalog.photo(id).cloned() else { continue }; + if !eligible(&p) || self.media.denoise.spec(id).is_some() { + continue; + } + let Some(spec) = self.denoise_job_spec(&p, parallel) else { continue }; + // another session or an export may have made it since it was queued + if product::is_current(&spec.product, &spec.key) { + self.denoise_check_photo(id); + continue; + } + let progress = Arc::new(Progress::default()); + let outcome: Arc>> = Arc::new(Mutex::new(None)); + let (key, product) = (spec.key.clone(), spec.product.clone()); + let (worker_progress, worker_outcome) = (progress.clone(), outcome.clone()); + let started = std::thread::Builder::new().name("denoise".into()).stack_size(STACK).spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| make_product(&spec, Some(&worker_progress)))); + let o = match result { + Ok(Ok(_)) => Outcome::Done, + Ok(Err(MakeError::Cancelled)) => Outcome::Cancelled, + Ok(Err(MakeError::Unsupported(why))) => Outcome::Failed(why, true), + Ok(Err(MakeError::Failed(why))) => Outcome::Failed(why, false), + Err(_) => Outcome::Failed("the denoiser stopped unexpectedly".into(), false), + }; + *worker_outcome.lock().unwrap_or_else(PoisonError::into_inner) = Some(o); + }); + match started { + Ok(_) => { + self.denoise.running = Some(Running { photo: id, key, product, progress, outcome, started: web_time::Instant::now() }); + } + Err(e) => { + self.denoise.failed.insert(id, Failure { key, why: format!("could not start the denoiser: {e}"), unsupported: false }); + continue; + } + } + return; + } + } + + /// Take in the running job's result. + fn denoise_poll(&mut self) { + let Some(r) = &self.denoise.running else { return }; + let Some(outcome) = r.outcome.lock().unwrap_or_else(PoisonError::into_inner).take() else { return }; + let Some(r) = self.denoise.running.take() else { return }; + match outcome { + Outcome::Done => { + self.denoise.made += 1; + self.denoise_check_photo(r.photo); + self.denoise_evict(Some(&r.product)); + } + Outcome::Failed(why, unsupported) => { + self.denoise.failed.insert(r.photo, Failure { key: r.key, why, unsupported }); + } + Outcome::Cancelled => {} + } + // the picture's state changed: the interface draws it again + self.denoise.generation += 1; + } + + /// Keep the cache within its limit: the pictures nobody asks for go first, the oldest first; `keep` (the one just + /// made) never goes. The index forgets what was deleted. + pub(crate) fn denoise_evict(&mut self, keep: Option<&Path>) { + let Some(models) = self.denoise.models_dir.clone() else { return }; + self.denoise_evict_to(read_settings(&models).cache_bytes(), keep); + } + + /// [`Self::denoise_evict`] for a limit of `limit` bytes. + pub(crate) fn denoise_evict_to(&mut self, limit: u64, keep: Option<&Path>) { + let Some(dir) = self.denoise_products_dir() else { return }; + let mut files = products_in(&dir); + let mut total: u64 = files.iter().map(|f| f.1).sum(); + if total <= limit { + return; + } + // the pictures photos use now (a Denoise amount above 0) are the last to go + let wanted: HashSet = self + .media + .denoise + .ids() + .into_iter() + .filter(|id| self.catalog.photo(*id).is_some_and(|p| p.develop.denoise_amount() > 0.0)) + .filter_map(|id| self.media.denoise.spec(id).map(|s| s.product.clone())) + .collect(); + files.sort_by_key(|(path, _, modified)| (wanted.contains(path), *modified)); + let mut gone: HashSet = HashSet::new(); + for (path, size, _) in files { + if total <= limit { + break; + } + if keep == Some(path.as_path()) { + continue; + } + if std::fs::remove_file(&path).is_ok() { + total = total.saturating_sub(size); + gone.insert(path); + } + } + if gone.is_empty() { + return; + } + for id in self.media.denoise.ids() { + if self.media.denoise.spec(id).is_some_and(|s| gone.contains(&s.product)) { + self.media.denoise.remove(id); + self.media.forget(id); + } + } + self.denoise.generation += 1; + } + + /// Delete every product of this library (they are made again when wanted). + pub(crate) fn denoise_clear(&mut self) -> (usize, u64) { + self.denoise_cancel(None, true); + let (mut files, mut bytes) = (0, 0); + if let Some(dir) = self.denoise_products_dir() { + for (path, size, _) in products_in(&dir) { + if std::fs::remove_file(&path).is_ok() { + files += 1; + bytes += size; + } + } + } + self.denoise.failed.clear(); + self.denoise.touch(); + (files, bytes) + } + + /// Per frame: take in what finished, look at what the user is looking at, and keep one job going. Cheap when + /// nothing is happening (no file is opened unless a photo with a Denoise amount lacks its picture). + pub(crate) fn denoise_pump(&mut self, pace: Pace) { + crate::cmd::denoise::finish_downloads(self); + self.denoise_refresh_active(false); + // photos that changed (a replaced file has another key) must not keep the picture of what they were + let stale = !self.media.denoise.is_empty() + && self.denoise.reindexed.is_none_or(|(rev, at)| rev != self.catalog.revision && at.elapsed() > SETTINGS_TTL); + if self.denoise.dirty || stale { + self.denoise_reindex(); + } + self.denoise_poll(); + if self.denoise.active.is_none() || self.denoise_products_dir().is_none() { + return; + } + let (cap, auto) = (self.denoise.settings.threads, self.denoise.settings.auto()); + let wanted = self.denoise_wanted(); + for id in &wanted { + self.denoise_check_photo(*id); + } + if auto { + // the photo being edited goes first + let (open, others): (Vec, Vec) = wanted.into_iter().partition(|id| self.selection.active == Some(*id)); + self.denoise_enqueue(&others, false, false); + self.denoise_enqueue(&open, true, false); + } + if self.denoise.running.is_none() && pace != Pace::Pause { + let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); + self.denoise_start_next(pace.parallel(cores, cap)); + } + } + + /// The statistics `denoise.status` reports besides the queue (reads the cache folder). + pub(crate) fn denoise_cache_usage(&self) -> (usize, u64) { + match self.denoise_products_dir() { + Some(dir) => { + let files = products_in(&dir); + (files.len(), files.iter().map(|f| f.1).sum()) + } + None => (0, 0), + } + } + + /// Are pictures made without being asked for photos being looked at (Settings ▸ AI Denoise)? + pub fn denoise_auto(&self) -> bool { + self.denoise.settings.auto() + } + + /// Is a picture being made, or waiting for its turn with a model to make it? (Headless runs wait for this.) + pub fn denoise_busy(&self) -> bool { + self.denoise.running.is_some() || (!self.denoise.queue.is_empty() && self.denoise.active.is_some()) + } + + /// Keep AI Denoise's row in the activity stack up to date (each `denoise.pump`): there while a photo is being made or + /// waits its turn, with the running photo's tiles and its file name; gone when the queue is idle. It has no ✕: what is + /// made follows the photos' Denoise amounts and Settings ▸ AI Denoise. + pub(crate) fn denoise_track(&mut self) { + if !self.denoise_busy() { + self.denoise.task = None; + return; + } + let task = self.denoise.task.get_or_insert_with(|| { + let t = self.activity.start("denoise", "AI Denoise", crate::activity::Cancel::No); + t.set_unit(crate::activity::Unit::Percent); + t + }); + match &self.denoise.running { + Some(r) => { + task.progress(r.progress.done.load(Ordering::Relaxed) as u64, r.progress.total.load(Ordering::Relaxed) as u64); + task.detail(self.catalog.photo(r.photo).map_or("", |p| p.file_name.as_str())); + } + None => { + task.progress(0, 0); + task.detail(""); + } + } + } + + /// Seconds the running job has been going, and its progress. + pub(crate) fn denoise_running_json(&self) -> Value { + match &self.denoise.running { + Some(r) => json!({ + "photo": r.photo.0, + "done": r.progress.done.load(Ordering::Relaxed), + "total": r.progress.total.load(Ordering::Relaxed), + "seconds": r.started.elapsed().as_secs_f64(), + }), + None => Value::Null, + } + } + + pub(crate) fn denoise_made(&self) -> u64 { + self.denoise.made + } + + /// The running job's product path and key (tests stand in for the worker's save). + #[cfg(test)] + pub(crate) fn denoise_running_product(&self) -> Option<(PathBuf, String)> { + self.denoise.running.as_ref().map(|r| (r.product.clone(), r.key.clone())) + } + + /// What an export of photo `id` with `settings` needs for its Denoise amount: its denoised picture, made now when the + /// queue has not got to it. `Ok(None)` when denoise does not apply (amount 0, not a Bayer raw), `Err` when it should + /// have and cannot (no model chosen, nowhere to keep the picture, the model failing). + pub(crate) fn denoise_for_export(&mut self, id: PhotoId, settings: &DevelopSettings) -> Result, String> { + if settings.denoise_amount() <= 0.0 { + return Ok(None); + } + let Some(p) = self.catalog.photo(id).cloned() else { return Ok(None) }; + let Some(loader) = self.media.denoise.loader.clone() else { return Ok(None) }; + if !eligible(&p) { + return Ok(None); + } + self.denoise_refresh_active(false); + let name = &p.file_name; + if self.denoise.active.is_none() { + return Err(format!("{name}: Denoise is on for this photo but no denoise model is chosen; choose one in Settings, or set Denoise to 0")); + } + if self.denoise_products_dir().is_none() { + return Err(format!("{name}: Denoise needs a library on disk to keep its pictures in")); + } + self.denoise_check_photo(id); + let Some(job) = self.denoise_job_spec(&p, Pace::Full.parallel(std::thread::available_parallelism().map_or(1, |n| n.get()), None)) else { + return Ok(None); + }; + let label = name.clone(); + let spec = job.clone(); + let make: MakeProduct = Arc::new(move || match make_product(&spec, None) { + Ok(_) => Ok(true), + Err(MakeError::Unsupported(_)) => Ok(false), + Err(MakeError::Failed(why)) => Err(format!("{label}: could not denoise: {why}")), + Err(MakeError::Cancelled) => Err(format!("{label}: denoise was cancelled")), + }); + Ok(Some((DenoiseSpec { product: job.product, key: job.key, make: Some(make) }, loader))) + } +} diff --git a/crates/engine/src/denoise/settings.rs b/crates/engine/src/denoise/settings.rs new file mode 100644 index 000000000..f8acd5320 --- /dev/null +++ b/crates/engine/src/denoise/settings.rs @@ -0,0 +1,135 @@ +use super::gpu::GPU_SETUP_MARKER; +use super::model::RunOn; +use super::*; + +/// Extension of a product file. +pub const EXT: &str = "lcdn"; + +/// Where the products of a library are kept, inside the library's folder (safe to delete, like `thumbs/`). +pub const DIR: &str = "denoise"; + +/// What the cache may take up unless the user says otherwise (GB). A product is about as large as the raw file's +/// decoded samples, compressed: ~100 MB for 24 megapixels. +pub const DEFAULT_CACHE_GB: u32 = 20; + +/// How long a look at the model settings is trusted. +pub(super) const SETTINGS_TTL: Duration = Duration::from_secs(2); + +// --------------------------------------------------------------------------------------------------------------------- +// settings and installed models + +/// `settings.json` in the models folder. +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub(crate) struct Settings { + /// The installed model in use; `None`: denoise is off. + pub model: Option, + /// Photos with a Denoise amount above 0 that are being looked at (active, selected) get their picture made without + /// being asked (`None`: yes). + pub auto: Option, + /// Cache size limit (GB; `None`: [`DEFAULT_CACHE_GB`]). + pub cache_gb: Option, + /// Most tiles run at once (`None`: by how hard the pump says to work). + pub threads: Option, + /// Where the model runs: `auto`, `gpu` or `cpu` (`None` or anything else: `auto`; see [`RunOn`]). + pub run_on: Option, + /// What came before `runOn`: `false` was the processor only. + pub gpu: Option, +} + +impl Settings { + pub fn auto(&self) -> bool { + self.auto.unwrap_or(true) + } + + pub fn run_on(&self) -> RunOn { + match self.run_on.as_deref().and_then(RunOn::parse) { + Some(r) => r, + None if self.gpu == Some(false) => RunOn::Cpu, + None => RunOn::Auto, + } + } + + pub fn cache_bytes(&self) -> u64 { + u64::from(self.cache_gb.unwrap_or(DEFAULT_CACHE_GB).clamp(1, 10_000)) << 30 + } +} + +pub(crate) fn read_capped(path: &Path, max: usize) -> Option> { + use std::io::Read; + let mut buf = Vec::new(); + std::fs::File::open(path).ok()?.take(max as u64 + 1).read_to_end(&mut buf).ok()?; + (buf.len() <= max).then_some(buf) +} + +pub(crate) fn read_settings(dir: &Path) -> Settings { + read_capped(&dir.join("settings.json"), 4096).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() +} + +/// Write a file next to its final name and rename it into place. +pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let part = path.with_extension("part"); + std::fs::write(&part, bytes).map_err(|e| format!("could not write {}: {e}", path.display()))?; + std::fs::rename(&part, path).map_err(|e| { + let _ = std::fs::remove_file(&part); + format!("could not write {}: {e}", path.display()) + }) +} + +pub(crate) fn write_settings(dir: &Path, st: &Settings) -> Result<(), String> { + std::fs::create_dir_all(dir).map_err(|e| format!("could not create the denoise models folder: {e}"))?; + write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| e.to_string())?) +} + +/// An installed model folder: `/model.onnx`, `denoise-model.json`, `installed.json`. +pub(crate) struct Installed { + pub manifest: DenoiserManifest, + pub onnx: PathBuf, + /// What was recorded when it was installed (terms accepted, hash of the file, self-test). + pub accepted: Value, +} + +impl Installed { + /// Names the model file the cache is made with: a hash of the file as installed, so another file with the same id + /// never finds this one's products. + pub fn fingerprint(&self) -> String { + let sha = self.accepted.get("sha256").and_then(Value::as_str).or(self.manifest.sha256.as_deref()).unwrap_or(""); + let len = std::fs::metadata(&self.onnx).map(|m| m.len()).unwrap_or(0); + format!("{}@{}:{len}", self.manifest.id, sha) + } +} + +/// The models in the folder. A folder that is not a valid model (wrong name, no model file, a manifest that does not +/// validate or does not match its folder) is ignored, never trusted. +pub(crate) fn installed_models(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut out = Vec::new(); + for e in entries.flatten() { + let p = e.path(); + let Some(name) = p.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue }; + let onnx = p.join("model.onnx"); + if !lightcraft_denoise::licence::valid_id(&name) || !p.is_dir() || !onnx.is_file() { + continue; + } + let Some(m) = read_capped(&p.join("denoise-model.json"), lightcraft_denoise::manifest::MAX_MANIFEST_BYTES) + .and_then(|b| lightcraft_denoise::manifest::parse(&b).ok()) + else { + continue; + }; + if m.id != name { + continue; + } + let accepted = read_capped(&p.join("installed.json"), 8192).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null); + out.push(Installed { manifest: m, onnx, accepted }); + } + out.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id)); + out +} + +/// Forget that setting the graphics card up for a model closed LightCraft before (the markers [`set_up_card`] leaves): +/// the user chose where denoise runs, so the card may be tried again. +pub(crate) fn forget_gpu_set_ups(dir: &Path) { + for i in installed_models(dir) { + let _ = std::fs::remove_file(i.onnx.with_file_name(GPU_SETUP_MARKER)); + } +} diff --git a/crates/engine/src/denoise/state.rs b/crates/engine/src/denoise/state.rs new file mode 100644 index 000000000..76a4b6130 --- /dev/null +++ b/crates/engine/src/denoise/state.rs @@ -0,0 +1,179 @@ +use super::model::{Loader, default_loader}; +use super::products::{Active, Progress}; +use super::settings::Settings; +use super::*; + +/// Most photos waiting in the queue. +pub(super) const MAX_QUEUE: usize = 5000; + +/// Most selected photos the automatic queue looks at in one pump. +pub(super) const AUTO_LOOK: usize = 64; + +// --------------------------------------------------------------------------------------------------------------------- +// the session's state + +/// How hard the background work may go (the app picks it from what the user is doing, like the face scan). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pace { + /// Start nothing new. + Pause, + /// Two tiles at once. + Light, + /// Half the machine. + Normal, + /// Most of the machine: the user is watching the progress. + Full, +} + +impl Pace { + /// `pause`, `light`, `normal` or `full`; anything else is `normal`. + pub fn parse(name: Option<&str>) -> Pace { + match name { + Some("pause") => Pace::Pause, + Some("light") => Pace::Light, + Some("full") => Pace::Full, + _ => Pace::Normal, + } + } + + /// Tiles to run at once on a machine with `cores` threads (`cap`: the user's own limit). + pub fn parallel(self, cores: usize, cap: Option) -> usize { + let n = match self { + Pace::Pause | Pace::Light => 2, + Pace::Normal => cores / 2, + Pace::Full => cores * 4 / 5, + } + .clamp(1, 16); + cap.map_or(n, |c| n.min(c.max(1) as usize)) + } +} + +/// How a photo stands, for the interface. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum PhotoState { + /// Not a photo denoise applies to (not a raw, offline, a demo). + NotApplicable, + /// No model is chosen: nothing can be made. + NoModel, + /// Its picture is made: the Amount slider works. + Ready, + Queued { + /// Photos ahead of it (0 = next). + ahead: usize, + }, + Running { + done: usize, + total: usize, + }, + /// Making it failed (`why`), or denoise does not apply to this file (`unsupported`). + Failed { + why: String, + unsupported: bool, + }, + /// Nothing has asked for it yet. + Idle, +} + +#[derive(Clone)] +pub(super) struct Failure { + pub(super) key: String, + pub(super) why: String, + pub(super) unsupported: bool, +} + +pub(super) enum Outcome { + Done, + Failed(String, bool), + Cancelled, +} + +pub(super) struct Running { + pub(super) photo: PhotoId, + pub(super) key: String, + pub(super) product: PathBuf, + pub(super) progress: Arc, + pub(super) outcome: Arc>>, + pub(super) started: web_time::Instant, +} + +pub(crate) struct State { + /// Where the models are kept (`None` where there is no file system). + pub models_dir: Option, + /// Models being downloaded at the user's request. + pub downloads: Downloads, + pub(crate) install: Option, + /// How a model is loaded (tests put another in). + pub loader: Loader, + pub(crate) active: Option, + pub(super) active_seen: Option, + /// `settings.json` as last read (with the model choice). + pub(crate) settings: Settings, + /// The index must be made again (the library, the model or the cache changed). + pub(super) dirty: bool, + /// The catalog revision and time of the last index, to look again when photos changed. + pub(super) reindexed: Option<(u64, web_time::Instant)>, + pub(super) queue: VecDeque, + pub(super) running: Option, + pub(super) failed: HashMap, + /// Counts every change of what is ready, so the interface knows to draw again. + pub generation: u64, + /// Photos made since the session started. + pub(super) made: u64, + /// AI Denoise's row in the activity stack, while a photo is being made or waits its turn. + pub(super) task: Option, +} + +impl Default for State { + fn default() -> Self { + State { + models_dir: None, + downloads: Downloads::default(), + install: None, + loader: default_loader(), + active: None, + active_seen: None, + settings: Settings::default(), + dirty: true, + reindexed: None, + queue: VecDeque::new(), + running: None, + failed: HashMap::new(), + generation: 0, + made: 0, + task: None, + } + } +} + +impl Drop for State { + /// Quitting stops the job instead of leaving it to finish a photo nobody will see. + fn drop(&mut self) { + if let Some(r) = &self.running { + r.progress.cancel_now(); + } + } +} + +impl State { + /// The library was replaced: nothing carries over (photo ids are per library). + pub fn library_changed(&mut self) { + if let Some(r) = self.running.take() { + r.progress.cancel_now(); + } + self.queue.clear(); + self.failed.clear(); + self.dirty = true; + self.generation += 1; + } + + /// The model choice or the cache changed on disk: look again at the next pump. + pub fn touch(&mut self) { + self.active_seen = None; + self.dirty = true; + } + + pub fn queued(&self) -> usize { + self.queue.len() + } +} From 39d6a8d6683a5afcbcaed88ae151cf69dba2d3f3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:08:55 +0000 Subject: [PATCH 113/191] refactor(lightcraft-engine): split rename.rs into rename/ --- crates/engine/src/rename.rs | 1163 +-------------------------- crates/engine/src/rename/fs.rs | 242 ++++++ crates/engine/src/rename/moves.rs | 168 ++++ crates/engine/src/rename/plan.rs | 295 +++++++ crates/engine/src/rename/sidecar.rs | 174 ++++ crates/engine/src/rename/tokens.rs | 271 +++++++ 6 files changed, 1176 insertions(+), 1137 deletions(-) create mode 100644 crates/engine/src/rename/fs.rs create mode 100644 crates/engine/src/rename/moves.rs create mode 100644 crates/engine/src/rename/plan.rs create mode 100644 crates/engine/src/rename/sidecar.rs create mode 100644 crates/engine/src/rename/tokens.rs diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index b48f31291..8b165245b 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -32,6 +32,32 @@ use serde::Serialize; use crate::sidecar::SidecarNaming; use crate::{EngineError, Result, Session}; +mod fs; +mod moves; +mod plan; +mod sidecar; +mod tokens; + +pub(crate) use fs::{MoveFs, RealFs}; +#[cfg(test)] +use fs::{folded_path, same_contents}; +pub(crate) use moves::move_all; +#[cfg(test)] +pub(crate) use moves::move_file_with; +pub use moves::{MoveError, move_file}; +pub use plan::{plan_rename_photos, rename_photos}; +#[cfg(test)] +pub(crate) use sidecar::PacketVersion; +#[cfg(test)] +use sidecar::RetainedPacket; +pub(crate) use sidecar::{SidecarMove, retry_sidecars, validate_sidecars}; +#[cfg(test)] +use tokens::format_date; +pub use tokens::{ + DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, TokenHelp, expand, expand_folder, expand_tokens, expand_tokens_with, folder_template_error, + sample_photo, token_example, token_help_json, token_summary, unknown_tokens, +}; + /// One planned rename. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct RenamePlan { @@ -45,1142 +71,5 @@ pub struct RenamePlan { pub to_path: Option, } -fn split_ext(name: &str) -> (&str, &str) { - match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => (stem, ext), - _ => (name, ""), - } -} - -fn sanitize(s: &str) -> String { - let t: String = - s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() { '-' } else { c }).collect(); - t.trim().trim_matches('.').trim().to_string() -} - -/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name -/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month -/// that doesn't parse gives nothing. -fn format_date(iso: &str, fmt: &str) -> String { - let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string(); - let month = iso.get(5..7).and_then(|m| m.parse::().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or(""); - let mut out = String::new(); - let mut it = fmt.chars(); - while let Some(c) = it.next() { - if c != '%' { - out.push(c); - continue; - } - match it.next() { - Some('Y') => out.push_str(&part(0, 4)), - Some('y') => out.push_str(&part(2, 4)), - Some('m') => out.push_str(&part(5, 7)), - Some('B') => out.push_str(month), - Some('b') => out.push_str(month.get(..3).unwrap_or(month)), - Some('d') => out.push_str(&part(8, 10)), - Some('H') => out.push_str(&part(11, 13)), - Some('M') => out.push_str(&part(14, 16)), - Some('S') => out.push_str(&part(17, 19)), - Some('%') => out.push('%'), - Some(o) => { - out.push('%'); - out.push(o); - } - None => out.push('%'), - } - } - out -} - -/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and -/// command description shows; a test checks that [`expand_tokens`] knows each of them. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -pub struct TokenHelp { - /// The token as typed, e.g. `{seq:3}`. - pub tag: &'static str, - /// Other spellings with the same meaning. - pub aliases: &'static [&'static str], - pub meaning: &'static str, -} - -/// Every template token, in the order the help shows them. -pub const TOKENS: &[TokenHelp] = &[ - TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" }, - TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" }, - TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" }, - TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" }, - TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" }, - TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" }, - TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" }, - TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" }, - TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" }, - TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" }, - TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" }, - TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" }, - TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" }, - TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" }, -]; - -/// The `%` directives of `{date:…}`. -pub const DATE_DIRECTIVES: &[(&str, &str)] = &[ - ("%Y", "year, 4 digits"), - ("%y", "year, 2 digits"), - ("%m", "month 01–12"), - ("%B", "month name (January)"), - ("%b", "short month name (Jan)"), - ("%d", "day 01–31"), - ("%H", "hour 00–23"), - ("%M", "minute"), - ("%S", "second"), - ("%%", "a literal %"), -]; - -/// How templates behave, one sentence each (shown under the token list). -pub const TEMPLATE_NOTES: &[&str] = &[ - "The original extension is always added; {ext} only puts it inside the name as well.", - "A blank template keeps the original names.", - "{date} is the capture time; a photo without one uses the time it was imported.", - "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.", - "Unknown tags stay as typed — check the preview for a {typo}.", - "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….", -]; - -/// The tokens as one line (`{name} {num} {seq} …`), for compact hints. -pub fn token_summary() -> String { - TOKENS.iter().map(|t| t.tag).collect::>().join(" ") -} - -/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48). -pub fn sample_photo() -> Photo { - let mut p = - Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00"); - p.captured = Some("2026-01-14T05:58:48".into()); - p.rating = 4; - p.meta.camera = "Canon EOS R5".into(); - p.meta.lens = "RF24-70mm F2.8".into(); - p.meta.iso = Some(400); - p.meta.title = "Harbour".into(); - p.meta.creator = "Ann Lee".into(); - p -} - -/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column. -pub fn token_example(tag: &str) -> String { - expand_tokens(tag, &sample_photo(), 1, 1) -} - -/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be -/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels. -pub fn folder_template_error(template: &str) -> Option { - let t = template.trim(); - if t.is_empty() { - return Some("the folder template is empty".into()); - } - let b = t.as_bytes(); - if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') { - return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into()); - } - if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) { - return Some("the folder template may not contain . or .. folders".into()); - } - None -} - -/// The folders (relative to the destination) a folder template gives `p`: the template's own `/` -/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder -/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and -/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty -/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s -/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped. -pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec { - template - .split(['/', '\\']) - .map(str::trim) - .filter(|s| !s.is_empty() && !matches!(*s, "." | "..")) - .map(|s| { - let v = sanitize(&expand_tokens(s, p, seq, 1)); - if v.is_empty() { "unknown".to_string() } else { v } - }) - .collect() -} - -/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning. -pub fn unknown_tokens(template: &str) -> Vec { - let p = sample_photo(); - let mut out = Vec::new(); - let mut rest = template; - while let Some(i) = rest.find('{') { - let Some(j) = rest[i..].find('}') else { break }; - let tag = &rest[i..i + j + 1]; - if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) { - out.push(tag.to_string()); - } - rest = &rest[i + j + 1..]; - } - out -} - -/// Every token with its meaning and example, the date directives and the notes, as JSON (the -/// `photo.renameTokens` command). -pub fn token_help_json() -> serde_json::Value { - let tokens: Vec = TOKENS - .iter() - .map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)})) - .collect(); - let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect(); - serde_json::json!({ - "tokens": tokens, - "dateDirectives": directives, - "notes": TEMPLATE_NOTES, - "sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48", - }) -} - -/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension. -pub fn expand(template: &str, p: &Photo, seq: usize) -> String { - let (stem, ext) = split_ext(&p.file_name); - let out = expand_tokens(template, p, seq, 1); - let mut stem_out = sanitize(&out); - if stem_out.is_empty() { - stem_out = sanitize(stem); - } - if stem_out.is_empty() { - stem_out = "photo".into(); - } - if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") } -} - -/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}` -/// is zero-padded to `seq_width` digits. -pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String { - expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None) -} - -/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text -/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename -/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way. -pub fn expand_tokens_with( - template: &str, - p: &Photo, - seq: usize, - seq_width: usize, - extra: &dyn Fn(&str, Option<&str>, &str) -> Option, -) -> String { - let (stem, ext) = split_ext(&p.file_name); - let mut out = String::new(); - let mut rest = template; - while let Some(i) = rest.find('{') { - out.push_str(&rest[..i]); - let Some(j) = rest[i..].find('}') else { - out.push_str(&rest[i..]); - rest = ""; - break; - }; - let tok = &rest[i + 1..i + j]; - let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); - let lower = name.trim().to_ascii_lowercase(); - let v = match extra(&lower, arg, tok) { - Some(v) => v, - None => match lower.as_str() { - "name" | "filename" => stem.to_string(), - "num" => { - let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); - stem[stem.len() - digits..].to_string() - } - "seq" | "n" => { - let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); - format!("{seq:0w$}") - } - "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), - "folder" => match &p.source { - Source::File { path } => { - Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() - } - Source::Demo { .. } => String::new(), - }, - "camera" => p.meta.camera.clone(), - "lens" => p.meta.lens.clone(), - "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), - "rating" => p.rating.to_string(), - "title" => p.meta.title.clone(), - "creator" => p.meta.creator.clone(), - "ext" => ext.to_string(), - _ => format!("{{{tok}}}"), - }, - }; - out.push_str(&v); - rest = &rest[i + j + 1..]; - } - out.push_str(rest); - out -} - -/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`). -fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> { - let mut v = Vec::new(); - for naming in [SidecarNaming::Stem, SidecarNaming::Full] { - let p = crate::sidecar::sidecar_path(path, naming); - v.push((p.with_extension("XMP"), naming, true)); - v.push((p, naming, false)); - } - v -} - -/// The file-system primitives renaming uses. [`RealFs`] is the disk; tests substitute a model of -/// a case-insensitive or case-sensitive volume (and inject failures). -pub(crate) trait MoveFs { - /// Something (file, folder, link) answers to `p`. - fn exists(&self, p: &Path) -> bool; - fn is_file(&self, p: &Path) -> bool; - /// `a` and `b` name the same file (e.g. `IMG.JPG` and `img.jpg` on a case-insensitive - /// volume). Decided by file identity, never by comparing names: on a case-sensitive volume - /// they are two different photos. When in doubt: `false`. - fn same_file(&self, a: &Path, b: &Path) -> bool; - /// Rename `a` to `b`, failing (never replacing) when `b` exists. - fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; - /// Copy `a` to a new file `b` (an independent copy, never a link), failing when `b` exists. - fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; - fn remove_file(&self, p: &Path) -> std::io::Result<()>; - /// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g. - /// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar. - fn stem_sibling(&self, sidecar: &Path) -> Option; - /// The folder lists an entry spelled exactly like `p` (letter case included). On a - /// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two - /// apart. When the folder can't be listed: `true`, so nothing is skipped. - fn listed(&self, p: &Path) -> bool { - let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true }; - let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; - match std::fs::read_dir(dir) { - Ok(entries) => entries.flatten().any(|e| e.file_name() == name), - Err(_) => true, - } - } - /// Content observed in a packet, independent of its pathname or inode. Model volumes - /// override this; disk implementations stream a bounded buffer without retaining the XML. - fn packet_version(&self, path: &Path) -> std::io::Result { - use std::io::Read; - let mut file = std::fs::File::open(path)?; - let mut hash = lightcraft_preview::Hasher128::new(); - let mut len = 0u64; - let mut buffer = [0u8; 16 * 1024]; - loop { - let n = match file.read(&mut buffer) { - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, - result => result?, - }; - if n == 0 { - return Ok(PacketVersion { len, hash: hash.finish() }); - } - let bytes = buffer.get(..n).ok_or_else(|| std::io::Error::other("invalid sidecar read length"))?; - len = len.checked_add(n as u64).ok_or_else(|| std::io::Error::other("sidecar is too large"))?; - hash.update(bytes); - } - } -} - -/// The real file system. -pub(crate) struct RealFs; - -impl MoveFs for RealFs { - fn exists(&self, p: &Path) -> bool { - std::fs::symlink_metadata(p).is_ok() - } - - fn is_file(&self, p: &Path) -> bool { - p.is_file() - } - - fn same_file(&self, a: &Path, b: &Path) -> bool { - if a == b { - return true; - } - let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else { return false }; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - ma.dev() == mb.dev() && ma.ino() == mb.ino() - } - #[cfg(not(unix))] - { - // no stable file id in std here: `a` and `b` are one file under two spellings when - // they look alike and the folder doesn't list both (on a case-sensitive folder - // holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the - // listed one: the answer must not depend on the argument order. - let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false }; - let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase(); - if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() { - return false; - } - let Ok(entries) = std::fs::read_dir(dir) else { return false }; - let (mut listed_a, mut listed_b) = (false, false); - for e in entries.flatten() { - let name = e.file_name(); - listed_a |= name == name_a; - listed_b |= name == name_b; - } - !(listed_a && listed_b) - } - } - - fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.exists(b) { - return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); - } - // A hard link fails when `b` exists — even if it appeared since the check above — so - // nothing is ever replaced. Volumes without hard links (FAT, some network shares) fall back - // to a plain rename after the check. - match std::fs::hard_link(a, b) { - Ok(()) => std::fs::remove_file(a).inspect_err(|_| { - // both names are the same file: dropping the new one loses nothing - let _ = std::fs::remove_file(b); - }), - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e), - Err(_) => std::fs::rename(a, b) - .or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }), - } - } - - fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.exists(b) { - return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); - } - // Streamed (a large raw or video is never read into memory whole), into a hidden name - // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so - // the partial file can't be removed either, leaves no truncated file under the photo's - // name for a retry to take for the photo. - let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't - // block the next - let partial = (0..1000u32) - .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) - .find(|p| !self.exists(p)) - .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; - let copied = (|| { - let mut from = std::fs::File::open(a)?; - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; - std::io::copy(&mut from, &mut f)?; - f.sync_all() - })(); - let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); - if placed.is_err() { - let _ = std::fs::remove_file(&partial); - } - placed - } - - fn remove_file(&self, p: &Path) -> std::io::Result<()> { - std::fs::remove_file(p) - } - - fn stem_sibling(&self, sidecar: &Path) -> Option { - crate::import_move::sibling_photo(sidecar) - } -} - -/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original -/// byte for byte, and only then remove the original. A copy that doesn't match is removed. -fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> { - fs.copy_no_replace(a, b)?; - if !same_contents(a, b).unwrap_or(false) { - let _ = std::fs::remove_file(b); - return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display()))); - } - std::fs::remove_file(a).inspect_err(|_| { - // the original stays: drop the copy so the file isn't in two places - let _ = std::fs::remove_file(b); - }) -} - -/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so -/// a large raw or video is never read into memory whole. -fn same_contents(a: &Path, b: &Path) -> std::io::Result { - const CHUNK: usize = 1 << 20; - let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?); - if fa.metadata()?.len() != fb.metadata()?.len() { - return Ok(false); - } - let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]); - loop { - let n = read_full(&mut fa, &mut ba)?; - let m = read_full(&mut fb, &mut bb)?; - if n != m || ba.get(..n) != bb.get(..m) { - return Ok(false); - } - if n == 0 { - return Ok(true); - } - } -} - -/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer -/// only at the end of the file). -fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result { - let mut filled = 0; - while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) { - match r.read(rest) { - Ok(0) => break, - Ok(k) => filled = filled.saturating_add(k), - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} - Err(e) => return Err(e), - } - } - Ok(filled) -} - -/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive -/// volume): the rename must go through a temporary name. -fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool { - from != to && folded_path(&from.to_string_lossy()) == folded_path(&to.to_string_lossy()) && fs.exists(to) && fs.same_file(from, to) -} - -/// Windows accepts both separators; spelling differences must not hide file identity. -fn folded_path(path: &str) -> String { - if cfg!(windows) { path.replace('\\', "/").to_lowercase() } else { path.to_lowercase() } -} - -/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a -/// unique temporary name so case-insensitive volumes see a real change. -fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> { - if !is_respelling(fs, a, b) { - return fs.rename_no_replace(a, b); - } - let tmp = (0..1000u32) - .map(|k| a.with_file_name(format!(".lc-rename-{}-{k}", std::process::id()))) - .find(|t| !fs.exists(t)) - .ok_or_else(|| std::io::Error::other("no free temporary name"))?; - fs.rename_no_replace(a, &tmp)?; - fs.rename_no_replace(&tmp, b).map_err(|e| match fs.rename_no_replace(&tmp, a) { - Ok(()) => e, - Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())), - }) -} - -/// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may -/// already have followed its new path, so a retry cannot infer this work from the catalog. -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct SidecarMove { - pub from: PathBuf, - pub to: PathBuf, - copied: bool, - /// Observed packets in the partial move, including the separated source. Absent planned - /// destinations are never owned; a completed repair advances these observations. - companions: Vec, -} - -/// A content version, not an assertion that a pathname still names the same packet. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct PacketVersion { - len: u64, - hash: lightcraft_preview::Hash128, -} - -impl PacketVersion { - fn of(bytes: &[u8]) -> Self { - Self { len: bytes.len() as u64, hash: lightcraft_preview::hash_bytes(bytes) } - } -} - -#[derive(Clone, Debug, PartialEq)] -struct RetainedPacket { - path: PathBuf, - // An unreadable observation remains untrusted even if a later read succeeds. - version: Option, -} - -impl RetainedPacket { - fn validate(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { - if self.version.is_none() { - return Err(format!("can't verify retained XMP {}: its content was unreadable when the partial move was recorded", self.path.display())); - } - let current = fs.packet_version(&self.path).map_err(|e| format!("can't verify retained XMP {}: {e}", self.path.display()))?; - if self.version != Some(current) { - return Err(format!( - "retained XMP {} changed since the partial move; packet ownership must be resolved before retrying", - self.path.display() - )); - } - Ok(()) - } -} - -/// Advance only packets whose operation actually succeeded. A copy retains its original; -/// a move retires vanished source aliases and adds the observed destination. -fn advance_packets(packets: &mut Vec, aliases: &[PathBuf], to: &Path, copied: bool, fs: &dyn MoveFs) { - let Some(mut packet) = packets.iter().find(|p| aliases.contains(&p.path)).cloned() else { return }; - if !copied { - packets.retain(|p| !aliases.contains(&p.path) || fs.is_file(&p.path)); - } - packet.path = to.to_path_buf(); - if !packets.iter().any(|p| p.path == packet.path) { - packets.push(packet); - } -} - -fn packet_aliases(packets: &[RetainedPacket], fs: &dyn MoveFs, from: &Path) -> Vec { - packets.iter().filter(|p| p.path == from || fs.same_file(&p.path, from)).map(|p| p.path.clone()).collect() -} - -impl SidecarMove { - fn validate_packets(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { - for packet in &self.companions { - packet.validate(fs)?; - } - Ok(()) - } - - /// Validate a queued retry and return the copy decision for this physical operation. - fn validate_repair(&self, fs: &dyn MoveFs) -> std::result::Result { - self.validate_packets(fs)?; - let respelling = is_respelling(fs, &self.from, &self.to); - // Batch rollback may have restored another photo using this source packet. - let copied = self.copied || (!respelling && fs.stem_sibling(&self.from).is_some()); - if fs.exists(&self.to) && (copied || !respelling) { - return Err(format!("{} already exists while repairing {} → {}", self.to.display(), self.from.display(), self.to.display())); - } - Ok(copied) - } - - fn needed(&self, fs: &dyn MoveFs) -> bool { - fs.is_file(&self.from) && !(fs.exists(&self.to) && (self.copied || !is_respelling(fs, &self.from, &self.to))) - } - - fn touches(&self, fs: &dyn MoveFs, path: &Path) -> bool { - let same_path = |endpoint: &Path| { - endpoint == path - || fs.same_file(endpoint, path) - || (endpoint.file_name() == path.file_name() - && endpoint.parent().zip(path.parent()).is_some_and(|(a, b)| a == b || fs.same_file(a, b))) - }; - same_path(&self.from) || same_path(&self.to) - } - - fn apply(&self, fs: &dyn MoveFs) -> std::io::Result<()> { - if self.copied { fs.copy_no_replace(&self.from, &self.to) } else { move_one(fs, &self.from, &self.to) } - } -} - -/// Complete known unfinished sidecars before moving any more photos. Completed operations -/// leave the queue; a failure retains that operation and the rest, without replacing a file. -pub(crate) fn retry_sidecars(fs: &dyn MoveFs, pending: &mut Vec) -> std::result::Result<(), String> { - validate_sidecars(fs, pending)?; - while !pending.is_empty() { - apply_repair(fs, pending, 0)?; - } - Ok(()) -} - -/// Preflight a detached history entry before global queues are allowed to retire. The -/// caller can validate without mutating it; retry revalidates before each physical operation. -pub(crate) fn validate_sidecars(fs: &dyn MoveFs, pending: &[SidecarMove]) -> std::result::Result<(), String> { - for repair in pending { - repair.validate_repair(fs)?; - } - Ok(()) -} - -/// Finish one operation and transfer its observed content to the remaining repairs. Do not -/// infer ownership by reading an occupied destination or by retaining a planned pathname. -fn apply_repair(fs: &dyn MoveFs, pending: &mut Vec, i: usize) -> std::result::Result<(), String> { - let Some(mut repair) = pending.get(i).cloned() else { return Ok(()) }; - repair.copied = repair.validate_repair(fs)?; - let aliases = packet_aliases(&repair.companions, fs, &repair.from); - repair.apply(fs).map_err(|e| format!("rename {} → {}: {e}", repair.from.display(), repair.to.display()))?; - for remaining in pending.iter_mut() { - advance_packets(&mut remaining.companions, &aliases, &repair.to, repair.copied, fs); - } - pending.remove(i); - Ok(()) -} - -/// Repair only operations that affect a sidecar being saved. Unrelated unavailable originals -/// must not block healthy metadata saves. Completed repairs retire; failures stay queued. -pub(crate) fn retry_sidecars_for_paths(fs: &dyn MoveFs, pending: &mut Vec, paths: &[PathBuf]) -> std::result::Result<(), String> { - for repair in pending.iter().filter(|m| paths.iter().any(|path| m.touches(fs, path))) { - repair.validate_repair(fs)?; - } - let mut i = 0; - while let Some(m) = pending.get(i) { - if paths.iter().any(|path| m.touches(fs, path)) { - // Path/alias equality only locates an observation; validation above proves its - // content. A newly occupied, previously absent peer destination is a collision. - if let Some(path) = paths.iter().find(|path| { - fs.exists(path) - && !fs.same_file(path, &m.from) - && !fs.same_file(path, &m.to) - && !m.companions.iter().any(|known| fs.same_file(path, &known.path)) - }) { - return Err(format!("{} already exists while repairing {} → {}", path.display(), m.from.display(), m.to.display())); - } - apply_repair(fs, pending, i)?; - } else { - i += 1; - } - } - Ok(()) -} - -/// Every record came from an actual packet observed before this move. Successful physical -/// operations have already advanced its path; missing planned destinations are not recorded. -fn note_companions(pending: &mut [SidecarMove], packets: Vec) { - for repair in pending { - repair.companions.clone_from(&packets); - } -} - -/// Why [`move_file`] failed, and what it left behind. -#[derive(Clone, Debug, PartialEq)] -pub struct MoveError { - /// What went wrong, including every step that could not be undone and where that file is now. - pub message: String, - /// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file - /// back failed too. Its sidecars that did move stay with it. - pub moved: bool, - pub(crate) pending_sidecars: Vec, -} - -impl std::fmt::Display for MoveError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } -} - -/// Move `from` to `to` (never over an existing file) together with its sidecars. On error the -/// moves are undone; whatever can't be undone is named in the error (see [`MoveError`]). -pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> { - move_file_with(&RealFs, from, to) -} - -/// [`move_file`] on the file system `fs`. -pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> { - if Path::new(from) == Path::new(to) { - return Ok(()); - } - let fail = |message: String| MoveError { message, moved: false, pending_sidecars: Vec::new() }; - let (f, t) = (Path::new(from), Path::new(to)); - // `to` may be this very file spelled differently (case-insensitive volume); any other file - // there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced - if fs.exists(t) && !is_respelling(fs, f, t) { - return Err(fail(format!("{to} already exists"))); - } - // An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the - // lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`. - let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc)); - let mut packets: Vec = - sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect(); - move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?; - let mut done: Vec = Vec::new(); - let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| { - let mut dst = crate::sidecar::sidecar_path(to, naming); - if upper { - dst = dst.with_extension("XMP"); - } - // A stem sidecar another photo still uses is copied, not taken away (issue #92). - let copied = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some(); - SidecarMove { from: sc, to: dst, copied, companions: Vec::new() } - }); - while let Some(m) = remaining.next() { - if !m.needed(fs) { - continue; // never overwrite: the old sidecar stays where it was - } - let aliases = packet_aliases(&packets, fs, &m.from); - if let Err(e) = m.apply(fs) { - let mut message = format!("rename {}: {e}", m.from.display()); - // The file first: if it can't go back, sidecars that moved stay with it. - if let Err(e) = move_one(fs, t, f) { - message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}")); - if !done.is_empty() { - let names: Vec = done.iter().map(|m| m.to.display().to_string()).collect(); - message.push_str(&format!(" with its sidecar {}", names.join(", "))); - } - let mut pending_sidecars = vec![m]; - for m in remaining.filter(|m| m.needed(fs)) { - // The two case spellings can name one file. Keep one operation for - // those aliases, while retaining distinct sidecars on sensitive volumes. - let alias = pending_sidecars.iter().any(|p| { - folded_path(&p.from.to_string_lossy()) == folded_path(&m.from.to_string_lossy()) - && folded_path(&p.to.to_string_lossy()) == folded_path(&m.to.to_string_lossy()) - && fs.same_file(&p.from, &m.from) - }); - if !alias { - pending_sidecars.push(m); - } - } - note_companions(&mut pending_sidecars, packets); - return Err(MoveError { message, moved: true, pending_sidecars }); - } - let mut pending_sidecars = Vec::new(); - for m in done.iter().rev() { - if m.copied { - // The original is still there: a leftover copy loses nothing. - if fs.remove_file(&m.to).is_ok() { - packets.retain(|p| p.path != m.to); - } - } else { - let aliases = packet_aliases(&packets, fs, &m.to); - if let Err(e) = move_one(fs, &m.to, &m.from) { - message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", m.from.display(), m.to.display())); - pending_sidecars.push(SidecarMove { from: m.to.clone(), to: m.from.clone(), copied: false, companions: Vec::new() }); - } else { - advance_packets(&mut packets, &aliases, &m.from, false, fs); - } - } - } - note_companions(&mut pending_sidecars, packets); - return Err(MoveError { message, moved: false, pending_sidecars }); - } - advance_packets(&mut packets, &aliases, &m.to, m.copied, fs); - done.push(m); - } - Ok(()) -} - -/// What a failed batch of moves ([`move_all`]) left behind. -#[derive(Debug)] -pub(crate) struct BatchMoveError { - /// The first failure and every move that could not be undone. - pub message: String, - /// Moves that stayed done, (from, to): those files are at `to` now. - pub stuck: Vec<(String, String)>, - /// Sidecars still separated from the path where their photo ended up, in either direction. - pub pending_sidecars: Vec, -} - -/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone. -/// Moves that can't be undone (e.g. the network share went away) are reported, not ignored. -pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::result::Result<(), BatchMoveError> { - let mut done: Vec<&(String, String)> = Vec::new(); - for m in moves { - let Err(e) = move_file_with(fs, &m.0, &m.1) else { - done.push(m); - continue; - }; - let mut message = e.message; - let mut pending_sidecars = e.pending_sidecars; - let mut stuck = Vec::new(); - if e.moved { - stuck.push(m.clone()); - } - for d in done.iter().rev() { - if let Err(mut e) = move_file_with(fs, &d.1, &d.0) { - message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message)); - if !e.moved { - stuck.push((*d).clone()); - } - pending_sidecars.append(&mut e.pending_sidecars); - } - } - return Err(BatchMoveError { message, stuck, pending_sidecars }); - } - Ok(()) -} - -/// Final `SetFile` per photo (nested batches included), filtered by destination only afterward. -fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec) { - let mut pending = vec![op]; - let mut order = Vec::new(); - let mut final_ops = HashMap::new(); - while let Some(op) = pending.pop() { - match op { - Op::Batch { ops } => pending.extend(ops.iter().rev()), - Op::SetFile { id, .. } if final_ops.insert(*id, op).is_none() => order.push(*id), - _ => {} - } - } - out.extend(order.into_iter().filter_map(|id| { - let op = final_ops.get(&id).copied()?; - matches!(op, Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str())).then(|| op.clone()) - })); -} - -/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies) -/// rename once. No file-system calls. -pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec> { - let mut seen_paths: HashSet = HashSet::new(); - ids.iter() - .filter_map(|id| cat.photo(*id)) - .filter(|p| match &p.source { - Source::File { path } => seen_paths.insert(path.clone()), - _ => true, - }) - .cloned() - .collect() -} - -/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check -/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow -/// drive). Whether a case-only change names this very file is decided on the disk. -pub fn plan_rename_photos(photos: &[std::sync::Arc], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec { - plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b)) -} - -/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether -/// two spellings name one file (see [`MoveFs`]). -fn plan_rename_core( - photos: &[std::sync::Arc], - template: &str, - start: usize, - exists: &dyn Fn(&Path) -> bool, - same_file: &dyn Fn(&Path, &Path) -> bool, -) -> Vec { - let mut taken: HashSet = HashSet::new(); // lower-case target paths/names claimed in this batch - let mut out = Vec::new(); - for (i, p) in photos.iter().enumerate() { - let seq = start.saturating_add(i); - let want = expand(template, p, seq); - let (stem, ext) = split_ext(&want); - let (stem, ext) = (stem.to_string(), ext.to_string()); - let candidate = |k: usize| { - if k == 0 { - want.clone() - } else if ext.is_empty() { - format!("{stem}-{k}") - } else { - format!("{stem}-{k}.{ext}") - } - }; - let (to, to_path) = match &p.source { - Source::File { path } => { - let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); - let mut k = 0; - loop { - let name = candidate(k); - let tp = dir.join(&name).to_string_lossy().to_string(); - let key = folded_path(&tp); - // this very file, maybe spelled differently (case-insensitive volume)? By - // identity: on a case-sensitive volume `img_1.jpg` may be another photo - let (from, to) = (Path::new(path), Path::new(&tp)); - let same = from == to || (key == folded_path(path) && exists(to) && same_file(from, to)); - // free: not claimed in this batch and not on disk (unless it is this very file). - // A file this batch moves away still counts as taken: simple and safe. - if !taken.contains(&key) && (same || !exists(to)) { - taken.insert(key); - break (name, Some(tp)); - } - k += 1; - } - } - Source::Demo { .. } => { - let mut k = 0; - loop { - let name = candidate(k); - if taken.insert(format!("demo:{}", name.to_lowercase())) { - break (name, None); - } - k += 1; - } - } - }; - let from_path = match &p.source { - Source::File { path } => Some(path.clone()), - _ => None, - }; - out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path }); - } - out -} - -impl Session { - /// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions. - pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec { - self.plan_rename_with(&RealFs, ids, template, start) - } - - /// [`Session::plan_rename`] on the file system `fs`. - pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec { - plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b)) - } - - /// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op - /// that updates the photos (and their virtual copies). - /// - /// When a failure can't be fully rolled back, the files that kept their new names are - /// committed as a smaller undoable rename, so the library points at them (nothing goes - /// missing), and the error lists them (old → new). - pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result { - self.apply_rename_with(&RealFs, plans) - } - - /// Complete retained repairs before a file or folder change can stale their exact paths. - pub(crate) fn repair_sidecars_with(&mut self, fs: &dyn MoveFs) -> Result<()> { - for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { - repair.validate_repair(fs).map_err(EngineError::Other)?; - } - retry_sidecars(fs, &mut self.pending_sidecar_repairs).map_err(EngineError::Other)?; - for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { - retry_sidecars(fs, &mut entry.retry_sidecars).map_err(EngineError::Other)?; - } - Ok(()) - } - - pub(crate) fn repair_sidecars_for_paths(&mut self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { - self.validate_sidecar_packets_for_paths(paths, fs)?; - retry_sidecars_for_paths(fs, &mut self.pending_sidecar_repairs, paths).map_err(EngineError::Other)?; - for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { - retry_sidecars_for_paths(fs, &mut entry.retry_sidecars, paths).map_err(EngineError::Other)?; - } - Ok(()) - } - - /// A healthy shared packet may be saved while another photo's repair is unavailable. - /// Check its retained content too, without forcing that unrelated physical operation. - pub(crate) fn validate_sidecar_packets_for_paths(&self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { - for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { - if paths.iter().any(|path| repair.touches(fs, path)) { - repair.validate_repair(fs).map_err(EngineError::Other)?; - } else { - for packet in &repair.companions { - if paths.iter().any(|path| fs.same_file(path, &packet.path)) { - packet.validate(fs).map_err(EngineError::Other)?; - } - } - } - } - Ok(()) - } - - /// Only a successful application write updates a retained content version. Atomic writes - /// replace inodes; aliases that now resolve to the written path observe the new content. - pub(crate) fn note_sidecar_write(&mut self, path: &Path, bytes: &[u8]) { - let version = PacketVersion::of(bytes); - for repair in self.pending_sidecar_repairs.iter_mut().chain(self.undo.iter_mut().chain(&mut self.redo).flat_map(|e| &mut e.retry_sidecars)) { - for packet in &mut repair.companions { - if RealFs.same_file(path, &packet.path) { - packet.version = Some(version); - } - } - } - } - - /// [`Session::apply_rename`] on the file system `fs`. - pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result { - self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" })) - } - - /// Carry out `plans` (renames or moves to another folder): move the files with their sidecars - /// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the - /// photos (and their virtual copies) at the new paths. Undo moves the files back. - pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result { - // A partly completed rename can already have the requested photo name. Repair - // its sidecars before treating that empty move list as a successful retry. - self.repair_sidecars_with(fs)?; - let mut ops = Vec::new(); - let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect(); - for p in self.catalog.photos() { - let plan = match &p.source { - Source::File { path } => by_path.get(path.as_str()).copied(), - Source::Demo { .. } => plans.iter().find(|x| x.id == p.id.0 && x.from_path.is_none()), - }; - let Some(plan) = plan else { continue }; - let source = match &plan.to_path { - Some(t) if matches!(&p.source, Source::File { path } if Path::new(path) == Path::new(t)) => p.source.clone(), - Some(t) => Source::File { path: t.clone() }, - None => p.source.clone(), - }; - if plan.to != p.file_name || source != p.source { - ops.push(Op::SetFile { id: p.id, file_name: plan.to.clone(), source }); - } - } - let n = ops.len(); - let op = Op::Batch { ops }; - let moves: Vec<(String, String)> = plans - .iter() - .filter_map(|pl| match (&pl.from_path, &pl.to_path) { - (Some(a), Some(b)) if Path::new(a) != Path::new(b) => Some((a.clone(), b.clone())), - _ => None, - }) - .collect(); - if let Err(e) = move_all(fs, &moves) { - return Err(EngineError::Other(self.follow_stuck(&op, e, true).0)); - } - if n > 0 - && let Err(err) = self.commit(&label(n), op) - { - let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); - if let Err(e) = move_all(fs, &back) { - return Err(EngineError::Other(format!("{err}; the renamed files could not all be moved back: {}", e.message))); - } - return Err(err); - } - Ok(n) - } - - /// After a failed batch of moves for `op`: point the photos whose files stayed moved at - /// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged - /// change), so the library matches the disk. Returns the message and, for a history retry, - /// the correction's inverse file ops (so a retry doesn't lose the original paths). - pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> (String, Vec) { - let mut msg = e.message; - let pending_sidecars = e.pending_sidecars; - if e.stuck.is_empty() && (!undoable || pending_sidecars.is_empty()) { - return (msg, Vec::new()); - } - let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect(); - let mut keep = Vec::new(); - set_file_ops_to(op, &to, &mut keep); - let list: Vec = e.stuck.iter().map(|(a, b)| format!("{a} → {b}")).collect(); - let n = e.stuck.len(); - let s = if n == 1 { "" } else { "s" }; - let fix = Op::Batch { ops: keep }; - let mut inverses = Vec::new(); - let applied = if undoable { - let label = if n == 0 { "Repair Rename Sidecars".to_string() } else { format!("Rename {n} Photo{s} (partly)") }; - self.commit(&label, fix).map(|()| { - if let Some(entry) = self.undo.last_mut() { - entry.retry_sidecars.extend(pending_sidecars); - } - }) - } else { - match self.apply_catalog(fix.clone()) { - Ok(inv) => { - inverses = match inv { - Op::Batch { ops } => ops, - op => vec![op], - }; - self.pending_log.push(fix); - Ok(()) - } - Err(e) => Err(e), - } - }; - match applied { - Ok(()) if n > 0 => msg - .push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))), - Ok(()) => {} - Err(err) => msg.push_str(&format!( - ". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}", - list.join(", ") - )), - } - (msg, inverses) - } - - /// Physical moves to the final `SetFile` destination of each photo. A merged history - /// batch can rename the same photo repeatedly; intermediate catalog paths need no file. - pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> { - let mut pending = vec![op]; - let mut order = Vec::new(); - let mut targets = HashMap::new(); - while let Some(op) = pending.pop() { - match op { - Op::Batch { ops } => pending.extend(ops.iter().rev()), - Op::SetFile { id, source, .. } if targets.insert(*id, source).is_none() => { - order.push(*id); - } - _ => {} - } - } - // Virtual copies share one physical move, keeping the original batch order. - let mut seen = HashSet::new(); - order - .into_iter() - .filter_map(|id| { - let Source::File { path: to } = targets.get(&id).copied()? else { return None }; - let Source::File { path: from } = &self.catalog.photo(id)?.source else { return None }; - (Path::new(from) != Path::new(to) && seen.insert(from)).then(|| (from.clone(), to.clone())) - }) - .collect() - } -} - #[cfg(test)] pub(crate) mod tests; diff --git a/crates/engine/src/rename/fs.rs b/crates/engine/src/rename/fs.rs new file mode 100644 index 000000000..352140b38 --- /dev/null +++ b/crates/engine/src/rename/fs.rs @@ -0,0 +1,242 @@ +use super::sidecar::PacketVersion; +use super::*; + +/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`). +pub(super) fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> { + let mut v = Vec::new(); + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + let p = crate::sidecar::sidecar_path(path, naming); + v.push((p.with_extension("XMP"), naming, true)); + v.push((p, naming, false)); + } + v +} + +/// The file-system primitives renaming uses. [`RealFs`] is the disk; tests substitute a model of +/// a case-insensitive or case-sensitive volume (and inject failures). +pub(crate) trait MoveFs { + /// Something (file, folder, link) answers to `p`. + fn exists(&self, p: &Path) -> bool; + fn is_file(&self, p: &Path) -> bool; + /// `a` and `b` name the same file (e.g. `IMG.JPG` and `img.jpg` on a case-insensitive + /// volume). Decided by file identity, never by comparing names: on a case-sensitive volume + /// they are two different photos. When in doubt: `false`. + fn same_file(&self, a: &Path, b: &Path) -> bool; + /// Rename `a` to `b`, failing (never replacing) when `b` exists. + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; + /// Copy `a` to a new file `b` (an independent copy, never a link), failing when `b` exists. + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; + fn remove_file(&self, p: &Path) -> std::io::Result<()>; + /// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g. + /// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar. + fn stem_sibling(&self, sidecar: &Path) -> Option; + /// The folder lists an entry spelled exactly like `p` (letter case included). On a + /// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two + /// apart. When the folder can't be listed: `true`, so nothing is skipped. + fn listed(&self, p: &Path) -> bool { + let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true }; + let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; + match std::fs::read_dir(dir) { + Ok(entries) => entries.flatten().any(|e| e.file_name() == name), + Err(_) => true, + } + } + /// Content observed in a packet, independent of its pathname or inode. Model volumes + /// override this; disk implementations stream a bounded buffer without retaining the XML. + fn packet_version(&self, path: &Path) -> std::io::Result { + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let mut hash = lightcraft_preview::Hasher128::new(); + let mut len = 0u64; + let mut buffer = [0u8; 16 * 1024]; + loop { + let n = match file.read(&mut buffer) { + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + result => result?, + }; + if n == 0 { + return Ok(PacketVersion { len, hash: hash.finish() }); + } + let bytes = buffer.get(..n).ok_or_else(|| std::io::Error::other("invalid sidecar read length"))?; + len = len.checked_add(n as u64).ok_or_else(|| std::io::Error::other("sidecar is too large"))?; + hash.update(bytes); + } + } +} + +/// The real file system. +pub(crate) struct RealFs; + +impl MoveFs for RealFs { + fn exists(&self, p: &Path) -> bool { + std::fs::symlink_metadata(p).is_ok() + } + + fn is_file(&self, p: &Path) -> bool { + p.is_file() + } + + fn same_file(&self, a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else { return false }; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + ma.dev() == mb.dev() && ma.ino() == mb.ino() + } + #[cfg(not(unix))] + { + // no stable file id in std here: `a` and `b` are one file under two spellings when + // they look alike and the folder doesn't list both (on a case-sensitive folder + // holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the + // listed one: the answer must not depend on the argument order. + let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false }; + let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase(); + if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() { + return false; + } + let Ok(entries) = std::fs::read_dir(dir) else { return false }; + let (mut listed_a, mut listed_b) = (false, false); + for e in entries.flatten() { + let name = e.file_name(); + listed_a |= name == name_a; + listed_b |= name == name_b; + } + !(listed_a && listed_b) + } + } + + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // A hard link fails when `b` exists — even if it appeared since the check above — so + // nothing is ever replaced. Volumes without hard links (FAT, some network shares) fall back + // to a plain rename after the check. + match std::fs::hard_link(a, b) { + Ok(()) => std::fs::remove_file(a).inspect_err(|_| { + // both names are the same file: dropping the new one loses nothing + let _ = std::fs::remove_file(b); + }), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e), + Err(_) => std::fs::rename(a, b) + .or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }), + } + } + + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // Streamed (a large raw or video is never read into memory whole), into a hidden name + // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so + // the partial file can't be removed either, leaves no truncated file under the photo's + // name for a retry to take for the photo. + let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't + // block the next + let partial = (0..1000u32) + .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) + .find(|p| !self.exists(p)) + .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; + let copied = (|| { + let mut from = std::fs::File::open(a)?; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; + std::io::copy(&mut from, &mut f)?; + f.sync_all() + })(); + let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); + if placed.is_err() { + let _ = std::fs::remove_file(&partial); + } + placed + } + + fn remove_file(&self, p: &Path) -> std::io::Result<()> { + std::fs::remove_file(p) + } + + fn stem_sibling(&self, sidecar: &Path) -> Option { + crate::import_move::sibling_photo(sidecar) + } +} + +/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original +/// byte for byte, and only then remove the original. A copy that doesn't match is removed. +fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> { + fs.copy_no_replace(a, b)?; + if !same_contents(a, b).unwrap_or(false) { + let _ = std::fs::remove_file(b); + return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display()))); + } + std::fs::remove_file(a).inspect_err(|_| { + // the original stays: drop the copy so the file isn't in two places + let _ = std::fs::remove_file(b); + }) +} + +/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so +/// a large raw or video is never read into memory whole. +pub(super) fn same_contents(a: &Path, b: &Path) -> std::io::Result { + const CHUNK: usize = 1 << 20; + let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?); + if fa.metadata()?.len() != fb.metadata()?.len() { + return Ok(false); + } + let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]); + loop { + let n = read_full(&mut fa, &mut ba)?; + let m = read_full(&mut fb, &mut bb)?; + if n != m || ba.get(..n) != bb.get(..m) { + return Ok(false); + } + if n == 0 { + return Ok(true); + } + } +} + +/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer +/// only at the end of the file). +fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result { + let mut filled = 0; + while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) { + match r.read(rest) { + Ok(0) => break, + Ok(k) => filled = filled.saturating_add(k), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(filled) +} + +/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive +/// volume): the rename must go through a temporary name. +pub(super) fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool { + from != to && folded_path(&from.to_string_lossy()) == folded_path(&to.to_string_lossy()) && fs.exists(to) && fs.same_file(from, to) +} + +/// Windows accepts both separators; spelling differences must not hide file identity. +pub(super) fn folded_path(path: &str) -> String { + if cfg!(windows) { path.replace('\\', "/").to_lowercase() } else { path.to_lowercase() } +} + +/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a +/// unique temporary name so case-insensitive volumes see a real change. +pub(super) fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> { + if !is_respelling(fs, a, b) { + return fs.rename_no_replace(a, b); + } + let tmp = (0..1000u32) + .map(|k| a.with_file_name(format!(".lc-rename-{}-{k}", std::process::id()))) + .find(|t| !fs.exists(t)) + .ok_or_else(|| std::io::Error::other("no free temporary name"))?; + fs.rename_no_replace(a, &tmp)?; + fs.rename_no_replace(&tmp, b).map_err(|e| match fs.rename_no_replace(&tmp, a) { + Ok(()) => e, + Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())), + }) +} diff --git a/crates/engine/src/rename/moves.rs b/crates/engine/src/rename/moves.rs new file mode 100644 index 000000000..6f7c245c9 --- /dev/null +++ b/crates/engine/src/rename/moves.rs @@ -0,0 +1,168 @@ +use super::fs::{MoveFs, RealFs, folded_path, is_respelling, move_one, sidecars}; +use super::sidecar::{RetainedPacket, SidecarMove, advance_packets, note_companions, packet_aliases}; +use super::*; + +/// Why [`move_file`] failed, and what it left behind. +#[derive(Clone, Debug, PartialEq)] +pub struct MoveError { + /// What went wrong, including every step that could not be undone and where that file is now. + pub message: String, + /// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file + /// back failed too. Its sidecars that did move stay with it. + pub moved: bool, + pub(crate) pending_sidecars: Vec, +} + +impl std::fmt::Display for MoveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +/// Move `from` to `to` (never over an existing file) together with its sidecars. On error the +/// moves are undone; whatever can't be undone is named in the error (see [`MoveError`]). +pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> { + move_file_with(&RealFs, from, to) +} + +/// [`move_file`] on the file system `fs`. +pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> { + if Path::new(from) == Path::new(to) { + return Ok(()); + } + let fail = |message: String| MoveError { message, moved: false, pending_sidecars: Vec::new() }; + let (f, t) = (Path::new(from), Path::new(to)); + // `to` may be this very file spelled differently (case-insensitive volume); any other file + // there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced + if fs.exists(t) && !is_respelling(fs, f, t) { + return Err(fail(format!("{to} already exists"))); + } + // An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the + // lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`. + let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc)); + let mut packets: Vec = + sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect(); + move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?; + let mut done: Vec = Vec::new(); + let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| { + let mut dst = crate::sidecar::sidecar_path(to, naming); + if upper { + dst = dst.with_extension("XMP"); + } + // A stem sidecar another photo still uses is copied, not taken away (issue #92). + let copied = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some(); + SidecarMove { from: sc, to: dst, copied, companions: Vec::new() } + }); + while let Some(m) = remaining.next() { + if !m.needed(fs) { + continue; // never overwrite: the old sidecar stays where it was + } + let aliases = packet_aliases(&packets, fs, &m.from); + if let Err(e) = m.apply(fs) { + let mut message = format!("rename {}: {e}", m.from.display()); + // The file first: if it can't go back, sidecars that moved stay with it. + if let Err(e) = move_one(fs, t, f) { + message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}")); + if !done.is_empty() { + let names: Vec = done.iter().map(|m| m.to.display().to_string()).collect(); + message.push_str(&format!(" with its sidecar {}", names.join(", "))); + } + let mut pending_sidecars = vec![m]; + for m in remaining.filter(|m| m.needed(fs)) { + // The two case spellings can name one file. Keep one operation for + // those aliases, while retaining distinct sidecars on sensitive volumes. + let alias = pending_sidecars.iter().any(|p| { + folded_path(&p.from.to_string_lossy()) == folded_path(&m.from.to_string_lossy()) + && folded_path(&p.to.to_string_lossy()) == folded_path(&m.to.to_string_lossy()) + && fs.same_file(&p.from, &m.from) + }); + if !alias { + pending_sidecars.push(m); + } + } + note_companions(&mut pending_sidecars, packets); + return Err(MoveError { message, moved: true, pending_sidecars }); + } + let mut pending_sidecars = Vec::new(); + for m in done.iter().rev() { + if m.copied { + // The original is still there: a leftover copy loses nothing. + if fs.remove_file(&m.to).is_ok() { + packets.retain(|p| p.path != m.to); + } + } else { + let aliases = packet_aliases(&packets, fs, &m.to); + if let Err(e) = move_one(fs, &m.to, &m.from) { + message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", m.from.display(), m.to.display())); + pending_sidecars.push(SidecarMove { from: m.to.clone(), to: m.from.clone(), copied: false, companions: Vec::new() }); + } else { + advance_packets(&mut packets, &aliases, &m.from, false, fs); + } + } + } + note_companions(&mut pending_sidecars, packets); + return Err(MoveError { message, moved: false, pending_sidecars }); + } + advance_packets(&mut packets, &aliases, &m.to, m.copied, fs); + done.push(m); + } + Ok(()) +} + +/// What a failed batch of moves ([`move_all`]) left behind. +#[derive(Debug)] +pub(crate) struct BatchMoveError { + /// The first failure and every move that could not be undone. + pub message: String, + /// Moves that stayed done, (from, to): those files are at `to` now. + pub stuck: Vec<(String, String)>, + /// Sidecars still separated from the path where their photo ended up, in either direction. + pub pending_sidecars: Vec, +} + +/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone. +/// Moves that can't be undone (e.g. the network share went away) are reported, not ignored. +pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::result::Result<(), BatchMoveError> { + let mut done: Vec<&(String, String)> = Vec::new(); + for m in moves { + let Err(e) = move_file_with(fs, &m.0, &m.1) else { + done.push(m); + continue; + }; + let mut message = e.message; + let mut pending_sidecars = e.pending_sidecars; + let mut stuck = Vec::new(); + if e.moved { + stuck.push(m.clone()); + } + for d in done.iter().rev() { + if let Err(mut e) = move_file_with(fs, &d.1, &d.0) { + message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message)); + if !e.moved { + stuck.push((*d).clone()); + } + pending_sidecars.append(&mut e.pending_sidecars); + } + } + return Err(BatchMoveError { message, stuck, pending_sidecars }); + } + Ok(()) +} + +/// Final `SetFile` per photo (nested batches included), filtered by destination only afterward. +pub(super) fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec) { + let mut pending = vec![op]; + let mut order = Vec::new(); + let mut final_ops = HashMap::new(); + while let Some(op) = pending.pop() { + match op { + Op::Batch { ops } => pending.extend(ops.iter().rev()), + Op::SetFile { id, .. } if final_ops.insert(*id, op).is_none() => order.push(*id), + _ => {} + } + } + out.extend(order.into_iter().filter_map(|id| { + let op = final_ops.get(&id).copied()?; + matches!(op, Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str())).then(|| op.clone()) + })); +} diff --git a/crates/engine/src/rename/plan.rs b/crates/engine/src/rename/plan.rs new file mode 100644 index 000000000..331297ba4 --- /dev/null +++ b/crates/engine/src/rename/plan.rs @@ -0,0 +1,295 @@ +use super::fs::{MoveFs, RealFs, folded_path}; +use super::moves::{BatchMoveError, move_all, set_file_ops_to}; +use super::sidecar::{PacketVersion, retry_sidecars, retry_sidecars_for_paths}; +use super::tokens::{expand, split_ext}; +use super::*; + +/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies) +/// rename once. No file-system calls. +pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec> { + let mut seen_paths: HashSet = HashSet::new(); + ids.iter() + .filter_map(|id| cat.photo(*id)) + .filter(|p| match &p.source { + Source::File { path } => seen_paths.insert(path.clone()), + _ => true, + }) + .cloned() + .collect() +} + +/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check +/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow +/// drive). Whether a case-only change names this very file is decided on the disk. +pub fn plan_rename_photos(photos: &[std::sync::Arc], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec { + plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b)) +} + +/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether +/// two spellings name one file (see [`MoveFs`]). +fn plan_rename_core( + photos: &[std::sync::Arc], + template: &str, + start: usize, + exists: &dyn Fn(&Path) -> bool, + same_file: &dyn Fn(&Path, &Path) -> bool, +) -> Vec { + let mut taken: HashSet = HashSet::new(); // lower-case target paths/names claimed in this batch + let mut out = Vec::new(); + for (i, p) in photos.iter().enumerate() { + let seq = start.saturating_add(i); + let want = expand(template, p, seq); + let (stem, ext) = split_ext(&want); + let (stem, ext) = (stem.to_string(), ext.to_string()); + let candidate = |k: usize| { + if k == 0 { + want.clone() + } else if ext.is_empty() { + format!("{stem}-{k}") + } else { + format!("{stem}-{k}.{ext}") + } + }; + let (to, to_path) = match &p.source { + Source::File { path } => { + let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); + let mut k = 0; + loop { + let name = candidate(k); + let tp = dir.join(&name).to_string_lossy().to_string(); + let key = folded_path(&tp); + // this very file, maybe spelled differently (case-insensitive volume)? By + // identity: on a case-sensitive volume `img_1.jpg` may be another photo + let (from, to) = (Path::new(path), Path::new(&tp)); + let same = from == to || (key == folded_path(path) && exists(to) && same_file(from, to)); + // free: not claimed in this batch and not on disk (unless it is this very file). + // A file this batch moves away still counts as taken: simple and safe. + if !taken.contains(&key) && (same || !exists(to)) { + taken.insert(key); + break (name, Some(tp)); + } + k += 1; + } + } + Source::Demo { .. } => { + let mut k = 0; + loop { + let name = candidate(k); + if taken.insert(format!("demo:{}", name.to_lowercase())) { + break (name, None); + } + k += 1; + } + } + }; + let from_path = match &p.source { + Source::File { path } => Some(path.clone()), + _ => None, + }; + out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path }); + } + out +} + +impl Session { + /// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions. + pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec { + self.plan_rename_with(&RealFs, ids, template, start) + } + + /// [`Session::plan_rename`] on the file system `fs`. + pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec { + plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b)) + } + + /// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op + /// that updates the photos (and their virtual copies). + /// + /// When a failure can't be fully rolled back, the files that kept their new names are + /// committed as a smaller undoable rename, so the library points at them (nothing goes + /// missing), and the error lists them (old → new). + pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result { + self.apply_rename_with(&RealFs, plans) + } + + /// Complete retained repairs before a file or folder change can stale their exact paths. + pub(crate) fn repair_sidecars_with(&mut self, fs: &dyn MoveFs) -> Result<()> { + for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { + repair.validate_repair(fs).map_err(EngineError::Other)?; + } + retry_sidecars(fs, &mut self.pending_sidecar_repairs).map_err(EngineError::Other)?; + for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { + retry_sidecars(fs, &mut entry.retry_sidecars).map_err(EngineError::Other)?; + } + Ok(()) + } + + pub(crate) fn repair_sidecars_for_paths(&mut self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { + self.validate_sidecar_packets_for_paths(paths, fs)?; + retry_sidecars_for_paths(fs, &mut self.pending_sidecar_repairs, paths).map_err(EngineError::Other)?; + for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { + retry_sidecars_for_paths(fs, &mut entry.retry_sidecars, paths).map_err(EngineError::Other)?; + } + Ok(()) + } + + /// A healthy shared packet may be saved while another photo's repair is unavailable. + /// Check its retained content too, without forcing that unrelated physical operation. + pub(crate) fn validate_sidecar_packets_for_paths(&self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { + for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { + if paths.iter().any(|path| repair.touches(fs, path)) { + repair.validate_repair(fs).map_err(EngineError::Other)?; + } else { + for packet in &repair.companions { + if paths.iter().any(|path| fs.same_file(path, &packet.path)) { + packet.validate(fs).map_err(EngineError::Other)?; + } + } + } + } + Ok(()) + } + + /// Only a successful application write updates a retained content version. Atomic writes + /// replace inodes; aliases that now resolve to the written path observe the new content. + pub(crate) fn note_sidecar_write(&mut self, path: &Path, bytes: &[u8]) { + let version = PacketVersion::of(bytes); + for repair in self.pending_sidecar_repairs.iter_mut().chain(self.undo.iter_mut().chain(&mut self.redo).flat_map(|e| &mut e.retry_sidecars)) { + for packet in &mut repair.companions { + if RealFs.same_file(path, &packet.path) { + packet.version = Some(version); + } + } + } + } + + /// [`Session::apply_rename`] on the file system `fs`. + pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result { + self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" })) + } + + /// Carry out `plans` (renames or moves to another folder): move the files with their sidecars + /// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the + /// photos (and their virtual copies) at the new paths. Undo moves the files back. + pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result { + // A partly completed rename can already have the requested photo name. Repair + // its sidecars before treating that empty move list as a successful retry. + self.repair_sidecars_with(fs)?; + let mut ops = Vec::new(); + let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect(); + for p in self.catalog.photos() { + let plan = match &p.source { + Source::File { path } => by_path.get(path.as_str()).copied(), + Source::Demo { .. } => plans.iter().find(|x| x.id == p.id.0 && x.from_path.is_none()), + }; + let Some(plan) = plan else { continue }; + let source = match &plan.to_path { + Some(t) if matches!(&p.source, Source::File { path } if Path::new(path) == Path::new(t)) => p.source.clone(), + Some(t) => Source::File { path: t.clone() }, + None => p.source.clone(), + }; + if plan.to != p.file_name || source != p.source { + ops.push(Op::SetFile { id: p.id, file_name: plan.to.clone(), source }); + } + } + let n = ops.len(); + let op = Op::Batch { ops }; + let moves: Vec<(String, String)> = plans + .iter() + .filter_map(|pl| match (&pl.from_path, &pl.to_path) { + (Some(a), Some(b)) if Path::new(a) != Path::new(b) => Some((a.clone(), b.clone())), + _ => None, + }) + .collect(); + if let Err(e) = move_all(fs, &moves) { + return Err(EngineError::Other(self.follow_stuck(&op, e, true).0)); + } + if n > 0 + && let Err(err) = self.commit(&label(n), op) + { + let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); + if let Err(e) = move_all(fs, &back) { + return Err(EngineError::Other(format!("{err}; the renamed files could not all be moved back: {}", e.message))); + } + return Err(err); + } + Ok(n) + } + + /// After a failed batch of moves for `op`: point the photos whose files stayed moved at + /// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged + /// change), so the library matches the disk. Returns the message and, for a history retry, + /// the correction's inverse file ops (so a retry doesn't lose the original paths). + pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> (String, Vec) { + let mut msg = e.message; + let pending_sidecars = e.pending_sidecars; + if e.stuck.is_empty() && (!undoable || pending_sidecars.is_empty()) { + return (msg, Vec::new()); + } + let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect(); + let mut keep = Vec::new(); + set_file_ops_to(op, &to, &mut keep); + let list: Vec = e.stuck.iter().map(|(a, b)| format!("{a} → {b}")).collect(); + let n = e.stuck.len(); + let s = if n == 1 { "" } else { "s" }; + let fix = Op::Batch { ops: keep }; + let mut inverses = Vec::new(); + let applied = if undoable { + let label = if n == 0 { "Repair Rename Sidecars".to_string() } else { format!("Rename {n} Photo{s} (partly)") }; + self.commit(&label, fix).map(|()| { + if let Some(entry) = self.undo.last_mut() { + entry.retry_sidecars.extend(pending_sidecars); + } + }) + } else { + match self.apply_catalog(fix.clone()) { + Ok(inv) => { + inverses = match inv { + Op::Batch { ops } => ops, + op => vec![op], + }; + self.pending_log.push(fix); + Ok(()) + } + Err(e) => Err(e), + } + }; + match applied { + Ok(()) if n > 0 => msg + .push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))), + Ok(()) => {} + Err(err) => msg.push_str(&format!( + ". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}", + list.join(", ") + )), + } + (msg, inverses) + } + + /// Physical moves to the final `SetFile` destination of each photo. A merged history + /// batch can rename the same photo repeatedly; intermediate catalog paths need no file. + pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> { + let mut pending = vec![op]; + let mut order = Vec::new(); + let mut targets = HashMap::new(); + while let Some(op) = pending.pop() { + match op { + Op::Batch { ops } => pending.extend(ops.iter().rev()), + Op::SetFile { id, source, .. } if targets.insert(*id, source).is_none() => { + order.push(*id); + } + _ => {} + } + } + // Virtual copies share one physical move, keeping the original batch order. + let mut seen = HashSet::new(); + order + .into_iter() + .filter_map(|id| { + let Source::File { path: to } = targets.get(&id).copied()? else { return None }; + let Source::File { path: from } = &self.catalog.photo(id)?.source else { return None }; + (Path::new(from) != Path::new(to) && seen.insert(from)).then(|| (from.clone(), to.clone())) + }) + .collect() + } +} diff --git a/crates/engine/src/rename/sidecar.rs b/crates/engine/src/rename/sidecar.rs new file mode 100644 index 000000000..24ebcd3f9 --- /dev/null +++ b/crates/engine/src/rename/sidecar.rs @@ -0,0 +1,174 @@ +use super::fs::{MoveFs, is_respelling, move_one}; +use super::*; + +/// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may +/// already have followed its new path, so a retry cannot infer this work from the catalog. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SidecarMove { + pub from: PathBuf, + pub to: PathBuf, + pub(super) copied: bool, + /// Observed packets in the partial move, including the separated source. Absent planned + /// destinations are never owned; a completed repair advances these observations. + pub(super) companions: Vec, +} + +/// A content version, not an assertion that a pathname still names the same packet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PacketVersion { + pub(super) len: u64, + pub(super) hash: lightcraft_preview::Hash128, +} + +impl PacketVersion { + pub(super) fn of(bytes: &[u8]) -> Self { + Self { len: bytes.len() as u64, hash: lightcraft_preview::hash_bytes(bytes) } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct RetainedPacket { + pub(super) path: PathBuf, + // An unreadable observation remains untrusted even if a later read succeeds. + pub(super) version: Option, +} + +impl RetainedPacket { + pub(super) fn validate(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { + if self.version.is_none() { + return Err(format!("can't verify retained XMP {}: its content was unreadable when the partial move was recorded", self.path.display())); + } + let current = fs.packet_version(&self.path).map_err(|e| format!("can't verify retained XMP {}: {e}", self.path.display()))?; + if self.version != Some(current) { + return Err(format!( + "retained XMP {} changed since the partial move; packet ownership must be resolved before retrying", + self.path.display() + )); + } + Ok(()) + } +} + +/// Advance only packets whose operation actually succeeded. A copy retains its original; +/// a move retires vanished source aliases and adds the observed destination. +pub(super) fn advance_packets(packets: &mut Vec, aliases: &[PathBuf], to: &Path, copied: bool, fs: &dyn MoveFs) { + let Some(mut packet) = packets.iter().find(|p| aliases.contains(&p.path)).cloned() else { return }; + if !copied { + packets.retain(|p| !aliases.contains(&p.path) || fs.is_file(&p.path)); + } + packet.path = to.to_path_buf(); + if !packets.iter().any(|p| p.path == packet.path) { + packets.push(packet); + } +} + +pub(super) fn packet_aliases(packets: &[RetainedPacket], fs: &dyn MoveFs, from: &Path) -> Vec { + packets.iter().filter(|p| p.path == from || fs.same_file(&p.path, from)).map(|p| p.path.clone()).collect() +} + +impl SidecarMove { + fn validate_packets(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { + for packet in &self.companions { + packet.validate(fs)?; + } + Ok(()) + } + + /// Validate a queued retry and return the copy decision for this physical operation. + pub(super) fn validate_repair(&self, fs: &dyn MoveFs) -> std::result::Result { + self.validate_packets(fs)?; + let respelling = is_respelling(fs, &self.from, &self.to); + // Batch rollback may have restored another photo using this source packet. + let copied = self.copied || (!respelling && fs.stem_sibling(&self.from).is_some()); + if fs.exists(&self.to) && (copied || !respelling) { + return Err(format!("{} already exists while repairing {} → {}", self.to.display(), self.from.display(), self.to.display())); + } + Ok(copied) + } + + pub(super) fn needed(&self, fs: &dyn MoveFs) -> bool { + fs.is_file(&self.from) && !(fs.exists(&self.to) && (self.copied || !is_respelling(fs, &self.from, &self.to))) + } + + pub(super) fn touches(&self, fs: &dyn MoveFs, path: &Path) -> bool { + let same_path = |endpoint: &Path| { + endpoint == path + || fs.same_file(endpoint, path) + || (endpoint.file_name() == path.file_name() + && endpoint.parent().zip(path.parent()).is_some_and(|(a, b)| a == b || fs.same_file(a, b))) + }; + same_path(&self.from) || same_path(&self.to) + } + + pub(super) fn apply(&self, fs: &dyn MoveFs) -> std::io::Result<()> { + if self.copied { fs.copy_no_replace(&self.from, &self.to) } else { move_one(fs, &self.from, &self.to) } + } +} + +/// Complete known unfinished sidecars before moving any more photos. Completed operations +/// leave the queue; a failure retains that operation and the rest, without replacing a file. +pub(crate) fn retry_sidecars(fs: &dyn MoveFs, pending: &mut Vec) -> std::result::Result<(), String> { + validate_sidecars(fs, pending)?; + while !pending.is_empty() { + apply_repair(fs, pending, 0)?; + } + Ok(()) +} + +/// Preflight a detached history entry before global queues are allowed to retire. The +/// caller can validate without mutating it; retry revalidates before each physical operation. +pub(crate) fn validate_sidecars(fs: &dyn MoveFs, pending: &[SidecarMove]) -> std::result::Result<(), String> { + for repair in pending { + repair.validate_repair(fs)?; + } + Ok(()) +} + +/// Finish one operation and transfer its observed content to the remaining repairs. Do not +/// infer ownership by reading an occupied destination or by retaining a planned pathname. +fn apply_repair(fs: &dyn MoveFs, pending: &mut Vec, i: usize) -> std::result::Result<(), String> { + let Some(mut repair) = pending.get(i).cloned() else { return Ok(()) }; + repair.copied = repair.validate_repair(fs)?; + let aliases = packet_aliases(&repair.companions, fs, &repair.from); + repair.apply(fs).map_err(|e| format!("rename {} → {}: {e}", repair.from.display(), repair.to.display()))?; + for remaining in pending.iter_mut() { + advance_packets(&mut remaining.companions, &aliases, &repair.to, repair.copied, fs); + } + pending.remove(i); + Ok(()) +} + +/// Repair only operations that affect a sidecar being saved. Unrelated unavailable originals +/// must not block healthy metadata saves. Completed repairs retire; failures stay queued. +pub(crate) fn retry_sidecars_for_paths(fs: &dyn MoveFs, pending: &mut Vec, paths: &[PathBuf]) -> std::result::Result<(), String> { + for repair in pending.iter().filter(|m| paths.iter().any(|path| m.touches(fs, path))) { + repair.validate_repair(fs)?; + } + let mut i = 0; + while let Some(m) = pending.get(i) { + if paths.iter().any(|path| m.touches(fs, path)) { + // Path/alias equality only locates an observation; validation above proves its + // content. A newly occupied, previously absent peer destination is a collision. + if let Some(path) = paths.iter().find(|path| { + fs.exists(path) + && !fs.same_file(path, &m.from) + && !fs.same_file(path, &m.to) + && !m.companions.iter().any(|known| fs.same_file(path, &known.path)) + }) { + return Err(format!("{} already exists while repairing {} → {}", path.display(), m.from.display(), m.to.display())); + } + apply_repair(fs, pending, i)?; + } else { + i += 1; + } + } + Ok(()) +} + +/// Every record came from an actual packet observed before this move. Successful physical +/// operations have already advanced its path; missing planned destinations are not recorded. +pub(super) fn note_companions(pending: &mut [SidecarMove], packets: Vec) { + for repair in pending { + repair.companions.clone_from(&packets); + } +} diff --git a/crates/engine/src/rename/tokens.rs b/crates/engine/src/rename/tokens.rs new file mode 100644 index 000000000..3490e272b --- /dev/null +++ b/crates/engine/src/rename/tokens.rs @@ -0,0 +1,271 @@ +use super::*; + +pub(super) fn split_ext(name: &str) -> (&str, &str) { + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => (stem, ext), + _ => (name, ""), + } +} + +fn sanitize(s: &str) -> String { + let t: String = + s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() { '-' } else { c }).collect(); + t.trim().trim_matches('.').trim().to_string() +} + +/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name +/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month +/// that doesn't parse gives nothing. +pub(super) fn format_date(iso: &str, fmt: &str) -> String { + let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string(); + let month = iso.get(5..7).and_then(|m| m.parse::().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or(""); + let mut out = String::new(); + let mut it = fmt.chars(); + while let Some(c) = it.next() { + if c != '%' { + out.push(c); + continue; + } + match it.next() { + Some('Y') => out.push_str(&part(0, 4)), + Some('y') => out.push_str(&part(2, 4)), + Some('m') => out.push_str(&part(5, 7)), + Some('B') => out.push_str(month), + Some('b') => out.push_str(month.get(..3).unwrap_or(month)), + Some('d') => out.push_str(&part(8, 10)), + Some('H') => out.push_str(&part(11, 13)), + Some('M') => out.push_str(&part(14, 16)), + Some('S') => out.push_str(&part(17, 19)), + Some('%') => out.push('%'), + Some(o) => { + out.push('%'); + out.push(o); + } + None => out.push('%'), + } + } + out +} + +/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and +/// command description shows; a test checks that [`expand_tokens`] knows each of them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct TokenHelp { + /// The token as typed, e.g. `{seq:3}`. + pub tag: &'static str, + /// Other spellings with the same meaning. + pub aliases: &'static [&'static str], + pub meaning: &'static str, +} + +/// Every template token, in the order the help shows them. +pub const TOKENS: &[TokenHelp] = &[ + TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" }, + TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" }, + TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" }, + TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" }, + TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" }, + TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" }, + TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" }, + TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" }, + TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" }, + TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" }, + TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" }, + TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" }, + TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" }, + TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" }, +]; + +/// The `%` directives of `{date:…}`. +pub const DATE_DIRECTIVES: &[(&str, &str)] = &[ + ("%Y", "year, 4 digits"), + ("%y", "year, 2 digits"), + ("%m", "month 01–12"), + ("%B", "month name (January)"), + ("%b", "short month name (Jan)"), + ("%d", "day 01–31"), + ("%H", "hour 00–23"), + ("%M", "minute"), + ("%S", "second"), + ("%%", "a literal %"), +]; + +/// How templates behave, one sentence each (shown under the token list). +pub const TEMPLATE_NOTES: &[&str] = &[ + "The original extension is always added; {ext} only puts it inside the name as well.", + "A blank template keeps the original names.", + "{date} is the capture time; a photo without one uses the time it was imported.", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.", + "Unknown tags stay as typed — check the preview for a {typo}.", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….", +]; + +/// The tokens as one line (`{name} {num} {seq} …`), for compact hints. +pub fn token_summary() -> String { + TOKENS.iter().map(|t| t.tag).collect::>().join(" ") +} + +/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48). +pub fn sample_photo() -> Photo { + let mut p = + Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00"); + p.captured = Some("2026-01-14T05:58:48".into()); + p.rating = 4; + p.meta.camera = "Canon EOS R5".into(); + p.meta.lens = "RF24-70mm F2.8".into(); + p.meta.iso = Some(400); + p.meta.title = "Harbour".into(); + p.meta.creator = "Ann Lee".into(); + p +} + +/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column. +pub fn token_example(tag: &str) -> String { + expand_tokens(tag, &sample_photo(), 1, 1) +} + +/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be +/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels. +pub fn folder_template_error(template: &str) -> Option { + let t = template.trim(); + if t.is_empty() { + return Some("the folder template is empty".into()); + } + let b = t.as_bytes(); + if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') { + return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into()); + } + if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) { + return Some("the folder template may not contain . or .. folders".into()); + } + None +} + +/// The folders (relative to the destination) a folder template gives `p`: the template's own `/` +/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder +/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and +/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty +/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s +/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped. +pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec { + template + .split(['/', '\\']) + .map(str::trim) + .filter(|s| !s.is_empty() && !matches!(*s, "." | "..")) + .map(|s| { + let v = sanitize(&expand_tokens(s, p, seq, 1)); + if v.is_empty() { "unknown".to_string() } else { v } + }) + .collect() +} + +/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning. +pub fn unknown_tokens(template: &str) -> Vec { + let p = sample_photo(); + let mut out = Vec::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + let Some(j) = rest[i..].find('}') else { break }; + let tag = &rest[i..i + j + 1]; + if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) { + out.push(tag.to_string()); + } + rest = &rest[i + j + 1..]; + } + out +} + +/// Every token with its meaning and example, the date directives and the notes, as JSON (the +/// `photo.renameTokens` command). +pub fn token_help_json() -> serde_json::Value { + let tokens: Vec = TOKENS + .iter() + .map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)})) + .collect(); + let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect(); + serde_json::json!({ + "tokens": tokens, + "dateDirectives": directives, + "notes": TEMPLATE_NOTES, + "sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48", + }) +} + +/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension. +pub fn expand(template: &str, p: &Photo, seq: usize) -> String { + let (stem, ext) = split_ext(&p.file_name); + let out = expand_tokens(template, p, seq, 1); + let mut stem_out = sanitize(&out); + if stem_out.is_empty() { + stem_out = sanitize(stem); + } + if stem_out.is_empty() { + stem_out = "photo".into(); + } + if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") } +} + +/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}` +/// is zero-padded to `seq_width` digits. +pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String { + expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None) +} + +/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text +/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename +/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way. +pub fn expand_tokens_with( + template: &str, + p: &Photo, + seq: usize, + seq_width: usize, + extra: &dyn Fn(&str, Option<&str>, &str) -> Option, +) -> String { + let (stem, ext) = split_ext(&p.file_name); + let mut out = String::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + out.push_str(&rest[..i]); + let Some(j) = rest[i..].find('}') else { + out.push_str(&rest[i..]); + rest = ""; + break; + }; + let tok = &rest[i + 1..i + j]; + let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); + let lower = name.trim().to_ascii_lowercase(); + let v = match extra(&lower, arg, tok) { + Some(v) => v, + None => match lower.as_str() { + "name" | "filename" => stem.to_string(), + "num" => { + let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); + stem[stem.len() - digits..].to_string() + } + "seq" | "n" => { + let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); + format!("{seq:0w$}") + } + "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), + "folder" => match &p.source { + Source::File { path } => { + Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + } + Source::Demo { .. } => String::new(), + }, + "camera" => p.meta.camera.clone(), + "lens" => p.meta.lens.clone(), + "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), + "rating" => p.rating.to_string(), + "title" => p.meta.title.clone(), + "creator" => p.meta.creator.clone(), + "ext" => ext.to_string(), + _ => format!("{{{tok}}}"), + }, + }; + out.push_str(&v); + rest = &rest[i + j + 1..]; + } + out.push_str(rest); + out +} From ff614cbe430b9416fa48c44a75ba384d48cf4c72 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:08:55 +0000 Subject: [PATCH 114/191] refactor(lightcraft-engine): split import.rs into import/ --- crates/engine/src/import.rs | 1743 +------------------------- crates/engine/src/import/commit.rs | 371 ++++++ crates/engine/src/import/defaults.rs | 99 ++ crates/engine/src/import/files.rs | 372 ++++++ crates/engine/src/import/job.rs | 486 +++++++ crates/engine/src/import/scan.rs | 188 +++ crates/engine/src/import/types.rs | 230 ++++ 7 files changed, 1768 insertions(+), 1721 deletions(-) create mode 100644 crates/engine/src/import/commit.rs create mode 100644 crates/engine/src/import/defaults.rs create mode 100644 crates/engine/src/import/files.rs create mode 100644 crates/engine/src/import/job.rs create mode 100644 crates/engine/src/import/scan.rs create mode 100644 crates/engine/src/import/types.rs diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index bb81bd240..6fe665896 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -32,1730 +32,31 @@ use serde::{Deserialize, Serialize}; use crate::Session; use crate::media::ProbeInfo; -/// File extensions offered by import discovery and file pickers (lower case). -/// This includes preview-only raws; the file probe decides whether sensor decoding is available. -pub const EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef", - "srw", "psd", "psb", "jxl", "gif", "bmp", - // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as - // failed with the reason ("HEIC/HEIF support isn't included in this build"). - "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef", -]; +mod commit; +mod defaults; +mod files; +mod job; +mod scan; +mod types; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum ImportMode { - /// Reference the files where they are. - #[default] - Add, - /// Copy the files into the library's `Originals/` folder. - Copy, - /// Move the files there: as Copy, then each original (and its sidecars) is removed from the - /// source once the copy is verified and catalogued. - Move, -} - -/// What an import does with a file the library already has in Recently Deleted. -/// -/// Someone who imports such a file wants the photo in the library again, so the default brings -/// it back (issues #298, #706: before, the import skipped it as "already in the library" while -/// the grid showed it deleted, and the user was stuck). Restoring loses nothing — the edits, -/// rating and albums come back with the photo and Reset Edits gives a clean start — where -/// importing afresh would silently drop them. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum OnDeleted { - /// Leave it there; the report says so ([`Duplicate::existing_deleted`]). - Skip, - /// Bring the trashed photo back, with its edits ([`ImportReport::restored`]); a photo whose - /// file is no longer where the library had it is relinked to the imported file. - #[default] - Restore, - /// Delete the trashed record permanently and import the file as a new photo. - Fresh, -} - -impl OnDeleted { - pub fn parse(s: &str) -> Option { - match s { - "skip" => Some(Self::Skip), - "restore" => Some(Self::Restore), - "fresh" => Some(Self::Fresh), - _ => None, - } - } -} - -/// What an import does besides adding the photos. -#[derive(Clone, Debug, Default)] -pub struct ImportOptions { - /// A file that is in Recently Deleted: restore (default), skip, or import afresh. - pub on_deleted: OnDeleted, - pub mode: ImportMode, - /// Applied to every imported photo (one History entry). - pub preset: Option, - /// Added to every imported photo. - pub keywords: Vec, - /// Browsing a folder: photos come in as `local` (not in the library), and a file with the - /// same content as one already known is still listed. - pub local: bool, - /// Copy: where the copies go (default: the library's `Originals/`). - pub destination: Option, - /// Copy: the folders inside the destination. - pub organize: Organize, - /// Copy: a file-name template for the copies ([`crate::rename::expand`] tokens: `{name}`, - /// `{seq:N}`, `{date:%Y%m%d}`, `{camera}`…), numbered from `rename_start`. - pub rename: Option, - pub rename_start: usize, - /// A metadata preset (by name) applied to every imported photo. - pub metadata_preset: Option, - /// Copy: raws are copied as DNG (Copy as DNG). - pub convert_dng: bool, -} - -/// How copies are filed in the destination. The date is the capture time, else the time of the -/// import. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub enum Organize { - /// `YYYY/YYYY-MM-DD/` by capture date. - #[default] - ByDay, - /// `YYYY/YYYY-MM/`. - ByMonth, - /// All in the destination itself. - Flat, - /// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own - /// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`]; - /// never outside the destination). - Template(String), -} - -impl Organize { - /// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`). - pub fn parse(s: &str) -> Option { - match s { - "date" | "day" | "byDay" => Some(Organize::ByDay), - "month" | "byMonth" => Some(Organize::ByMonth), - "flat" | "none" | "intoOneFolder" => Some(Organize::Flat), - t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())), - _ => None, - } - } - - /// The folders (inside the destination) a copy of `p` goes to. - pub fn folders(&self, p: &Photo) -> Vec { - let date = p.date(); - let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated"); - let year = day.get(..4).unwrap_or("undated"); - match self { - Organize::ByDay => vec![year.into(), day.into()], - Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()], - Organize::Flat => Vec::new(), - Organize::Template(t) => crate::rename::expand_folder(t, p, 1), - } - } -} - -/// A file found by [`scan`], for the import review. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct ImportCandidate { - pub path: String, - pub name: String, - pub format: String, - pub kind: MediaKind, - pub width: u32, - pub height: u32, - pub file_size: u64, - pub captured: Option, - /// `"path"` (already in the library), `"content"` (same bytes in the library or earlier in this - /// list): skipped by the import. - pub duplicate: Option, - /// The photo that already has it. - pub existing: Option, - /// Not readable. - pub error: Option, - /// A raw variant that can't be decoded yet (why): it imports as preview only. - #[serde(skip_serializing_if = "Option::is_none")] - pub preview_only: Option, -} - -#[derive(Clone, Debug, Default, Serialize)] -pub struct Duplicate { - pub path: String, - /// The photo that already has these bytes (or this path). - pub existing: Option, - /// `"path"` (already imported from there) or `"content"` (same bytes elsewhere). - pub reason: &'static str, - /// That photo is in Recently Deleted (see [`OnDeleted`]). - #[serde(rename = "existingDeleted", skip_serializing_if = "std::ops::Not::not")] - pub existing_deleted: bool, -} - -#[derive(Clone, Debug, Default, Serialize)] -pub struct ImportReport { - pub imported: Vec, - pub duplicates: Vec, - /// [`OnDeleted::Restore`]: photos brought back from Recently Deleted instead of imported. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub restored: Vec, - /// Move: the originals that were moved (and removed from the source). - #[serde(skip_serializing_if = "Vec::is_empty")] - pub moved: Vec, - /// Move: originals (or sidecars) left at the source, and why — the photo may still have been - /// imported (from its copy, or in place). - #[serde(skip_serializing_if = "Vec::is_empty")] - pub kept: Vec, - /// (path, error) - pub failed: Vec<(String, String)>, - /// Files found after expanding folders. - pub scanned: usize, - /// Photos whose metadata/develop settings were read from an XMP sidecar (or embedded XMP). - pub sidecars: usize, - /// Per-photo diagnostics from foreign XMP develop settings that could not be fully carried over. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub warnings: Vec, -} - -/// A structured diagnostic from foreign XMP develop settings. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ImportWarningCode { - /// A named camera profile was found but cannot be applied as Lightroom does. - CameraProfile, - /// Lightroom geometry data was found but its saved transform is not retained. - UprightGeometry, - /// One or more other XMP develop fields have no LightCraft mapping. - UnmappedXmp, -} - -/// The saved Lightroom Upright mode associated with a foreign geometry transform. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ImportUprightMode { - Off, - Auto, - Level, - Vertical, - Full, - Guided, - Unknown, -} - -/// One actionable XMP import diagnostic. `path` is filled when its photo is committed. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportWarning { - pub path: String, - pub code: ImportWarningCode, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mode: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub fields: Vec, -} - -/// A file moved by an import. -#[derive(Clone, Debug, Default, Serialize)] -pub struct Moved { - pub from: String, - pub to: String, - /// Its sidecars' new paths. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub sidecars: Vec, -} - -/// A source a Move left where it was. -#[derive(Clone, Debug, Default, Serialize)] -pub struct Kept { - pub path: String, - pub reason: String, -} - -/// The develop settings a photo gets on import: raws start from their as-shot white balance with -/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the -/// camera intended); a user default preset ([`ImportDefaults`]) goes on top. -pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings { - p.import_defaults() -} - -/// Does the photo still look as the camera rendered it (its embedded camera preview is then a -/// fair stand-in)? Not when a default preset changed it on import. -pub fn has_import_look(p: &Photo) -> bool { - *p.develop == p.camera_defaults() -} - -/// User defaults applied on import (Settings → Import; saved in the library's `prefs.json`). -/// Presets are referenced by id; a preset that no longer exists is ignored. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct ImportDefaults { - /// Preset applied to raw files (`None` = the LightCraft default). - pub raw_preset: Option, - /// Use a camera's own default (below) when the photo's camera has one. - pub per_camera: bool, - /// Per-camera raw defaults, keyed by the camera's make + model as shown in Info - /// (`"Canon EOS R5"`). - pub cameras: Vec, - /// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none). - pub other_preset: Option, - /// Copyright notice given to imported photos that don't carry one (empty = none). - pub copyright: String, - /// Creator given to imported photos that don't name one (empty = none). - pub creator: String, - /// Metadata preset applied to every imported photo (`metadata.*`; `None` = none). - pub metadata_preset: Option, - /// Auto import: a watched folder whose new photos are added as they arrive (`None` = off), - /// copied into the library's Originals instead of added in place when `auto_copy`, and an - /// album they go to. - pub auto_folder: Option, - pub auto_copy: bool, - pub auto_album: Option, - /// Open the import review when a camera or memory card is connected. - pub show_on_card: bool, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct CameraDefault { - /// Make + model (`Meta::camera`). - pub camera: String, - /// Preset id; `None` = the LightCraft default for this camera. - pub preset: Option, -} - -impl ImportDefaults { - /// The preset id that applies to a photo of this kind and camera, if any. - pub fn preset_for(&self, raw: bool, camera: &str) -> Option<&str> { - if !raw { - return self.other_preset.as_deref(); - } - if self.per_camera - && let Some(c) = self.cameras.iter().find(|c| !camera.is_empty() && c.camera.eq_ignore_ascii_case(camera)) - { - return c.preset.as_deref(); - } - self.raw_preset.as_deref() - } -} - -/// Give a freshly imported photo its default settings: the camera defaults, then the matching -/// default preset ([`ImportDefaults::preset_for`]), remembered as the photo's import look. -pub fn apply_import_defaults(s: &Session, p: &mut Photo) { - // metadata defaults fill gaps only: the file's own copyright / creator win - let d = &s.import_defaults; - if p.meta.copyright.trim().is_empty() && !d.copyright.trim().is_empty() { - p.meta.copyright = d.copyright.trim().to_string(); - } - if p.meta.creator.trim().is_empty() && !d.creator.trim().is_empty() { - p.meta.creator = d.creator.trim().to_string(); - } - if let Some(mp) = d.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { - crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); - } - let base = p.camera_defaults(); - // a raw shown from its embedded JPEG gets the rendered-file default, not the raw one - let raw = p.develops_raw(); - let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id)); - match preset { - Some(pr) => { - let look = std::sync::Arc::new(pr.apply(&base, 1.0)); - p.develop = look.clone(); - p.import_look = (*look != base).then_some(look); - } - None => { - p.develop = std::sync::Arc::new(base); - p.import_look = None; - } - } -} - -pub fn is_supported(path: &Path) -> bool { - path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) -} - -/// The web file picker's accept list, derived from [`EXTENSIONS`]. Each extension -/// is listed in both cases: browsers' `accept` matching is case-insensitive per -/// spec, but some implementations (e.g. Windows/Edge) match case-sensitively and -/// rejected uppercase `.NEF` files, so a lowercase-only list silently hid raws -/// such as CR3, NRW, RAF, ORF, RW2, RWL, RAW, PEF and SRW from the picker. -/// -/// The picker only offers files this build can actually import: `.avif` has no -/// AV1 decoder in any build, and `.heic`/`.heif` decode only when the codecs' -/// `heif` feature is enabled ([`lightcraft_codecs::Format::Heif::can_decode`]). -/// [`EXTENSIONS`] itself stays complete — files on disk are still recognised, -/// and import reports them as failed, saying why. -pub fn accept_list() -> String { - let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); - let mut out = String::new(); - for &ext in EXTENSIONS { - // Skip extensions a web user could pick but this build could not import. - let pickable = match ext { - "avif" => false, - "heic" | "heif" => heif_decodable, - _ => true, - }; - if !pickable { - continue; - } - if !out.is_empty() { - out.push(','); - } - out.push('.'); - out.push_str(ext); - out.push(','); - out.push('.'); - out.push_str(&ext.to_uppercase()); - } - out -} - -/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder) -/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a -/// walk that stops at a bound is logged. -pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec { - expand_within(paths, skip, crate::walk::Limits::default()).0 -} - -/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound). -pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec, bool) { - expand_noting(paths, skip, limits, None) -} - -/// [`expand_within`], handing the listing of every folder that is walked to `note` -/// ([`crate::walk::files_in_noting`]). -pub(crate) fn expand_noting( - paths: &[String], - skip: Option<&Path>, - limits: crate::walk::Limits, - mut note: Option, -) -> (Vec, bool) { - let mut out = Vec::new(); - let mut truncated = false; - for p in paths { - let p = Path::new(p); - if skip.is_some_and(|s| p == s) { - continue; - } - if p.is_dir() { - let w = match note.as_mut() { - Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note), - None => crate::walk::files_in(p, skip, limits, is_supported), - }; - if w.truncated { - log::warn!( - "import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)", - p.display(), - w.entries, - limits.max_entries, - limits.max_depth - ); - } - truncated |= w.truncated; - out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string())); - } else { - // explicitly named files are attempted even with an unknown extension (sniffed) - out.push(p.to_string_lossy().to_string()); - } - } - let mut seen = std::collections::HashSet::new(); - out.retain(|p| seen.insert(p.clone())); - (out, truncated) -} - -/// What a path given on the command line at launch becomes ([`launch_path`]). -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum LaunchPath { - /// Import this (absolute) file or folder, in the background. - Import(String), - /// Not imported: `path` (absolute) is a whole drive or the whole home folder, which is never - /// walked without being chosen in the app (issue #374). - Refused { path: String, why: String }, -} - -/// Resolve a launch argument for importing (issue #374). An empty argument is ignored (`None`); -/// a relative one (`.`, `..`, `photos`; `/` or `\` on Windows, the current drive's root) is -/// resolved against `cwd` — the working folder a launcher picked, often a drive root or -/// `C:\Windows\System32` — and refused when that leaves no folder (`cwd` unknown). A drive -/// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or -/// refused, and so is the home folder `home`: either would walk most of the disk. -pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option) -> Option { - if arg.trim().is_empty() { - return None; - } - let given = Path::new(arg); - let joined = if given.is_absolute() { - given.to_path_buf() - } else { - match cwd { - Some(c) => c.join(given), - None => return Some(LaunchPath::Refused { path: arg.to_string(), why: "a relative path, and the working folder is unknown".into() }), - } - }; - // `.` and `..` resolved by name (no file-system access) - let mut abs = PathBuf::new(); - for c in joined.components() { - match c { - std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - abs.pop(); - } - other => abs.push(other.as_os_str()), - } - } - let shown = abs.to_string_lossy().to_string(); - if abs.parent().is_none() { - return Some(match dcim(&abs) { - Some(d) => LaunchPath::Import(d.to_string_lossy().to_string()), - None => LaunchPath::Refused { path: shown, why: "a whole drive".into() }, - }); - } - let key = lightcraft_catalog::query::folder_key; - if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) { - return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() }); - } - Some(LaunchPath::Import(shown)) -} - -/// Probe files (headers + content hash) as an import would. -pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec> { - let progress = ScanProgress::default(); - probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel) -} - -/// Probe files with `probe` (a clone of the session's file probe), off the session: on a worker thread. -pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> Vec> { - let progress = ScanProgress::default(); - probe_all(Some(probe), paths, &progress.done, &progress.cancel) -} - -/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once -/// `cancel` is set: the files not reached come back as errors. -pub(crate) fn probe_all( - probe: Option<&crate::media::FileProbe>, - paths: &[String], - done: &std::sync::atomic::AtomicUsize, - cancel: &std::sync::atomic::AtomicBool, -) -> Vec> { - use std::sync::atomic::Ordering::Relaxed; - let Some(probe) = probe.cloned() else { - return paths - .iter() - .map(|p| { - Ok(ProbeInfo { - format: Path::new(p).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(), - ..Default::default() - }) - }) - .collect(); - }; - #[cfg(not(target_arch = "wasm32"))] - { - let n = workers(paths.len()); - if n > 1 { - let mut results: Vec>> = vec![None; paths.len()]; - let next = std::sync::atomic::AtomicUsize::new(0); - let out = std::sync::Mutex::new(&mut results); - std::thread::scope(|sc| { - for _ in 0..n { - sc.spawn(|| { - loop { - let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if i >= paths.len() { - break; - } - if cancel.load(Relaxed) { - break; - } - let r = probe(&paths[i]); - done.fetch_add(1, Relaxed); - out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r); - } - }); - } - }); - return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect(); - } - } - paths - .iter() - .map(|p| { - if cancel.load(Relaxed) { - return Err("cancelled".into()); - } - let r = probe(p); - done.fetch_add(1, Relaxed); - r - }) - .collect() -} - -/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the -/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and -/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte -/// with the source when there is none); a bad one is removed and the photo reported as failed. -fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result { - let name = name - .map(str::to_string) - .unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into())); - let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse); - crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string()) -} - -/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network -/// share can take minutes to read). -pub struct ScanInput { - probe: Option, - skip: Option, - /// The library as it is now (photos are shared with the session, not copied): looked up for - /// the files the scan finds ([`library_files`]) and the contents it probes - /// ([`library_hashes`]), on the scan's thread. - library: std::sync::Arc, - cache: HashMap, -} - -/// The library's photos of `files`: path → (photo, its summary as a candidate that is there -/// already). One pass over the library, and a record only for a file that was found: a folder of -/// a hundred files in a library of millions costs a hundred records. -fn library_files(library: &lightcraft_catalog::Catalog, files: &[String]) -> HashMap { - let found: std::collections::HashSet<&Path> = files.iter().map(Path::new).collect(); - let mut by_path = HashMap::new(); - if found.is_empty() { - return by_path; - } - for p in library.photos() { - if let Source::File { path } = &p.source - && found.contains(Path::new(path)) - { - let c = ImportCandidate { - path: path.clone(), - format: p.format.clone(), - kind: p.kind, - width: p.width, - height: p.height, - file_size: p.file_size, - captured: p.captured.clone(), - duplicate: Some("path".into()), - existing: Some(p.id.0), - ..Default::default() - }; - by_path.insert(PathBuf::from(path), (p.id, c)); - } - } - by_path -} - -/// Which photo of the library has each of the contents `hashes` (the later one where several -/// have it). One pass over the library, and an entry only for a content that was asked about. -fn library_hashes<'a>(library: &lightcraft_catalog::Catalog, hashes: impl Iterator) -> HashMap { - let wanted: std::collections::HashSet<&str> = hashes.collect(); - let mut by_hash = HashMap::new(); - if wanted.is_empty() { - return by_hash; - } - for p in library.photos() { - if let Some(h) = &p.content_hash - && wanted.contains(h.as_str()) - { - by_hash.insert(h.clone(), p.id); - } - } - by_hash -} - -/// Progress and cancellation of a running [`scan_with`]. -#[derive(Default)] -pub struct ScanProgress { - /// Files to probe, set once the folders are expanded. - pub total: std::sync::atomic::AtomicUsize, - pub done: std::sync::atomic::AtomicUsize, - /// Shared with the scan's row in the activity stack (its ✕ sets it). - pub cancel: std::sync::Arc, -} - -/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows. -pub struct ScanOutput { - pub candidates: Vec, - pub probes: HashMap, -} - -impl ScanInput { - pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec) { - let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - // (no table of the library here: this is the session's thread, and the library may hold - // millions of photos. The scan looks up what it finds) - let library = std::sync::Arc::new(s.catalog.clone()); - let input = ScanInput { probe: s.media.file_probe.clone(), skip, library, cache: std::mem::take(&mut s.import_probes) }; - (input, paths.to_vec()) - } - - /// The folder a scan never enters (the library's own). - pub(crate) fn skip(&self) -> Option<&Path> { - self.skip.as_deref() - } - - /// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one). - pub(crate) fn library(&self) -> std::sync::Arc { - std::sync::Arc::clone(&self.library) - } -} - -/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content -/// against the library and earlier candidates). Nothing is added; the probes are kept for the -/// import that follows. -pub fn scan(s: &mut Session, paths: &[String]) -> Vec { - let (input, paths) = ScanInput::new(s, paths); - let out = scan_with(input, &paths, &ScanProgress::default()); - s.import_probes = out.probes; - out.candidates -} - -/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it -/// has) when `progress.cancel` is set. -pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput { - let files = expand(paths, input.skip.as_deref()); - scan_files(input, files, progress) -} - -/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]). -pub(crate) fn scan_files(mut input: ScanInput, files: Vec, progress: &ScanProgress) -> ScanOutput { - use std::sync::atomic::Ordering::Relaxed; - let by_path = library_files(&input.library, &files); - let todo: Vec = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect(); - progress.total.store(todo.len(), Relaxed); - // probes from a preceding `scan` are reused when the file is unchanged (same size) - let cached: Vec> = todo - .iter() - .map(|f| { - let info = input.cache.remove(f)?; - // (✕ set while the files' cached probes are checked: no more round trips to the disk; what is - // not reached counts as not probed, like the files after it) - if progress.cancel.load(Relaxed) { - return None; - } - let size = std::fs::metadata(f).map(|m| m.len()).ok(); - (size.is_none() || size == Some(info.file_size)).then_some(info) - }) - .collect(); - let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); - progress.done.store(todo.len() - missing.len(), Relaxed); - let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter(); - let probed: Vec> = - cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); - crate::memory::release(); - let mut probes: HashMap> = todo.into_iter().zip(probed).collect(); - // (who in the library has a content that was probed: asked once the probes are in) - let probed_hashes = probes.values().filter_map(|r| r.as_ref().ok()).filter_map(|info| info.content_hash.as_deref()); - let mut seen_hash: HashMap> = - library_hashes(&input.library, probed_hashes).into_iter().map(|(h, id)| (h, Some(id.0))).collect(); - let mut out = Vec::with_capacity(files.len()); - let mut kept = HashMap::new(); - for f in files { - if let Some((_, c)) = by_path.get(Path::new(&f)) { - let mut c = c.clone(); - c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()); - out.push(c); - continue; - } - let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()); - let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() }; - match probes.remove(&f) { - Some(Ok(info)) => { - (c.format, c.kind, c.width, c.height, c.file_size, c.captured) = - (info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone()); - // as the import will date it (a sidecar's date, when there is one, still wins there) - c.captured = c.captured.or_else(|| info.file_modified.clone()); - c.preview_only = info.preview_only.clone(); - if let Some(h) = &info.content_hash { - match seen_hash.get(h) { - Some(existing) => { - c.duplicate = Some("content".into()); - c.existing = *existing; - } - None => { - seen_hash.insert(h.clone(), None); - } - } - } - kept.insert(f, info); - } - Some(Err(e)) => c.error = Some(e), - None => c.error = Some("not probed".into()), - } - out.push(c); - } - ScanOutput { candidates: out, probes: kept } -} - -/// Import files/folders. See the module docs. -pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result { - import_with(s, paths, &ImportOptions { mode, ..Default::default() }) -} - -/// Import files/folders with options (preset, keywords). See the module docs. -/// -/// The file-system half ([`ImportJob::prepare`]: expanding folders, probing, reading sidecars, -/// copying / placing files) and the catalog half ([`commit_prepared`]) are separate so the app can -/// run the first on a worker thread; here both run in turn. -pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> crate::Result { - let mut job = ImportJob::new(s, opts.clone())?; - let prepared = job.prepare(paths, &std::sync::atomic::AtomicBool::new(false)); - // probes a scan left for files not imported now stay for a later import - s.import_probes.extend(std::mem::take(&mut job.cache)); - commit_prepared(s, &job.opts, &job.now, prepared) -} - -/// Everything the file-system half of an import needs, detached from the session so it can run -/// on another thread (a network share or a card can take minutes). Built by [`ImportJob::new`]; -/// [`ImportJob::prepare`] may be called for several batches in turn (renamed copies keep counting, -/// duplicates are found across batches); each batch is then [`commit_prepared`] on the session. -pub struct ImportJob { - pub opts: ImportOptions, - /// The mode in effect (Copy / Move become Add in a library without its own folder). - mode: ImportMode, - lib_dir: Option, - copy_root: Option, - /// The library as it was when the import was set up, until it is looked up - /// ([`ImportJob::look_up_library`], with the first files): setting an import up is the - /// session's thread, and a table of a library of millions of photos takes seconds. With it, - /// whether a photo in Recently Deleted is passed over (an import that replaces such photos, - /// as the options said when the import was set up). - library: Option<(lightcraft_catalog::Catalog, bool)>, - /// path → (photo, a Local browse record?) - by_path: HashMap, - /// content hash → the photo that has it (`None`: a file earlier in this import) - by_hash: HashMap>, - probe: Option, - file_bytes: Option, - /// Probes from a preceding scan (not read again). - cache: HashMap, - naming: crate::sidecar::SidecarNaming, - now: String, - seq: usize, - /// Files found so far (folders expanded) and handled so far, for progress. - pub total: std::sync::Arc, - pub done: std::sync::Arc, - /// Of those, the files that have to be read first (not probed by a preceding scan) and the - /// ones read so far: the step before they are handled. - pub read_total: std::sync::Arc, - pub read_done: std::sync::Arc, -} - -/// The outcome of [`ImportJob::prepare`] for one batch, in file order. -#[derive(Debug, Default)] -pub struct Prepared { - pub scanned: usize, - items: Vec, -} - -#[derive(Debug)] -enum PreparedItem { - /// A Local (browsed) record imported for real. - Promote(PhotoId), - Duplicate { - path: String, - existing: Option, - reason: &'static str, - hash: Option, - }, - Failed(String, String), - Kept(Kept), - Ready(Box), -} - -#[derive(Debug)] -struct ReadyFile { - path: String, - stored: String, - info: ProbeInfo, - sidecar: Option, - placed: Option, -} - -impl Prepared { - /// Undo what the batch did on disk that only a commit would make permanent: files placed by a - /// Move (their sources were never touched). Copies stay, like copies of a failed commit. - pub fn rollback(&self) { - for it in &self.items { - if let PreparedItem::Ready(r) = it - && let Some(pl) = &r.placed - { - crate::import_move::rollback(pl); - } - } - } - - /// Entries in the batch (after expanding folders). - pub fn len(&self) -> usize { - self.items.len() - } - - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } - - /// Recheck Add-only entries against the owner catalog immediately before commit. - /// Preparation may have run while another import added the same path or content. - pub fn revalidate_add(&mut self, s: &Session) { - let mut paths = HashMap::new(); - let mut hashes = HashMap::new(); - for photo in s.catalog.photos() { - if let Source::File { path } = &photo.source { - paths.insert(normalize_import_path(path), photo.id); - } - if let Some(hash) = &photo.content_hash { - hashes.insert(hash.clone(), photo.id); - } - } - let mut items = Vec::with_capacity(self.items.len()); - for item in std::mem::take(&mut self.items) { - let PreparedItem::Ready(ready) = item else { - items.push(item); - continue; - }; - let ReadyFile { path, stored, info, sidecar, placed } = *ready; - let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied()); - let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied()); - if let Some(existing) = existing_path.or(existing_hash) { - if let Some(placed) = &placed { - crate::import_move::rollback(placed); - } - items.push(PreparedItem::Duplicate { - path, - existing: Some(existing), - reason: if existing_path.is_some() { "path" } else { "content" }, - hash: info.content_hash, - }); - } else { - items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))); - } - } - self.items = items; - } -} - -fn normalize_import_path(path: &str) -> String { - let path = path.replace('\\', "/"); - if cfg!(windows) { path.to_lowercase() } else { path } -} - -impl ImportJob { - /// Snapshot what an import with `opts` needs from the session (no file-system calls). Takes - /// the probes a preceding scan left ([`Session::import_probes`]). - pub fn new(s: &mut Session, opts: ImportOptions) -> crate::Result { - let mode = opts.mode; - // Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of - // every added file in its own storage already, so "copy" there means "add". - let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move); - let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode }; - if mode == ImportMode::Move && opts.local { - return Err(crate::EngineError::Other("browsing a folder never moves its files".into())); - } - let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - // where copies go: the chosen folder, else the library's Originals/ - let copy_root = opts - .destination - .as_deref() - .filter(|d| !d.trim().is_empty()) - .map(std::path::PathBuf::from) - .or_else(|| lib_dir.as_ref().map(|l| l.join("Originals"))); - if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() { - return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into())); - } - let fresh = opts.on_deleted == OnDeleted::Fresh; - Ok(ImportJob { - seq: opts.rename_start.max(1), - opts, - mode, - lib_dir, - copy_root, - library: Some((s.catalog.clone(), fresh)), - by_path: HashMap::new(), - by_hash: HashMap::new(), - probe: s.media.file_probe.clone(), - file_bytes: s.media.file_bytes.clone(), - cache: std::mem::take(&mut s.import_probes), - naming: s.xmp.naming, - now: (s.clock)(), - total: Default::default(), - done: Default::default(), - read_total: Default::default(), - read_done: Default::default(), - }) - } - - /// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip - /// duplicates by content, read sidecars, copy or place (Move) the files — several at once, - /// with the names and outcomes of one after another. Nothing in the catalog changes. Stops - /// before the next file once `cancel` is set. - pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared { - let files = self.expand(paths); - self.prepare_files(files, cancel) - } - - /// Expand `paths` (folders recursively, the library's own folder skipped) into the files an - /// import would handle; counted in [`ImportJob::total`]. - pub fn expand(&self, paths: &[String]) -> Vec { - let files = expand(paths, self.lib_dir.as_deref()); - self.total.fetch_add(files.len(), std::sync::atomic::Ordering::Relaxed); - files - } - - /// When the import started ([`Session::clock`]), for [`commit_prepared`]. - pub fn now(&self) -> &str { - &self.now - } - - /// What the library held when the import was set up, by path and by content: made once, with - /// the first files (on the thread that prepares them), from the library as it was then. The - /// snapshot comes back: the files the import is adding are ranked against it for the ownership - /// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]). - fn look_up_library(&mut self) -> Option { - let (library, fresh) = self.library.take()?; - for p in library.photos() { - // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate - if p.deleted && fresh { - continue; - } - if let Source::File { path } = &p.source { - self.by_path.insert(path.clone(), (p.id, p.local)); - } - if let Some(h) = &p.content_hash { - self.by_hash.insert(h.clone(), Some(p.id)); - } - } - Some(library) - } - - /// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned. - pub fn prepare_files(&mut self, files: Vec, cancel: &std::sync::atomic::AtomicBool) -> Prepared { - use std::sync::atomic::Ordering::Relaxed; - // the library as it was, kept for the ownership of the shared stem sidecars below - let library = self.look_up_library(); - let opts = self.opts.clone(); - let mode = self.mode; - let mut out = Prepared { scanned: files.len(), items: Vec::new() }; - let mut todo = Vec::new(); - for f in files { - match self.by_path.get(f.as_str()).copied() { - Some((id, true)) if !opts.local => { - // a file that was only browsed (Local) joins the library when it is imported for real - self.by_path.insert(f, (id, false)); - out.items.push(PreparedItem::Promote(id)); - self.done.fetch_add(1, Relaxed); - } - Some((id, _)) => { - out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None }); - self.done.fetch_add(1, Relaxed); - } - None => todo.push(f), - } - } - - // files a preceding scan already probed are not read again (a network share is slow to read), - // as long as they are still there, as they were: a file gone or changed since is probed again - let cached: Vec> = todo - .iter() - .map(|f| { - // (the web's files are not std::fs files: kept as probed) - self.cache - .remove(f) - .filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size)) - }) - .collect(); - let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); - // Reading the files is the slow part of adding them where they are: it is counted on its - // own, and `cancel` stops it too (a large library takes long enough to want both). - self.read_total.fetch_add(missing.len(), Relaxed); - let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter(); - let probed: Vec> = - cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); - crate::memory::release(); - // Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones - // arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar - // and never the raw's packet (issue #1905). - let stems = crate::sidecar::ImportStems::for_import( - library.as_ref(), - todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))), - ); - // Files are planned one by one, in order (duplicates, sidecars, names, folders); their - // copies or moves — the slow part, reading and writing every byte and verifying it — are - // collected and run on several threads (issue #367), then their outcomes take their places. - let mut plan = Plan::default(); - for (path, info) in todo.into_iter().zip(probed) { - if cancel.load(Relaxed) { - break; - } - let mut info = match info { - Ok(i) => i, - Err(e) => { - log::warn!("import {path}: {e}"); - self.done.fetch_add(1, Relaxed); - plan.slots.push(Slot::Item(PreparedItem::Failed(path, e))); - continue; - } - }; - // a copy of a file whose transfer is still pending: a duplicate only if that one succeeds - if !opts.local - && let Some(h) = &info.content_hash - && plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h)) - { - self.run_plan(&mut plan, &mut out, cancel); - if cancel.load(Relaxed) { - break; - } - } - if let Some(h) = &info.content_hash - && !opts.local - && let Some(existing) = self.by_hash.get(h) - { - self.done.fetch_add(1, Relaxed); - plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) })); - continue; - } - // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files - // and names the copy when the file itself has none - let raw = info.kind == MediaKind::Raw; - let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path) - .and_then(|f| std::fs::read_to_string(f).ok()) - .or_else(|| info.xmp.clone().filter(|_| raw)); - let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); - let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { - Ok(sc) => Some(sc), - Err(e) => { - log::warn!("import {path}: XMP: {e}"); - None - } - }); - if info.captured.is_none() { - info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); - } - // no date in the file or its sidecar: the file's own date, as Lightroom does - if info.captured.is_none() { - info.captured = info.file_modified.clone(); - } - let stored = match (mode, &self.copy_root) { - (ImportMode::Copy | ImportMode::Move, Some(root)) - if !crate::import_move::inside(Path::new(&path), root) - && !self.lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) => - { - // the templates see the photo as it will be catalogued (undated: the import time) - let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &self.now); - q.captured = info.captured.clone(); - q.meta = info.meta.clone(); - let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| { - let n = crate::rename::expand(t, &q, self.seq); - self.seq += 1; - n - }); - let folders = opts.organize.folders(&q); - let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); - // the name this file gets depends on what a pending transfer of a like-named file - // into the same folder ends up with: that one goes first - let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() }); - let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named)); - if plan.transfers.iter().any(|t| t.family == family) { - self.run_plan(&mut plan, &mut out, cancel); - if cancel.load(Relaxed) { - break; - } - } - let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) { - How::Place { name: name.unwrap_or(own) } - } else { - if mode == ImportMode::Move { - let reason = "a symbolic link: the file it points to was copied, the link and its target stay"; - plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); - } - How::Copy { name } - }; - // Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace) - let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG"); - plan.slots.push(Slot::Transfer); - plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family }); - continue; - } - (ImportMode::Move, _) => { - let reason = "already inside the destination or the library: added where it is"; - plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); - path.clone() - } - _ => path.clone(), - }; - if let Some(h) = &info.content_hash { - self.by_hash.insert(h.clone(), None); - } - self.done.fetch_add(1, Relaxed); - plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None })))); - } - self.run_plan(&mut plan, &mut out, cancel); - out - } - - /// Run the transfers `plan` collected (on several threads) and append its items to `out` in - /// file order. Transfers not started because `cancel` was set leave no item. - fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) { - let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter(); - for slot in std::mem::take(&mut plan.slots) { - let item = match slot { - Slot::Item(it) => it, - Slot::Transfer => match done.next().flatten() { - Some(it) => it, - None => continue, - }, - }; - if let PreparedItem::Ready(r) = &item - && let Some(h) = &r.info.content_hash - { - self.by_hash.insert(h.clone(), None); - } - out.items.push(item); - } - } -} - -/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]). -#[derive(Default)] -struct Plan { - slots: Vec, - transfers: Vec, -} - -enum Slot { - Item(PreparedItem), - /// The outcome of the next of [`Plan::transfers`]. - Transfer, -} - -/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]). -struct Transfer { - path: String, - info: ProbeInfo, - sidecar: Option, - dir: PathBuf, - how: How, - /// Copy as DNG. - dng: bool, - /// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's - /// names, so they never run at the same time. - family: (String, String), -} - -enum How { - /// A verified copy, under `name` (default: the file's own). - Copy { name: Option }, - /// Move: placed (linked or copied) without touching the source. - Place { name: String }, -} - -/// The names a file called `name` could end up with, as one key: the transfers number a taken name -/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`, -/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems). -fn name_family(name: &str) -> String { - let mut stem = match name.rsplit_once('.') { - Some((s, _)) if !s.is_empty() => s, - _ => name, - }; - while let Some((head, tail)) = stem.rsplit_once('-') - && !head.is_empty() - && !tail.is_empty() - && tail.bytes().all(|b| b.is_ascii_digit()) - { - stem = head; - } - stem.to_lowercase() -} - -/// Threads for `jobs` file reads (probes, copies) at once. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn workers(jobs: usize) -> usize { - std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1)) -} - -/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]): -/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few -/// enough that each batch soon joins the catalog. -pub fn batch_size() -> usize { - #[cfg(not(target_arch = "wasm32"))] - { - workers(usize::MAX).saturating_mul(2).clamp(8, 16) - } - #[cfg(target_arch = "wasm32")] - { - 8 - } -} - -/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one -/// not started because `cancel` was set. `done` counts each one finished. -fn run_transfers( - transfers: Vec, - file_bytes: Option<&crate::merge::ByteReader>, - cancel: &std::sync::atomic::AtomicBool, - done: &std::sync::atomic::AtomicUsize, -) -> Vec> { - use std::sync::atomic::Ordering::Relaxed; - let one = |t: Transfer| { - if cancel.load(Relaxed) { - return None; - } - let path = t.path.clone(); - // last-resort guard: a panic is this file's failure, not the import's - let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e)); - done.fetch_add(1, Relaxed); - Some(item) - }; - #[cfg(not(target_arch = "wasm32"))] - { - let n = workers(transfers.len()); - if n > 1 { - let mut results: Vec> = std::iter::repeat_with(|| None).take(transfers.len()).collect(); - let queue = std::sync::Mutex::new(transfers.into_iter().enumerate()); - let out = std::sync::Mutex::new(&mut results); - // (the tests' injected faults hold for the workers too) - #[cfg(test)] - let fault = crate::import_move::injected_fault(); - let work = &|| { - #[cfg(test)] - crate::import_move::inject(fault); - loop { - let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next(); - let Some((i, t)) = next else { break }; - let r = one(t); - if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) { - *slot = r; - } - } - }; - std::thread::scope(|sc| { - let handles: Vec<_> = - (0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect(); - if handles.is_empty() { - // no thread to be had: copy here, one after another - work(); - } - for h in handles { - if h.join().is_err() { - log::error!("import: a copy worker stopped unexpectedly"); - } - } - }); - return results; - } - } - transfers.into_iter().map(one).collect() -} - -/// Copy or move one planned file (and Copy as DNG); its item. -fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem { - let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t; - let mut placed = None; - let mut stored = match how { - How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) { - Ok(pl) => { - let dst = pl.dst.to_string_lossy().to_string(); - placed = Some(pl); - dst - } - Err(e) => return PreparedItem::Failed(path, e), - }, - How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) { - Ok(p) => p, - Err(e) => return PreparedItem::Failed(path, e), - }, - }; - if dng { - // decoding the raw twice and encoding it takes several times the file's size in memory - let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6); - let _permit = crate::memory::work_gate().acquire(weight); - match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) { - Ok(dng) => { - let _ = std::fs::remove_file(&stored); - stored = dng; - info.format = "DNG".into(); - } - Err(e) => log::warn!("import {path}: copy as DNG: {e}"), - } - } - PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })) -} - -/// A trashed photo's file is not where the library had it (never on the web, where paths are -/// storage keys, not files). -fn trashed_file_is_gone(cat: &lightcraft_catalog::Catalog, id: PhotoId) -> bool { - !cfg!(target_arch = "wasm32") - && match cat.photo(id).map(|p| &p.source) { - Some(Source::File { path }) => !Path::new(path).exists(), - _ => false, - } -} - -/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op; -/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are -/// taken back. -pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepared: Prepared) -> crate::Result { - let now = now.to_string(); - let mut report = ImportReport { scanned: prepared.scanned, ..Default::default() }; - let mut ops = Vec::new(); - // Move: placed at the destination, sources untouched until the records are committed - let mut placed: Vec = Vec::new(); - // photos added by this batch, by content (a duplicate of a file earlier in the import names it) - let mut new_hash: HashMap = HashMap::new(); - // Fresh: trashed photos a new import replaces (by content or by file), each removed once - let (mut trash_hash, mut trash_path): (HashMap, HashMap) = Default::default(); - if opts.on_deleted == OnDeleted::Fresh { - for p in s.catalog.photos().filter(|p| p.deleted) { - if let Some(h) = &p.content_hash { - trash_hash.insert(h.clone(), p.id); - } - if let Source::File { path } = &p.source { - trash_path.insert(path.clone(), p.id); - } - } - } - let mut purge: Vec = Vec::new(); - let mut purged: std::collections::HashSet = Default::default(); - for it in prepared.items { - match it { - PreparedItem::Promote(id) => { - if s.catalog.photo(id).is_some_and(|p| p.local) { - ops.push(Op::SetLocal { id, local: false }); - } - report.imported.push(id.0); - } - PreparedItem::Duplicate { path, existing, reason, hash } => { - let existing = existing.or_else(|| { - let h = hash?; - new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id)) - }); - let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted)); - match trashed { - // (browsing a folder in Local only looks: it never restores) - Some(id) if opts.on_deleted == OnDeleted::Restore && !opts.local => { - if !report.restored.contains(&id.0) { - ops.push(Op::SetDeleted { id, deleted: false }); - // matched by content from another path, and the file the record - // points at is gone (moved, renamed): the photo follows the file - if reason == "content" && trashed_file_is_gone(&s.catalog, id) { - ops.push(crate::cmd::missing::relink_op(id, &path)); - } - report.restored.push(id.0); - } - } - _ => report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason, existing_deleted: trashed.is_some() }), - } - } - PreparedItem::Failed(path, e) => report.failed.push((path, e)), - PreparedItem::Kept(k) => report.kept.push(k), - PreparedItem::Ready(r) => { - let ReadyFile { path, stored, info, sidecar, placed: pl } = *r; - placed.extend(pl); - let id = s.catalog.alloc_photo_id(); - if let Some(h) = &info.content_hash { - new_hash.insert(h.clone(), id); - } - for old in info.content_hash.as_ref().and_then(|h| trash_hash.get(h)).into_iter().chain(trash_path.get(&stored)) { - if purged.insert(*old) { - purge.push(*old); - } - } - let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default()); - // a copy is catalogued under its new name - let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); - let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now); - p.kind = info.kind; - p.file_size = info.file_size; - p.captured = info.captured; - p.meta = info.meta; - p.as_shot_wb = info.as_shot_wb; - p.content_hash = info.content_hash; - p.embedded_lens = info.embedded_lens; - p.set_preview_only(info.preview_only.clone()); - apply_import_defaults(s, &mut p); - if let Some(sc) = &sidecar { - crate::sidecar::merge_into(&mut p, sc, &now); - report.sidecars += 1; - report.warnings.extend(sc.warnings.iter().cloned().map(|mut warning| { - warning.path = path.clone(); - warning - })); - } - if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { - crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); - } - for k in &opts.keywords { - let k = lightcraft_catalog::keywords::clean(k); - if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) { - p.meta.keywords.push(k); - } - } - if let Some(preset) = &opts.preset { - let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0)); - let label = format!("Preset: {}", preset.name); - p.develop = d.clone(); - p.edited = Some(now.clone()); - p.history.push(lightcraft_catalog::HistoryStep { label, settings: d }); - } - report.imported.push(id.0); - p.local = opts.local; - if opts.local { - // the state as browsed: a later change keeps the record from being forgotten - p.set_local_baseline(); - } - ops.push(Op::AddPhoto { photo: Box::new(p) }); - } - } - } - // (built against the catalog as it is: one op list for all of them, ahead of the additions) - if !purge.is_empty() { - ops.insert(0, s.catalog.delete_photos_permanently_ops(&purge)); - } - let log0 = s.pending_log.len(); - let (verb, n) = if report.imported.is_empty() && !report.restored.is_empty() { - ("Restore", report.restored.len()) - } else { - ("Add", report.imported.len().max(1)) - }; - if !ops.is_empty() - && let Err(e) = s.commit(&format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops }) - { - // nothing was catalogued: take the moves back (their sources were never touched) - placed.iter().for_each(crate::import_move::rollback); - return Err(e); - } - if !placed.is_empty() { - finish_moves(s, placed, log0, &mut report); - } - Ok(report) -} - -/// Move, after the commit: save the library, then remove each source whose destination is -/// still intact. Nothing is removed when the library can't be saved. -fn finish_moves(s: &mut Session, placed: Vec, log0: usize, report: &mut ImportReport) { - // (saving here clears the pending ops, so the sidecars `execute` would auto-write are written here) - let auto: Vec = if s.xmp.auto_write { s.pending_log.get(log0..).map(<[Op]>::to_vec).unwrap_or_default() } else { Vec::new() }; - let before = s.pending_log.len(); - if let Err(e) = s.persist() { - for p in placed { - let reason = format!("the library could not be saved ({e}): the original stays; the photo uses the copy at {}", p.dst.display()); - report.kept.push(Kept { path: p.src.to_string_lossy().to_string(), reason }); - } - return; - } - for p in placed { - let from = p.src.to_string_lossy().to_string(); - match crate::import_move::finish(&p) { - Ok(kept) => { - let sidecars = p.sidecars.iter().map(|sc| sc.dst.to_string_lossy().to_string()).collect(); - report.moved.push(Moved { from, to: p.dst.to_string_lossy().to_string(), sidecars }); - report.kept.extend(kept.into_iter().map(|(path, reason)| Kept { path, reason })); - } - Err(reason) => { - log::warn!("import: move {from}: {reason}"); - report.kept.push(Kept { path: from, reason }); - } - } - } - if s.pending_log.len() < before && s.interaction.is_none() && !auto.is_empty() { - s.auto_write_sidecars(&auto); - } -} - -/// The current local time as ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, UTC on targets without a clock -/// offset); for [`Session::clock`] on native hosts. -pub fn system_clock() -> String { - #[cfg(not(target_arch = "wasm32"))] - { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); - civil(secs) - } - #[cfg(target_arch = "wasm32")] - { - "2026-01-01T00:00:00".to_string() - } -} - -/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian. -pub fn civil(secs: i64) -> String { - lightcraft_catalog::dates::civil(secs) -} - -impl Session { - /// A thumbnail for an import candidate (not in the catalog): a raw's embedded preview, else a - /// small render of the file. `slot` makes the job's photo id unique (`u64::MAX - slot`). - pub fn candidate_thumb_job(&mut self, c: &ImportCandidate, edge: usize, slot: u64) -> Option { - if c.error.is_some() { - return None; - } - let id = PhotoId(u64::MAX - slot); - let mut p = Photo::new(id, Source::File { path: c.path.clone() }, &c.name, &c.format, c.width.max(1), c.height.max(1), ""); - p.kind = c.kind; - if let Some(info) = self.import_probes.get(&c.path) { - p.as_shot_wb = info.as_shot_wb; - p.embedded_lens = info.embedded_lens; - p.preview_only = info.preview_only.clone(); - } - p.develop = std::sync::Arc::new(p.import_defaults()); - let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge()); - let level = crate::media::SourceLevel::Thumb; - let source = self.media.origin_ref(&p.source, level.max_edge()); - let key = lightcraft_preview::Hasher128::new().str(&c.path).u64(c.file_size).u64(edge as u64).finish().0 as u64; - let small = crate::media::RenderJob { - request_id: 0, - cache_generation: self.media.rendered.generation(), - source_key: None, - photo: id, - level, - source, - origin: p.source.clone(), - info: crate::media::source_info(&p), - settings: p.develop.clone(), - request: lightcraft_pipeline::RenderRequest::fit(edge, edge), - key, - cache: None, - stages: None, - view_cache: None, - display: None, - slide: None, - preview_cut: None, - display_fit: None, - }; - let embedded = match (&self.media.preview_loader, c.kind) { - (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), - _ => None, - }; - Some(crate::media::QuickJob { request_id: 0, photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) }) - } - - /// Use the system clock for import/edit times (native hosts). - pub fn with_system_clock(mut self) -> Self { - self.clock = Box::new(system_clock); - self - } -} +pub(crate) use commit::workers; +pub use commit::{batch_size, civil, commit_prepared, system_clock}; +pub use defaults::{CameraDefault, ImportDefaults, apply_import_defaults, has_import_look, import_defaults}; +pub use files::{EXTENSIONS, LaunchPath, accept_list, expand, expand_within, is_supported, launch_path}; +pub(crate) use files::{expand_noting, probe_all, probe_paths, probe_with}; +#[cfg(test)] +use job::PreparedItem; +pub use job::{ImportJob, Prepared, import, import_with}; +pub(crate) use scan::scan_files; +pub use scan::{ScanInput, ScanOutput, ScanProgress, scan, scan_with}; +#[cfg(test)] +use scan::{library_files, library_hashes}; +pub use types::{ + Duplicate, ImportCandidate, ImportMode, ImportOptions, ImportReport, ImportUprightMode, ImportWarning, ImportWarningCode, Kept, Moved, OnDeleted, + Organize, +}; #[cfg(test)] #[path = "import/tests_known.rs"] mod tests_known; - -#[cfg(test)] -mod prepared_tests { - use super::*; - - /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the - /// files this build cannot import (see [`accept_list`]). - fn pickable_extensions() -> Vec<&'static str> { - let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); - EXTENSIONS - .iter() - .copied() - .filter(|ext| match *ext { - "avif" => false, - "heic" | "heif" => heif_decodable, - _ => true, - }) - .collect() - } - - #[test] - fn accept_list_covers_every_pickable_extension_in_both_cases() { - let list = accept_list(); - for ext in pickable_extensions() { - assert!(list.contains(&format!(".{ext}")), "lowercase .{ext} missing from accept list"); - assert!(list.contains(&format!(".{}", ext.to_uppercase())), "uppercase .{} missing from accept list", ext.to_uppercase()); - } - } - - /// The web picker must not offer files this build cannot import: `.avif` has - /// no AV1 decoder in any build, so it is never listed, in either case. - #[test] - fn accept_list_never_offers_avif() { - let list = accept_list(); - assert!(!list.contains(".avif"), "{list}"); - assert!(!list.contains(".AVIF"), "{list}"); - } - - /// `.heic`/`.heif` follow the codecs' `heif` feature: listed (in both cases) - /// only when this build can decode them; otherwise hidden, so a web user - /// cannot pick a file that would fail to import. - #[test] - fn accept_list_offers_heic_heif_only_when_decodable() { - let list = accept_list(); - let listed = lightcraft_codecs::Format::Heif.can_decode(); - for ext in ["heic", "heif"] { - assert_eq!(list.contains(&format!(".{ext}")), listed, "lowercase .{ext}"); - assert_eq!(list.contains(&format!(".{}", ext.to_uppercase())), listed, "uppercase .{ext}"); - } - } - - #[test] - fn revalidate_add_only_converts_matching_ready_item() { - let mut s = Session::new(); - let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg"); - let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg"); - let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now"); - s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap(); - let ready = |path: &Path| ReadyFile { - path: path.to_string_lossy().into(), - stored: path.to_string_lossy().into(), - info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() }, - sidecar: None, - placed: None, - }; - let mut prepared = Prepared { - scanned: 2, - items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))], - }; - prepared.revalidate_add(&s); - assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); - assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); - } - - fn unread_paths(n: usize) -> Vec { - (0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect() - } - - /// Reading the files of a large import (a Lightroom catalog's originals) reported nothing: - /// the progress stood still at "14 of 24554" for as long as it took. - #[test] - fn prepare_counts_the_files_it_reads() { - use std::sync::atomic::Ordering::Relaxed; - let mut s = Session::new(); - let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))); - let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); - let (done, last) = (job.read_done.clone(), done_in_probe.clone()); - job.probe = Some(std::sync::Arc::new(move |_: &str| { - seen_in_probe.fetch_add(1, Relaxed); - last.fetch_max(done.load(Relaxed), Relaxed); - Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) - })); - let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false)); - assert_eq!(prepared.items.len(), 40); - assert_eq!(seen.load(Relaxed), 40); - assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read"); - assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted"); - assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own"); - } - - /// Cancel had no effect until every file had been read. - #[test] - fn cancel_stops_reading_files() { - use std::sync::atomic::Ordering::Relaxed; - let mut s = Session::new(); - let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); - let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone()); - job.probe = Some(std::sync::Arc::new(move |_: &str| { - seen_in_probe.fetch_add(1, Relaxed); - cancel_in_probe.store(true, Relaxed); - Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) - })); - let prepared = job.prepare(&unread_paths(400), &cancel); - // at most the files the (up to 8) readers had already started - assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed)); - assert!(prepared.items.is_empty()); - } -} diff --git a/crates/engine/src/import/commit.rs b/crates/engine/src/import/commit.rs new file mode 100644 index 000000000..bed8356fb --- /dev/null +++ b/crates/engine/src/import/commit.rs @@ -0,0 +1,371 @@ +use super::defaults::apply_import_defaults; +use super::files::copy_into; +use super::job::{Prepared, PreparedItem, ReadyFile}; +use super::types::{Duplicate, ImportOptions, ImportReport, Kept, Moved, OnDeleted}; +use super::*; + +/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]). +#[derive(Default)] +pub(super) struct Plan { + pub(super) slots: Vec, + pub(super) transfers: Vec, +} + +pub(super) enum Slot { + Item(PreparedItem), + /// The outcome of the next of [`Plan::transfers`]. + Transfer, +} + +/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]). +pub(super) struct Transfer { + pub(super) path: String, + pub(super) info: ProbeInfo, + pub(super) sidecar: Option, + pub(super) dir: PathBuf, + pub(super) how: How, + /// Copy as DNG. + pub(super) dng: bool, + /// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's + /// names, so they never run at the same time. + pub(super) family: (String, String), +} + +pub(super) enum How { + /// A verified copy, under `name` (default: the file's own). + Copy { name: Option }, + /// Move: placed (linked or copied) without touching the source. + Place { name: String }, +} + +/// The names a file called `name` could end up with, as one key: the transfers number a taken name +/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`, +/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems). +pub(super) fn name_family(name: &str) -> String { + let mut stem = match name.rsplit_once('.') { + Some((s, _)) if !s.is_empty() => s, + _ => name, + }; + while let Some((head, tail)) = stem.rsplit_once('-') + && !head.is_empty() + && !tail.is_empty() + && tail.bytes().all(|b| b.is_ascii_digit()) + { + stem = head; + } + stem.to_lowercase() +} + +/// Threads for `jobs` file reads (probes, copies) at once. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn workers(jobs: usize) -> usize { + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1)) +} + +/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]): +/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few +/// enough that each batch soon joins the catalog. +pub fn batch_size() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + workers(usize::MAX).saturating_mul(2).clamp(8, 16) + } + #[cfg(target_arch = "wasm32")] + { + 8 + } +} + +/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one +/// not started because `cancel` was set. `done` counts each one finished. +pub(super) fn run_transfers( + transfers: Vec, + file_bytes: Option<&crate::merge::ByteReader>, + cancel: &std::sync::atomic::AtomicBool, + done: &std::sync::atomic::AtomicUsize, +) -> Vec> { + use std::sync::atomic::Ordering::Relaxed; + let one = |t: Transfer| { + if cancel.load(Relaxed) { + return None; + } + let path = t.path.clone(); + // last-resort guard: a panic is this file's failure, not the import's + let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e)); + done.fetch_add(1, Relaxed); + Some(item) + }; + #[cfg(not(target_arch = "wasm32"))] + { + let n = workers(transfers.len()); + if n > 1 { + let mut results: Vec> = std::iter::repeat_with(|| None).take(transfers.len()).collect(); + let queue = std::sync::Mutex::new(transfers.into_iter().enumerate()); + let out = std::sync::Mutex::new(&mut results); + // (the tests' injected faults hold for the workers too) + #[cfg(test)] + let fault = crate::import_move::injected_fault(); + let work = &|| { + #[cfg(test)] + crate::import_move::inject(fault); + loop { + let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next(); + let Some((i, t)) = next else { break }; + let r = one(t); + if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) { + *slot = r; + } + } + }; + std::thread::scope(|sc| { + let handles: Vec<_> = + (0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect(); + if handles.is_empty() { + // no thread to be had: copy here, one after another + work(); + } + for h in handles { + if h.join().is_err() { + log::error!("import: a copy worker stopped unexpectedly"); + } + } + }); + return results; + } + } + transfers.into_iter().map(one).collect() +} + +/// Copy or move one planned file (and Copy as DNG); its item. +fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem { + let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t; + let mut placed = None; + let mut stored = match how { + How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) { + Ok(pl) => { + let dst = pl.dst.to_string_lossy().to_string(); + placed = Some(pl); + dst + } + Err(e) => return PreparedItem::Failed(path, e), + }, + How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) { + Ok(p) => p, + Err(e) => return PreparedItem::Failed(path, e), + }, + }; + if dng { + // decoding the raw twice and encoding it takes several times the file's size in memory + let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6); + let _permit = crate::memory::work_gate().acquire(weight); + match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) { + Ok(dng) => { + let _ = std::fs::remove_file(&stored); + stored = dng; + info.format = "DNG".into(); + } + Err(e) => log::warn!("import {path}: copy as DNG: {e}"), + } + } + PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })) +} + +/// A trashed photo's file is not where the library had it (never on the web, where paths are +/// storage keys, not files). +fn trashed_file_is_gone(cat: &lightcraft_catalog::Catalog, id: PhotoId) -> bool { + !cfg!(target_arch = "wasm32") + && match cat.photo(id).map(|p| &p.source) { + Some(Source::File { path }) => !Path::new(path).exists(), + _ => false, + } +} + +/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op; +/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are +/// taken back. +pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepared: Prepared) -> crate::Result { + let now = now.to_string(); + let mut report = ImportReport { scanned: prepared.scanned, ..Default::default() }; + let mut ops = Vec::new(); + // Move: placed at the destination, sources untouched until the records are committed + let mut placed: Vec = Vec::new(); + // photos added by this batch, by content (a duplicate of a file earlier in the import names it) + let mut new_hash: HashMap = HashMap::new(); + // Fresh: trashed photos a new import replaces (by content or by file), each removed once + let (mut trash_hash, mut trash_path): (HashMap, HashMap) = Default::default(); + if opts.on_deleted == OnDeleted::Fresh { + for p in s.catalog.photos().filter(|p| p.deleted) { + if let Some(h) = &p.content_hash { + trash_hash.insert(h.clone(), p.id); + } + if let Source::File { path } = &p.source { + trash_path.insert(path.clone(), p.id); + } + } + } + let mut purge: Vec = Vec::new(); + let mut purged: std::collections::HashSet = Default::default(); + for it in prepared.items { + match it { + PreparedItem::Promote(id) => { + if s.catalog.photo(id).is_some_and(|p| p.local) { + ops.push(Op::SetLocal { id, local: false }); + } + report.imported.push(id.0); + } + PreparedItem::Duplicate { path, existing, reason, hash } => { + let existing = existing.or_else(|| { + let h = hash?; + new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id)) + }); + let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted)); + match trashed { + // (browsing a folder in Local only looks: it never restores) + Some(id) if opts.on_deleted == OnDeleted::Restore && !opts.local => { + if !report.restored.contains(&id.0) { + ops.push(Op::SetDeleted { id, deleted: false }); + // matched by content from another path, and the file the record + // points at is gone (moved, renamed): the photo follows the file + if reason == "content" && trashed_file_is_gone(&s.catalog, id) { + ops.push(crate::cmd::missing::relink_op(id, &path)); + } + report.restored.push(id.0); + } + } + _ => report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason, existing_deleted: trashed.is_some() }), + } + } + PreparedItem::Failed(path, e) => report.failed.push((path, e)), + PreparedItem::Kept(k) => report.kept.push(k), + PreparedItem::Ready(r) => { + let ReadyFile { path, stored, info, sidecar, placed: pl } = *r; + placed.extend(pl); + let id = s.catalog.alloc_photo_id(); + if let Some(h) = &info.content_hash { + new_hash.insert(h.clone(), id); + } + for old in info.content_hash.as_ref().and_then(|h| trash_hash.get(h)).into_iter().chain(trash_path.get(&stored)) { + if purged.insert(*old) { + purge.push(*old); + } + } + let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default()); + // a copy is catalogued under its new name + let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); + let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now); + p.kind = info.kind; + p.file_size = info.file_size; + p.captured = info.captured; + p.meta = info.meta; + p.as_shot_wb = info.as_shot_wb; + p.content_hash = info.content_hash; + p.embedded_lens = info.embedded_lens; + p.set_preview_only(info.preview_only.clone()); + apply_import_defaults(s, &mut p); + if let Some(sc) = &sidecar { + crate::sidecar::merge_into(&mut p, sc, &now); + report.sidecars += 1; + report.warnings.extend(sc.warnings.iter().cloned().map(|mut warning| { + warning.path = path.clone(); + warning + })); + } + if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { + crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); + } + for k in &opts.keywords { + let k = lightcraft_catalog::keywords::clean(k); + if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) { + p.meta.keywords.push(k); + } + } + if let Some(preset) = &opts.preset { + let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0)); + let label = format!("Preset: {}", preset.name); + p.develop = d.clone(); + p.edited = Some(now.clone()); + p.history.push(lightcraft_catalog::HistoryStep { label, settings: d }); + } + report.imported.push(id.0); + p.local = opts.local; + if opts.local { + // the state as browsed: a later change keeps the record from being forgotten + p.set_local_baseline(); + } + ops.push(Op::AddPhoto { photo: Box::new(p) }); + } + } + } + // (built against the catalog as it is: one op list for all of them, ahead of the additions) + if !purge.is_empty() { + ops.insert(0, s.catalog.delete_photos_permanently_ops(&purge)); + } + let log0 = s.pending_log.len(); + let (verb, n) = if report.imported.is_empty() && !report.restored.is_empty() { + ("Restore", report.restored.len()) + } else { + ("Add", report.imported.len().max(1)) + }; + if !ops.is_empty() + && let Err(e) = s.commit(&format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops }) + { + // nothing was catalogued: take the moves back (their sources were never touched) + placed.iter().for_each(crate::import_move::rollback); + return Err(e); + } + if !placed.is_empty() { + finish_moves(s, placed, log0, &mut report); + } + Ok(report) +} + +/// Move, after the commit: save the library, then remove each source whose destination is +/// still intact. Nothing is removed when the library can't be saved. +fn finish_moves(s: &mut Session, placed: Vec, log0: usize, report: &mut ImportReport) { + // (saving here clears the pending ops, so the sidecars `execute` would auto-write are written here) + let auto: Vec = if s.xmp.auto_write { s.pending_log.get(log0..).map(<[Op]>::to_vec).unwrap_or_default() } else { Vec::new() }; + let before = s.pending_log.len(); + if let Err(e) = s.persist() { + for p in placed { + let reason = format!("the library could not be saved ({e}): the original stays; the photo uses the copy at {}", p.dst.display()); + report.kept.push(Kept { path: p.src.to_string_lossy().to_string(), reason }); + } + return; + } + for p in placed { + let from = p.src.to_string_lossy().to_string(); + match crate::import_move::finish(&p) { + Ok(kept) => { + let sidecars = p.sidecars.iter().map(|sc| sc.dst.to_string_lossy().to_string()).collect(); + report.moved.push(Moved { from, to: p.dst.to_string_lossy().to_string(), sidecars }); + report.kept.extend(kept.into_iter().map(|(path, reason)| Kept { path, reason })); + } + Err(reason) => { + log::warn!("import: move {from}: {reason}"); + report.kept.push(Kept { path: from, reason }); + } + } + } + if s.pending_log.len() < before && s.interaction.is_none() && !auto.is_empty() { + s.auto_write_sidecars(&auto); + } +} + +/// The current local time as ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, UTC on targets without a clock +/// offset); for [`Session::clock`] on native hosts. +pub fn system_clock() -> String { + #[cfg(not(target_arch = "wasm32"))] + { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + civil(secs) + } + #[cfg(target_arch = "wasm32")] + { + "2026-01-01T00:00:00".to_string() + } +} + +/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian. +pub fn civil(secs: i64) -> String { + lightcraft_catalog::dates::civil(secs) +} diff --git a/crates/engine/src/import/defaults.rs b/crates/engine/src/import/defaults.rs new file mode 100644 index 000000000..7ca5cecc4 --- /dev/null +++ b/crates/engine/src/import/defaults.rs @@ -0,0 +1,99 @@ +use super::*; + +/// The develop settings a photo gets on import: raws start from their as-shot white balance with +/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the +/// camera intended); a user default preset ([`ImportDefaults`]) goes on top. +pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings { + p.import_defaults() +} + +/// Does the photo still look as the camera rendered it (its embedded camera preview is then a +/// fair stand-in)? Not when a default preset changed it on import. +pub fn has_import_look(p: &Photo) -> bool { + *p.develop == p.camera_defaults() +} + +/// User defaults applied on import (Settings → Import; saved in the library's `prefs.json`). +/// Presets are referenced by id; a preset that no longer exists is ignored. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ImportDefaults { + /// Preset applied to raw files (`None` = the LightCraft default). + pub raw_preset: Option, + /// Use a camera's own default (below) when the photo's camera has one. + pub per_camera: bool, + /// Per-camera raw defaults, keyed by the camera's make + model as shown in Info + /// (`"Canon EOS R5"`). + pub cameras: Vec, + /// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none). + pub other_preset: Option, + /// Copyright notice given to imported photos that don't carry one (empty = none). + pub copyright: String, + /// Creator given to imported photos that don't name one (empty = none). + pub creator: String, + /// Metadata preset applied to every imported photo (`metadata.*`; `None` = none). + pub metadata_preset: Option, + /// Auto import: a watched folder whose new photos are added as they arrive (`None` = off), + /// copied into the library's Originals instead of added in place when `auto_copy`, and an + /// album they go to. + pub auto_folder: Option, + pub auto_copy: bool, + pub auto_album: Option, + /// Open the import review when a camera or memory card is connected. + pub show_on_card: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CameraDefault { + /// Make + model (`Meta::camera`). + pub camera: String, + /// Preset id; `None` = the LightCraft default for this camera. + pub preset: Option, +} + +impl ImportDefaults { + /// The preset id that applies to a photo of this kind and camera, if any. + pub fn preset_for(&self, raw: bool, camera: &str) -> Option<&str> { + if !raw { + return self.other_preset.as_deref(); + } + if self.per_camera + && let Some(c) = self.cameras.iter().find(|c| !camera.is_empty() && c.camera.eq_ignore_ascii_case(camera)) + { + return c.preset.as_deref(); + } + self.raw_preset.as_deref() + } +} + +/// Give a freshly imported photo its default settings: the camera defaults, then the matching +/// default preset ([`ImportDefaults::preset_for`]), remembered as the photo's import look. +pub fn apply_import_defaults(s: &Session, p: &mut Photo) { + // metadata defaults fill gaps only: the file's own copyright / creator win + let d = &s.import_defaults; + if p.meta.copyright.trim().is_empty() && !d.copyright.trim().is_empty() { + p.meta.copyright = d.copyright.trim().to_string(); + } + if p.meta.creator.trim().is_empty() && !d.creator.trim().is_empty() { + p.meta.creator = d.creator.trim().to_string(); + } + if let Some(mp) = d.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { + crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); + } + let base = p.camera_defaults(); + // a raw shown from its embedded JPEG gets the rendered-file default, not the raw one + let raw = p.develops_raw(); + let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id)); + match preset { + Some(pr) => { + let look = std::sync::Arc::new(pr.apply(&base, 1.0)); + p.develop = look.clone(); + p.import_look = (*look != base).then_some(look); + } + None => { + p.develop = std::sync::Arc::new(base); + p.import_look = None; + } + } +} diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs new file mode 100644 index 000000000..2cbfc85e7 --- /dev/null +++ b/crates/engine/src/import/files.rs @@ -0,0 +1,372 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::commit::workers; +#[cfg(test)] +use super::job::{ImportJob, Prepared, PreparedItem, ReadyFile}; +use super::scan::ScanProgress; +#[cfg(test)] +use super::types::{ImportMode, ImportOptions}; +use super::*; + +/// File extensions offered by import discovery and file pickers (lower case). +/// This includes preview-only raws; the file probe decides whether sensor decoding is available. +pub const EXTENSIONS: &[&str] = &[ + "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef", + "srw", "psd", "psb", "jxl", "gif", "bmp", + // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as + // failed with the reason ("HEIC/HEIF support isn't included in this build"). + "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef", +]; + +pub fn is_supported(path: &Path) -> bool { + path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) +} + +/// The web file picker's accept list, derived from [`EXTENSIONS`]. Each extension +/// is listed in both cases: browsers' `accept` matching is case-insensitive per +/// spec, but some implementations (e.g. Windows/Edge) match case-sensitively and +/// rejected uppercase `.NEF` files, so a lowercase-only list silently hid raws +/// such as CR3, NRW, RAF, ORF, RW2, RWL, RAW, PEF and SRW from the picker. +/// +/// The picker only offers files this build can actually import: `.avif` has no +/// AV1 decoder in any build, and `.heic`/`.heif` decode only when the codecs' +/// `heif` feature is enabled ([`lightcraft_codecs::Format::Heif::can_decode`]). +/// [`EXTENSIONS`] itself stays complete — files on disk are still recognised, +/// and import reports them as failed, saying why. +pub fn accept_list() -> String { + let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); + let mut out = String::new(); + for &ext in EXTENSIONS { + // Skip extensions a web user could pick but this build could not import. + let pickable = match ext { + "avif" => false, + "heic" | "heif" => heif_decodable, + _ => true, + }; + if !pickable { + continue; + } + if !out.is_empty() { + out.push(','); + } + out.push('.'); + out.push_str(ext); + out.push(','); + out.push('.'); + out.push_str(&ext.to_uppercase()); + } + out +} + +/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder) +/// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a +/// walk that stops at a bound is logged. +pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec { + expand_within(paths, skip, crate::walk::Limits::default()).0 +} + +/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound). +pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec, bool) { + expand_noting(paths, skip, limits, None) +} + +/// [`expand_within`], handing the listing of every folder that is walked to `note` +/// ([`crate::walk::files_in_noting`]). +pub(crate) fn expand_noting( + paths: &[String], + skip: Option<&Path>, + limits: crate::walk::Limits, + mut note: Option, +) -> (Vec, bool) { + let mut out = Vec::new(); + let mut truncated = false; + for p in paths { + let p = Path::new(p); + if skip.is_some_and(|s| p == s) { + continue; + } + if p.is_dir() { + let w = match note.as_mut() { + Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note), + None => crate::walk::files_in(p, skip, limits, is_supported), + }; + if w.truncated { + log::warn!( + "import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)", + p.display(), + w.entries, + limits.max_entries, + limits.max_depth + ); + } + truncated |= w.truncated; + out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string())); + } else { + // explicitly named files are attempted even with an unknown extension (sniffed) + out.push(p.to_string_lossy().to_string()); + } + } + let mut seen = std::collections::HashSet::new(); + out.retain(|p| seen.insert(p.clone())); + (out, truncated) +} + +/// What a path given on the command line at launch becomes ([`launch_path`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LaunchPath { + /// Import this (absolute) file or folder, in the background. + Import(String), + /// Not imported: `path` (absolute) is a whole drive or the whole home folder, which is never + /// walked without being chosen in the app (issue #374). + Refused { path: String, why: String }, +} + +/// Resolve a launch argument for importing (issue #374). An empty argument is ignored (`None`); +/// a relative one (`.`, `..`, `photos`; `/` or `\` on Windows, the current drive's root) is +/// resolved against `cwd` — the working folder a launcher picked, often a drive root or +/// `C:\Windows\System32` — and refused when that leaves no folder (`cwd` unknown). A drive +/// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or +/// refused, and so is the home folder `home`: either would walk most of the disk. +pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option) -> Option { + if arg.trim().is_empty() { + return None; + } + let given = Path::new(arg); + let joined = if given.is_absolute() { + given.to_path_buf() + } else { + match cwd { + Some(c) => c.join(given), + None => return Some(LaunchPath::Refused { path: arg.to_string(), why: "a relative path, and the working folder is unknown".into() }), + } + }; + // `.` and `..` resolved by name (no file-system access) + let mut abs = PathBuf::new(); + for c in joined.components() { + match c { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + abs.pop(); + } + other => abs.push(other.as_os_str()), + } + } + let shown = abs.to_string_lossy().to_string(); + if abs.parent().is_none() { + return Some(match dcim(&abs) { + Some(d) => LaunchPath::Import(d.to_string_lossy().to_string()), + None => LaunchPath::Refused { path: shown, why: "a whole drive".into() }, + }); + } + let key = lightcraft_catalog::query::folder_key; + if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) { + return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() }); + } + Some(LaunchPath::Import(shown)) +} + +/// Probe files (headers + content hash) as an import would. +pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec> { + let progress = ScanProgress::default(); + probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel) +} + +/// Probe files with `probe` (a clone of the session's file probe), off the session: on a worker thread. +pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> Vec> { + let progress = ScanProgress::default(); + probe_all(Some(probe), paths, &progress.done, &progress.cancel) +} + +/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once +/// `cancel` is set: the files not reached come back as errors. +pub(crate) fn probe_all( + probe: Option<&crate::media::FileProbe>, + paths: &[String], + done: &std::sync::atomic::AtomicUsize, + cancel: &std::sync::atomic::AtomicBool, +) -> Vec> { + use std::sync::atomic::Ordering::Relaxed; + let Some(probe) = probe.cloned() else { + return paths + .iter() + .map(|p| { + Ok(ProbeInfo { + format: Path::new(p).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(), + ..Default::default() + }) + }) + .collect(); + }; + #[cfg(not(target_arch = "wasm32"))] + { + let n = workers(paths.len()); + if n > 1 { + let mut results: Vec>> = vec![None; paths.len()]; + let next = std::sync::atomic::AtomicUsize::new(0); + let out = std::sync::Mutex::new(&mut results); + std::thread::scope(|sc| { + for _ in 0..n { + sc.spawn(|| { + loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if i >= paths.len() { + break; + } + if cancel.load(Relaxed) { + break; + } + let r = probe(&paths[i]); + done.fetch_add(1, Relaxed); + out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r); + } + }); + } + }); + return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect(); + } + } + paths + .iter() + .map(|p| { + if cancel.load(Relaxed) { + return Err("cancelled".into()); + } + let r = probe(p); + done.fetch_add(1, Relaxed); + r + }) + .collect() +} + +/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the +/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and +/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte +/// with the source when there is none); a bad one is removed and the photo reported as failed. +pub(super) fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result { + let name = name + .map(str::to_string) + .unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into())); + let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse); + crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string()) +} + +#[cfg(test)] +mod prepared_tests { + use super::*; + + /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the + /// files this build cannot import (see [`accept_list`]). + fn pickable_extensions() -> Vec<&'static str> { + let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); + EXTENSIONS + .iter() + .copied() + .filter(|ext| match *ext { + "avif" => false, + "heic" | "heif" => heif_decodable, + _ => true, + }) + .collect() + } + + #[test] + fn accept_list_covers_every_pickable_extension_in_both_cases() { + let list = accept_list(); + for ext in pickable_extensions() { + assert!(list.contains(&format!(".{ext}")), "lowercase .{ext} missing from accept list"); + assert!(list.contains(&format!(".{}", ext.to_uppercase())), "uppercase .{} missing from accept list", ext.to_uppercase()); + } + } + + /// The web picker must not offer files this build cannot import: `.avif` has + /// no AV1 decoder in any build, so it is never listed, in either case. + #[test] + fn accept_list_never_offers_avif() { + let list = accept_list(); + assert!(!list.contains(".avif"), "{list}"); + assert!(!list.contains(".AVIF"), "{list}"); + } + + /// `.heic`/`.heif` follow the codecs' `heif` feature: listed (in both cases) + /// only when this build can decode them; otherwise hidden, so a web user + /// cannot pick a file that would fail to import. + #[test] + fn accept_list_offers_heic_heif_only_when_decodable() { + let list = accept_list(); + let listed = lightcraft_codecs::Format::Heif.can_decode(); + for ext in ["heic", "heif"] { + assert_eq!(list.contains(&format!(".{ext}")), listed, "lowercase .{ext}"); + assert_eq!(list.contains(&format!(".{}", ext.to_uppercase())), listed, "uppercase .{ext}"); + } + } + + #[test] + fn revalidate_add_only_converts_matching_ready_item() { + let mut s = Session::new(); + let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg"); + let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg"); + let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now"); + s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap(); + let ready = |path: &Path| ReadyFile { + path: path.to_string_lossy().into(), + stored: path.to_string_lossy().into(), + info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() }, + sidecar: None, + placed: None, + }; + let mut prepared = Prepared { + scanned: 2, + items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))], + }; + prepared.revalidate_add(&s); + assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); + assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); + } + + fn unread_paths(n: usize) -> Vec { + (0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect() + } + + /// Reading the files of a large import (a Lightroom catalog's originals) reported nothing: + /// the progress stood still at "14 of 24554" for as long as it took. + #[test] + fn prepare_counts_the_files_it_reads() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::new(); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let (done, last) = (job.read_done.clone(), done_in_probe.clone()); + job.probe = Some(std::sync::Arc::new(move |_: &str| { + seen_in_probe.fetch_add(1, Relaxed); + last.fetch_max(done.load(Relaxed), Relaxed); + Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) + })); + let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false)); + assert_eq!(prepared.items.len(), 40); + assert_eq!(seen.load(Relaxed), 40); + assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read"); + assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted"); + assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own"); + } + + /// Cancel had no effect until every file had been read. + #[test] + fn cancel_stops_reading_files() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::new(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone()); + job.probe = Some(std::sync::Arc::new(move |_: &str| { + seen_in_probe.fetch_add(1, Relaxed); + cancel_in_probe.store(true, Relaxed); + Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) + })); + let prepared = job.prepare(&unread_paths(400), &cancel); + // at most the files the (up to 8) readers had already started + assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed)); + assert!(prepared.items.is_empty()); + } +} diff --git a/crates/engine/src/import/job.rs b/crates/engine/src/import/job.rs new file mode 100644 index 000000000..47bc0756f --- /dev/null +++ b/crates/engine/src/import/job.rs @@ -0,0 +1,486 @@ +use super::commit::{How, Plan, Slot, Transfer, commit_prepared, name_family, run_transfers, system_clock}; +use super::files::{expand, probe_all}; +use super::types::{ImportCandidate, ImportMode, ImportOptions, ImportReport, Kept, OnDeleted}; +use super::*; + +/// Import files/folders. See the module docs. +pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result { + import_with(s, paths, &ImportOptions { mode, ..Default::default() }) +} + +/// Import files/folders with options (preset, keywords). See the module docs. +/// +/// The file-system half ([`ImportJob::prepare`]: expanding folders, probing, reading sidecars, +/// copying / placing files) and the catalog half ([`commit_prepared`]) are separate so the app can +/// run the first on a worker thread; here both run in turn. +pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> crate::Result { + let mut job = ImportJob::new(s, opts.clone())?; + let prepared = job.prepare(paths, &std::sync::atomic::AtomicBool::new(false)); + // probes a scan left for files not imported now stay for a later import + s.import_probes.extend(std::mem::take(&mut job.cache)); + commit_prepared(s, &job.opts, &job.now, prepared) +} + +/// Everything the file-system half of an import needs, detached from the session so it can run +/// on another thread (a network share or a card can take minutes). Built by [`ImportJob::new`]; +/// [`ImportJob::prepare`] may be called for several batches in turn (renamed copies keep counting, +/// duplicates are found across batches); each batch is then [`commit_prepared`] on the session. +pub struct ImportJob { + pub opts: ImportOptions, + /// The mode in effect (Copy / Move become Add in a library without its own folder). + mode: ImportMode, + lib_dir: Option, + copy_root: Option, + /// The library as it was when the import was set up, until it is looked up + /// ([`ImportJob::look_up_library`], with the first files): setting an import up is the + /// session's thread, and a table of a library of millions of photos takes seconds. With it, + /// whether a photo in Recently Deleted is passed over (an import that replaces such photos, + /// as the options said when the import was set up). + pub(super) library: Option<(lightcraft_catalog::Catalog, bool)>, + /// path → (photo, a Local browse record?) + pub(super) by_path: HashMap, + /// content hash → the photo that has it (`None`: a file earlier in this import) + pub(super) by_hash: HashMap>, + pub(super) probe: Option, + file_bytes: Option, + /// Probes from a preceding scan (not read again). + cache: HashMap, + naming: crate::sidecar::SidecarNaming, + now: String, + seq: usize, + /// Files found so far (folders expanded) and handled so far, for progress. + pub total: std::sync::Arc, + pub done: std::sync::Arc, + /// Of those, the files that have to be read first (not probed by a preceding scan) and the + /// ones read so far: the step before they are handled. + pub read_total: std::sync::Arc, + pub read_done: std::sync::Arc, +} + +/// The outcome of [`ImportJob::prepare`] for one batch, in file order. +#[derive(Debug, Default)] +pub struct Prepared { + pub scanned: usize, + pub(super) items: Vec, +} + +#[derive(Debug)] +pub(super) enum PreparedItem { + /// A Local (browsed) record imported for real. + Promote(PhotoId), + Duplicate { + path: String, + existing: Option, + reason: &'static str, + hash: Option, + }, + Failed(String, String), + Kept(Kept), + Ready(Box), +} + +#[derive(Debug)] +pub(super) struct ReadyFile { + pub(super) path: String, + pub(super) stored: String, + pub(super) info: ProbeInfo, + pub(super) sidecar: Option, + pub(super) placed: Option, +} + +impl Prepared { + /// Undo what the batch did on disk that only a commit would make permanent: files placed by a + /// Move (their sources were never touched). Copies stay, like copies of a failed commit. + pub fn rollback(&self) { + for it in &self.items { + if let PreparedItem::Ready(r) = it + && let Some(pl) = &r.placed + { + crate::import_move::rollback(pl); + } + } + } + + /// Entries in the batch (after expanding folders). + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Recheck Add-only entries against the owner catalog immediately before commit. + /// Preparation may have run while another import added the same path or content. + pub fn revalidate_add(&mut self, s: &Session) { + let mut paths = HashMap::new(); + let mut hashes = HashMap::new(); + for photo in s.catalog.photos() { + if let Source::File { path } = &photo.source { + paths.insert(normalize_import_path(path), photo.id); + } + if let Some(hash) = &photo.content_hash { + hashes.insert(hash.clone(), photo.id); + } + } + let mut items = Vec::with_capacity(self.items.len()); + for item in std::mem::take(&mut self.items) { + let PreparedItem::Ready(ready) = item else { + items.push(item); + continue; + }; + let ReadyFile { path, stored, info, sidecar, placed } = *ready; + let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied()); + let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied()); + if let Some(existing) = existing_path.or(existing_hash) { + if let Some(placed) = &placed { + crate::import_move::rollback(placed); + } + items.push(PreparedItem::Duplicate { + path, + existing: Some(existing), + reason: if existing_path.is_some() { "path" } else { "content" }, + hash: info.content_hash, + }); + } else { + items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))); + } + } + self.items = items; + } +} + +fn normalize_import_path(path: &str) -> String { + let path = path.replace('\\', "/"); + if cfg!(windows) { path.to_lowercase() } else { path } +} + +impl ImportJob { + /// Snapshot what an import with `opts` needs from the session (no file-system calls). Takes + /// the probes a preceding scan left ([`Session::import_probes`]). + pub fn new(s: &mut Session, opts: ImportOptions) -> crate::Result { + let mode = opts.mode; + // Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of + // every added file in its own storage already, so "copy" there means "add". + let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move); + let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode }; + if mode == ImportMode::Move && opts.local { + return Err(crate::EngineError::Other("browsing a folder never moves its files".into())); + } + let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + // where copies go: the chosen folder, else the library's Originals/ + let copy_root = opts + .destination + .as_deref() + .filter(|d| !d.trim().is_empty()) + .map(std::path::PathBuf::from) + .or_else(|| lib_dir.as_ref().map(|l| l.join("Originals"))); + if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() { + return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into())); + } + let fresh = opts.on_deleted == OnDeleted::Fresh; + Ok(ImportJob { + seq: opts.rename_start.max(1), + opts, + mode, + lib_dir, + copy_root, + library: Some((s.catalog.clone(), fresh)), + by_path: HashMap::new(), + by_hash: HashMap::new(), + probe: s.media.file_probe.clone(), + file_bytes: s.media.file_bytes.clone(), + cache: std::mem::take(&mut s.import_probes), + naming: s.xmp.naming, + now: (s.clock)(), + total: Default::default(), + done: Default::default(), + read_total: Default::default(), + read_done: Default::default(), + }) + } + + /// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip + /// duplicates by content, read sidecars, copy or place (Move) the files — several at once, + /// with the names and outcomes of one after another. Nothing in the catalog changes. Stops + /// before the next file once `cancel` is set. + pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared { + let files = self.expand(paths); + self.prepare_files(files, cancel) + } + + /// Expand `paths` (folders recursively, the library's own folder skipped) into the files an + /// import would handle; counted in [`ImportJob::total`]. + pub fn expand(&self, paths: &[String]) -> Vec { + let files = expand(paths, self.lib_dir.as_deref()); + self.total.fetch_add(files.len(), std::sync::atomic::Ordering::Relaxed); + files + } + + /// When the import started ([`Session::clock`]), for [`commit_prepared`]. + pub fn now(&self) -> &str { + &self.now + } + + /// What the library held when the import was set up, by path and by content: made once, with + /// the first files (on the thread that prepares them), from the library as it was then. The + /// snapshot comes back: the files the import is adding are ranked against it for the ownership + /// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]). + fn look_up_library(&mut self) -> Option { + let (library, fresh) = self.library.take()?; + for p in library.photos() { + // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate + if p.deleted && fresh { + continue; + } + if let Source::File { path } = &p.source { + self.by_path.insert(path.clone(), (p.id, p.local)); + } + if let Some(h) = &p.content_hash { + self.by_hash.insert(h.clone(), Some(p.id)); + } + } + Some(library) + } + + /// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned. + pub fn prepare_files(&mut self, files: Vec, cancel: &std::sync::atomic::AtomicBool) -> Prepared { + use std::sync::atomic::Ordering::Relaxed; + // the library as it was, kept for the ownership of the shared stem sidecars below + let library = self.look_up_library(); + let opts = self.opts.clone(); + let mode = self.mode; + let mut out = Prepared { scanned: files.len(), items: Vec::new() }; + let mut todo = Vec::new(); + for f in files { + match self.by_path.get(f.as_str()).copied() { + Some((id, true)) if !opts.local => { + // a file that was only browsed (Local) joins the library when it is imported for real + self.by_path.insert(f, (id, false)); + out.items.push(PreparedItem::Promote(id)); + self.done.fetch_add(1, Relaxed); + } + Some((id, _)) => { + out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None }); + self.done.fetch_add(1, Relaxed); + } + None => todo.push(f), + } + } + + // files a preceding scan already probed are not read again (a network share is slow to read), + // as long as they are still there, as they were: a file gone or changed since is probed again + let cached: Vec> = todo + .iter() + .map(|f| { + // (the web's files are not std::fs files: kept as probed) + self.cache + .remove(f) + .filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size)) + }) + .collect(); + let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); + // Reading the files is the slow part of adding them where they are: it is counted on its + // own, and `cancel` stops it too (a large library takes long enough to want both). + self.read_total.fetch_add(missing.len(), Relaxed); + let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter(); + let probed: Vec> = + cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); + crate::memory::release(); + // Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones + // arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar + // and never the raw's packet (issue #1905). + let stems = crate::sidecar::ImportStems::for_import( + library.as_ref(), + todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))), + ); + // Files are planned one by one, in order (duplicates, sidecars, names, folders); their + // copies or moves — the slow part, reading and writing every byte and verifying it — are + // collected and run on several threads (issue #367), then their outcomes take their places. + let mut plan = Plan::default(); + for (path, info) in todo.into_iter().zip(probed) { + if cancel.load(Relaxed) { + break; + } + let mut info = match info { + Ok(i) => i, + Err(e) => { + log::warn!("import {path}: {e}"); + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Failed(path, e))); + continue; + } + }; + // a copy of a file whose transfer is still pending: a duplicate only if that one succeeds + if !opts.local + && let Some(h) = &info.content_hash + && plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h)) + { + self.run_plan(&mut plan, &mut out, cancel); + if cancel.load(Relaxed) { + break; + } + } + if let Some(h) = &info.content_hash + && !opts.local + && let Some(existing) = self.by_hash.get(h) + { + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) })); + continue; + } + // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files + // and names the copy when the file itself has none + let raw = info.kind == MediaKind::Raw; + let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path) + .and_then(|f| std::fs::read_to_string(f).ok()) + .or_else(|| info.xmp.clone().filter(|_| raw)); + let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); + let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { + Ok(sc) => Some(sc), + Err(e) => { + log::warn!("import {path}: XMP: {e}"); + None + } + }); + if info.captured.is_none() { + info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); + } + // no date in the file or its sidecar: the file's own date, as Lightroom does + if info.captured.is_none() { + info.captured = info.file_modified.clone(); + } + let stored = match (mode, &self.copy_root) { + (ImportMode::Copy | ImportMode::Move, Some(root)) + if !crate::import_move::inside(Path::new(&path), root) + && !self.lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) => + { + // the templates see the photo as it will be catalogued (undated: the import time) + let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &self.now); + q.captured = info.captured.clone(); + q.meta = info.meta.clone(); + let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| { + let n = crate::rename::expand(t, &q, self.seq); + self.seq += 1; + n + }); + let folders = opts.organize.folders(&q); + let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); + // the name this file gets depends on what a pending transfer of a like-named file + // into the same folder ends up with: that one goes first + let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() }); + let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named)); + if plan.transfers.iter().any(|t| t.family == family) { + self.run_plan(&mut plan, &mut out, cancel); + if cancel.load(Relaxed) { + break; + } + } + let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) { + How::Place { name: name.unwrap_or(own) } + } else { + if mode == ImportMode::Move { + let reason = "a symbolic link: the file it points to was copied, the link and its target stay"; + plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); + } + How::Copy { name } + }; + // Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace) + let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG"); + plan.slots.push(Slot::Transfer); + plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family }); + continue; + } + (ImportMode::Move, _) => { + let reason = "already inside the destination or the library: added where it is"; + plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }))); + path.clone() + } + _ => path.clone(), + }; + if let Some(h) = &info.content_hash { + self.by_hash.insert(h.clone(), None); + } + self.done.fetch_add(1, Relaxed); + plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None })))); + } + self.run_plan(&mut plan, &mut out, cancel); + out + } + + /// Run the transfers `plan` collected (on several threads) and append its items to `out` in + /// file order. Transfers not started because `cancel` was set leave no item. + fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) { + let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter(); + for slot in std::mem::take(&mut plan.slots) { + let item = match slot { + Slot::Item(it) => it, + Slot::Transfer => match done.next().flatten() { + Some(it) => it, + None => continue, + }, + }; + if let PreparedItem::Ready(r) = &item + && let Some(h) = &r.info.content_hash + { + self.by_hash.insert(h.clone(), None); + } + out.items.push(item); + } + } +} + +impl Session { + /// A thumbnail for an import candidate (not in the catalog): a raw's embedded preview, else a + /// small render of the file. `slot` makes the job's photo id unique (`u64::MAX - slot`). + pub fn candidate_thumb_job(&mut self, c: &ImportCandidate, edge: usize, slot: u64) -> Option { + if c.error.is_some() { + return None; + } + let id = PhotoId(u64::MAX - slot); + let mut p = Photo::new(id, Source::File { path: c.path.clone() }, &c.name, &c.format, c.width.max(1), c.height.max(1), ""); + p.kind = c.kind; + if let Some(info) = self.import_probes.get(&c.path) { + p.as_shot_wb = info.as_shot_wb; + p.embedded_lens = info.embedded_lens; + p.preview_only = info.preview_only.clone(); + } + p.develop = std::sync::Arc::new(p.import_defaults()); + let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge()); + let level = crate::media::SourceLevel::Thumb; + let source = self.media.origin_ref(&p.source, level.max_edge()); + let key = lightcraft_preview::Hasher128::new().str(&c.path).u64(c.file_size).u64(edge as u64).finish().0 as u64; + let small = crate::media::RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: None, + photo: id, + level, + source, + origin: p.source.clone(), + info: crate::media::source_info(&p), + settings: p.develop.clone(), + request: lightcraft_pipeline::RenderRequest::fit(edge, edge), + key, + cache: None, + stages: None, + view_cache: None, + display: None, + slide: None, + preview_cut: None, + display_fit: None, + }; + let embedded = match (&self.media.preview_loader, c.kind) { + (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), + _ => None, + }; + Some(crate::media::QuickJob { request_id: 0, photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) }) + } + + /// Use the system clock for import/edit times (native hosts). + pub fn with_system_clock(mut self) -> Self { + self.clock = Box::new(system_clock); + self + } +} diff --git a/crates/engine/src/import/scan.rs b/crates/engine/src/import/scan.rs new file mode 100644 index 000000000..61d8d4690 --- /dev/null +++ b/crates/engine/src/import/scan.rs @@ -0,0 +1,188 @@ +use super::files::{expand, probe_all}; +use super::types::ImportCandidate; +use super::*; + +/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network +/// share can take minutes to read). +pub struct ScanInput { + probe: Option, + skip: Option, + /// The library as it is now (photos are shared with the session, not copied): looked up for + /// the files the scan finds ([`library_files`]) and the contents it probes + /// ([`library_hashes`]), on the scan's thread. + library: std::sync::Arc, + cache: HashMap, +} + +/// The library's photos of `files`: path → (photo, its summary as a candidate that is there +/// already). One pass over the library, and a record only for a file that was found: a folder of +/// a hundred files in a library of millions costs a hundred records. +pub(super) fn library_files(library: &lightcraft_catalog::Catalog, files: &[String]) -> HashMap { + let found: std::collections::HashSet<&Path> = files.iter().map(Path::new).collect(); + let mut by_path = HashMap::new(); + if found.is_empty() { + return by_path; + } + for p in library.photos() { + if let Source::File { path } = &p.source + && found.contains(Path::new(path)) + { + let c = ImportCandidate { + path: path.clone(), + format: p.format.clone(), + kind: p.kind, + width: p.width, + height: p.height, + file_size: p.file_size, + captured: p.captured.clone(), + duplicate: Some("path".into()), + existing: Some(p.id.0), + ..Default::default() + }; + by_path.insert(PathBuf::from(path), (p.id, c)); + } + } + by_path +} + +/// Which photo of the library has each of the contents `hashes` (the later one where several +/// have it). One pass over the library, and an entry only for a content that was asked about. +pub(super) fn library_hashes<'a>(library: &lightcraft_catalog::Catalog, hashes: impl Iterator) -> HashMap { + let wanted: std::collections::HashSet<&str> = hashes.collect(); + let mut by_hash = HashMap::new(); + if wanted.is_empty() { + return by_hash; + } + for p in library.photos() { + if let Some(h) = &p.content_hash + && wanted.contains(h.as_str()) + { + by_hash.insert(h.clone(), p.id); + } + } + by_hash +} + +/// Progress and cancellation of a running [`scan_with`]. +#[derive(Default)] +pub struct ScanProgress { + /// Files to probe, set once the folders are expanded. + pub total: std::sync::atomic::AtomicUsize, + pub done: std::sync::atomic::AtomicUsize, + /// Shared with the scan's row in the activity stack (its ✕ sets it). + pub cancel: std::sync::Arc, +} + +/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows. +pub struct ScanOutput { + pub candidates: Vec, + pub probes: HashMap, +} + +impl ScanInput { + pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec) { + let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + // (no table of the library here: this is the session's thread, and the library may hold + // millions of photos. The scan looks up what it finds) + let library = std::sync::Arc::new(s.catalog.clone()); + let input = ScanInput { probe: s.media.file_probe.clone(), skip, library, cache: std::mem::take(&mut s.import_probes) }; + (input, paths.to_vec()) + } + + /// The folder a scan never enters (the library's own). + pub(crate) fn skip(&self) -> Option<&Path> { + self.skip.as_deref() + } + + /// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one). + pub(crate) fn library(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.library) + } +} + +/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content +/// against the library and earlier candidates). Nothing is added; the probes are kept for the +/// import that follows. +pub fn scan(s: &mut Session, paths: &[String]) -> Vec { + let (input, paths) = ScanInput::new(s, paths); + let out = scan_with(input, &paths, &ScanProgress::default()); + s.import_probes = out.probes; + out.candidates +} + +/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it +/// has) when `progress.cancel` is set. +pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput { + let files = expand(paths, input.skip.as_deref()); + scan_files(input, files, progress) +} + +/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]). +pub(crate) fn scan_files(mut input: ScanInput, files: Vec, progress: &ScanProgress) -> ScanOutput { + use std::sync::atomic::Ordering::Relaxed; + let by_path = library_files(&input.library, &files); + let todo: Vec = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect(); + progress.total.store(todo.len(), Relaxed); + // probes from a preceding `scan` are reused when the file is unchanged (same size) + let cached: Vec> = todo + .iter() + .map(|f| { + let info = input.cache.remove(f)?; + // (✕ set while the files' cached probes are checked: no more round trips to the disk; what is + // not reached counts as not probed, like the files after it) + if progress.cancel.load(Relaxed) { + return None; + } + let size = std::fs::metadata(f).map(|m| m.len()).ok(); + (size.is_none() || size == Some(info.file_size)).then_some(info) + }) + .collect(); + let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); + progress.done.store(todo.len() - missing.len(), Relaxed); + let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter(); + let probed: Vec> = + cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); + crate::memory::release(); + let mut probes: HashMap> = todo.into_iter().zip(probed).collect(); + // (who in the library has a content that was probed: asked once the probes are in) + let probed_hashes = probes.values().filter_map(|r| r.as_ref().ok()).filter_map(|info| info.content_hash.as_deref()); + let mut seen_hash: HashMap> = + library_hashes(&input.library, probed_hashes).into_iter().map(|(h, id)| (h, Some(id.0))).collect(); + let mut out = Vec::with_capacity(files.len()); + let mut kept = HashMap::new(); + for f in files { + if let Some((_, c)) = by_path.get(Path::new(&f)) { + let mut c = c.clone(); + c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()); + out.push(c); + continue; + } + let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()); + let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() }; + match probes.remove(&f) { + Some(Ok(info)) => { + (c.format, c.kind, c.width, c.height, c.file_size, c.captured) = + (info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone()); + // as the import will date it (a sidecar's date, when there is one, still wins there) + c.captured = c.captured.or_else(|| info.file_modified.clone()); + c.preview_only = info.preview_only.clone(); + if let Some(h) = &info.content_hash { + match seen_hash.get(h) { + Some(existing) => { + c.duplicate = Some("content".into()); + c.existing = *existing; + } + None => { + seen_hash.insert(h.clone(), None); + } + } + } + kept.insert(f, info); + } + Some(Err(e)) => c.error = Some(e), + None => c.error = Some("not probed".into()), + } + out.push(c); + } + ScanOutput { candidates: out, probes: kept } +} diff --git a/crates/engine/src/import/types.rs b/crates/engine/src/import/types.rs new file mode 100644 index 000000000..4a848015b --- /dev/null +++ b/crates/engine/src/import/types.rs @@ -0,0 +1,230 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ImportMode { + /// Reference the files where they are. + #[default] + Add, + /// Copy the files into the library's `Originals/` folder. + Copy, + /// Move the files there: as Copy, then each original (and its sidecars) is removed from the + /// source once the copy is verified and catalogued. + Move, +} + +/// What an import does with a file the library already has in Recently Deleted. +/// +/// Someone who imports such a file wants the photo in the library again, so the default brings +/// it back (issues #298, #706: before, the import skipped it as "already in the library" while +/// the grid showed it deleted, and the user was stuck). Restoring loses nothing — the edits, +/// rating and albums come back with the photo and Reset Edits gives a clean start — where +/// importing afresh would silently drop them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OnDeleted { + /// Leave it there; the report says so ([`Duplicate::existing_deleted`]). + Skip, + /// Bring the trashed photo back, with its edits ([`ImportReport::restored`]); a photo whose + /// file is no longer where the library had it is relinked to the imported file. + #[default] + Restore, + /// Delete the trashed record permanently and import the file as a new photo. + Fresh, +} + +impl OnDeleted { + pub fn parse(s: &str) -> Option { + match s { + "skip" => Some(Self::Skip), + "restore" => Some(Self::Restore), + "fresh" => Some(Self::Fresh), + _ => None, + } + } +} + +/// What an import does besides adding the photos. +#[derive(Clone, Debug, Default)] +pub struct ImportOptions { + /// A file that is in Recently Deleted: restore (default), skip, or import afresh. + pub on_deleted: OnDeleted, + pub mode: ImportMode, + /// Applied to every imported photo (one History entry). + pub preset: Option, + /// Added to every imported photo. + pub keywords: Vec, + /// Browsing a folder: photos come in as `local` (not in the library), and a file with the + /// same content as one already known is still listed. + pub local: bool, + /// Copy: where the copies go (default: the library's `Originals/`). + pub destination: Option, + /// Copy: the folders inside the destination. + pub organize: Organize, + /// Copy: a file-name template for the copies ([`crate::rename::expand`] tokens: `{name}`, + /// `{seq:N}`, `{date:%Y%m%d}`, `{camera}`…), numbered from `rename_start`. + pub rename: Option, + pub rename_start: usize, + /// A metadata preset (by name) applied to every imported photo. + pub metadata_preset: Option, + /// Copy: raws are copied as DNG (Copy as DNG). + pub convert_dng: bool, +} + +/// How copies are filed in the destination. The date is the capture time, else the time of the +/// import. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Organize { + /// `YYYY/YYYY-MM-DD/` by capture date. + #[default] + ByDay, + /// `YYYY/YYYY-MM/`. + ByMonth, + /// All in the destination itself. + Flat, + /// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own + /// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`]; + /// never outside the destination). + Template(String), +} + +impl Organize { + /// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`). + pub fn parse(s: &str) -> Option { + match s { + "date" | "day" | "byDay" => Some(Organize::ByDay), + "month" | "byMonth" => Some(Organize::ByMonth), + "flat" | "none" | "intoOneFolder" => Some(Organize::Flat), + t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())), + _ => None, + } + } + + /// The folders (inside the destination) a copy of `p` goes to. + pub fn folders(&self, p: &Photo) -> Vec { + let date = p.date(); + let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated"); + let year = day.get(..4).unwrap_or("undated"); + match self { + Organize::ByDay => vec![year.into(), day.into()], + Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()], + Organize::Flat => Vec::new(), + Organize::Template(t) => crate::rename::expand_folder(t, p, 1), + } + } +} + +/// A file found by [`scan`], for the import review. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ImportCandidate { + pub path: String, + pub name: String, + pub format: String, + pub kind: MediaKind, + pub width: u32, + pub height: u32, + pub file_size: u64, + pub captured: Option, + /// `"path"` (already in the library), `"content"` (same bytes in the library or earlier in this + /// list): skipped by the import. + pub duplicate: Option, + /// The photo that already has it. + pub existing: Option, + /// Not readable. + pub error: Option, + /// A raw variant that can't be decoded yet (why): it imports as preview only. + #[serde(skip_serializing_if = "Option::is_none")] + pub preview_only: Option, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Duplicate { + pub path: String, + /// The photo that already has these bytes (or this path). + pub existing: Option, + /// `"path"` (already imported from there) or `"content"` (same bytes elsewhere). + pub reason: &'static str, + /// That photo is in Recently Deleted (see [`OnDeleted`]). + #[serde(rename = "existingDeleted", skip_serializing_if = "std::ops::Not::not")] + pub existing_deleted: bool, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct ImportReport { + pub imported: Vec, + pub duplicates: Vec, + /// [`OnDeleted::Restore`]: photos brought back from Recently Deleted instead of imported. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub restored: Vec, + /// Move: the originals that were moved (and removed from the source). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub moved: Vec, + /// Move: originals (or sidecars) left at the source, and why — the photo may still have been + /// imported (from its copy, or in place). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub kept: Vec, + /// (path, error) + pub failed: Vec<(String, String)>, + /// Files found after expanding folders. + pub scanned: usize, + /// Photos whose metadata/develop settings were read from an XMP sidecar (or embedded XMP). + pub sidecars: usize, + /// Per-photo diagnostics from foreign XMP develop settings that could not be fully carried over. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +/// A structured diagnostic from foreign XMP develop settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ImportWarningCode { + /// A named camera profile was found but cannot be applied as Lightroom does. + CameraProfile, + /// Lightroom geometry data was found but its saved transform is not retained. + UprightGeometry, + /// One or more other XMP develop fields have no LightCraft mapping. + UnmappedXmp, +} + +/// The saved Lightroom Upright mode associated with a foreign geometry transform. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ImportUprightMode { + Off, + Auto, + Level, + Vertical, + Full, + Guided, + Unknown, +} + +/// One actionable XMP import diagnostic. `path` is filled when its photo is committed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportWarning { + pub path: String, + pub code: ImportWarningCode, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, +} + +/// A file moved by an import. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Moved { + pub from: String, + pub to: String, + /// Its sidecars' new paths. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub sidecars: Vec, +} + +/// A source a Move left where it was. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Kept { + pub path: String, + pub reason: String, +} From 8d8994ec87eb007314077bb1f6be11b54c621991 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:08:55 +0000 Subject: [PATCH 115/191] refactor(lightcraft-engine): split cmd/library.rs into cmd/library/ --- crates/engine/src/cmd/library.rs | 1874 +------------------- crates/engine/src/cmd/library/filters.rs | 92 + crates/engine/src/cmd/library/gps.rs | 57 + crates/engine/src/cmd/library/import.rs | 110 ++ crates/engine/src/cmd/library/selection.rs | 247 +++ crates/engine/src/cmd/library/specs.rs | 1374 ++++++++++++++ 6 files changed, 1888 insertions(+), 1866 deletions(-) create mode 100644 crates/engine/src/cmd/library/filters.rs create mode 100644 crates/engine/src/cmd/library/gps.rs create mode 100644 crates/engine/src/cmd/library/import.rs create mode 100644 crates/engine/src/cmd/library/selection.rs create mode 100644 crates/engine/src/cmd/library/specs.rs diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 25a03eab8..5d1b0fef1 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -7,1873 +7,15 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param}; use crate::{LibrarySource, Result, Selection, Session}; -/// The selection after photos left the view (deleted, or rated, flagged or labelled out of it). -/// Its callers end an open edit (a slider drag) before their own change, as moving to the next -/// photo does, so it is its own undo step and comes first. -fn select_after_deletion(s: &mut Session, before: &[PhotoId]) { - let at = before.iter().position(|id| Some(*id) == s.selection.active); - let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect(); - s.selection.ids.retain(|id| remaining.contains(id)); - s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied()); - if s.selection.active.is_none() - && let Some(at) = at - { - // Keep culling near the deleted photo: next visible survivor, or the previous one at the end. - let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id)); - s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default(); - } -} +mod filters; +mod gps; +mod import; +mod selection; +mod specs; -/// The auto-import folder's visible files with their sizes (the file-system half of -/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`). -pub fn list_auto_import_folder(folder: &str) -> std::result::Result, String> { - let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?; - Ok(rd - .flatten() - .filter(|e| !e.file_name().to_string_lossy().starts_with('.')) - .filter_map(|e| { - let m = std::fs::metadata(e.path()).ok()?; - m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len())) - }) - .collect()) -} - -/// `library.import`'s params, checked (no file-system calls). -pub struct ImportRequest { - pub paths: Vec, - pub opts: crate::import::ImportOptions, - pub album: Option, - pub album_name: Option, -} - -/// Parse and check `library.import`'s params (the app's import task runs the import itself, on a -/// worker thread, with the same options). -pub fn import_params(s: &Session, p: &Value) -> Result { - let paths = strs(p, "paths"); - let mode = match str_param(p, "mode").unwrap_or("add") { - "add" => crate::import::ImportMode::Add, - "copy" => crate::import::ImportMode::Copy, - "move" => crate::import::ImportMode::Move, - other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))), - }; - let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) { - Some(id) => Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?), - None => None, - }; - let album = p.get("album").and_then(Value::as_u64); - if let Some(a) = album - && s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart()) - { - return Err(bad("library.import", "album must be a regular album")); - } - let organize = match str_param(p, "organize") { - Some(o) => crate::import::Organize::parse(o).ok_or_else(|| { - bad("library.import", format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})")) - })?, - None => Default::default(), - }; - if let crate::import::Organize::Template(t) = &organize - && let Some(e) = crate::rename::folder_template_error(t) - { - return Err(bad("library.import", e)); - } - let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - if let Some(n) = &metadata_preset - && !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) - { - return Err(bad("library.import", format!("unknown metadata preset `{n}`"))); - } - let on_deleted = match str_param(p, "onDeleted") { - Some(v) => { - crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))? - } - None => Default::default(), - }; - let opts = crate::import::ImportOptions { - on_deleted, - mode, - preset, - keywords: strs(p, "keywords"), - destination: str_param(p, "destination").map(str::to_string), - organize, - rename: str_param(p, "rename").map(str::to_string), - rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize, - metadata_preset, - convert_dng: bool_or(p, "dng", false), - local: bool_or(p, "local", false), - }; - let album_name = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - Ok(ImportRequest { paths, opts, album, album_name }) -} - -/// After a batch of an import was committed: the album (a new one named `album_name` when there -/// is no `album` and something was imported) gets the photos. Returns the report as JSON (with -/// `album`). -pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option, album_name: Option<&str>) -> Result { - let mut report = serde_json::to_value(report).unwrap_or_default(); - // photos an import restored from Recently Deleted take part like new ones (album, selection) - let imported: Vec = - ["imported", "restored"].iter().flat_map(|k| report[*k].as_array().into_iter().flatten().filter_map(Value::as_u64)).collect(); - if album.is_none() - && let Some(name) = album_name - && !imported.is_empty() - { - let r = s.execute("album.create", &json!({"name": name}))?; - album = r["id"].as_u64(); - } - if let Some(a) = album - && !imported.is_empty() - { - // (album.addPhotos wants a selection: the new photos are about to be it) - s.selection = Selection::single(PhotoId(imported[0])); - s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?; - report["album"] = json!(a); - } - Ok(report) -} - -fn album_param(p: &Value, key: &str, c: &str) -> Result { - p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`"))) -} - -/// An optional album id: absent or `null` is `None`, anything but a number is an error. -fn opt_album_param(p: &Value, key: &str, c: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => v.as_u64().map(|n| Some(AlbumId(n))).ok_or_else(|| bad(c, format!("`{key}` must be an album id or null"))), - } -} - -fn strs(p: &Value, key: &str) -> Vec { - p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() -} - -/// The targets an album can hold and how many were browsed (Local) photos, which album views -/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members. -fn album_targets(s: &Session, targets: Vec) -> (Vec, usize) { - let before = targets.len(); - let kept: Vec = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect(); - let skipped = before - kept.len(); - (kept, skipped) -} - -/// Album targets, or the error for a selection that was nothing but Local photos. -fn album_targets_or_err(s: &Session, command: &str, targets: Vec) -> Result<(Vec, usize)> { - let (kept, skipped) = album_targets(s, targets); - if kept.is_empty() && skipped > 0 { - return Err(bad(command, super::LOCAL_ONLY)); - } - Ok((kept, skipped)) -} - -/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep -/// showing `i/n` (issue #1493). -fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result { - let mut ops: Vec = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); - let n = ops.len(); - if n == 0 { - return Ok(0); - } - ops.extend(s.catalog.remove_from_stacks_ops(ids)); - s.commit(label, Op::Batch { ops })?; - Ok(n) -} - -/// Apply one op per target as a single undo step. -fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option) -> Result { - let targets = s.targets(p)?; - let ops: Vec = targets.iter().filter_map(|id| f(*id)).collect(); - let n = ops.len(); - if n > 0 { - s.commit(label, Op::Batch { ops })?; - } - Ok(json!({"changed": n})) -} - -/// Adjust each target's own rating; unchanged bounds do not create an undo step. -fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result { - let mut ops = Vec::new(); - for id in s.targets(p)? { - let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating; - let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8; - if rating != old { - ops.push(Op::SetRating { id, rating }); - } - } - let n = ops.len(); - if n > 0 { - s.commit(label, Op::Batch { ops })?; - } - Ok(json!({"changed": n})) -} - -/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged, -/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step. -fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result { - let mut ops = Vec::new(); - for id in s.targets(p)? { - let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag; - let flag = step(old); - if flag != old { - ops.push(Op::SetFlag { id, flag }); - } - } - let n = ops.len(); - if n > 0 { - s.commit(label, Op::Batch { ops })?; - } - Ok(json!({"changed": n})) -} - -/// Rate, flag or label (`edit`), then `advance`. When the edit took the active photo out of the view (the filter bar, -/// Picks or a smart album shows it no more), culling goes on from its place, as after a delete: the photo after it is -/// active, or the one before it at the end (#2312), not the view's first photo. That photo is the next one, so -/// `advance` doesn't step past it. Photos named by `ids` / `id` leave the selection to the caller: Compare and Survey -/// keep showing the photo they rate. -fn cull(s: &mut Session, p: &Value, edit: impl FnOnce(&mut Session) -> Result) -> Result { - let named = [p.get("ids"), p.get("id")].into_iter().any(|t| t.is_some_and(|t| !t.is_null())); - // the view as it was, then an open drag ends first, as its own undo step (the selection may - // move on; the drag itself can take the photo out of the view) - let before = (!named).then(|| s.visible_shared().1); - if !named { - s.end_interaction()?; - } - let active = s.selection.active; - let v = edit(s)?; - if let Some(before) = before - && active.is_some_and(|a| before.contains(&a) && !s.visible().contains(&a)) - { - select_after_deletion(s, &before); - s.active_mask = None; - s.active_spot = None; - } else { - advance_if(s, p); - } - Ok(v) -} - -fn advance_if(s: &mut Session, p: &Value) { - if bool_or(p, "advance", false) { - let _ = step(s, 1); - } -} - -fn step(s: &mut Session, d: isize) -> Result { - let vis = s.visible_cloned(); - if vis.is_empty() { - return ok(); - } - let cur = s.selection.active.and_then(|a| vis.iter().position(|x| *x == a)); - let next = match cur { - Some(i) => (i as isize + d).clamp(0, vis.len() as isize - 1) as usize, - None => 0, - }; - s.end_interaction()?; - s.selection = Selection::single(vis[next]); - s.active_mask = None; - s.active_spot = None; - Ok(json!({"active": vis[next].0})) -} - -/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input -/// can't overflow). -fn row_step(p: &Value) -> isize { - p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize -} - -fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { - let targets = s.targets(p)?; - let mut ops = Vec::new(); - for id in targets { - if let Some(d) = s.develop_of(id) { - let mut d = (*d).clone(); - d.orientation = d.orientation.rotated(cw); - // the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo) - let turn = lightcraft_geom::Orientation::Normal.rotated(cw); - d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect); - d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w)); - ops.extend(s.develop_op(id, d, "Rotate")); - } - } - let n = ops.len(); - s.commit(if cw { "Rotate Right" } else { "Rotate Left" }, Op::Batch { ops })?; - Ok(json!({"changed": n})) -} - -fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result { - let targets = s.targets(p)?; - let mut ops = Vec::new(); - for id in targets { - if let Some(d) = s.develop_of(id) { - let mut d = (*d).clone(); - if horizontal { - d.crop.flip_h = !d.crop.flip_h; - } else { - d.crop.flip_v = !d.crop.flip_v; - } - ops.extend(s.develop_op(id, d, "Flip")); - } - } - let n = ops.len(); - s.commit("Flip", Op::Batch { ops })?; - Ok(json!({"changed": n})) -} - -/// `library.selectBy`: select the photos in view that match every given criterion. -fn select_by(s: &mut Session, p: &Value) -> Result { - const C: &str = "library.selectBy"; - let flag: Option = match p.get("flag") { - Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(C, "flag is pick, reject or none"))?), - None => None, - }; - let rating = p.get("rating").and_then(Value::as_u64).map(|r| r.min(5) as u8); - let op = p.get("ratingOp").and_then(Value::as_str).unwrap_or("gte"); - let label: Option> = match p.get("label").and_then(Value::as_str) { - Some("none") => Some(None), - Some(l) => Some(Some(serde_json::from_value(json!(l)).map_err(|_| bad(C, format!("unknown label `{l}`")))?)), - None => None, - }; - if flag.is_none() && rating.is_none() && label.is_none() { - return Err(bad(C, "give flag, rating and/or label")); - } - let matches = |ph: &lightcraft_catalog::Photo| { - flag.is_none_or(|f| ph.flag == f) - && rating.is_none_or(|r| match op { - "eq" => ph.rating == r, - "lte" => ph.rating <= r, - _ => ph.rating >= r, - }) - && label.is_none_or(|l| ph.label == l) - }; - let mut ids: Vec = if bool_or(p, "add", false) { s.selection.ids.clone() } else { Vec::new() }; - for id in s.visible_cloned() { - if !ids.contains(&id) && s.catalog.photo(id).is_some_and(|ph| matches(ph)) { - ids.push(id); - } - } - let active = s.selection.active.filter(|a| ids.contains(a)).or(ids.first().copied()); - let n = ids.len(); - s.selection = Selection { ids, active }; - Ok(json!({"selected": n})) -} - -/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote. -const MAX_SEED: u64 = 1 << 53; - -/// `seed` as an integer in `0..2^53`, or `default` when absent or null; anything else is an error. -fn seed_param(p: &Value, cmd: &str, default: u64) -> Result { - match p.get("seed") { - None | Some(Value::Null) => Ok(default), - Some(v) => v.as_u64().filter(|n| *n < MAX_SEED).ok_or_else(|| bad(cmd, "seed must be an integer from 0 to 2^53 - 1")), - } -} - -/// The seed after `prev` at time `now`: a mix of `prev` and the clock text, so repeated reshuffles -/// within one clock tick still differ (barring a 2^-53 collision). Kept below 2^53 so JSON -/// clients that read numbers as doubles (web, MCP agents) get the same seed back. -fn next_seed(prev: u64, now: &str) -> u64 { - let t = now.bytes().fold(0u64, |h, b| lightcraft_catalog::mix64(h ^ u64::from(b))); - (lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & (MAX_SEED - 1) -} - -pub fn specs() -> Vec { - vec![ - // ---- view source / filter / sort - cmd!( - "library.source", - "Show Source", - [], - None, - "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing|libraryFolder, id?: albumId, path?: a path from library.folders (for libraryFolder)}", - always, - |s, p| { - let kind = str_param(p, "kind").unwrap_or("all"); - s.source = match kind { - "all" => LibrarySource::All, - "recentlyAdded" => LibrarySource::RecentlyAdded, - "recentlyDeleted" => LibrarySource::RecentlyDeleted, - "picks" => LibrarySource::Picks, - "missing" => LibrarySource::Missing, - "album" => { - let a = album_param(p, "id", "library.source")?; - if s.catalog.album(a).is_none_or(|a| a.folder) { - return Err(bad("library.source", "no such album")); - } - // one album at a time: `to_filter` makes the album shown replace the filter's, and the - // filter bar has no album chip, so a left-over one would narrow the next source unseen - s.filter.album = None; - LibrarySource::Album(a) - } - "libraryFolder" => { - let path = str_param(p, "path").filter(|d| !d.trim().is_empty() && !lightcraft_catalog::query::folder_key(d).is_empty()); - let path = path.ok_or_else(|| bad("library.source", "libraryFolder needs the `path` of a folder from library.folders"))?; - if lightcraft_catalog::folders::is_startup_disk(path) { - return Err(bad("library.source", "the startup disk's path covers every disk: choose a folder in it")); - } - let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; - let ids = s.catalog.query(&f, &Sort::default()); - if ids.is_empty() { - return Err(bad("library.source", format!("{path}: no photo in the library was imported from it"))); - } - if covers_other_disks(s, path, &ids) { - return Err(bad("library.source", format!("{path} holds other disks as well: choose a disk or a folder on one"))); - } - s.library_folder = Some(path.to_string()); - // one folder at a time: a folder filter left over would be ignored - s.filter.library_folder = None; - LibrarySource::LibraryFolder - } - other => return Err(bad("library.source", format!("unknown source `{other}`"))), - }; - s.keep_active_in_view(); - Ok(json!({"count": s.visible().len()})) - } - ), - cmd!( - "library.filter", - "Filter", - [], - None, - "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}", - always, - |s, p| { - let mut v = serde_json::to_value(&s.filter).unwrap_or_default(); - lightcraft_develop::presets::deep_merge(&mut v, p); - let f: Filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?; - let mut f = f; - // a blank folder is no folder: no hidden "filters active" state - f.library_folder = f.library_folder.filter(|d| !d.trim().is_empty()); - if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder { - return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); - } - // checked like a smart album's rules when this call sets them: an unknown field or a value - // that can't match is an error, not an empty grid. Rules that stopped checking since - // (their album was deleted) don't block other filter changes. - if let Some(rules) = f.rule_set.as_mut() { - rules.upgrade(); - } - let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null()); - if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) { - return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::>().join("; "))); - } - s.filter = f; - s.shelved_filter = None; - Ok(json!({"count": s.visible().len()})) - } - ), - cmd!( - query "library.folders", - "Library Folders", - [], - None, - "{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)", - always, - |s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default()) - ), - cmd!( - "library.removeFolder", - "Remove Folder from Library", - [], - None, - "{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}", - always, - |s, p| { - const C: &str = "library.removeFolder"; - let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; - if lightcraft_catalog::query::folder_key(path).is_empty() { - return Err(bad(C, format!("{path}: not a folder"))); - } - if lightcraft_catalog::folders::is_startup_disk(path) { - return Err(bad(C, "choose a folder, not the whole startup disk")); - } - if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) { - return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it"))); - } - let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; - let ids = s.catalog.query(&f, &Sort::default()); - if ids.is_empty() { - return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); - } - if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) { - return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them"))); - } - mark_deleted(s, &ids, "Remove Folder from Library")?; - if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { - s.filter.library_folder = None; - } - // a filter put aside by Enable Filters must not bring the removed folder back - if let Some(f) = s.shelved_filter.as_mut() - && f.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) - { - f.library_folder = None; - } - if s.shelved_filter.as_ref() == Some(&Filter::default()) { - s.shelved_filter = None; - } - // the folder being shown is gone: back to everything, as for a deleted album - if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { - s.library_folder = None; - if s.source == LibrarySource::LibraryFolder { - s.source = LibrarySource::All; - } - } - let vis = s.visible_cloned(); - s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); - Ok(json!({"removed": ids.len()})) - } - ), - cmd!( - "folder.label", - "Set Folder Color Label", - [], - None, - "{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}", - always, - |s, p| { - const C: &str = "folder.label"; - let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; - let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? { - "none" => None, - x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?), - }; - // taking a label off always works, also once the folder's photos are gone - let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; - if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() { - return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); - } - if label.is_none() && s.catalog.folder_record(path).is_none() { - return Ok(json!({"path": path, "label": label})); - } - let op = s.catalog.folder_label_op(path, label); - s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?; - Ok(json!({"path": path, "label": label})) - } - ), - cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| { - s.filter = Default::default(); - s.shelved_filter = None; - Ok(json!({"count": s.visible().len()})) - }), - // Classic: Library > Enable Filters (Cmd+L) switches the filter off and on again as it was - cmd!( - "library.toggleFilters", - "Enable Filters", - ["View"], - Some("Cmd+L"), - "{} — with a filter set, puts it aside and shows everything; with one put aside, brings it back as it was (stars, flag, text… together). Setting or clearing a filter while it is off forgets the one put aside → {enabled, count}", - always, - |s, _| { - if s.filter != Filter::default() { - s.shelved_filter = Some(std::mem::take(&mut s.filter)); - } else if let Some(mut f) = s.shelved_filter.take() { - // one folder at a time: the folder shown now wins over the one the filter had - if s.source == LibrarySource::LibraryFolder { - f.library_folder = None; - } - s.filter = f; - } - Ok(json!({"enabled": s.shelved_filter.is_none(), "count": s.visible().len()})) - } - ), - cmd!( - "library.sort", - "Sort", - ["View", "Sort"], - None, - "{key?: captureDate|importDate|editDate|fileName|rating|fileSize|random, ascending?: bool, group?: auto|none|day|month|year, seed?: u64 (the shuffle `random` gives)}", - always, - |s, p| { - let key: SortKey = match p.get("key") { - Some(k) => serde_json::from_value(k.clone()).map_err(|e| bad("library.sort", e.to_string()))?, - None => s.sort.key, - }; - let group = match str_param(p, "group") { - Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?, - None => s.sort.group, - }; - let mut seed = seed_param(p, "library.sort", s.sort.seed)?; - let explicit = p.get("seed").is_some_and(|v| !v.is_null()); - if key == SortKey::Random && s.sort.key != SortKey::Random && !explicit { - // switching to Random is a fresh shuffle, not whatever seed was left behind - seed = next_seed(seed, &(s.clock)()); - } - s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group, seed }; - ok() - } - ), - cmd!( - "library.refresh", - "Refresh View", - ["View"], - None, - "{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed", - always, - |s, _| { - s.refresh_view(); - ok() - } - ), - cmd!( - "library.holdView", - "Hold View Until Refreshed", - ["View"], - None, - "{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing", - always, - |s, p| { - let on = match p.get("on") { - None | Some(Value::Null) => !s.hold_view, - Some(Value::Bool(on)) => *on, - Some(_) => return Err(bad("library.holdView", "on must be true or false")), - }; - if on != s.hold_view { - let was = s.hold_view; - s.hold_view = on; - if let Err(e) = s.save_prefs() { - // not kept: not changed either, so asking again does what was asked - s.hold_view = was; - return Err(e); - } - s.refresh_view(); - } - Ok(json!({"hold": s.hold_view})) - } - ), - cmd!( - "library.shuffle", - "Reshuffle", - ["View", "Sort"], - None, - "{seed?: 0..2^53-1} — sort at random; without `seed` a new shuffle each time", - always, - |s, p| { - let seed = match seed_param(p, "library.shuffle", s.sort.seed)? { - given if p.get("seed").is_some_and(|v| !v.is_null()) => given, - // derived from the previous seed and the session clock: reproducible under a test - // clock, and no RNG needed - prev => next_seed(prev, &(s.clock)()), - }; - s.sort = Sort { key: SortKey::Random, seed, ..s.sort }; - ok() - } - ), - cmd!( - query "library.groups", - "Date Groups", - [], - None, - "{by?: day|month|year (default: the sort's grouping; auto = day)} → [{key, label, start, count}] date headers of the grid", - always, - |s, p| { - let by = match str_param(p, "by") { - Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.groups", "by must be auto|none|day|month|year"))?, - None => s.sort.group, - }; - let vis = s.visible_cloned(); - Ok(serde_json::to_value(s.catalog.date_runs(&vis, s.sort.key, by)).unwrap_or_default()) - } - ), - // ---- selection - cmd!( - "library.select", - "Select Photos", - [], - None, - "{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library", - always, - |s, p| { - let ids = super::photo_ids(p, "library.select")?.unwrap_or_default(); - let active = p.get("active").and_then(Value::as_u64).map(PhotoId); - let mode = str_param(p, "mode").unwrap_or("replace"); - // An id that is not in the library is an error, and the selection stays as it was: - // selecting it would otherwise report success and leave the session with no usable - // active photo (#182). - if let Some(id) = ids.iter().chain(active.as_ref()).find(|id| s.catalog.photo(**id).is_none()) { - return Err(bad("library.select", format!("no such photo {}", id.0))); - } - s.end_interaction()?; - match mode { - "replace" => { - s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) }; - } - "add" => { - for id in ids { - if !s.selection.contains(id) { - s.selection.ids.push(id); - } - s.selection.active = Some(id); - } - } - "toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)), - "range" => { - let vis = s.visible_cloned(); - if let Some(id) = ids.first() { - s.selection.extend_to(*id, &vis); - } - } - other => return Err(bad("library.select", format!("unknown mode `{other}`"))), - } - s.active_mask = None; - s.active_spot = None; - Ok(json!({"selected": s.selection.ids.len()})) - } - ), - cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| { - let vis = s.visible_cloned(); - s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis }; - Ok(json!({"selected": s.selection.ids.len()})) - }), - cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| { - Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default()) - }), - cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], Some("Cmd+Alt+Shift+C"), "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| { - let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?; - let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta; - let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description, - "copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url, - "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country, - "keywords": m.keywords}); - s.meta_clipboard = Some(v.clone()); - Ok(v) - }), - cmd!( - "photo.pasteMetadata", - "Paste Metadata", - ["Photo"], - Some("Cmd+Alt+Shift+V"), - "{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}", - has_selection, - |s, p| { - let c = "photo.pasteMetadata"; - let clip = s.meta_clipboard.clone().ok_or_else(|| bad(c, "nothing copied (photo.copyMetadata)"))?; - let fields: Option> = - p.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - let mut params = serde_json::Map::new(); - for (k, v) in clip.as_object().into_iter().flatten() { - if fields.as_ref().is_none_or(|f| f.iter().any(|x| x == k)) { - params.insert(k.clone(), v.clone()); - } - } - if let Some(ids) = p.get("ids") { - params.insert("ids".into(), ids.clone()); - } - s.execute("photo.setMeta", &Value::Object(params)) - } - ), - cmd!( - "library.selectBy", - "Select by Flag, Rating or Label", - [], - None, - "{flag?: pick|reject|none, rating?: 0..5, ratingOp?: gte|eq|lte (default gte), label?: red|…|none, add?: bool (extend the selection)} — among the photos in view", - always, - select_by - ), - cmd!("library.selectNone", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| { - let a = s.selection.active; - s.selection = Selection { ids: a.into_iter().collect(), active: a }; - ok() - }), - cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)), - cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)), - cmd!( - "library.down", - "Photo Below", - [], - Some("Down"), - "{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}", - always, - |s, p| { step(s, row_step(p)) } - ), - cmd!( - "library.up", - "Photo Above", - [], - Some("Up"), - "{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}", - always, - |s, p| { step(s, -row_step(p)) } - ), - // ---- rating / flags / labels - cmd!( - "photo.rate", - "Set Rating", - ["Photo", "Set Rating"], - None, - "{rating: 0..5, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { - let r = super::index_req(p, "rating", "photo.rate") - .ok() - .filter(|r| *r <= 5) - .ok_or_else(|| bad("photo.rate", "rating must be a whole number 0..5"))?; - cull(s, p, |s| for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))) - } - ), - cmd!( - "photo.decreaseRating", - "Decrease Rating", - [], - None, - "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| change_rating(s, p, -1, "Decrease Rating")) } - ), - cmd!( - "photo.increaseRating", - "Increase Rating", - [], - None, - "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| change_rating(s, p, 1, "Increase Rating")) } - ), - cmd!( - "photo.flag", - "Set Flag", - ["Photo", "Set Flag"], - None, - "{flag: pick|reject|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { - let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?; - cull(s, p, |s| for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))) - } - ), - // Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux) - cmd!( - "photo.cycleFlag", - "Cycle Flag", - ["Photo", "Set Flag"], - Some("`"), - "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged; without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { - cull(s, p, |s| { - change_flag(s, p, "Cycle Flag", |f| match f { - Flag::None => Flag::Pick, - Flag::Pick => Flag::Reject, - Flag::Reject => Flag::None, - }) - }) - } - ), - cmd!( - "photo.increaseFlag", - "Increase Flag Status", - ["Photo", "Set Flag"], - Some("Cmd+Up"), - "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick); without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick })) } - ), - cmd!( - "photo.decreaseFlag", - "Decrease Flag Status", - ["Photo", "Set Flag"], - Some("Cmd+Down"), - "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject); without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject })) } - ), - cmd!( - "photo.pick", - "Flag as Pick", - ["Photo", "Set Flag"], - Some("P"), - "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))) } - ), - cmd!( - "photo.reject", - "Flag as Reject", - ["Photo", "Set Flag"], - Some("X"), - "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))) } - ), - cmd!( - "photo.unflag", - "Unflag", - ["Photo", "Set Flag"], - Some("U"), - "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { cull(s, p, |s| for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))) } - ), - cmd!( - "photo.label", - "Set Color Label", - ["Photo", "Set Color Label"], - None, - "{label: red|yellow|green|blue|purple|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", - has_selection, - |s, p| { - // `none` (or null) clears; a missing label or one that isn't text is an error, not a clear - let l = match p.get("label") { - Some(Value::Null) => None, - Some(Value::String(x)) if x == "none" => None, - Some(Value::String(x)) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?), - None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")), - Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")), - }; - cull(s, p, |s| for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))) - } - ), - // ---- orientation - cmd!("photo.rotateLeft", "Rotate Left", ["Photo"], Some("Cmd+["), "{ids?}", has_selection, |s, p| rotate(s, p, false)), - cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)), - cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)), - cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)), - // ---- face / pet regions - cmd!( - "photo.removeRegion", - "Remove Face Box", - [], - None, - "{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back", - always, - |s, p| { - let id = super::photo_id(p, "photo.removeRegion")?.or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?; - let index = p - .get("index") - .and_then(Value::as_u64) - .and_then(|i| usize::try_from(i).ok()) - .ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?; - let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone(); - if index >= meta.regions.len() { - return Err(bad("photo.removeRegion", "no such region")); - } - let gone = meta.regions.remove(index); - s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; - s.skip_auto_write = true; - Ok(json!({"removed": gone.name})) - } - ), - cmd!( - "photo.setRegion", - "Resize Face Box", - [], - None, - "{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar", - always, - |s, p| { - const C: &str = "photo.setRegion"; - // a drag is one undo step however many frames it took: the caller commits once, on release - let id = super::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?; - let index = p - .get("index") - .and_then(Value::as_u64) - .and_then(|i| usize::try_from(i).ok()) - .ok_or_else(|| bad(C, "missing or invalid `index`"))?; - let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?; - let num = |k: &str| { - r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number"))) - }; - let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?); - let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0)); - let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0)); - if x1 - x0 < 0.005 || y1 - y0 < 0.005 { - return Err(bad(C, "the box would be too small")); - } - let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone(); - let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?; - region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 }; - s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; - s.skip_auto_write = true; - Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}})) - } - ), - // ---- delete / restore - cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { - let before = s.visible_cloned(); - let targets = s.targets(p)?; - // an open drag ends first, as its own undo step (the selection moves on) - s.end_interaction()?; - let n = mark_deleted(s, &targets, "Delete")?; - select_after_deletion(s, &before); - Ok(json!({"changed": n})) - }), - cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { - id, - deleted: false - }))), - cmd!( - "library.emptyRecentlyDeleted", - "Empty Recently Deleted", - ["Photo"], - None, - "{} → {deleted} — removes every photo in Recently Deleted from the library (files on disk stay)", - always, - |s, _| { - let ids: Vec = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect(); - if ids.is_empty() { - return Ok(json!({"deleted": 0})); - } - let op = s.catalog.delete_photos_permanently_ops(&ids); - s.commit("Empty Recently Deleted", op)?; - s.selection = Selection::default(); - Ok(json!({"deleted": ids.len()})) - } - ), - cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { - let before = s.visible_cloned(); - let t = s.targets(p)?; - // before the inverse ops are taken, so undo brings the photo back with its edit - s.end_interaction()?; - let op = s.catalog.delete_photos_permanently_ops(&t); - s.commit("Delete Permanently", op)?; - select_after_deletion(s, &before); - Ok(json!({"deleted": t.len()})) - }), - // ---- metadata - cmd!( - "photo.setMeta", - "Edit Info", - [], - None, - "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}", - has_selection, - |s, p| { - let strs = - |k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::>()); - let targets = s.targets(p)?; - // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear - let gps: Option> = match p.get("gps") { - None => None, - Some(Value::Null) => Some(None), - Some(Value::String(t)) if t.trim().is_empty() => Some(None), - Some(Value::String(t)) => Some(Some( - parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?, - )), - Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) { - (Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))), - _ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")), - }, - Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")), - }; - let status = match str_param(p, "copyrightStatus") { - Some(t) => Some( - CopyrightStatus::parse(t) - .ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?, - ), - None => None, - }; - let mut ops = Vec::new(); - for id in targets { - let Some(ph) = s.catalog.photo(id) else { continue }; - let mut m = ph.meta.clone(); - if let Some(g) = gps { - m.gps = g; - } - if let Some(st) = status { - m.copyright_status = st; - } - for (k, field) in [ - ("title", &mut m.title), - ("caption", &mut m.caption), - ("copyright", &mut m.copyright), - ("usageTerms", &mut m.usage_terms), - ("copyrightUrl", &mut m.copyright_url), - ("creator", &mut m.creator), - ("location", &mut m.location), - ("city", &mut m.city), - ("state", &mut m.state), - ("country", &mut m.country), - ("altText", &mut m.alt_text), - ("extendedDescription", &mut m.extended_description), - ] { - if let Some(v) = str_param(p, k) { - *field = v.to_string(); - } - } - // keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case - use lightcraft_catalog::keywords::{clean, same}; - let add = |m: &mut lightcraft_catalog::Meta, k: &str| { - let k = clean(k); - if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) { - m.keywords.push(k); - } - }; - if let Some(k) = strs("keywords") { - m.keywords.clear(); - for k in k { - add(&mut m, &k); - } - } - for k in strs("addKeywords").unwrap_or_default() { - add(&mut m, &k); - } - for k in strs("removeKeywords").unwrap_or_default() { - let k = clean(&k); - m.keywords.retain(|x| !same(&clean(x), &k)); - } - ops.push(Op::SetMeta { id, meta: Box::new(m) }); - } - let n = ops.len(); - s.commit("Edit Info", Op::Batch { ops })?; - // keywords just added become the Recent Keywords set - let added: Vec = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect(); - if n > 0 && !added.is_empty() { - crate::cmd::keywords::note_recent(s, &added); - } - Ok(json!({"changed": n})) - } - ), - // ---- albums - cmd!( - "album.create", - "New Album", - ["File"], - None, - "{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)", - always, - |s, p| { - let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); - if name.is_empty() { - return Err(bad("album.create", "empty name")); - } - let folder = bool_or(p, "folder", false); - let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) }; - let id = s.catalog.alloc_album_id(); - let cover = photos.first().copied(); - s.commit( - if folder { "New Folder" } else { "New Album" }, - Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } }, - )?; - Ok(json!({"id": id.0, "skipped": skipped})) - } - ), - cmd!( - "album.createSmart", - "New Smart Album…", - [], - None, - "{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)", - always, - |s, p| { - let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string(); - if name.is_empty() { - return Err(bad("album.createSmart", "empty name")); - } - let rules = match p.get("rules") { - Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?, - None => view_rules(s), - }; - let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let id = s.catalog.alloc_album_id(); - let album = Album { parent, smart: Some(Box::new(rules)), ..Album::new(id, name) }; - s.commit("New Smart Album", Op::AddAlbum { album })?; - Ok(json!({"id": id.0, "count": s.catalog.album_count(id)})) - } - ), - cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| { - use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for}; - Ok(json!(FIELDS - .iter() - .map(|(id, label, kind)| { - let k = match kind { - Kind::Text => "text", - Kind::Keywords => "keywords", - Kind::Number => "number", - Kind::Album => "album", - Kind::Date => "date", - Kind::Choice(_) => "choice", - Kind::Bool => "bool", - }; - let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::>()}); - if let Kind::Choice(c) = kind { - v["choices"] = json!(c.iter().map(|c| c.0).collect::>()); - v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::>().into(); - } - v - }) - .collect::>())) - }), - cmd!( - "album.setRules", - "Edit Smart Album", - [], - None, - "{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}", - always, - |s, p| { - let id = album_param(p, "id", "album.setRules")?; - let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?; - let rules = if bool_or(p, "fromView", false) { - // the view is saved as it is, but never as a loop (showing A, which tests this - // album, and saving that view here would make it test itself) - let view = view_rules(s); - let loops: Vec = view - .rule_set - .as_ref() - .map(|r| r.check_for(&s.catalog, Some(id))) - .unwrap_or_default() - .into_iter() - .chain(s.catalog.album_filter_problem(&view, Some(id))) - .filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop) - .map(|p| p.to_string()) - .collect(); - if !loops.is_empty() { - return Err(bad("album.setRules", loops.join("; "))); - } - view - } else { - let replace = bool_or(p, "replace", false); - let folder = cur.library_folder.clone(); - let base = if replace { Default::default() } else { cur }; - let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?; - // the rules dialog has no folder field: replacing its rules keeps the folder - // unless the call says (`libraryFolder: null`) to drop it - if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() { - r.library_folder = folder; - } - r - }; - let set = Op::SetAlbumRules { id, rules: Box::new(rules) }; - // a new name goes with the rules: one step, applied (and undone) together - let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n)); - match rename { - Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?, - None => s.commit("Edit Smart Album", set)?, - }; - Ok(json!({"count": s.catalog.album_count(id)})) - } - ), - cmd!("album.rename", "Rename Album", [], None, "{id, name}", always, |s, p| { - let id = album_param(p, "id", "album.rename")?; - let name = str_param(p, "name").ok_or_else(|| bad("album.rename", "missing name"))?.to_string(); - s.commit("Rename Album", Op::RenameAlbum { id, name })?; - ok() - }), - cmd!( - "album.delete", - "Delete Album", - [], - None, - "{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step", - always, - |s, p| { - let id = album_param(p, "id", "album.delete")?; - let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder; - let mut children = std::collections::BTreeMap::>::new(); - for album in s.catalog.albums() { - if let Some(parent) = album.parent { - children.entry(parent).or_default().push(album.id); - } - } - // Iterative traversal: even a malformed cycle is rejected before any change. - // Reverse parent-first order removes children first; the batch inverse restores - // parents first, including memberships, smart rules, covers and manual ordering. - let mut pending = vec![id]; - let mut order = Vec::new(); - let mut removed = std::collections::BTreeSet::new(); - while let Some(next) = pending.pop() { - if !removed.insert(next) { - return Err(bad("album.delete", "cycle in album folders")); - } - order.push(next); - if let Some(nested) = children.remove(&next) { - pending.extend(nested); - } - } - let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect(); - s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?; - if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) { - s.source = LibrarySource::All; - } - ok() - } - ), - cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| { - let id = album_param(p, "id", "album.move")?; - let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - s.commit("Move Album", Op::MoveAlbum { id, parent })?; - ok() - }), - cmd!( - "album.reorder", - "Reorder Album", - [], - None, - "{id, parent?: folderId|null, before?: albumId|null} — places the album (or folder) in `parent` (default: where it is; null: the top level) before the sibling `before` of the same kind, or last of its kind; the folder is then ordered by hand. One undo step. → {changed}: how many edits it took (0: already there)", - always, - |s, p| { - let id = album_param(p, "id", "album.reorder")?; - let me = s.catalog.album(id).ok_or_else(|| bad("album.reorder", "no such album"))?; - let parent = if p.get("parent").is_some() { opt_album_param(p, "parent", "album.reorder")? } else { me.parent }; - let before = opt_album_param(p, "before", "album.reorder")?; - if before == Some(id) { - // before itself: where it is - return Ok(json!({"changed": 0})); - } - let mut order: Vec = s.catalog.album_children(parent).into_iter().filter(|a| a.id != id).map(|a| a.id).collect(); - let at = match before { - Some(b) => { - let at = order.iter().position(|x| *x == b).ok_or_else(|| bad("album.reorder", "`before` is not inside that folder"))?; - if s.catalog.album(b).is_none_or(|x| x.folder != me.folder) { - return Err(bad("album.reorder", "folders are placed among folders and albums among albums")); - } - at - } - None if me.folder => order.iter().take_while(|x| s.catalog.album(**x).is_some_and(|a| a.folder)).count(), - None => order.len(), - }; - order.insert(at.min(order.len()), id); - let moved = me.parent != parent; - let mut ops = Vec::new(); - if moved { - ops.push(Op::MoveAlbum { id, parent }); - } - for (i, aid) in order.iter().enumerate() { - let want = u32::try_from(i).ok(); - // a moved album starts without a place in its new folder - let have = if moved && *aid == id { None } else { s.catalog.album(*aid).and_then(|a| a.order) }; - if have != want { - ops.push(Op::SetAlbumOrder { id: *aid, order: want }); - } - } - if ops.is_empty() { - return Ok(json!({"changed": 0})); - } - let changed = ops.len(); - s.commit("Reorder Album", Op::Batch { ops })?; - Ok(json!({"changed": changed})) - } - ), - cmd!( - "album.sort", - "Sort Albums by Name", - [], - None, - "{parent?: folderId|null} — drops the hand order of the albums inside the folder (default: the top level), so they are listed by name → {changed}: how many albums lost their place", - always, - |s, p| { - let parent = opt_album_param(p, "parent", "album.sort")?; - let ops: Vec = s - .catalog - .album_children(parent) - .into_iter() - .filter(|a| a.order.is_some()) - .map(|a| Op::SetAlbumOrder { id: a.id, order: None }) - .collect(); - if ops.is_empty() { - return Ok(json!({"changed": 0})); - } - let changed = ops.len(); - s.commit("Sort Albums by Name", Op::Batch { ops })?; - Ok(json!({"changed": changed})) - } - ), - cmd!( - "album.addPhotos", - "Add to Album", - ["Photo"], - None, - "{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error", - has_library_selection, - |s, p| { - let id = album_param(p, "id", "album.addPhotos")?; - let targets = super::photo_ids(p, "album.addPhotos")?.unwrap_or_else(|| s.selected()); - let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; - if al.is_smart() || al.folder { - return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); - } - let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; - let mut photos = al.photos.clone(); - let before = photos.len(); - // looked up, not searched for: adding thousands to a big album stays linear - let mut held: std::collections::HashSet = photos.iter().copied().collect(); - for t in targets { - if held.insert(t) { - photos.push(t); - } - } - let added = photos.len() - before; - let cover = al.cover.or(photos.first().copied()); - s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?; - Ok(json!({"added": added, "skipped": skipped})) - } - ), - cmd!( - "album.toggleTarget", - "Add to Target Album", - [], - None, - "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", - has_library_selection, - |s, p| { - let targets = - album_targets_or_err(s, "album.toggleTarget", super::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()))?; - let (targets, skipped) = targets; - let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); - let id = match target.or_else(|| s.catalog.quick_collection()) { - Some(id) => id, - None => { - let id = s.catalog.alloc_album_id(); - let album = Album { quick: true, ..Album::new(id, "Quick Collection") }; - s.commit("New Quick Collection", Op::AddAlbum { album })?; - id - } - }; - let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; - // looked up, not searched for: thousands toggled in a big album stay linear - let mut held: std::collections::HashSet = al.photos.iter().copied().collect(); - let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t)); - let photos: Vec = if all_in { - let targets: std::collections::HashSet = targets.iter().copied().collect(); - al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() - } else { - al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() - }; - let name = al.name.clone(); - let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); - s.commit( - if all_in { "Remove from Target Album" } else { "Add to Target Album" }, - Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] }, - )?; - Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped})) - } - ), - cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| { - s.target_album = match p.get("id").and_then(Value::as_u64) { - Some(a) => { - let id = AlbumId(a); - let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?; - if al.is_smart() || al.folder { - return Err(bad("album.setTarget", "smart albums and folders can't hold photos")); - } - Some(id) - } - None => None, - }; - Ok(json!({"target": s.target_album.map(|a| a.0)})) - }), - cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| { - let Some(id) = s.catalog.quick_collection() else { return ok() }; - s.commit( - "Clear Quick Collection", - Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] }, - )?; - ok() - }), - cmd!( - "library.autoImport", - "Auto Import Settings", - [], - None, - "{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings", - always, - |s, p| { - if let Some(f) = p.get("folder") { - s.import_defaults.auto_folder = match f.as_str().map(str::trim).filter(|f| !f.is_empty()) { - Some(f) if std::path::Path::new(f).is_dir() || cfg!(target_arch = "wasm32") => Some(f.to_string()), - Some(f) => return Err(bad("library.autoImport", format!("`{f}` is not a folder"))), - None => None, - }; - } - if let Some(c) = p.get("copy").and_then(Value::as_bool) { - s.import_defaults.auto_copy = c; - } - if let Some(a) = p.get("album") { - s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string); - } - s.save_prefs()?; - let d = &s.import_defaults; - Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album})) - } - ), - cmd!( - "library.autoImportScan", - "Auto Import Now", - [], - None, - "{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}", - always, - |s, p| { - let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) }; - // only files that stopped growing: a file still being written is left for later - let known: std::collections::HashSet = s - .catalog - .photos() - .filter_map(|p| match &p.source { - lightcraft_catalog::Source::File { path } => Some(path.clone()), - _ => None, - }) - .collect(); - // the folder's files and sizes: listed here, or already listed on a worker thread (the app) - let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) { - Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(), - None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?, - }; - let mut fresh = Vec::new(); - for (ps, size) in listing { - if known.contains(&ps) { - continue; - } - // each file is tried once (a non-photo isn't retried every scan) - if s.auto_import_seen.get(&ps) == Some(&u64::MAX) { - continue; - } - let seen = s.auto_import_seen.insert(ps.clone(), size); - if size > 0 && seen == Some(size) { - fresh.push(ps); - } - } - if fresh.is_empty() { - return Ok(json!({"imported": [], "folder": folder})); - } - let mode = if s.import_defaults.auto_copy { "copy" } else { "add" }; - // a photo deleted from the library doesn't come back on its own because its file - // is still in the watched folder (a copy's source is not a known path) - let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"}); - if let Some(a) = s.import_defaults.auto_album.clone() { - match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) { - Some(al) => params["album"] = json!(al.id.0), - None => params["albumName"] = json!(a), - } - } - for f in &fresh { - s.auto_import_seen.insert(f.clone(), u64::MAX); - } - if p.get("start").and_then(Value::as_bool) == Some(false) { - // the caller imports them (the app: on a worker thread) - return Ok(json!({"imported": [], "folder": folder, "import": params})); - } - let sel = s.selection.clone(); - let r = s.execute("library.import", ¶ms)?; - // arriving photos don't take over the selection - s.selection = sel; - let mut result = json!({"imported": r["imported"], "folder": folder}); - if let Some(warnings) = r.get("warnings") { - result["warnings"] = warnings.clone(); - } - Ok(result) - } - ), - cmd!( - "album.removePhotos", - "Remove from Album", - ["Photo"], - None, - "{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}", - has_selection, - |s, p| { - let id = match p.get("id") { - Some(_) => album_param(p, "id", "album.removePhotos")?, - None => match s.source { - LibrarySource::Album(a) => a, - _ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")), - }, - }; - let targets = super::photo_ids(p, "album.removePhotos")?.unwrap_or_else(|| s.selected()); - let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; - if al.is_smart() { - return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); - } - if al.folder { - return Err(bad("album.removePhotos", "album folders don't hold photos")); - } - let name = al.name.clone(); - let targets: std::collections::HashSet = targets.into_iter().collect(); - let photos: Vec = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); - let removed = al.photos.len().saturating_sub(photos.len()); - if removed > 0 { - s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; - } - Ok(json!({"removed": removed, "album": name})) - } - ), - cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| { - let id = album_param(p, "id", "album.setCover")?; - let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active()); - s.commit("Set Album Cover", Op::SetAlbumCover { id, cover: photo })?; - ok() - }), - // ---- import - cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None, - "{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required", - always, |_, p| { - let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?; - let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?; - Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings,"skipped":data.skipped})) - }), - cmd!( - "library.importLightroom", - "Import Lightroom Catalog", - [], - None, - "{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.", - always, - |s, p| { - let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?; - crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false)) - } - ), - cmd!( - query "library.importPreview", - "Review Import", - [], - None, - "{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added", - always, - |s, p| { - let paths = strs(p, "paths"); - if paths.is_empty() { - return Err(bad("library.importPreview", "no paths")); - } - let c = crate::import::scan(s, &paths); - let dups = c.iter().filter(|c| c.duplicate.is_some()).count(); - Ok(json!({"scanned": c.len(), "duplicates": dups, "candidates": c})) - } - ), - cmd!( - "library.import", - "Import Photos", - ["File"], - Some("Cmd+Shift+I"), - "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, warnings?: [{path, code: cameraProfile|uprightGeometry|unmappedXmp, profile?, mode?: off|auto|level|vertical|full|guided|unknown, fields?}], moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", - always, - |s, p| { - let req = import_params(s, p)?; - if req.paths.is_empty() { - return Err(bad("library.import", "no paths")); - } - let undo0 = s.undo.len(); - let report = crate::import::import_with(s, &req.paths, &req.opts)?; - let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?; - // one undo step for the whole import - let ids_of = |k: &str| -> Vec { report[k].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default() }; - let (imported, restored) = (ids_of("imported"), ids_of("restored")); - let n = s.undo.len().saturating_sub(undo0); - let (verb, count) = if imported.is_empty() && !restored.is_empty() { ("Restore", restored.len()) } else { ("Add", imported.len()) }; - s.merge_undo(n, &format!("{verb} {count} Photo{}", if count == 1 { "" } else { "s" })); - let imported = if imported.is_empty() { restored } else { imported }; - if let Some(f) = imported.first() { - s.selection = Selection::single(PhotoId(*f)); - } - Ok(report) - } - ), - // ---- persistence - cmd!(query "library.info", "Library Info", [], None, "{}", always, |s, _| { - let photos = s.catalog.len(); - let albums = s.catalog.albums().count(); - let (rendered_n, rendered_bytes) = s.media.rendered.mem_usage(); - let (sources_n, sources_bytes) = s.media.source_usage(); - let disk = s.media.rendered.disk().map(|d| { - use std::sync::atomic::Ordering::Relaxed; - json!({ - "path": d.dir().display().to_string(), - "bytes": d.size(), - "hits": d.hits.load(Relaxed), - "misses": d.misses.load(Relaxed), - "writes": d.writes.load(Relaxed), - }) - }); - let cache = json!({ - "renderedInMemory": rendered_n, "renderedBytes": rendered_bytes, - "sourcesInMemory": sources_n, "sourceBytes": sources_bytes, - "disk": disk, - }); - let Some(lib) = &s.library else { - return Ok(json!({"persistent": false, "photos": photos, "albums": albums, "cache": cache})); - }; - let j = lib.journal(); - Ok(json!({ - "persistent": true, - "path": lib.dir.display().to_string(), - "photos": photos, - "albums": albums, - "seq": j.seq(), - "snapshotSeq": j.snapshot_seq(), - "logRecords": j.log_records(), - "logBytes": j.log_bytes(), - "lastError": lib.last_error, - // settings files that were unreadable or damaged at open (kept, defaults used) - "settingsWarnings": lib.settings_warnings, - // changes applied in memory whose write failed (retried by every save) - "unsavedOps": s.unsaved().map_or(0, |u| u.0), - "unsavedError": s.unsaved().map(|u| u.1), - // untouched Local records forgotten when the library opened - "forgotLocal": lib.forgot_local.as_ref().map(|p| json!({"forgotten": p.evict.len(), "local": p.local, "keptRecent": p.kept_recent, "keptTouched": p.kept_touched, "keptInUse": p.kept_in_use})), - "persistence": j.stats(), - "cache": cache, - "load": { - "created": lib.report.created, - "replayed": lib.report.replayed, - "tornBytes": lib.report.torn_bytes, - "damaged": lib.report.damaged, - }, - })) - }), - cmd!("library.compact", "Optimize Library", ["File"], None, "{}", has_library, |s, _| { - s.compact_library()?; - ok() - }), - cmd!("library.clearPreviews", "Discard Preview Cache", ["File"], None, "{}", always, |s, _| { - s.media.rendered.clear(); - s.media.clear_sources(); - ok() - }), - ] -} - -/// `base` with a partial Filter (JSON) merged on top. -/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see -/// [`merge_rules`]). -fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { - let mut v = serde_json::to_value(base).unwrap_or_default(); - lightcraft_develop::presets::deep_merge(&mut v, patch); - let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; - if let Some(rules) = f.rule_set.as_mut() { - rules.upgrade(); - } - Ok(f) -} - -/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`; -/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a -/// problem too). -fn merge_rules( - base: &lightcraft_catalog::Filter, - patch: &Value, - c: &str, - cat: &lightcraft_catalog::Catalog, - owner: Option, -) -> Result { - let f = merge_filter(base, patch, c)?; - // only rules this change sets are checked: one that stopped checking since (its album was - // deleted) doesn't block editing the album's other settings - let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null()); - let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default(); - // and a loop through the album field the change sets - if patch.get("album").is_some_and(|a| !a.is_null()) { - problems.extend(cat.album_filter_problem(&f, owner)); - } - if !problems.is_empty() { - return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); - } - Ok(f) -} - -/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup -/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of -/// one disk but a way to reach several. -pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool { - ids.iter() - .filter_map(|id| s.catalog.photo(*id)) - .filter_map(|p| lightcraft_catalog::folders::volume_of(p)) - .any(|v| v != "/" && !lightcraft_catalog::query::folder_within(path, &v)) -} - -/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from -/// its rules with the filter bar's settings on top. -fn view_rules(s: &Session) -> lightcraft_catalog::Filter { - use lightcraft_catalog::Filter; - let smart = match s.source { - LibrarySource::Album(a) => s.catalog.album(a).and_then(|a| a.smart.as_deref().cloned()), - _ => None, - }; - match smart { - Some(base) => { - // overlay the fields the filter bar changed - let cur = serde_json::to_value(&s.filter).unwrap_or_default(); - let def = serde_json::to_value(Filter::default()).unwrap_or_default(); - let mut patch = serde_json::Map::new(); - if let (Some(c), Some(d)) = (cur.as_object(), def.as_object()) { - for (k, v) in c { - if d.get(k) != Some(v) { - patch.insert(k.clone(), v.clone()); - } - } - } - // the view as it is: a rule that no longer checks (its album was deleted) just matches - // nothing there, and mustn't cost the filter bar's settings - merge_filter(&base, &Value::Object(patch), "").unwrap_or(base) - } - None => { - let mut f = s.source.to_filter(&s.filter, &s.catalog); - f.deleted = false; - if s.source == LibrarySource::LibraryFolder { - f.library_folder = s.library_folder.clone(); - } - f - } - } -} - -impl Session { - /// The current view (source + filter) as smart-album rules (see `album.createSmart`). - pub fn view_rules(&self) -> lightcraft_catalog::Filter { - view_rules(self) - } -} - -fn has_library(s: &Session) -> std::result::Result<(), String> { - if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) } -} - -/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W", -/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs). -pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> { - // typographic minus signs and dashes mean a negative value, like ASCII '-' - let t = t.replace(['\u{2212}', '\u{2012}', '\u{2013}', '\u{FE63}', '\u{FF0D}'], "-"); - let t = t.trim(); - // split into the two coordinates: at a comma, else after the first N/S hemisphere letter - let (a, b) = match t.split_once(',') { - Some((a, b)) => (a.trim().to_string(), b.trim().to_string()), - None => { - let lead = t.starts_with(['N', 'S', 'n', 's']); - let i = if lead { - // hemisphere letter first: "N 51.5 W 0.1" splits at the E/W letter - t.char_indices().skip(1).find(|(_, c)| matches!(c, 'E' | 'W' | 'e' | 'w')).map(|(i, _)| i) - } else { - t.find(['N', 'S', 'n', 's']).map(|i| i + 1) - } - .or_else(|| t.find(char::is_whitespace))?; - (t[..i].trim().to_string(), t[i..].trim().to_string()) - } - }; - let one = |s: &str, pos: char, neg: char| -> Option { - let up = s.to_ascii_uppercase(); - let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 }; - let nums: Vec = - up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::().ok()).collect::>()?; - let v = match nums.as_slice() { - [d] => *d, - [d, m] => d + m / 60.0, - [d, m, s] => d + m / 60.0 + s / 3600.0, - _ => return None, - }; - let _ = pos; - Some(sign * v) - }; - let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?); - (la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo)) -} - -#[cfg(test)] -mod gps_tests { - #[test] - fn coordinates_people_type() { - let p = super::parse_gps; - assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276))); - let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap(); - assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}"); - let (la, lo) = p("33.8688 S, 151.2093 E").unwrap(); - assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9); - assert_eq!(p("51.5 N 0.1 W"), Some((51.5, -0.1))); - assert_eq!(p("N 51.5072 W 0.1276"), Some((51.5072, -0.1276))); - assert_eq!(p("S 33.8688 E 151.2093"), Some((-33.8688, 151.2093))); - assert_eq!(p("\u{2212}33.8688, 151.2093"), Some((-33.8688, 151.2093))); - assert_eq!(p("95, 10"), None); - assert_eq!(p("hello"), None); - } -} +pub(crate) use filters::covers_other_disks; +pub use import::{ImportRequest, import_batch_done, import_params, list_auto_import_folder}; +pub use specs::specs; #[cfg(test)] #[path = "library/tests_relative_ratings.rs"] diff --git a/crates/engine/src/cmd/library/filters.rs b/crates/engine/src/cmd/library/filters.rs new file mode 100644 index 000000000..1eaa5223f --- /dev/null +++ b/crates/engine/src/cmd/library/filters.rs @@ -0,0 +1,92 @@ +use super::*; + +/// `base` with a partial Filter (JSON) merged on top. +/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see +/// [`merge_rules`]). +fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { + let mut v = serde_json::to_value(base).unwrap_or_default(); + lightcraft_develop::presets::deep_merge(&mut v, patch); + let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; + if let Some(rules) = f.rule_set.as_mut() { + rules.upgrade(); + } + Ok(f) +} + +/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`; +/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a +/// problem too). +pub(super) fn merge_rules( + base: &lightcraft_catalog::Filter, + patch: &Value, + c: &str, + cat: &lightcraft_catalog::Catalog, + owner: Option, +) -> Result { + let f = merge_filter(base, patch, c)?; + // only rules this change sets are checked: one that stopped checking since (its album was + // deleted) doesn't block editing the album's other settings + let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null()); + let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default(); + // and a loop through the album field the change sets + if patch.get("album").is_some_and(|a| !a.is_null()) { + problems.extend(cat.album_filter_problem(&f, owner)); + } + if !problems.is_empty() { + return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); + } + Ok(f) +} + +/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup +/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of +/// one disk but a way to reach several. +pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool { + ids.iter() + .filter_map(|id| s.catalog.photo(*id)) + .filter_map(|p| lightcraft_catalog::folders::volume_of(p)) + .any(|v| v != "/" && !lightcraft_catalog::query::folder_within(path, &v)) +} + +/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from +/// its rules with the filter bar's settings on top. +pub(super) fn view_rules(s: &Session) -> lightcraft_catalog::Filter { + use lightcraft_catalog::Filter; + let smart = match s.source { + LibrarySource::Album(a) => s.catalog.album(a).and_then(|a| a.smart.as_deref().cloned()), + _ => None, + }; + match smart { + Some(base) => { + // overlay the fields the filter bar changed + let cur = serde_json::to_value(&s.filter).unwrap_or_default(); + let def = serde_json::to_value(Filter::default()).unwrap_or_default(); + let mut patch = serde_json::Map::new(); + if let (Some(c), Some(d)) = (cur.as_object(), def.as_object()) { + for (k, v) in c { + if d.get(k) != Some(v) { + patch.insert(k.clone(), v.clone()); + } + } + } + // the view as it is: a rule that no longer checks (its album was deleted) just matches + // nothing there, and mustn't cost the filter bar's settings + merge_filter(&base, &Value::Object(patch), "").unwrap_or(base) + } + None => { + let mut f = s.source.to_filter(&s.filter, &s.catalog); + f.deleted = false; + if s.source == LibrarySource::LibraryFolder { + f.library_folder = s.library_folder.clone(); + } + f + } + } +} + +impl Session { + /// The current view (source + filter) as smart-album rules (see `album.createSmart`). + pub fn view_rules(&self) -> lightcraft_catalog::Filter { + view_rules(self) + } +} diff --git a/crates/engine/src/cmd/library/gps.rs b/crates/engine/src/cmd/library/gps.rs new file mode 100644 index 000000000..44f0e02c5 --- /dev/null +++ b/crates/engine/src/cmd/library/gps.rs @@ -0,0 +1,57 @@ +/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W", +/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs). +pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> { + // typographic minus signs and dashes mean a negative value, like ASCII '-' + let t = t.replace(['\u{2212}', '\u{2012}', '\u{2013}', '\u{FE63}', '\u{FF0D}'], "-"); + let t = t.trim(); + // split into the two coordinates: at a comma, else after the first N/S hemisphere letter + let (a, b) = match t.split_once(',') { + Some((a, b)) => (a.trim().to_string(), b.trim().to_string()), + None => { + let lead = t.starts_with(['N', 'S', 'n', 's']); + let i = if lead { + // hemisphere letter first: "N 51.5 W 0.1" splits at the E/W letter + t.char_indices().skip(1).find(|(_, c)| matches!(c, 'E' | 'W' | 'e' | 'w')).map(|(i, _)| i) + } else { + t.find(['N', 'S', 'n', 's']).map(|i| i + 1) + } + .or_else(|| t.find(char::is_whitespace))?; + (t[..i].trim().to_string(), t[i..].trim().to_string()) + } + }; + let one = |s: &str, pos: char, neg: char| -> Option { + let up = s.to_ascii_uppercase(); + let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 }; + let nums: Vec = + up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::().ok()).collect::>()?; + let v = match nums.as_slice() { + [d] => *d, + [d, m] => d + m / 60.0, + [d, m, s] => d + m / 60.0 + s / 3600.0, + _ => return None, + }; + let _ = pos; + Some(sign * v) + }; + let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?); + (la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo)) +} + +#[cfg(test)] +mod gps_tests { + #[test] + fn coordinates_people_type() { + let p = super::parse_gps; + assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276))); + let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap(); + assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}"); + let (la, lo) = p("33.8688 S, 151.2093 E").unwrap(); + assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9); + assert_eq!(p("51.5 N 0.1 W"), Some((51.5, -0.1))); + assert_eq!(p("N 51.5072 W 0.1276"), Some((51.5072, -0.1276))); + assert_eq!(p("S 33.8688 E 151.2093"), Some((-33.8688, 151.2093))); + assert_eq!(p("\u{2212}33.8688, 151.2093"), Some((-33.8688, 151.2093))); + assert_eq!(p("95, 10"), None); + assert_eq!(p("hello"), None); + } +} diff --git a/crates/engine/src/cmd/library/import.rs b/crates/engine/src/cmd/library/import.rs new file mode 100644 index 000000000..81b82671f --- /dev/null +++ b/crates/engine/src/cmd/library/import.rs @@ -0,0 +1,110 @@ +use super::selection::strs; +use super::*; + +/// The auto-import folder's visible files with their sizes (the file-system half of +/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`). +pub fn list_auto_import_folder(folder: &str) -> std::result::Result, String> { + let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?; + Ok(rd + .flatten() + .filter(|e| !e.file_name().to_string_lossy().starts_with('.')) + .filter_map(|e| { + let m = std::fs::metadata(e.path()).ok()?; + m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len())) + }) + .collect()) +} + +/// `library.import`'s params, checked (no file-system calls). +pub struct ImportRequest { + pub paths: Vec, + pub opts: crate::import::ImportOptions, + pub album: Option, + pub album_name: Option, +} + +/// Parse and check `library.import`'s params (the app's import task runs the import itself, on a +/// worker thread, with the same options). +pub fn import_params(s: &Session, p: &Value) -> Result { + let paths = strs(p, "paths"); + let mode = match str_param(p, "mode").unwrap_or("add") { + "add" => crate::import::ImportMode::Add, + "copy" => crate::import::ImportMode::Copy, + "move" => crate::import::ImportMode::Move, + other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))), + }; + let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) { + Some(id) => Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?), + None => None, + }; + let album = p.get("album").and_then(Value::as_u64); + if let Some(a) = album + && s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart()) + { + return Err(bad("library.import", "album must be a regular album")); + } + let organize = match str_param(p, "organize") { + Some(o) => crate::import::Organize::parse(o).ok_or_else(|| { + bad("library.import", format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})")) + })?, + None => Default::default(), + }; + if let crate::import::Organize::Template(t) = &organize + && let Some(e) = crate::rename::folder_template_error(t) + { + return Err(bad("library.import", e)); + } + let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + if let Some(n) = &metadata_preset + && !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) + { + return Err(bad("library.import", format!("unknown metadata preset `{n}`"))); + } + let on_deleted = match str_param(p, "onDeleted") { + Some(v) => { + crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))? + } + None => Default::default(), + }; + let opts = crate::import::ImportOptions { + on_deleted, + mode, + preset, + keywords: strs(p, "keywords"), + destination: str_param(p, "destination").map(str::to_string), + organize, + rename: str_param(p, "rename").map(str::to_string), + rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize, + metadata_preset, + convert_dng: bool_or(p, "dng", false), + local: bool_or(p, "local", false), + }; + let album_name = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + Ok(ImportRequest { paths, opts, album, album_name }) +} + +/// After a batch of an import was committed: the album (a new one named `album_name` when there +/// is no `album` and something was imported) gets the photos. Returns the report as JSON (with +/// `album`). +pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option, album_name: Option<&str>) -> Result { + let mut report = serde_json::to_value(report).unwrap_or_default(); + // photos an import restored from Recently Deleted take part like new ones (album, selection) + let imported: Vec = + ["imported", "restored"].iter().flat_map(|k| report[*k].as_array().into_iter().flatten().filter_map(Value::as_u64)).collect(); + if album.is_none() + && let Some(name) = album_name + && !imported.is_empty() + { + let r = s.execute("album.create", &json!({"name": name}))?; + album = r["id"].as_u64(); + } + if let Some(a) = album + && !imported.is_empty() + { + // (album.addPhotos wants a selection: the new photos are about to be it) + s.selection = Selection::single(PhotoId(imported[0])); + s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?; + report["album"] = json!(a); + } + Ok(report) +} diff --git a/crates/engine/src/cmd/library/selection.rs b/crates/engine/src/cmd/library/selection.rs new file mode 100644 index 000000000..1ed1868d7 --- /dev/null +++ b/crates/engine/src/cmd/library/selection.rs @@ -0,0 +1,247 @@ +use super::*; + +/// The selection after photos left the view (deleted, or rated, flagged or labelled out of it). +/// Its callers end an open edit (a slider drag) before their own change, as moving to the next +/// photo does, so it is its own undo step and comes first. +pub(super) fn select_after_deletion(s: &mut Session, before: &[PhotoId]) { + let at = before.iter().position(|id| Some(*id) == s.selection.active); + let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect(); + s.selection.ids.retain(|id| remaining.contains(id)); + s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied()); + if s.selection.active.is_none() + && let Some(at) = at + { + // Keep culling near the deleted photo: next visible survivor, or the previous one at the end. + let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id)); + s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default(); + } +} + +pub(super) fn album_param(p: &Value, key: &str, c: &str) -> Result { + p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`"))) +} + +/// An optional album id: absent or `null` is `None`, anything but a number is an error. +pub(super) fn opt_album_param(p: &Value, key: &str, c: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(|n| Some(AlbumId(n))).ok_or_else(|| bad(c, format!("`{key}` must be an album id or null"))), + } +} + +pub(super) fn strs(p: &Value, key: &str) -> Vec { + p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() +} + +/// The targets an album can hold and how many were browsed (Local) photos, which album views +/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members. +pub(super) fn album_targets(s: &Session, targets: Vec) -> (Vec, usize) { + let before = targets.len(); + let kept: Vec = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect(); + let skipped = before - kept.len(); + (kept, skipped) +} + +/// Album targets, or the error for a selection that was nothing but Local photos. +pub(super) fn album_targets_or_err(s: &Session, command: &str, targets: Vec) -> Result<(Vec, usize)> { + let (kept, skipped) = album_targets(s, targets); + if kept.is_empty() && skipped > 0 { + return Err(bad(command, crate::cmd::LOCAL_ONLY)); + } + Ok((kept, skipped)) +} + +/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep +/// showing `i/n` (issue #1493). +pub(super) fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result { + let mut ops: Vec = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); + let n = ops.len(); + if n == 0 { + return Ok(0); + } + ops.extend(s.catalog.remove_from_stacks_ops(ids)); + s.commit(label, Op::Batch { ops })?; + Ok(n) +} + +/// Apply one op per target as a single undo step. +pub(super) fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option) -> Result { + let targets = s.targets(p)?; + let ops: Vec = targets.iter().filter_map(|id| f(*id)).collect(); + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + Ok(json!({"changed": n})) +} + +/// Adjust each target's own rating; unchanged bounds do not create an undo step. +pub(super) fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result { + let mut ops = Vec::new(); + for id in s.targets(p)? { + let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating; + let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8; + if rating != old { + ops.push(Op::SetRating { id, rating }); + } + } + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + Ok(json!({"changed": n})) +} + +/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged, +/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step. +pub(super) fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result { + let mut ops = Vec::new(); + for id in s.targets(p)? { + let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag; + let flag = step(old); + if flag != old { + ops.push(Op::SetFlag { id, flag }); + } + } + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + Ok(json!({"changed": n})) +} + +/// Rate, flag or label (`edit`), then `advance`. When the edit took the active photo out of the view (the filter bar, +/// Picks or a smart album shows it no more), culling goes on from its place, as after a delete: the photo after it is +/// active, or the one before it at the end (#2312), not the view's first photo. That photo is the next one, so +/// `advance` doesn't step past it. Photos named by `ids` / `id` leave the selection to the caller: Compare and Survey +/// keep showing the photo they rate. +pub(super) fn cull(s: &mut Session, p: &Value, edit: impl FnOnce(&mut Session) -> Result) -> Result { + let named = [p.get("ids"), p.get("id")].into_iter().any(|t| t.is_some_and(|t| !t.is_null())); + // the view as it was, then an open drag ends first, as its own undo step (the selection may + // move on; the drag itself can take the photo out of the view) + let before = (!named).then(|| s.visible_shared().1); + if !named { + s.end_interaction()?; + } + let active = s.selection.active; + let v = edit(s)?; + if let Some(before) = before + && active.is_some_and(|a| before.contains(&a) && !s.visible().contains(&a)) + { + select_after_deletion(s, &before); + s.active_mask = None; + s.active_spot = None; + } else { + advance_if(s, p); + } + Ok(v) +} + +fn advance_if(s: &mut Session, p: &Value) { + if bool_or(p, "advance", false) { + let _ = step(s, 1); + } +} + +pub(super) fn step(s: &mut Session, d: isize) -> Result { + let vis = s.visible_cloned(); + if vis.is_empty() { + return ok(); + } + let cur = s.selection.active.and_then(|a| vis.iter().position(|x| *x == a)); + let next = match cur { + Some(i) => (i as isize + d).clamp(0, vis.len() as isize - 1) as usize, + None => 0, + }; + s.end_interaction()?; + s.selection = Selection::single(vis[next]); + s.active_mask = None; + s.active_spot = None; + Ok(json!({"active": vis[next].0})) +} + +/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input +/// can't overflow). +pub(super) fn row_step(p: &Value) -> isize { + p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize +} + +pub(super) fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { + let targets = s.targets(p)?; + let mut ops = Vec::new(); + for id in targets { + if let Some(d) = s.develop_of(id) { + let mut d = (*d).clone(); + d.orientation = d.orientation.rotated(cw); + // the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo) + let turn = lightcraft_geom::Orientation::Normal.rotated(cw); + d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect); + d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w)); + ops.extend(s.develop_op(id, d, "Rotate")); + } + } + let n = ops.len(); + s.commit(if cw { "Rotate Right" } else { "Rotate Left" }, Op::Batch { ops })?; + Ok(json!({"changed": n})) +} + +pub(super) fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result { + let targets = s.targets(p)?; + let mut ops = Vec::new(); + for id in targets { + if let Some(d) = s.develop_of(id) { + let mut d = (*d).clone(); + if horizontal { + d.crop.flip_h = !d.crop.flip_h; + } else { + d.crop.flip_v = !d.crop.flip_v; + } + ops.extend(s.develop_op(id, d, "Flip")); + } + } + let n = ops.len(); + s.commit("Flip", Op::Batch { ops })?; + Ok(json!({"changed": n})) +} + +/// `library.selectBy`: select the photos in view that match every given criterion. +pub(super) fn select_by(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.selectBy"; + let flag: Option = match p.get("flag") { + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(C, "flag is pick, reject or none"))?), + None => None, + }; + let rating = p.get("rating").and_then(Value::as_u64).map(|r| r.min(5) as u8); + let op = p.get("ratingOp").and_then(Value::as_str).unwrap_or("gte"); + let label: Option> = match p.get("label").and_then(Value::as_str) { + Some("none") => Some(None), + Some(l) => Some(Some(serde_json::from_value(json!(l)).map_err(|_| bad(C, format!("unknown label `{l}`")))?)), + None => None, + }; + if flag.is_none() && rating.is_none() && label.is_none() { + return Err(bad(C, "give flag, rating and/or label")); + } + let matches = |ph: &lightcraft_catalog::Photo| { + flag.is_none_or(|f| ph.flag == f) + && rating.is_none_or(|r| match op { + "eq" => ph.rating == r, + "lte" => ph.rating <= r, + _ => ph.rating >= r, + }) + && label.is_none_or(|l| ph.label == l) + }; + let mut ids: Vec = if bool_or(p, "add", false) { s.selection.ids.clone() } else { Vec::new() }; + for id in s.visible_cloned() { + if !ids.contains(&id) && s.catalog.photo(id).is_some_and(|ph| matches(ph)) { + ids.push(id); + } + } + let active = s.selection.active.filter(|a| ids.contains(a)).or(ids.first().copied()); + let n = ids.len(); + s.selection = Selection { ids, active }; + Ok(json!({"selected": n})) +} + +pub(super) fn has_library(s: &Session) -> std::result::Result<(), String> { + if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) } +} diff --git a/crates/engine/src/cmd/library/specs.rs b/crates/engine/src/cmd/library/specs.rs new file mode 100644 index 000000000..07ab6f221 --- /dev/null +++ b/crates/engine/src/cmd/library/specs.rs @@ -0,0 +1,1374 @@ +use super::filters::{covers_other_disks, merge_rules, view_rules}; +use super::gps::parse_gps; +use super::import::{import_batch_done, import_params, list_auto_import_folder}; +use super::selection::{ + album_param, album_targets, album_targets_or_err, change_flag, change_rating, cull, flip, for_targets, has_library, mark_deleted, + opt_album_param, rotate, row_step, select_after_deletion, select_by, step, strs, +}; +use super::*; + +/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote. +const MAX_SEED: u64 = 1 << 53; + +/// `seed` as an integer in `0..2^53`, or `default` when absent or null; anything else is an error. +fn seed_param(p: &Value, cmd: &str, default: u64) -> Result { + match p.get("seed") { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_u64().filter(|n| *n < MAX_SEED).ok_or_else(|| bad(cmd, "seed must be an integer from 0 to 2^53 - 1")), + } +} + +/// The seed after `prev` at time `now`: a mix of `prev` and the clock text, so repeated reshuffles +/// within one clock tick still differ (barring a 2^-53 collision). Kept below 2^53 so JSON +/// clients that read numbers as doubles (web, MCP agents) get the same seed back. +fn next_seed(prev: u64, now: &str) -> u64 { + let t = now.bytes().fold(0u64, |h, b| lightcraft_catalog::mix64(h ^ u64::from(b))); + (lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & (MAX_SEED - 1) +} + +pub fn specs() -> Vec { + vec![ + // ---- view source / filter / sort + cmd!( + "library.source", + "Show Source", + [], + None, + "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing|libraryFolder, id?: albumId, path?: a path from library.folders (for libraryFolder)}", + always, + |s, p| { + let kind = str_param(p, "kind").unwrap_or("all"); + s.source = match kind { + "all" => LibrarySource::All, + "recentlyAdded" => LibrarySource::RecentlyAdded, + "recentlyDeleted" => LibrarySource::RecentlyDeleted, + "picks" => LibrarySource::Picks, + "missing" => LibrarySource::Missing, + "album" => { + let a = album_param(p, "id", "library.source")?; + if s.catalog.album(a).is_none_or(|a| a.folder) { + return Err(bad("library.source", "no such album")); + } + // one album at a time: `to_filter` makes the album shown replace the filter's, and the + // filter bar has no album chip, so a left-over one would narrow the next source unseen + s.filter.album = None; + LibrarySource::Album(a) + } + "libraryFolder" => { + let path = str_param(p, "path").filter(|d| !d.trim().is_empty() && !lightcraft_catalog::query::folder_key(d).is_empty()); + let path = path.ok_or_else(|| bad("library.source", "libraryFolder needs the `path` of a folder from library.folders"))?; + if lightcraft_catalog::folders::is_startup_disk(path) { + return Err(bad("library.source", "the startup disk's path covers every disk: choose a folder in it")); + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let ids = s.catalog.query(&f, &Sort::default()); + if ids.is_empty() { + return Err(bad("library.source", format!("{path}: no photo in the library was imported from it"))); + } + if covers_other_disks(s, path, &ids) { + return Err(bad("library.source", format!("{path} holds other disks as well: choose a disk or a folder on one"))); + } + s.library_folder = Some(path.to_string()); + // one folder at a time: a folder filter left over would be ignored + s.filter.library_folder = None; + LibrarySource::LibraryFolder + } + other => return Err(bad("library.source", format!("unknown source `{other}`"))), + }; + s.keep_active_in_view(); + Ok(json!({"count": s.visible().len()})) + } + ), + cmd!( + "library.filter", + "Filter", + [], + None, + "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}", + always, + |s, p| { + let mut v = serde_json::to_value(&s.filter).unwrap_or_default(); + lightcraft_develop::presets::deep_merge(&mut v, p); + let f: Filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?; + let mut f = f; + // a blank folder is no folder: no hidden "filters active" state + f.library_folder = f.library_folder.filter(|d| !d.trim().is_empty()); + if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder { + return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); + } + // checked like a smart album's rules when this call sets them: an unknown field or a value + // that can't match is an error, not an empty grid. Rules that stopped checking since + // (their album was deleted) don't block other filter changes. + if let Some(rules) = f.rule_set.as_mut() { + rules.upgrade(); + } + let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null()); + if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) { + return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::>().join("; "))); + } + s.filter = f; + s.shelved_filter = None; + Ok(json!({"count": s.visible().len()})) + } + ), + cmd!( + query "library.folders", + "Library Folders", + [], + None, + "{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)", + always, + |s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default()) + ), + cmd!( + "library.removeFolder", + "Remove Folder from Library", + [], + None, + "{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}", + always, + |s, p| { + const C: &str = "library.removeFolder"; + let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + if lightcraft_catalog::query::folder_key(path).is_empty() { + return Err(bad(C, format!("{path}: not a folder"))); + } + if lightcraft_catalog::folders::is_startup_disk(path) { + return Err(bad(C, "choose a folder, not the whole startup disk")); + } + if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) { + return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it"))); + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let ids = s.catalog.query(&f, &Sort::default()); + if ids.is_empty() { + return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); + } + if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) { + return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them"))); + } + mark_deleted(s, &ids, "Remove Folder from Library")?; + if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { + s.filter.library_folder = None; + } + // a filter put aside by Enable Filters must not bring the removed folder back + if let Some(f) = s.shelved_filter.as_mut() + && f.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) + { + f.library_folder = None; + } + if s.shelved_filter.as_ref() == Some(&Filter::default()) { + s.shelved_filter = None; + } + // the folder being shown is gone: back to everything, as for a deleted album + if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { + s.library_folder = None; + if s.source == LibrarySource::LibraryFolder { + s.source = LibrarySource::All; + } + } + let vis = s.visible_cloned(); + s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); + Ok(json!({"removed": ids.len()})) + } + ), + cmd!( + "folder.label", + "Set Folder Color Label", + [], + None, + "{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}", + always, + |s, p| { + const C: &str = "folder.label"; + let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? { + "none" => None, + x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?), + }; + // taking a label off always works, also once the folder's photos are gone + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() { + return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); + } + if label.is_none() && s.catalog.folder_record(path).is_none() { + return Ok(json!({"path": path, "label": label})); + } + let op = s.catalog.folder_label_op(path, label); + s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?; + Ok(json!({"path": path, "label": label})) + } + ), + cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| { + s.filter = Default::default(); + s.shelved_filter = None; + Ok(json!({"count": s.visible().len()})) + }), + // Classic: Library > Enable Filters (Cmd+L) switches the filter off and on again as it was + cmd!( + "library.toggleFilters", + "Enable Filters", + ["View"], + Some("Cmd+L"), + "{} — with a filter set, puts it aside and shows everything; with one put aside, brings it back as it was (stars, flag, text… together). Setting or clearing a filter while it is off forgets the one put aside → {enabled, count}", + always, + |s, _| { + if s.filter != Filter::default() { + s.shelved_filter = Some(std::mem::take(&mut s.filter)); + } else if let Some(mut f) = s.shelved_filter.take() { + // one folder at a time: the folder shown now wins over the one the filter had + if s.source == LibrarySource::LibraryFolder { + f.library_folder = None; + } + s.filter = f; + } + Ok(json!({"enabled": s.shelved_filter.is_none(), "count": s.visible().len()})) + } + ), + cmd!( + "library.sort", + "Sort", + ["View", "Sort"], + None, + "{key?: captureDate|importDate|editDate|fileName|rating|fileSize|random, ascending?: bool, group?: auto|none|day|month|year, seed?: u64 (the shuffle `random` gives)}", + always, + |s, p| { + let key: SortKey = match p.get("key") { + Some(k) => serde_json::from_value(k.clone()).map_err(|e| bad("library.sort", e.to_string()))?, + None => s.sort.key, + }; + let group = match str_param(p, "group") { + Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?, + None => s.sort.group, + }; + let mut seed = seed_param(p, "library.sort", s.sort.seed)?; + let explicit = p.get("seed").is_some_and(|v| !v.is_null()); + if key == SortKey::Random && s.sort.key != SortKey::Random && !explicit { + // switching to Random is a fresh shuffle, not whatever seed was left behind + seed = next_seed(seed, &(s.clock)()); + } + s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group, seed }; + ok() + } + ), + cmd!( + "library.refresh", + "Refresh View", + ["View"], + None, + "{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed", + always, + |s, _| { + s.refresh_view(); + ok() + } + ), + cmd!( + "library.holdView", + "Hold View Until Refreshed", + ["View"], + None, + "{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing", + always, + |s, p| { + let on = match p.get("on") { + None | Some(Value::Null) => !s.hold_view, + Some(Value::Bool(on)) => *on, + Some(_) => return Err(bad("library.holdView", "on must be true or false")), + }; + if on != s.hold_view { + let was = s.hold_view; + s.hold_view = on; + if let Err(e) = s.save_prefs() { + // not kept: not changed either, so asking again does what was asked + s.hold_view = was; + return Err(e); + } + s.refresh_view(); + } + Ok(json!({"hold": s.hold_view})) + } + ), + cmd!( + "library.shuffle", + "Reshuffle", + ["View", "Sort"], + None, + "{seed?: 0..2^53-1} — sort at random; without `seed` a new shuffle each time", + always, + |s, p| { + let seed = match seed_param(p, "library.shuffle", s.sort.seed)? { + given if p.get("seed").is_some_and(|v| !v.is_null()) => given, + // derived from the previous seed and the session clock: reproducible under a test + // clock, and no RNG needed + prev => next_seed(prev, &(s.clock)()), + }; + s.sort = Sort { key: SortKey::Random, seed, ..s.sort }; + ok() + } + ), + cmd!( + query "library.groups", + "Date Groups", + [], + None, + "{by?: day|month|year (default: the sort's grouping; auto = day)} → [{key, label, start, count}] date headers of the grid", + always, + |s, p| { + let by = match str_param(p, "by") { + Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.groups", "by must be auto|none|day|month|year"))?, + None => s.sort.group, + }; + let vis = s.visible_cloned(); + Ok(serde_json::to_value(s.catalog.date_runs(&vis, s.sort.key, by)).unwrap_or_default()) + } + ), + // ---- selection + cmd!( + "library.select", + "Select Photos", + [], + None, + "{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library", + always, + |s, p| { + let ids = crate::cmd::photo_ids(p, "library.select")?.unwrap_or_default(); + let active = p.get("active").and_then(Value::as_u64).map(PhotoId); + let mode = str_param(p, "mode").unwrap_or("replace"); + // An id that is not in the library is an error, and the selection stays as it was: + // selecting it would otherwise report success and leave the session with no usable + // active photo (#182). + if let Some(id) = ids.iter().chain(active.as_ref()).find(|id| s.catalog.photo(**id).is_none()) { + return Err(bad("library.select", format!("no such photo {}", id.0))); + } + s.end_interaction()?; + match mode { + "replace" => { + s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) }; + } + "add" => { + for id in ids { + if !s.selection.contains(id) { + s.selection.ids.push(id); + } + s.selection.active = Some(id); + } + } + "toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)), + "range" => { + let vis = s.visible_cloned(); + if let Some(id) = ids.first() { + s.selection.extend_to(*id, &vis); + } + } + other => return Err(bad("library.select", format!("unknown mode `{other}`"))), + } + s.active_mask = None; + s.active_spot = None; + Ok(json!({"selected": s.selection.ids.len()})) + } + ), + cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| { + let vis = s.visible_cloned(); + s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis }; + Ok(json!({"selected": s.selection.ids.len()})) + }), + cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| { + Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default()) + }), + cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], Some("Cmd+Alt+Shift+C"), "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| { + let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?; + let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta; + let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description, + "copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url, + "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country, + "keywords": m.keywords}); + s.meta_clipboard = Some(v.clone()); + Ok(v) + }), + cmd!( + "photo.pasteMetadata", + "Paste Metadata", + ["Photo"], + Some("Cmd+Alt+Shift+V"), + "{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}", + has_selection, + |s, p| { + let c = "photo.pasteMetadata"; + let clip = s.meta_clipboard.clone().ok_or_else(|| bad(c, "nothing copied (photo.copyMetadata)"))?; + let fields: Option> = + p.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + let mut params = serde_json::Map::new(); + for (k, v) in clip.as_object().into_iter().flatten() { + if fields.as_ref().is_none_or(|f| f.iter().any(|x| x == k)) { + params.insert(k.clone(), v.clone()); + } + } + if let Some(ids) = p.get("ids") { + params.insert("ids".into(), ids.clone()); + } + s.execute("photo.setMeta", &Value::Object(params)) + } + ), + cmd!( + "library.selectBy", + "Select by Flag, Rating or Label", + [], + None, + "{flag?: pick|reject|none, rating?: 0..5, ratingOp?: gte|eq|lte (default gte), label?: red|…|none, add?: bool (extend the selection)} — among the photos in view", + always, + select_by + ), + cmd!("library.selectNone", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| { + let a = s.selection.active; + s.selection = Selection { ids: a.into_iter().collect(), active: a }; + ok() + }), + cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)), + cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)), + cmd!( + "library.down", + "Photo Below", + [], + Some("Down"), + "{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}", + always, + |s, p| { step(s, row_step(p)) } + ), + cmd!( + "library.up", + "Photo Above", + [], + Some("Up"), + "{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}", + always, + |s, p| { step(s, -row_step(p)) } + ), + // ---- rating / flags / labels + cmd!( + "photo.rate", + "Set Rating", + ["Photo", "Set Rating"], + None, + "{rating: 0..5, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + let r = crate::cmd::index_req(p, "rating", "photo.rate") + .ok() + .filter(|r| *r <= 5) + .ok_or_else(|| bad("photo.rate", "rating must be a whole number 0..5"))?; + cull(s, p, |s| for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))) + } + ), + cmd!( + "photo.decreaseRating", + "Decrease Rating", + [], + None, + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_rating(s, p, -1, "Decrease Rating")) } + ), + cmd!( + "photo.increaseRating", + "Increase Rating", + [], + None, + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_rating(s, p, 1, "Increase Rating")) } + ), + cmd!( + "photo.flag", + "Set Flag", + ["Photo", "Set Flag"], + None, + "{flag: pick|reject|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?; + cull(s, p, |s| for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))) + } + ), + // Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux) + cmd!( + "photo.cycleFlag", + "Cycle Flag", + ["Photo", "Set Flag"], + Some("`"), + "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged; without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + cull(s, p, |s| { + change_flag(s, p, "Cycle Flag", |f| match f { + Flag::None => Flag::Pick, + Flag::Pick => Flag::Reject, + Flag::Reject => Flag::None, + }) + }) + } + ), + cmd!( + "photo.increaseFlag", + "Increase Flag Status", + ["Photo", "Set Flag"], + Some("Cmd+Up"), + "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick); without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick })) } + ), + cmd!( + "photo.decreaseFlag", + "Decrease Flag Status", + ["Photo", "Set Flag"], + Some("Cmd+Down"), + "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject); without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject })) } + ), + cmd!( + "photo.pick", + "Flag as Pick", + ["Photo", "Set Flag"], + Some("P"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))) } + ), + cmd!( + "photo.reject", + "Flag as Reject", + ["Photo", "Set Flag"], + Some("X"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))) } + ), + cmd!( + "photo.unflag", + "Unflag", + ["Photo", "Set Flag"], + Some("U"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))) } + ), + cmd!( + "photo.label", + "Set Color Label", + ["Photo", "Set Color Label"], + None, + "{label: red|yellow|green|blue|purple|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + // `none` (or null) clears; a missing label or one that isn't text is an error, not a clear + let l = match p.get("label") { + Some(Value::Null) => None, + Some(Value::String(x)) if x == "none" => None, + Some(Value::String(x)) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?), + None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")), + Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")), + }; + cull(s, p, |s| for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))) + } + ), + // ---- orientation + cmd!("photo.rotateLeft", "Rotate Left", ["Photo"], Some("Cmd+["), "{ids?}", has_selection, |s, p| rotate(s, p, false)), + cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)), + cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)), + cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)), + // ---- face / pet regions + cmd!( + "photo.removeRegion", + "Remove Face Box", + [], + None, + "{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back", + always, + |s, p| { + let id = + crate::cmd::photo_id(p, "photo.removeRegion")?.or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|i| usize::try_from(i).ok()) + .ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?; + let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone(); + if index >= meta.regions.len() { + return Err(bad("photo.removeRegion", "no such region")); + } + let gone = meta.regions.remove(index); + s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; + s.skip_auto_write = true; + Ok(json!({"removed": gone.name})) + } + ), + cmd!( + "photo.setRegion", + "Resize Face Box", + [], + None, + "{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar", + always, + |s, p| { + const C: &str = "photo.setRegion"; + // a drag is one undo step however many frames it took: the caller commits once, on release + let id = crate::cmd::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|i| usize::try_from(i).ok()) + .ok_or_else(|| bad(C, "missing or invalid `index`"))?; + let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?; + let num = |k: &str| { + r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number"))) + }; + let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?); + let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0)); + let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0)); + if x1 - x0 < 0.005 || y1 - y0 < 0.005 { + return Err(bad(C, "the box would be too small")); + } + let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone(); + let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?; + region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 }; + s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; + s.skip_auto_write = true; + Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}})) + } + ), + // ---- delete / restore + cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { + let before = s.visible_cloned(); + let targets = s.targets(p)?; + // an open drag ends first, as its own undo step (the selection moves on) + s.end_interaction()?; + let n = mark_deleted(s, &targets, "Delete")?; + select_after_deletion(s, &before); + Ok(json!({"changed": n})) + }), + cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { + id, + deleted: false + }))), + cmd!( + "library.emptyRecentlyDeleted", + "Empty Recently Deleted", + ["Photo"], + None, + "{} → {deleted} — removes every photo in Recently Deleted from the library (files on disk stay)", + always, + |s, _| { + let ids: Vec = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect(); + if ids.is_empty() { + return Ok(json!({"deleted": 0})); + } + let op = s.catalog.delete_photos_permanently_ops(&ids); + s.commit("Empty Recently Deleted", op)?; + s.selection = Selection::default(); + Ok(json!({"deleted": ids.len()})) + } + ), + cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { + let before = s.visible_cloned(); + let t = s.targets(p)?; + // before the inverse ops are taken, so undo brings the photo back with its edit + s.end_interaction()?; + let op = s.catalog.delete_photos_permanently_ops(&t); + s.commit("Delete Permanently", op)?; + select_after_deletion(s, &before); + Ok(json!({"deleted": t.len()})) + }), + // ---- metadata + cmd!( + "photo.setMeta", + "Edit Info", + [], + None, + "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}", + has_selection, + |s, p| { + let strs = + |k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::>()); + let targets = s.targets(p)?; + // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear + let gps: Option> = match p.get("gps") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(t)) if t.trim().is_empty() => Some(None), + Some(Value::String(t)) => Some(Some( + parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?, + )), + Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) { + (Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))), + _ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")), + }, + Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")), + }; + let status = match str_param(p, "copyrightStatus") { + Some(t) => Some( + CopyrightStatus::parse(t) + .ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?, + ), + None => None, + }; + let mut ops = Vec::new(); + for id in targets { + let Some(ph) = s.catalog.photo(id) else { continue }; + let mut m = ph.meta.clone(); + if let Some(g) = gps { + m.gps = g; + } + if let Some(st) = status { + m.copyright_status = st; + } + for (k, field) in [ + ("title", &mut m.title), + ("caption", &mut m.caption), + ("copyright", &mut m.copyright), + ("usageTerms", &mut m.usage_terms), + ("copyrightUrl", &mut m.copyright_url), + ("creator", &mut m.creator), + ("location", &mut m.location), + ("city", &mut m.city), + ("state", &mut m.state), + ("country", &mut m.country), + ("altText", &mut m.alt_text), + ("extendedDescription", &mut m.extended_description), + ] { + if let Some(v) = str_param(p, k) { + *field = v.to_string(); + } + } + // keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case + use lightcraft_catalog::keywords::{clean, same}; + let add = |m: &mut lightcraft_catalog::Meta, k: &str| { + let k = clean(k); + if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) { + m.keywords.push(k); + } + }; + if let Some(k) = strs("keywords") { + m.keywords.clear(); + for k in k { + add(&mut m, &k); + } + } + for k in strs("addKeywords").unwrap_or_default() { + add(&mut m, &k); + } + for k in strs("removeKeywords").unwrap_or_default() { + let k = clean(&k); + m.keywords.retain(|x| !same(&clean(x), &k)); + } + ops.push(Op::SetMeta { id, meta: Box::new(m) }); + } + let n = ops.len(); + s.commit("Edit Info", Op::Batch { ops })?; + // keywords just added become the Recent Keywords set + let added: Vec = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect(); + if n > 0 && !added.is_empty() { + crate::cmd::keywords::note_recent(s, &added); + } + Ok(json!({"changed": n})) + } + ), + // ---- albums + cmd!( + "album.create", + "New Album", + ["File"], + None, + "{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)", + always, + |s, p| { + let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); + if name.is_empty() { + return Err(bad("album.create", "empty name")); + } + let folder = bool_or(p, "folder", false); + let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); + let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) }; + let id = s.catalog.alloc_album_id(); + let cover = photos.first().copied(); + s.commit( + if folder { "New Folder" } else { "New Album" }, + Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } }, + )?; + Ok(json!({"id": id.0, "skipped": skipped})) + } + ), + cmd!( + "album.createSmart", + "New Smart Album…", + [], + None, + "{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)", + always, + |s, p| { + let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string(); + if name.is_empty() { + return Err(bad("album.createSmart", "empty name")); + } + let rules = match p.get("rules") { + Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?, + None => view_rules(s), + }; + let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); + let id = s.catalog.alloc_album_id(); + let album = Album { parent, smart: Some(Box::new(rules)), ..Album::new(id, name) }; + s.commit("New Smart Album", Op::AddAlbum { album })?; + Ok(json!({"id": id.0, "count": s.catalog.album_count(id)})) + } + ), + cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| { + use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for}; + Ok(json!(FIELDS + .iter() + .map(|(id, label, kind)| { + let k = match kind { + Kind::Text => "text", + Kind::Keywords => "keywords", + Kind::Number => "number", + Kind::Album => "album", + Kind::Date => "date", + Kind::Choice(_) => "choice", + Kind::Bool => "bool", + }; + let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::>()}); + if let Kind::Choice(c) = kind { + v["choices"] = json!(c.iter().map(|c| c.0).collect::>()); + v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::>().into(); + } + v + }) + .collect::>())) + }), + cmd!( + "album.setRules", + "Edit Smart Album", + [], + None, + "{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}", + always, + |s, p| { + let id = album_param(p, "id", "album.setRules")?; + let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?; + let rules = if bool_or(p, "fromView", false) { + // the view is saved as it is, but never as a loop (showing A, which tests this + // album, and saving that view here would make it test itself) + let view = view_rules(s); + let loops: Vec = view + .rule_set + .as_ref() + .map(|r| r.check_for(&s.catalog, Some(id))) + .unwrap_or_default() + .into_iter() + .chain(s.catalog.album_filter_problem(&view, Some(id))) + .filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop) + .map(|p| p.to_string()) + .collect(); + if !loops.is_empty() { + return Err(bad("album.setRules", loops.join("; "))); + } + view + } else { + let replace = bool_or(p, "replace", false); + let folder = cur.library_folder.clone(); + let base = if replace { Default::default() } else { cur }; + let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?; + // the rules dialog has no folder field: replacing its rules keeps the folder + // unless the call says (`libraryFolder: null`) to drop it + if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() { + r.library_folder = folder; + } + r + }; + let set = Op::SetAlbumRules { id, rules: Box::new(rules) }; + // a new name goes with the rules: one step, applied (and undone) together + let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n)); + match rename { + Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?, + None => s.commit("Edit Smart Album", set)?, + }; + Ok(json!({"count": s.catalog.album_count(id)})) + } + ), + cmd!("album.rename", "Rename Album", [], None, "{id, name}", always, |s, p| { + let id = album_param(p, "id", "album.rename")?; + let name = str_param(p, "name").ok_or_else(|| bad("album.rename", "missing name"))?.to_string(); + s.commit("Rename Album", Op::RenameAlbum { id, name })?; + ok() + }), + cmd!( + "album.delete", + "Delete Album", + [], + None, + "{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step", + always, + |s, p| { + let id = album_param(p, "id", "album.delete")?; + let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder; + let mut children = std::collections::BTreeMap::>::new(); + for album in s.catalog.albums() { + if let Some(parent) = album.parent { + children.entry(parent).or_default().push(album.id); + } + } + // Iterative traversal: even a malformed cycle is rejected before any change. + // Reverse parent-first order removes children first; the batch inverse restores + // parents first, including memberships, smart rules, covers and manual ordering. + let mut pending = vec![id]; + let mut order = Vec::new(); + let mut removed = std::collections::BTreeSet::new(); + while let Some(next) = pending.pop() { + if !removed.insert(next) { + return Err(bad("album.delete", "cycle in album folders")); + } + order.push(next); + if let Some(nested) = children.remove(&next) { + pending.extend(nested); + } + } + let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect(); + s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?; + if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) { + s.source = LibrarySource::All; + } + ok() + } + ), + cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| { + let id = album_param(p, "id", "album.move")?; + let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); + s.commit("Move Album", Op::MoveAlbum { id, parent })?; + ok() + }), + cmd!( + "album.reorder", + "Reorder Album", + [], + None, + "{id, parent?: folderId|null, before?: albumId|null} — places the album (or folder) in `parent` (default: where it is; null: the top level) before the sibling `before` of the same kind, or last of its kind; the folder is then ordered by hand. One undo step. → {changed}: how many edits it took (0: already there)", + always, + |s, p| { + let id = album_param(p, "id", "album.reorder")?; + let me = s.catalog.album(id).ok_or_else(|| bad("album.reorder", "no such album"))?; + let parent = if p.get("parent").is_some() { opt_album_param(p, "parent", "album.reorder")? } else { me.parent }; + let before = opt_album_param(p, "before", "album.reorder")?; + if before == Some(id) { + // before itself: where it is + return Ok(json!({"changed": 0})); + } + let mut order: Vec = s.catalog.album_children(parent).into_iter().filter(|a| a.id != id).map(|a| a.id).collect(); + let at = match before { + Some(b) => { + let at = order.iter().position(|x| *x == b).ok_or_else(|| bad("album.reorder", "`before` is not inside that folder"))?; + if s.catalog.album(b).is_none_or(|x| x.folder != me.folder) { + return Err(bad("album.reorder", "folders are placed among folders and albums among albums")); + } + at + } + None if me.folder => order.iter().take_while(|x| s.catalog.album(**x).is_some_and(|a| a.folder)).count(), + None => order.len(), + }; + order.insert(at.min(order.len()), id); + let moved = me.parent != parent; + let mut ops = Vec::new(); + if moved { + ops.push(Op::MoveAlbum { id, parent }); + } + for (i, aid) in order.iter().enumerate() { + let want = u32::try_from(i).ok(); + // a moved album starts without a place in its new folder + let have = if moved && *aid == id { None } else { s.catalog.album(*aid).and_then(|a| a.order) }; + if have != want { + ops.push(Op::SetAlbumOrder { id: *aid, order: want }); + } + } + if ops.is_empty() { + return Ok(json!({"changed": 0})); + } + let changed = ops.len(); + s.commit("Reorder Album", Op::Batch { ops })?; + Ok(json!({"changed": changed})) + } + ), + cmd!( + "album.sort", + "Sort Albums by Name", + [], + None, + "{parent?: folderId|null} — drops the hand order of the albums inside the folder (default: the top level), so they are listed by name → {changed}: how many albums lost their place", + always, + |s, p| { + let parent = opt_album_param(p, "parent", "album.sort")?; + let ops: Vec = s + .catalog + .album_children(parent) + .into_iter() + .filter(|a| a.order.is_some()) + .map(|a| Op::SetAlbumOrder { id: a.id, order: None }) + .collect(); + if ops.is_empty() { + return Ok(json!({"changed": 0})); + } + let changed = ops.len(); + s.commit("Sort Albums by Name", Op::Batch { ops })?; + Ok(json!({"changed": changed})) + } + ), + cmd!( + "album.addPhotos", + "Add to Album", + ["Photo"], + None, + "{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error", + has_library_selection, + |s, p| { + let id = album_param(p, "id", "album.addPhotos")?; + let targets = crate::cmd::photo_ids(p, "album.addPhotos")?.unwrap_or_else(|| s.selected()); + let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; + if al.is_smart() || al.folder { + return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); + } + let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; + let mut photos = al.photos.clone(); + let before = photos.len(); + // looked up, not searched for: adding thousands to a big album stays linear + let mut held: std::collections::HashSet = photos.iter().copied().collect(); + for t in targets { + if held.insert(t) { + photos.push(t); + } + } + let added = photos.len() - before; + let cover = al.cover.or(photos.first().copied()); + s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?; + Ok(json!({"added": added, "skipped": skipped})) + } + ), + cmd!( + "album.toggleTarget", + "Add to Target Album", + [], + None, + "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", + has_library_selection, + |s, p| { + let targets = + album_targets_or_err(s, "album.toggleTarget", crate::cmd::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()))?; + let (targets, skipped) = targets; + let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); + let id = match target.or_else(|| s.catalog.quick_collection()) { + Some(id) => id, + None => { + let id = s.catalog.alloc_album_id(); + let album = Album { quick: true, ..Album::new(id, "Quick Collection") }; + s.commit("New Quick Collection", Op::AddAlbum { album })?; + id + } + }; + let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; + // looked up, not searched for: thousands toggled in a big album stay linear + let mut held: std::collections::HashSet = al.photos.iter().copied().collect(); + let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t)); + let photos: Vec = if all_in { + let targets: std::collections::HashSet = targets.iter().copied().collect(); + al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() + } else { + al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() + }; + let name = al.name.clone(); + let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); + s.commit( + if all_in { "Remove from Target Album" } else { "Add to Target Album" }, + Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] }, + )?; + Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped})) + } + ), + cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| { + s.target_album = match p.get("id").and_then(Value::as_u64) { + Some(a) => { + let id = AlbumId(a); + let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?; + if al.is_smart() || al.folder { + return Err(bad("album.setTarget", "smart albums and folders can't hold photos")); + } + Some(id) + } + None => None, + }; + Ok(json!({"target": s.target_album.map(|a| a.0)})) + }), + cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| { + let Some(id) = s.catalog.quick_collection() else { return ok() }; + s.commit( + "Clear Quick Collection", + Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] }, + )?; + ok() + }), + cmd!( + "library.autoImport", + "Auto Import Settings", + [], + None, + "{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings", + always, + |s, p| { + if let Some(f) = p.get("folder") { + s.import_defaults.auto_folder = match f.as_str().map(str::trim).filter(|f| !f.is_empty()) { + Some(f) if std::path::Path::new(f).is_dir() || cfg!(target_arch = "wasm32") => Some(f.to_string()), + Some(f) => return Err(bad("library.autoImport", format!("`{f}` is not a folder"))), + None => None, + }; + } + if let Some(c) = p.get("copy").and_then(Value::as_bool) { + s.import_defaults.auto_copy = c; + } + if let Some(a) = p.get("album") { + s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string); + } + s.save_prefs()?; + let d = &s.import_defaults; + Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album})) + } + ), + cmd!( + "library.autoImportScan", + "Auto Import Now", + [], + None, + "{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}", + always, + |s, p| { + let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) }; + // only files that stopped growing: a file still being written is left for later + let known: std::collections::HashSet = s + .catalog + .photos() + .filter_map(|p| match &p.source { + lightcraft_catalog::Source::File { path } => Some(path.clone()), + _ => None, + }) + .collect(); + // the folder's files and sizes: listed here, or already listed on a worker thread (the app) + let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) { + Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(), + None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?, + }; + let mut fresh = Vec::new(); + for (ps, size) in listing { + if known.contains(&ps) { + continue; + } + // each file is tried once (a non-photo isn't retried every scan) + if s.auto_import_seen.get(&ps) == Some(&u64::MAX) { + continue; + } + let seen = s.auto_import_seen.insert(ps.clone(), size); + if size > 0 && seen == Some(size) { + fresh.push(ps); + } + } + if fresh.is_empty() { + return Ok(json!({"imported": [], "folder": folder})); + } + let mode = if s.import_defaults.auto_copy { "copy" } else { "add" }; + // a photo deleted from the library doesn't come back on its own because its file + // is still in the watched folder (a copy's source is not a known path) + let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"}); + if let Some(a) = s.import_defaults.auto_album.clone() { + match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) { + Some(al) => params["album"] = json!(al.id.0), + None => params["albumName"] = json!(a), + } + } + for f in &fresh { + s.auto_import_seen.insert(f.clone(), u64::MAX); + } + if p.get("start").and_then(Value::as_bool) == Some(false) { + // the caller imports them (the app: on a worker thread) + return Ok(json!({"imported": [], "folder": folder, "import": params})); + } + let sel = s.selection.clone(); + let r = s.execute("library.import", ¶ms)?; + // arriving photos don't take over the selection + s.selection = sel; + let mut result = json!({"imported": r["imported"], "folder": folder}); + if let Some(warnings) = r.get("warnings") { + result["warnings"] = warnings.clone(); + } + Ok(result) + } + ), + cmd!( + "album.removePhotos", + "Remove from Album", + ["Photo"], + None, + "{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}", + has_selection, + |s, p| { + let id = match p.get("id") { + Some(_) => album_param(p, "id", "album.removePhotos")?, + None => match s.source { + LibrarySource::Album(a) => a, + _ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")), + }, + }; + let targets = crate::cmd::photo_ids(p, "album.removePhotos")?.unwrap_or_else(|| s.selected()); + let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; + if al.is_smart() { + return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); + } + if al.folder { + return Err(bad("album.removePhotos", "album folders don't hold photos")); + } + let name = al.name.clone(); + let targets: std::collections::HashSet = targets.into_iter().collect(); + let photos: Vec = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); + let removed = al.photos.len().saturating_sub(photos.len()); + if removed > 0 { + s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; + } + Ok(json!({"removed": removed, "album": name})) + } + ), + cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| { + let id = album_param(p, "id", "album.setCover")?; + let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active()); + s.commit("Set Album Cover", Op::SetAlbumCover { id, cover: photo })?; + ok() + }), + // ---- import + cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None, + "{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required", + always, |_, p| { + let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?; + let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?; + Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings,"skipped":data.skipped})) + }), + cmd!( + "library.importLightroom", + "Import Lightroom Catalog", + [], + None, + "{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.", + always, + |s, p| { + let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?; + crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false)) + } + ), + cmd!( + query "library.importPreview", + "Review Import", + [], + None, + "{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added", + always, + |s, p| { + let paths = strs(p, "paths"); + if paths.is_empty() { + return Err(bad("library.importPreview", "no paths")); + } + let c = crate::import::scan(s, &paths); + let dups = c.iter().filter(|c| c.duplicate.is_some()).count(); + Ok(json!({"scanned": c.len(), "duplicates": dups, "candidates": c})) + } + ), + cmd!( + "library.import", + "Import Photos", + ["File"], + Some("Cmd+Shift+I"), + "{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, warnings?: [{path, code: cameraProfile|uprightGeometry|unmappedXmp, profile?, mode?: off|auto|level|vertical|full|guided|unknown, fields?}], moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}", + always, + |s, p| { + let req = import_params(s, p)?; + if req.paths.is_empty() { + return Err(bad("library.import", "no paths")); + } + let undo0 = s.undo.len(); + let report = crate::import::import_with(s, &req.paths, &req.opts)?; + let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?; + // one undo step for the whole import + let ids_of = |k: &str| -> Vec { report[k].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default() }; + let (imported, restored) = (ids_of("imported"), ids_of("restored")); + let n = s.undo.len().saturating_sub(undo0); + let (verb, count) = if imported.is_empty() && !restored.is_empty() { ("Restore", restored.len()) } else { ("Add", imported.len()) }; + s.merge_undo(n, &format!("{verb} {count} Photo{}", if count == 1 { "" } else { "s" })); + let imported = if imported.is_empty() { restored } else { imported }; + if let Some(f) = imported.first() { + s.selection = Selection::single(PhotoId(*f)); + } + Ok(report) + } + ), + // ---- persistence + cmd!(query "library.info", "Library Info", [], None, "{}", always, |s, _| { + let photos = s.catalog.len(); + let albums = s.catalog.albums().count(); + let (rendered_n, rendered_bytes) = s.media.rendered.mem_usage(); + let (sources_n, sources_bytes) = s.media.source_usage(); + let disk = s.media.rendered.disk().map(|d| { + use std::sync::atomic::Ordering::Relaxed; + json!({ + "path": d.dir().display().to_string(), + "bytes": d.size(), + "hits": d.hits.load(Relaxed), + "misses": d.misses.load(Relaxed), + "writes": d.writes.load(Relaxed), + }) + }); + let cache = json!({ + "renderedInMemory": rendered_n, "renderedBytes": rendered_bytes, + "sourcesInMemory": sources_n, "sourceBytes": sources_bytes, + "disk": disk, + }); + let Some(lib) = &s.library else { + return Ok(json!({"persistent": false, "photos": photos, "albums": albums, "cache": cache})); + }; + let j = lib.journal(); + Ok(json!({ + "persistent": true, + "path": lib.dir.display().to_string(), + "photos": photos, + "albums": albums, + "seq": j.seq(), + "snapshotSeq": j.snapshot_seq(), + "logRecords": j.log_records(), + "logBytes": j.log_bytes(), + "lastError": lib.last_error, + // settings files that were unreadable or damaged at open (kept, defaults used) + "settingsWarnings": lib.settings_warnings, + // changes applied in memory whose write failed (retried by every save) + "unsavedOps": s.unsaved().map_or(0, |u| u.0), + "unsavedError": s.unsaved().map(|u| u.1), + // untouched Local records forgotten when the library opened + "forgotLocal": lib.forgot_local.as_ref().map(|p| json!({"forgotten": p.evict.len(), "local": p.local, "keptRecent": p.kept_recent, "keptTouched": p.kept_touched, "keptInUse": p.kept_in_use})), + "persistence": j.stats(), + "cache": cache, + "load": { + "created": lib.report.created, + "replayed": lib.report.replayed, + "tornBytes": lib.report.torn_bytes, + "damaged": lib.report.damaged, + }, + })) + }), + cmd!("library.compact", "Optimize Library", ["File"], None, "{}", has_library, |s, _| { + s.compact_library()?; + ok() + }), + cmd!("library.clearPreviews", "Discard Preview Cache", ["File"], None, "{}", always, |s, _| { + s.media.rendered.clear(); + s.media.clear_sources(); + ok() + }), + ] +} From 27abe7557277b4aab89218a658e1356d25747d5e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:09:26 +0000 Subject: [PATCH 116/191] refactor(xtask): split main.rs into crate-root modules --- xtask/src/ci.rs | 462 ++++++ xtask/src/corpus.rs | 71 + xtask/src/layers.rs | 35 + xtask/src/main.rs | 2880 +------------------------------------- xtask/src/raw_samples.rs | 2272 ++++++++++++++++++++++++++++++ xtask/src/wasm.rs | 37 + 6 files changed, 2887 insertions(+), 2870 deletions(-) create mode 100644 xtask/src/ci.rs create mode 100644 xtask/src/corpus.rs create mode 100644 xtask/src/raw_samples.rs create mode 100644 xtask/src/wasm.rs diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs new file mode 100644 index 000000000..61e179ce0 --- /dev/null +++ b/xtask/src/ci.rs @@ -0,0 +1,462 @@ +use super::layers::cmd_layers; +use super::raw_samples::RAW_SAMPLES; +use super::wasm::{cmd_wasm, cmd_wasm_for}; +use super::*; + +/// CI build jobs: one per 1.5 GB of RAM available when CI starts, at most one per CPU, 4 when it +/// is unknown. With line-table debuginfo, 4 jobs measured about 3 GB of RAM in use and 2 jobs +/// about 1.5 GB, so this leaves about half the available RAM to spare. Counting available rather +/// than installed RAM keeps a busy machine (browsers, VMs, editors) from being overcommitted. +/// Test threads are this, capped at 4. +pub(crate) fn ci_jobs(available_mb: Option, cpus: usize) -> usize { + available_mb.map_or(4, |mb| usize::try_from(mb / 1536).unwrap_or(usize::MAX)).clamp(1, cpus.max(1)) +} + +/// RAM the OS could hand out now (free plus reclaimable cache) in MB, if it reports it. +pub(crate) fn available_ram_mb() -> Option { + let output = |program: &str, args: &[&str]| { + let out = Command::new(program).args(args).output().ok()?; + Some(String::from_utf8_lossy(&out.stdout).into_owned()) + }; + if cfg!(target_os = "linux") { + let info = std::fs::read_to_string("/proc/meminfo").ok()?; + let kb = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim(); + Some(kb.parse::().ok()? / 1024) + } else if cfg!(windows) { + let cmd = "(Get-CimInstance Win32_PerfFormattedData_PerfOS_Memory).AvailableMBytes"; + output("powershell", &["-NoProfile", "-Command", cmd])?.trim().parse().ok() + } else { + // macOS: free + inactive + speculative pages + let stat = output("vm_stat", &[])?; + let page: u64 = stat.split("page size of ").nth(1)?.split_whitespace().next()?.parse().ok()?; + let pages = |name: &str| -> Option { + let line = stat.lines().find(|l| l.starts_with(name))?; + line.rsplit(':').next()?.trim().trim_end_matches('.').parse().ok() + }; + let free = pages("Pages free")?.saturating_add(pages("Pages inactive")?).saturating_add(pages("Pages speculative").unwrap_or(0)); + Some(free.saturating_mul(page) >> 20) + } +} + +/// Installed physical memory in whole GB, if the OS reports it (the fallback when available RAM +/// can't be read: half of it is assumed available). +pub(crate) fn total_ram_gb() -> Option { + let bytes: u64 = if cfg!(target_os = "linux") { + let info = std::fs::read_to_string("/proc/meminfo").ok()?; + let kb = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim(); + kb.parse::().ok()?.checked_mul(1024)? + } else { + let (program, args): (&str, &[&str]) = if cfg!(windows) { + ("powershell", &["-NoProfile", "-Command", "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory"]) + } else { + ("sysctl", &["-n", "hw.memsize"]) + }; + let out = Command::new(program).args(args).output().ok()?; + String::from_utf8_lossy(&out.stdout).trim().parse().ok()? + }; + // round to the nearest GB: "8 GB" machines report slightly less + Some(bytes.saturating_add(1 << 29) >> 30) +} + +/// The targets the test runs build: no examples (clippy `--all-targets` still checks them). +/// Doctests run on their own, [`doctests`]: nextest can't run them. +const TEST_TARGETS: [&str; 3] = ["--lib", "--bins", "--tests"]; + +/// `args` followed by [`TEST_TARGETS`]. +fn with_test_targets<'a>(args: &[&'a str]) -> Vec<&'a str> { + args.iter().copied().chain(TEST_TARGETS).collect() +} + +/// Whether the tests run under `cargo nextest`: only when `LIGHTCRAFT_NEXTEST=1` asks for it and +/// nextest is installed. `cargo test` is the default because the demo library's generated scenes +/// are cached per process: libtest runs a binary's tests in one process and generates each scene +/// once, while nextest's process per test regenerates them (measured: the ui-egui tests take about +/// 30 min under nextest, under 5 min under cargo test). +fn nextest() -> bool { + static FOUND: std::sync::OnceLock = std::sync::OnceLock::new(); + *FOUND.get_or_init(|| { + if std::env::var_os("LIGHTCRAFT_NEXTEST").is_none_or(|v| v != "1") { + return false; + } + let found = cargo() + .args(["nextest", "--version"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()); + if !found { + eprintln!("note: LIGHTCRAFT_NEXTEST=1 but cargo-nextest is not installed, so the tests run under cargo test (cargo install cargo-nextest --locked)"); + } + found + }) +} + +/// A test run of `args` (packages, features, targets, filters): `cargo nextest run`, or `cargo +/// test`. The command and how to print it. +fn tests(args: &[&str]) -> (Command, String) { + let mut c = cargo(); + let tool = if nextest() { "nextest run" } else { "test" }; + c.args(tool.split(' ')).args(args); + (c, format!("cargo {tool} {}", args.join(" "))) +} + +/// The workspace's doctests. +fn doctests() -> (Command, String) { + let mut c = cargo(); + c.args(["test", "--workspace", "--doc"]); + (c, "cargo test --workspace --doc".to_owned()) +} + +/// `ci --quick`'s tests: build every workspace test target, then run only the test binaries of +/// `packages`. Building with `-p` instead would resolve features per selection: different +/// artifacts from `ci`'s workspace build, so it would recompile ~130 crates. Doc tests are left to +/// the full `ci`. +fn test_selected(root: &Path, packages: &std::collections::BTreeSet, skip_log: &Path) -> Result<(), String> { + let manifests: std::collections::BTreeMap = metadata()?["packages"] + .as_array() + .into_iter() + .flatten() + .filter_map(|p| Some((p["manifest_path"].as_str()?.to_owned(), p["name"].as_str()?.to_owned()))) + .filter(|(_, name)| packages.contains(name)) + .collect(); + let what = "cargo test --workspace --no-run --lib --bins --tests"; + eprintln!("$ {what} (then the test binaries of {})", packages.iter().map(String::as_str).collect::>().join(", ")); + let out = cargo() + .args(with_test_targets(&["test", "--workspace", "--no-run", "--message-format=json-render-diagnostics"])) + .stderr(std::process::Stdio::inherit()) + .output() + .map_err(|e| format!("{what}: failed to spawn: {e}"))?; + if !out.status.success() { + return Err(format!("{what}: exited with {}", out.status)); + } + let mut binaries = Vec::new(); + for line in String::from_utf8_lossy(&out.stdout).lines() { + let Ok(msg) = serde_json::from_str::(line) else { continue }; + if msg["reason"] != "compiler-artifact" || msg["profile"]["test"] != true { + continue; + } + let (Some(exe), Some(manifest)) = (msg["executable"].as_str(), msg["manifest_path"].as_str()) else { continue }; + if let Some(name) = manifests.get(manifest) { + binaries.push((name.clone(), manifest.to_owned(), exe.to_owned())); + } + } + if binaries.is_empty() { + println!("no test binaries in {}", packages.iter().map(String::as_str).collect::>().join(", ")); + return Ok(()); + } + let mut failed = Vec::new(); + for (name, manifest, exe) in &binaries { + let dir = Path::new(manifest).parent().unwrap_or(root); + let mut c = Command::new(exe); + c.current_dir(dir).env("CARGO_MANIFEST_DIR", dir).env("CARGO_PKG_NAME", name).env(ci_report::SKIP_LOG_ENV, skip_log); + if std::env::var_os("RUST_TEST_THREADS").is_none() { + // as the cargo runs of ci (`cargo()`): more threads make the frame-budget tests flaky + c.env("RUST_TEST_THREADS", std::thread::available_parallelism().map_or(4, |n| n.get()).min(4).to_string()); + } + let file = Path::new(exe).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(); + if run(c, &format!("{name}: {file}")).is_err() { + failed.push(format!("{name} ({file})")); + } + } + if failed.is_empty() { Ok(()) } else { Err(format!("tests failed in {}", failed.join(", "))) } +} + +/// The test that prints the messages each language lacks (shown in English); it always passes. +const TRANSLATION_GAPS_TEST: &str = "i18n::tests::translation_gaps_report"; + +/// Print the translation gaps report into the log: [`TRANSLATION_GAPS_TEST`] of lightcraft-ui-egui, +/// run from the test binary the test step built (found with `--no-run` and the step's own +/// arguments, so nothing rebuilds). A report that can't run is a note, never a failed step. +fn translation_gaps() { + eprintln!("\n=== ci: translation gaps ==="); + let mut c = cargo(); + c.args(["test", "--no-run", "--message-format=json"]).args(with_test_targets(&["--workspace"])); + let binary = c.output().map_err(|e| format!("cargo test --no-run: {e}")).and_then(|out| { + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|m| { + m["reason"] == "compiler-artifact" + && m["target"]["name"] == "lightcraft_ui_egui" + && m["profile"]["test"] == true + && m["target"]["kind"].as_array().is_some_and(|kinds| kinds.iter().any(|kind| kind == "lib")) + }) + .and_then(|m| m["executable"].as_str().map(PathBuf::from)) + .ok_or_else(|| format!("no lightcraft-ui-egui test binary in the build (cargo exited with {})", out.status)) + }); + let ran = binary.and_then(|binary| { + let mut c = Command::new(&binary); + c.args(["--exact", TRANSLATION_GAPS_TEST, "--nocapture", "--quiet"]); + run(c, &format!("{} --exact {TRANSLATION_GAPS_TEST} --nocapture --quiet", binary.display())) + }); + if let Err(e) = ran { + eprintln!("note: the translation gaps report did not run: {e}"); + } +} + +pub(crate) fn cmd_ci(args: &[&str]) -> Result<(), String> { + let hardware = args.contains(&"--hardware"); + let print_md = args.contains(&"--summary-md"); + let quick = args.contains(&"--quick"); + let base_at = args.iter().position(|a| *a == "--base"); + let base = base_at.map(|i| args.get(i + 1).copied().ok_or("ci: --base needs a ref")).transpose()?; + let unknown = args + .iter() + .enumerate() + .find(|(i, a)| !matches!(**a, "--hardware" | "--summary-md" | "--quick" | "--base") && base_at.is_none_or(|b| *i != b + 1)); + if let Some((_, other)) = unknown { + return Err(format!("ci: unknown option `{other}`\n\n{USAGE}")); + } + if base.is_some() && !quick { + return Err("ci: --base only applies to --quick".into()); + } + let root = root(); + let command = std::iter::once("cargo xtask ci").chain(args.iter().copied()).collect::>().join(" "); + // held until ci returns: one run at a time per machine (ci_lock.rs) + let _slot = ci_lock::acquire(&root, &command)?; + let plan = if quick { + let (base, files) = ci_quick::changed_files(&root, base)?; + let plan = ci_quick::plan(&files, &ci_quick::packages(&root, &metadata()?), base); + let packages: Vec<&str> = plan.packages.iter().map(String::as_str).collect(); + eprintln!("=== ci --quick: {} changed file(s) since {} ===", plan.files, plan.base); + eprintln!(" packages: {}", if packages.is_empty() { "none".to_owned() } else { packages.join(", ") }); + if plan.workspace_wide { + eprintln!(" a workspace-wide file changed: testing every package"); + } + Some(plan) + } else { + None + }; + let report_dir = ci_report::prepare(&root)?; + let started = std::time::Instant::now(); + let started_unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + // test runs append the skips their tests record (`lightcraft_testkit::skip!`) to + // target/ci/skips-.jsonl: a file, so it works the same under nextest and cargo test + let test = |(mut c, what): (Command, String), step: &str| { + c.env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, step)); + run(c, &what) + }; + let gpu = std::cell::RefCell::new(serde_json::Value::Null); + let hardware_runs = std::cell::RefCell::new(Vec::new()); + // What this machine offers the GPU tests, once, after the workspace tests (whose build the + // probe reuses). Never fails the run: the report says what went wrong. + let probe = || { + if gpu.borrow().is_null() { + eprintln!("\n=== ci: GPU probe ==="); + let t = std::time::Instant::now(); + let mut found = ci_report::gpu(probe_gpu, hardware); + found["seconds"] = serde_json::json!(t.elapsed().as_secs_f64()); + *gpu.borrow_mut() = found; + } + }; + + type Step<'a> = (&'static str, Box Result<(), String> + 'a>); + // `--quick`: which steps apply (all of them in a full run) and the packages to test + let wants = |step: &str| match &plan { + None => true, + Some(p) => match step { + "clippy" | "test" | "wasm" => p.rust(), + "heif" => p.heif, + "parity" => p.parity, + _ => true, + }, + }; + let mut steps: Vec = vec![ + ( + "fmt", + Box::new(|| { + let mut c = cargo(); + c.args(["fmt", "--all", "--", "--check"]); + run(c, "cargo fmt --all -- --check") + }), + ), + ( + "clippy", + Box::new(|| { + let mut c = cargo(); + c.args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]); + run(c, "cargo clippy --workspace --all-targets --keep-going -- -D warnings") + }), + ), + ( + "heif", + Box::new(|| { + // the optional HEIC/HEIF decoder is off in the workspace build above + let mut c = cargo(); + c.args(["clippy", "-p", "lightcraft-codecs", "--features", "heif", "--all-targets", "--", "-D", "warnings"]); + run(c, "cargo clippy -p lightcraft-codecs --features heif --all-targets -- -D warnings")?; + test( + tests(&with_test_targets(&["-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"])), + "heif", + )?; + // the engine's HEIC import test, with the decoder (the workspace run checks the error) + test(tests(&["-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"]), "heif") + }), + ), + ( + "test", + Box::new(|| match &plan { + Some(p) if !p.workspace_wide => test_selected(&root, &p.packages, &ci_report::skip_log(&root, "test")), + _ => test(tests(&with_test_targets(&["--workspace"])), "test").and_then(|()| test(doctests(), "test")), + }), + ), + ]; + steps.retain(|(name, _)| wants(name)); + if hardware { + steps.push(( + "gpu-backends", + Box::new(|| { + // the GPU tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`) + probe(); + let backends = ci_report::hardware_backends(&gpu.borrow()); + if backends.is_empty() { + eprintln!("no hardware GPU adapter on any backend: nothing to run"); + } + let mut failed = Vec::new(); + for (backend, adapter) in backends { + let step = format!("gpu-{backend}"); + let (mut c, what) = tests(&with_test_targets(&["-p", "lightcraft-gpu", "--features", "denoise"])); + c.env("LIGHTCRAFT_GPU_BACKEND", &backend).env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, &step)); + let t = std::time::Instant::now(); + let ok = run(c, &format!("LIGHTCRAFT_GPU_BACKEND={backend} {what}")).is_ok(); + hardware_runs.borrow_mut().push(serde_json::json!({ + "backend": backend, + "adapter": adapter, + "status": if ok { "ok" } else { "fail" }, + "seconds": t.elapsed().as_secs_f64(), + })); + if !ok { + failed.push(backend); + } + } + if failed.is_empty() { Ok(()) } else { Err(format!("the GPU tests failed on {}", failed.join(", "))) } + }), + )); + } + if wants("parity") { + steps.push(("parity", Box::new(|| parity::run(&root, false)))); + } + steps.push(("layers", Box::new(cmd_layers))); + steps.push(("assets", Box::new(|| assets::run(&root)))); + if wants("wasm") { + match plan.as_ref().filter(|p| !p.workspace_wide) { + Some(p) => steps.push(("wasm", Box::new(|| cmd_wasm_for(Some(&p.packages))))), + None => steps.push(("wasm", Box::new(cmd_wasm))), + } + } + + let mut results = Vec::new(); + let mut failure = None; + for (name, f) in &steps { + eprintln!("\n=== ci: {name} ==="); + let t = std::time::Instant::now(); + let r = f(); + let status = if r.is_ok() { "ok" } else { "fail" }; + results.push(serde_json::json!({"name": name, "status": status, "seconds": t.elapsed().as_secs_f64()})); + if *name == "test" && plan.is_none() { + // right after the workspace tests, before parity and wasm build other feature sets + probe(); + translation_gaps(); + } + if let Err(e) = r { + failure = Some((*name, e)); + break; + } + } + if gpu.borrow().is_null() { + let why = if plan.is_some() { "not probed: --quick" } else { "not probed: the test step did not run" }; + *gpu.borrow_mut() = serde_json::json!({"error": why}); + } + let done: Vec<&str> = steps.iter().take(results.len()).map(|(n, _)| *n).collect(); + match &failure { + Some((name, e)) => { + println!("\nCI summary:"); + for d in done.iter().filter(|d| *d != name) { + println!(" ok {d}"); + } + println!(" FAIL {name}: {e}"); + for (n, _) in steps.iter().skip(results.len()) { + println!(" skip {n}"); + } + } + None => println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")), + } + for (n, _) in steps.iter().skip(results.len()) { + results.push(serde_json::json!({"name": n, "status": "not run"})); + } + + let skips = ci_report::read_skips(&report_dir, &root); + let ram_mb = available_ram_mb(); + let report = serde_json::json!({ + "schema": ci_report::SCHEMA, + "command": command, + "quick": plan.as_ref().map(|p| serde_json::json!({"base": p.base, "files": p.files, "packages": p.packages, "workspace_wide": p.workspace_wide})), + "result": if failure.is_some() { "fail" } else { "pass" }, + "failed_step": failure.as_ref().map(|(n, _)| *n), + "started_unix": started_unix, + "seconds": started.elapsed().as_secs_f64(), + "git": ci_report::git(&root), + "machine": ci_report::machine(total_ram_gb(), ram_mb), + "toolchain": ci_report::toolchain(), + "capabilities": {"gpu": gpu.take(), "data": ci_report::data(&root, &RAW_SAMPLES.iter().map(|s| s.0).collect::>())}, + "steps": results, + "hardware": hardware_runs.take(), + "skips": ci_report::skips_json(&skips), + }); + print!("{}", ci_report::summary_text(&report)); + let md = ci_report::summary_md(&report); + let written = serde_json::to_string_pretty(&report) + .map_err(|e| e.to_string()) + .and_then(|json| std::fs::write(report_dir.join("report.json"), json + "\n").map_err(|e| e.to_string())) + .and_then(|()| std::fs::write(report_dir.join("summary.md"), &md).map_err(|e| e.to_string())); + match written { + Ok(()) => println!("Report: target/ci/report.json; to paste into a PR: target/ci/summary.md (or run with --summary-md)"), + Err(e) => eprintln!("ci: could not write the report to {}: {e}", report_dir.display()), + } + if print_md { + println!("\n{md}"); + } + match failure { + Some((name, _)) => Err(format!("ci failed at `{name}`")), + None => Ok(()), + } +} + +/// Run the ignored `gpu_probe` test of lightcraft-gpu: the pipeline's device (`None`) or one +/// backend's adapters. `--workspace`, like the test step, so its build is reused (every crate's `it` +/// binary starts; only lightcraft-gpu's has the test). Its stdout, or why +/// it failed (a driver that crashes while it is enumerated, issue #136, ends up here, not in xtask). +fn probe_gpu(backend: Option<&str>) -> Result { + let mut c = cargo(); + c.args(["test", "-q", "--workspace", "--test", "it", "--", "--ignored", "--exact", "gpu_probe::gpu_probe", "--nocapture"]); + c.env("LIGHTCRAFT_GPU_PROBE", backend.unwrap_or("")); + let out = c.output().map_err(|e| format!("gpu_probe: {e}"))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let tail: Vec<&str> = err.lines().rev().filter(|l| !l.trim().is_empty()).take(3).collect(); + return Err(format!( + "gpu_probe {}exited with {}: {}", + backend.map(|b| format!("{b} ")).unwrap_or_default(), + out.status, + tail.into_iter().rev().collect::>().join(" / ") + )); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +#[cfg(test)] +mod ci_jobs_tests { + use super::ci_jobs; + + #[test] + fn jobs_follow_available_ram_and_never_exceed_the_cpus() { + assert_eq!(ci_jobs(Some(3 * 1024), 8), 2, "a 4 GB machine with 3 GB free"); + assert_eq!(ci_jobs(Some(6 * 1024), 8), 4, "an 8 GB machine with 6 GB free"); + assert_eq!(ci_jobs(Some(7 * 1024), 32), 4, "a busy 32 GB machine: what is free counts"); + assert_eq!(ci_jobs(Some(24 * 1024), 32), 16); + assert_eq!(ci_jobs(Some(64 * 1024), 8), 8, "capped at the CPU count"); + assert_eq!(ci_jobs(Some(500), 8), 1, "at least one"); + assert_eq!(ci_jobs(None, 32), 4, "unknown RAM keeps the old default"); + assert_eq!(ci_jobs(None, 2), 2); + } +} diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs new file mode 100644 index 000000000..67de8c1ff --- /dev/null +++ b/xtask/src/corpus.rs @@ -0,0 +1,71 @@ +use super::raw_samples::RAW_SAMPLES; +use super::*; + +pub(crate) fn cmd_corpus(download: bool) -> Result<(), String> { + let corpus = root().join("corpus"); + println!( + "Test corpora live under {} (git-ignored, never committed). +Tests that use a corpus skip cleanly when it is absent. + + corpus/raw/ raw.pixls.us samples (CC0) — lightcraft-raw decodes every file. + corpus/images/ CC0 / public-domain JPEG/PNG/TIFF/HEIC samples (optional) +", + corpus.display() + ); + if !download { + return Ok(()); + } + let dest = corpus.join("raw"); + std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?; + let mut bad = Vec::new(); + for (name, url, sha256) in RAW_SAMPLES { + let out = dest.join(name); + if !out.exists() { + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "-o"]).arg(&out).arg(url); + run(curl, &format!("curl {url}"))?; + } + let actual = file_sha256(&out)?; + if actual != *sha256 { + // delete it, so the next run downloads it again instead of trusting it + std::fs::remove_file(&out).map_err(|e| format!("remove {}: {e}", out.display()))?; + bad.push(format!("{name}: sha256 is {actual}, expected {sha256} (file deleted; source {url})")); + } + } + if bad.is_empty() { + return Ok(()); + } + Err(format!( + "{} corpus file(s) do not match their pinned sha256: + {}", + bad.len(), + bad.join( + " + " + ) + )) +} + +fn file_sha256(path: &Path) -> Result { + use sha2::{Digest, Sha256}; + let mut file = std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?; + let mut hash = Sha256::new(); + std::io::copy(&mut file, &mut hash).map_err(|e| format!("read {}: {e}", path.display()))?; + Ok(format!("{:x}", hash.finalize())) +} + +#[cfg(test)] +mod corpus_tests { + use super::RAW_SAMPLES; + + #[test] + fn every_sample_has_a_unique_name_and_a_sha256() { + let mut names: Vec<_> = RAW_SAMPLES.iter().map(|s| s.0).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), RAW_SAMPLES.len(), "duplicate file name"); + for (name, _, sha256) in RAW_SAMPLES { + assert!(sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')), "{name}: not a lowercase sha256"); + } + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index d5be5523f..13756002d 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -3,6 +3,7 @@ //! The rule engine works on a small, metadata-independent model so it can be //! unit-tested; `from_metadata` builds that model from `cargo metadata`. +use super::*; use serde_json::Value; /// Where a workspace crate sits. @@ -251,6 +252,40 @@ pub fn describe(class: Option) -> String { } } +pub(crate) fn cmd_layers() -> Result<(), String> { + let crates = layers::from_metadata(&metadata()?)?; + println!("Dependency layering (plan/architecture.md §3)\n"); + println!("{:<28} {:<14} workspace deps", "crate", "layer"); + for c in &crates { + let ws: Vec = c + .deps + .iter() + .filter(|d| d.workspace) + .map(|d| { + let k = match d.kind { + layers::DepKind::Normal => "", + layers::DepKind::Dev => " (dev)", + layers::DepKind::Build => " (build)", + }; + format!("{}{k}", layers::short_name(&d.name)) + }) + .collect(); + println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", ")); + } + let violations = layers::check(&crates); + println!(); + if violations.is_empty() { + println!("OK: {} crates, no layering violations.", crates.len()); + Ok(()) + } else { + println!("{} violation(s):", violations.len()); + for v in &violations { + println!(" - {v}"); + } + Err(format!("{} layering violation(s)", violations.len())) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 088103f78..153498a00 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -18,6 +18,16 @@ mod web; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; +mod ci; +mod corpus; +mod raw_samples; +mod wasm; + +use ci::{available_ram_mb, ci_jobs, cmd_ci, total_ram_gb}; +use corpus::cmd_corpus; +use layers::cmd_layers; +use wasm::cmd_wasm; + const USAGE: &str = "\ usage: cargo xtask @@ -141,197 +151,6 @@ pub fn cargo() -> Command { c } -/// CI build jobs: one per 1.5 GB of RAM available when CI starts, at most one per CPU, 4 when it -/// is unknown. With line-table debuginfo, 4 jobs measured about 3 GB of RAM in use and 2 jobs -/// about 1.5 GB, so this leaves about half the available RAM to spare. Counting available rather -/// than installed RAM keeps a busy machine (browsers, VMs, editors) from being overcommitted. -/// Test threads are this, capped at 4. -fn ci_jobs(available_mb: Option, cpus: usize) -> usize { - available_mb.map_or(4, |mb| usize::try_from(mb / 1536).unwrap_or(usize::MAX)).clamp(1, cpus.max(1)) -} - -/// RAM the OS could hand out now (free plus reclaimable cache) in MB, if it reports it. -fn available_ram_mb() -> Option { - let output = |program: &str, args: &[&str]| { - let out = Command::new(program).args(args).output().ok()?; - Some(String::from_utf8_lossy(&out.stdout).into_owned()) - }; - if cfg!(target_os = "linux") { - let info = std::fs::read_to_string("/proc/meminfo").ok()?; - let kb = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim(); - Some(kb.parse::().ok()? / 1024) - } else if cfg!(windows) { - let cmd = "(Get-CimInstance Win32_PerfFormattedData_PerfOS_Memory).AvailableMBytes"; - output("powershell", &["-NoProfile", "-Command", cmd])?.trim().parse().ok() - } else { - // macOS: free + inactive + speculative pages - let stat = output("vm_stat", &[])?; - let page: u64 = stat.split("page size of ").nth(1)?.split_whitespace().next()?.parse().ok()?; - let pages = |name: &str| -> Option { - let line = stat.lines().find(|l| l.starts_with(name))?; - line.rsplit(':').next()?.trim().trim_end_matches('.').parse().ok() - }; - let free = pages("Pages free")?.saturating_add(pages("Pages inactive")?).saturating_add(pages("Pages speculative").unwrap_or(0)); - Some(free.saturating_mul(page) >> 20) - } -} - -/// Installed physical memory in whole GB, if the OS reports it (the fallback when available RAM -/// can't be read: half of it is assumed available). -fn total_ram_gb() -> Option { - let bytes: u64 = if cfg!(target_os = "linux") { - let info = std::fs::read_to_string("/proc/meminfo").ok()?; - let kb = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim(); - kb.parse::().ok()?.checked_mul(1024)? - } else { - let (program, args): (&str, &[&str]) = if cfg!(windows) { - ("powershell", &["-NoProfile", "-Command", "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory"]) - } else { - ("sysctl", &["-n", "hw.memsize"]) - }; - let out = Command::new(program).args(args).output().ok()?; - String::from_utf8_lossy(&out.stdout).trim().parse().ok()? - }; - // round to the nearest GB: "8 GB" machines report slightly less - Some(bytes.saturating_add(1 << 29) >> 30) -} - -/// The targets the test runs build: no examples (clippy `--all-targets` still checks them). -/// Doctests run on their own, [`doctests`]: nextest can't run them. -const TEST_TARGETS: [&str; 3] = ["--lib", "--bins", "--tests"]; - -/// `args` followed by [`TEST_TARGETS`]. -fn with_test_targets<'a>(args: &[&'a str]) -> Vec<&'a str> { - args.iter().copied().chain(TEST_TARGETS).collect() -} - -/// Whether the tests run under `cargo nextest`: only when `LIGHTCRAFT_NEXTEST=1` asks for it and -/// nextest is installed. `cargo test` is the default because the demo library's generated scenes -/// are cached per process: libtest runs a binary's tests in one process and generates each scene -/// once, while nextest's process per test regenerates them (measured: the ui-egui tests take about -/// 30 min under nextest, under 5 min under cargo test). -fn nextest() -> bool { - static FOUND: std::sync::OnceLock = std::sync::OnceLock::new(); - *FOUND.get_or_init(|| { - if std::env::var_os("LIGHTCRAFT_NEXTEST").is_none_or(|v| v != "1") { - return false; - } - let found = cargo() - .args(["nextest", "--version"]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()); - if !found { - eprintln!("note: LIGHTCRAFT_NEXTEST=1 but cargo-nextest is not installed, so the tests run under cargo test (cargo install cargo-nextest --locked)"); - } - found - }) -} - -/// A test run of `args` (packages, features, targets, filters): `cargo nextest run`, or `cargo -/// test`. The command and how to print it. -fn tests(args: &[&str]) -> (Command, String) { - let mut c = cargo(); - let tool = if nextest() { "nextest run" } else { "test" }; - c.args(tool.split(' ')).args(args); - (c, format!("cargo {tool} {}", args.join(" "))) -} - -/// The workspace's doctests. -fn doctests() -> (Command, String) { - let mut c = cargo(); - c.args(["test", "--workspace", "--doc"]); - (c, "cargo test --workspace --doc".to_owned()) -} - -/// `ci --quick`'s tests: build every workspace test target, then run only the test binaries of -/// `packages`. Building with `-p` instead would resolve features per selection: different -/// artifacts from `ci`'s workspace build, so it would recompile ~130 crates. Doc tests are left to -/// the full `ci`. -fn test_selected(root: &Path, packages: &std::collections::BTreeSet, skip_log: &Path) -> Result<(), String> { - let manifests: std::collections::BTreeMap = metadata()?["packages"] - .as_array() - .into_iter() - .flatten() - .filter_map(|p| Some((p["manifest_path"].as_str()?.to_owned(), p["name"].as_str()?.to_owned()))) - .filter(|(_, name)| packages.contains(name)) - .collect(); - let what = "cargo test --workspace --no-run --lib --bins --tests"; - eprintln!("$ {what} (then the test binaries of {})", packages.iter().map(String::as_str).collect::>().join(", ")); - let out = cargo() - .args(with_test_targets(&["test", "--workspace", "--no-run", "--message-format=json-render-diagnostics"])) - .stderr(std::process::Stdio::inherit()) - .output() - .map_err(|e| format!("{what}: failed to spawn: {e}"))?; - if !out.status.success() { - return Err(format!("{what}: exited with {}", out.status)); - } - let mut binaries = Vec::new(); - for line in String::from_utf8_lossy(&out.stdout).lines() { - let Ok(msg) = serde_json::from_str::(line) else { continue }; - if msg["reason"] != "compiler-artifact" || msg["profile"]["test"] != true { - continue; - } - let (Some(exe), Some(manifest)) = (msg["executable"].as_str(), msg["manifest_path"].as_str()) else { continue }; - if let Some(name) = manifests.get(manifest) { - binaries.push((name.clone(), manifest.to_owned(), exe.to_owned())); - } - } - if binaries.is_empty() { - println!("no test binaries in {}", packages.iter().map(String::as_str).collect::>().join(", ")); - return Ok(()); - } - let mut failed = Vec::new(); - for (name, manifest, exe) in &binaries { - let dir = Path::new(manifest).parent().unwrap_or(root); - let mut c = Command::new(exe); - c.current_dir(dir).env("CARGO_MANIFEST_DIR", dir).env("CARGO_PKG_NAME", name).env(ci_report::SKIP_LOG_ENV, skip_log); - if std::env::var_os("RUST_TEST_THREADS").is_none() { - // as the cargo runs of ci (`cargo()`): more threads make the frame-budget tests flaky - c.env("RUST_TEST_THREADS", std::thread::available_parallelism().map_or(4, |n| n.get()).min(4).to_string()); - } - let file = Path::new(exe).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(); - if run(c, &format!("{name}: {file}")).is_err() { - failed.push(format!("{name} ({file})")); - } - } - if failed.is_empty() { Ok(()) } else { Err(format!("tests failed in {}", failed.join(", "))) } -} - -/// The test that prints the messages each language lacks (shown in English); it always passes. -const TRANSLATION_GAPS_TEST: &str = "i18n::tests::translation_gaps_report"; - -/// Print the translation gaps report into the log: [`TRANSLATION_GAPS_TEST`] of lightcraft-ui-egui, -/// run from the test binary the test step built (found with `--no-run` and the step's own -/// arguments, so nothing rebuilds). A report that can't run is a note, never a failed step. -fn translation_gaps() { - eprintln!("\n=== ci: translation gaps ==="); - let mut c = cargo(); - c.args(["test", "--no-run", "--message-format=json"]).args(with_test_targets(&["--workspace"])); - let binary = c.output().map_err(|e| format!("cargo test --no-run: {e}")).and_then(|out| { - String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .find(|m| { - m["reason"] == "compiler-artifact" - && m["target"]["name"] == "lightcraft_ui_egui" - && m["profile"]["test"] == true - && m["target"]["kind"].as_array().is_some_and(|kinds| kinds.iter().any(|kind| kind == "lib")) - }) - .and_then(|m| m["executable"].as_str().map(PathBuf::from)) - .ok_or_else(|| format!("no lightcraft-ui-egui test binary in the build (cargo exited with {})", out.status)) - }); - let ran = binary.and_then(|binary| { - let mut c = Command::new(&binary); - c.args(["--exact", TRANSLATION_GAPS_TEST, "--nocapture", "--quiet"]); - run(c, &format!("{} --exact {TRANSLATION_GAPS_TEST} --nocapture --quiet", binary.display())) - }); - if let Err(e) = ran { - eprintln!("note: the translation gaps report did not run: {e}"); - } -} - pub fn run(mut cmd: Command, what: &str) -> Result<(), String> { eprintln!("$ {what}"); let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?; @@ -345,2682 +164,3 @@ pub fn metadata() -> Result { } serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}")) } - -fn cmd_layers() -> Result<(), String> { - let crates = layers::from_metadata(&metadata()?)?; - println!("Dependency layering (plan/architecture.md §3)\n"); - println!("{:<28} {:<14} workspace deps", "crate", "layer"); - for c in &crates { - let ws: Vec = c - .deps - .iter() - .filter(|d| d.workspace) - .map(|d| { - let k = match d.kind { - layers::DepKind::Normal => "", - layers::DepKind::Dev => " (dev)", - layers::DepKind::Build => " (build)", - }; - format!("{}{k}", layers::short_name(&d.name)) - }) - .collect(); - println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", ")); - } - let violations = layers::check(&crates); - println!(); - if violations.is_empty() { - println!("OK: {} crates, no layering violations.", crates.len()); - Ok(()) - } else { - println!("{} violation(s):", violations.len()); - for v in &violations { - println!(" - {v}"); - } - Err(format!("{} layering violation(s)", violations.len())) - } -} - -/// Workspace packages that must build for wasm32: all L0–L5 crates plus -/// the egui shell and the web app. -fn wasm_set() -> Result, String> { - let crates = layers::from_metadata(&metadata()?)?; - Ok(crates - .into_iter() - .filter(|c| match layers::classify(&c.name) { - Some(layers::Class::Layer(l)) => l <= 5, - Some(layers::Class::Standalone) => true, - _ => false, - }) - .map(|c| c.name) - .chain(std::iter::once("lightcraft-web".to_string())) - .collect()) -} - -/// One `cargo check` over the whole set; `--keep-going` reports every crate that fails. -fn cmd_wasm() -> Result<(), String> { - cmd_wasm_for(None) -} - -/// The wasm check of the wasm-safe crates, or only those of them in `only` (`ci --quick`). -fn cmd_wasm_for(only: Option<&std::collections::BTreeSet>) -> Result<(), String> { - let set: Vec = wasm_set()?.into_iter().filter(|p| only.is_none_or(|o| o.contains(p))).collect(); - if set.is_empty() { - println!("wasm32-unknown-unknown check: no changed crate builds for wasm32"); - return Ok(()); - } - let mut c = cargo(); - c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); - for pkg in &set { - c.args(["-p", pkg]); - } - run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p "))) -} - -fn cmd_ci(args: &[&str]) -> Result<(), String> { - let hardware = args.contains(&"--hardware"); - let print_md = args.contains(&"--summary-md"); - let quick = args.contains(&"--quick"); - let base_at = args.iter().position(|a| *a == "--base"); - let base = base_at.map(|i| args.get(i + 1).copied().ok_or("ci: --base needs a ref")).transpose()?; - let unknown = args - .iter() - .enumerate() - .find(|(i, a)| !matches!(**a, "--hardware" | "--summary-md" | "--quick" | "--base") && base_at.is_none_or(|b| *i != b + 1)); - if let Some((_, other)) = unknown { - return Err(format!("ci: unknown option `{other}`\n\n{USAGE}")); - } - if base.is_some() && !quick { - return Err("ci: --base only applies to --quick".into()); - } - let root = root(); - let command = std::iter::once("cargo xtask ci").chain(args.iter().copied()).collect::>().join(" "); - // held until ci returns: one run at a time per machine (ci_lock.rs) - let _slot = ci_lock::acquire(&root, &command)?; - let plan = if quick { - let (base, files) = ci_quick::changed_files(&root, base)?; - let plan = ci_quick::plan(&files, &ci_quick::packages(&root, &metadata()?), base); - let packages: Vec<&str> = plan.packages.iter().map(String::as_str).collect(); - eprintln!("=== ci --quick: {} changed file(s) since {} ===", plan.files, plan.base); - eprintln!(" packages: {}", if packages.is_empty() { "none".to_owned() } else { packages.join(", ") }); - if plan.workspace_wide { - eprintln!(" a workspace-wide file changed: testing every package"); - } - Some(plan) - } else { - None - }; - let report_dir = ci_report::prepare(&root)?; - let started = std::time::Instant::now(); - let started_unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - // test runs append the skips their tests record (`lightcraft_testkit::skip!`) to - // target/ci/skips-.jsonl: a file, so it works the same under nextest and cargo test - let test = |(mut c, what): (Command, String), step: &str| { - c.env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, step)); - run(c, &what) - }; - let gpu = std::cell::RefCell::new(serde_json::Value::Null); - let hardware_runs = std::cell::RefCell::new(Vec::new()); - // What this machine offers the GPU tests, once, after the workspace tests (whose build the - // probe reuses). Never fails the run: the report says what went wrong. - let probe = || { - if gpu.borrow().is_null() { - eprintln!("\n=== ci: GPU probe ==="); - let t = std::time::Instant::now(); - let mut found = ci_report::gpu(probe_gpu, hardware); - found["seconds"] = serde_json::json!(t.elapsed().as_secs_f64()); - *gpu.borrow_mut() = found; - } - }; - - type Step<'a> = (&'static str, Box Result<(), String> + 'a>); - // `--quick`: which steps apply (all of them in a full run) and the packages to test - let wants = |step: &str| match &plan { - None => true, - Some(p) => match step { - "clippy" | "test" | "wasm" => p.rust(), - "heif" => p.heif, - "parity" => p.parity, - _ => true, - }, - }; - let mut steps: Vec = vec![ - ( - "fmt", - Box::new(|| { - let mut c = cargo(); - c.args(["fmt", "--all", "--", "--check"]); - run(c, "cargo fmt --all -- --check") - }), - ), - ( - "clippy", - Box::new(|| { - let mut c = cargo(); - c.args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]); - run(c, "cargo clippy --workspace --all-targets --keep-going -- -D warnings") - }), - ), - ( - "heif", - Box::new(|| { - // the optional HEIC/HEIF decoder is off in the workspace build above - let mut c = cargo(); - c.args(["clippy", "-p", "lightcraft-codecs", "--features", "heif", "--all-targets", "--", "-D", "warnings"]); - run(c, "cargo clippy -p lightcraft-codecs --features heif --all-targets -- -D warnings")?; - test( - tests(&with_test_targets(&["-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"])), - "heif", - )?; - // the engine's HEIC import test, with the decoder (the workspace run checks the error) - test(tests(&["-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"]), "heif") - }), - ), - ( - "test", - Box::new(|| match &plan { - Some(p) if !p.workspace_wide => test_selected(&root, &p.packages, &ci_report::skip_log(&root, "test")), - _ => test(tests(&with_test_targets(&["--workspace"])), "test").and_then(|()| test(doctests(), "test")), - }), - ), - ]; - steps.retain(|(name, _)| wants(name)); - if hardware { - steps.push(( - "gpu-backends", - Box::new(|| { - // the GPU tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`) - probe(); - let backends = ci_report::hardware_backends(&gpu.borrow()); - if backends.is_empty() { - eprintln!("no hardware GPU adapter on any backend: nothing to run"); - } - let mut failed = Vec::new(); - for (backend, adapter) in backends { - let step = format!("gpu-{backend}"); - let (mut c, what) = tests(&with_test_targets(&["-p", "lightcraft-gpu", "--features", "denoise"])); - c.env("LIGHTCRAFT_GPU_BACKEND", &backend).env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, &step)); - let t = std::time::Instant::now(); - let ok = run(c, &format!("LIGHTCRAFT_GPU_BACKEND={backend} {what}")).is_ok(); - hardware_runs.borrow_mut().push(serde_json::json!({ - "backend": backend, - "adapter": adapter, - "status": if ok { "ok" } else { "fail" }, - "seconds": t.elapsed().as_secs_f64(), - })); - if !ok { - failed.push(backend); - } - } - if failed.is_empty() { Ok(()) } else { Err(format!("the GPU tests failed on {}", failed.join(", "))) } - }), - )); - } - if wants("parity") { - steps.push(("parity", Box::new(|| parity::run(&root, false)))); - } - steps.push(("layers", Box::new(cmd_layers))); - steps.push(("assets", Box::new(|| assets::run(&root)))); - if wants("wasm") { - match plan.as_ref().filter(|p| !p.workspace_wide) { - Some(p) => steps.push(("wasm", Box::new(|| cmd_wasm_for(Some(&p.packages))))), - None => steps.push(("wasm", Box::new(cmd_wasm))), - } - } - - let mut results = Vec::new(); - let mut failure = None; - for (name, f) in &steps { - eprintln!("\n=== ci: {name} ==="); - let t = std::time::Instant::now(); - let r = f(); - let status = if r.is_ok() { "ok" } else { "fail" }; - results.push(serde_json::json!({"name": name, "status": status, "seconds": t.elapsed().as_secs_f64()})); - if *name == "test" && plan.is_none() { - // right after the workspace tests, before parity and wasm build other feature sets - probe(); - translation_gaps(); - } - if let Err(e) = r { - failure = Some((*name, e)); - break; - } - } - if gpu.borrow().is_null() { - let why = if plan.is_some() { "not probed: --quick" } else { "not probed: the test step did not run" }; - *gpu.borrow_mut() = serde_json::json!({"error": why}); - } - let done: Vec<&str> = steps.iter().take(results.len()).map(|(n, _)| *n).collect(); - match &failure { - Some((name, e)) => { - println!("\nCI summary:"); - for d in done.iter().filter(|d| *d != name) { - println!(" ok {d}"); - } - println!(" FAIL {name}: {e}"); - for (n, _) in steps.iter().skip(results.len()) { - println!(" skip {n}"); - } - } - None => println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")), - } - for (n, _) in steps.iter().skip(results.len()) { - results.push(serde_json::json!({"name": n, "status": "not run"})); - } - - let skips = ci_report::read_skips(&report_dir, &root); - let ram_mb = available_ram_mb(); - let report = serde_json::json!({ - "schema": ci_report::SCHEMA, - "command": command, - "quick": plan.as_ref().map(|p| serde_json::json!({"base": p.base, "files": p.files, "packages": p.packages, "workspace_wide": p.workspace_wide})), - "result": if failure.is_some() { "fail" } else { "pass" }, - "failed_step": failure.as_ref().map(|(n, _)| *n), - "started_unix": started_unix, - "seconds": started.elapsed().as_secs_f64(), - "git": ci_report::git(&root), - "machine": ci_report::machine(total_ram_gb(), ram_mb), - "toolchain": ci_report::toolchain(), - "capabilities": {"gpu": gpu.take(), "data": ci_report::data(&root, &RAW_SAMPLES.iter().map(|s| s.0).collect::>())}, - "steps": results, - "hardware": hardware_runs.take(), - "skips": ci_report::skips_json(&skips), - }); - print!("{}", ci_report::summary_text(&report)); - let md = ci_report::summary_md(&report); - let written = serde_json::to_string_pretty(&report) - .map_err(|e| e.to_string()) - .and_then(|json| std::fs::write(report_dir.join("report.json"), json + "\n").map_err(|e| e.to_string())) - .and_then(|()| std::fs::write(report_dir.join("summary.md"), &md).map_err(|e| e.to_string())); - match written { - Ok(()) => println!("Report: target/ci/report.json; to paste into a PR: target/ci/summary.md (or run with --summary-md)"), - Err(e) => eprintln!("ci: could not write the report to {}: {e}", report_dir.display()), - } - if print_md { - println!("\n{md}"); - } - match failure { - Some((name, _)) => Err(format!("ci failed at `{name}`")), - None => Ok(()), - } -} - -/// Run the ignored `gpu_probe` test of lightcraft-gpu: the pipeline's device (`None`) or one -/// backend's adapters. `--workspace`, like the test step, so its build is reused (every crate's `it` -/// binary starts; only lightcraft-gpu's has the test). Its stdout, or why -/// it failed (a driver that crashes while it is enumerated, issue #136, ends up here, not in xtask). -fn probe_gpu(backend: Option<&str>) -> Result { - let mut c = cargo(); - c.args(["test", "-q", "--workspace", "--test", "it", "--", "--ignored", "--exact", "gpu_probe::gpu_probe", "--nocapture"]); - c.env("LIGHTCRAFT_GPU_PROBE", backend.unwrap_or("")); - let out = c.output().map_err(|e| format!("gpu_probe: {e}"))?; - if !out.status.success() { - let err = String::from_utf8_lossy(&out.stderr); - let tail: Vec<&str> = err.lines().rev().filter(|l| !l.trim().is_empty()).take(3).collect(); - return Err(format!( - "gpu_probe {}exited with {}: {}", - backend.map(|b| format!("{b} ")).unwrap_or_default(), - out.status, - tail.into_iter().rev().collect::>().join(" / ") - )); - } - Ok(String::from_utf8_lossy(&out.stdout).into_owned()) -} - -/// CC0 raw samples (raw.pixls.us). Small, representative set; extend freely (CC0 only). -/// CC0 raw samples from raw.pixls.us (each verified CC0 on the site). -/// The third field is the SHA-256 of the file as downloaded; `cargo xtask corpus --download` checks it. -/// One per format / compression variant we decode or deliberately report as unsupported (preview only). -const RAW_SAMPLES: &[(&str, &str, &str)] = &[ - // Camera-coverage sweep: CC0 files, sensor hashes and comparisons in docs/raw/coverage-sweep.tsv. - ( - "coverage-915.dng", - "https://raw.pixls.us/getfile.php/915/nice/Apple%20-%20iPhone%206s%20Plus%20-%2016bit%20%284:3%29.DNG", - "845fee4f09c832af3728778737f5ae9b99124debb821b6e2066b94aed1daa91c", - ), - ( - "coverage-939.dng", - "https://raw.pixls.us/getfile.php/939/nice/Apple%20-%20iPhone%207%20Plus%20-%2016bit%20%284:3%29.DNG", - "e8c7ebd8f22f7281d165496b91c8fea8a8e79c8113172fb499e1d66d6045332e", - ), - ( - "coverage-3372.dng", - "https://raw.pixls.us/getfile.php/3372/nice/Apple%20-%20iPhone%20XS%20-%2016bit%20%284:3%29.dng", - "50bc64ee8cfbbfb6c0531e29ab428ff985506a16fe2d1d2e74656f21b54505f6", - ), - ( - "coverage-3009.dng", - "https://raw.pixls.us/getfile.php/3009/nice/Hasselblad%20-%20L1D-20c%20-%2016bit%20%283:2%29.DNG", - "7afecae0db80d832941eeeb3c0eb2dc03f5696637b6e78b3c592e8c002dce4ec", - ), - ( - "coverage-5140.dng", - "https://raw.pixls.us/getfile.php/5140/nice/Hasselblad%20-%20L2D-20c%20-%2016bit%20%284:3%29.DNG", - "dbe98cb202f884ff11e0144899613b26d33cf9cddd9f1a62226f4352397f18e4", - ), - ( - "coverage-8041.dng", - "https://raw.pixls.us/getfile.php/8041/nice/DJI%20-%20FC9287%20-%2016bit%20%2816:9%29.DNG", - "901b7970f02f7197c782f111363feef02108f75811342d8cc4a2b66757bb7af0", - ), - ( - "coverage-4785.dng", - "https://raw.pixls.us/getfile.php/4785/nice/DJI%20-%20FC7303%20-%2016bit%20%284:3%29.DNG", - "21a49e63966f82391ca1188c7559c3acc01a35a89a70194fda4bf44cbd64a851", - ), - ( - "coverage-4898.dng", - "https://raw.pixls.us/getfile.php/4898/nice/DJI%20-%20FC7303%20-%2016bit%20%2816:9%29.DNG", - "fb4f294e12cab7b507fd0b7c07f24e5d13e7c0dd127b534ce12c48d644c9363d", - ), - ( - "coverage-3502.dng", - "https://raw.pixls.us/getfile.php/3502/nice/Google%20-%20Pixel%204%20XL%20-%2016bit%20%284:3%29.dng", - "0c03a6c93b4b8593cd0e4426166e6389710007238b82eb23861ef191322f0c54", - ), - ( - "coverage-4176.dng", - "https://raw.pixls.us/getfile.php/4176/nice/Google%20-%20Pixel%204a%20-%2016bit%20%284:3%29.dng", - "11b38186ee3455376291bcdb3049728a3f9dcd94b9e82ff76855d9f3430e44be", - ), - ( - "coverage-5961.dng", - "https://raw.pixls.us/getfile.php/5961/nice/Google%20-%20Pixel%206%20Pro%20-%2016bit%20%284:3%29.dng", - "3a859e917a91698a3e15ce3b3721a48a1fb1296873e0697d3bf7c2793297200d", - ), - ( - "coverage-7751.dng", - "https://raw.pixls.us/getfile.php/7751/nice/Google%20-%20Pixel%208%20Pro%20-%2016bit%20%284:3%29.dng", - "45420c595401547cca950ae58d552bc82b32d31ce1d58c082df33004016197f5", - ), - ( - "coverage-7217.3fr", - "https://raw.pixls.us/getfile.php/7217/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.3FR", - "e27ff177519298fe7bae64506b975190789dc0e6ad70bea58be7c78fd094eae0", - ), - ( - "coverage-7218.fff", - "https://raw.pixls.us/getfile.php/7218/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.fff", - "c8d78912ae725a59acff42a4b4731350bbddd4a600423f319579261bd41da932", - ), - ( - "coverage-1640.fff", - "https://raw.pixls.us/getfile.php/1640/nice/Hasselblad%20-%20H5D-40%20-%2016bit%20%284:3%29.fff", - "209d464e13702de5f4fdb830ac26ac140c98a8d0dc3e81c5ba61d93677330832", - ), - ( - "coverage-1639.fff", - "https://raw.pixls.us/getfile.php/1639/nice/Hasselblad%20-%20H5D-50c%20-%2016bit%20%284:3%29.fff", - "1aad9319fcbab81b851a74c3eeaa2a07fc5013bf2c4e272fd31d6419b6b9adb0", - ), - ( - "coverage-2058.3fr", - "https://raw.pixls.us/getfile.php/2058/nice/Hasselblad%20-%20X1D%20-%2016bit%20%284:3%29.3FR", - "395acb2c706dc10270c58ec261e42a5e36f6ac8c39d2a767e86e83648aa318a3", - ), - ( - "coverage-3874.fff", - "https://raw.pixls.us/getfile.php/3874/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.fff", - "8c2451b3478fb529b4e9bd51ed3267999ce26eb1fcb5c3b93c3ae7bc56267794", - ), - ( - "coverage-3941.3fr", - "https://raw.pixls.us/getfile.php/3941/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.3FR", - "809d498263d6f07cbaa4b9a5be97933057b4ab21e7243bb7afbe3862f3b48b47", - ), - ( - "coverage-6568.fff", - "https://raw.pixls.us/getfile.php/6568/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.fff", - "44977fdaa3063eacbb2ac416dfb531bb4e3b96f3745d073dc49fb1ebcd78bedc", - ), - ( - "coverage-6565.3fr", - "https://raw.pixls.us/getfile.php/6565/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.3FR", - "dcc5a4abe3498e6f25e89bb491995fd12c3b669d9277c71b45b49210d3e56280", - ), - ( - "coverage-4726.nef", - "https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233", - ), - ( - "coverage-4720.nef", - "https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f", - ), - ( - "coverage-4721.nef", - "https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838", - ), - ( - "coverage-3584.nef", - "https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", - "3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886", - ), - ( - "coverage-3581.nef", - "https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", - "8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02", - ), - ( - "coverage-3582.nef", - "https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF", - "c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31", - ), - ( - "coverage-2792.nef", - "https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", - "c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded", - ), - ( - "coverage-2789.nef", - "https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", - "e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e", - ), - ( - "coverage-2790.nef", - "https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", - "f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114", - ), - ( - "coverage-6617.nef", - "https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF", - "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", - ), - ( - "coverage-2912.dng", - "https://raw.pixls.us/getfile.php/2912/nice/Parrot%20-%20Anafi%20-%2016bit%20%284:3%29.DNG", - "a257a296047ff2d396109fd2e6947fa36aa7986695b6815f83979d59e98a84e6", - ), - ( - "coverage-6728.dng", - "https://raw.pixls.us/getfile.php/6728/nice/Samsung%20-%20Galaxy%20S21%20Ultra%20-%204:3.dng", - "5baeb5f0c4a125647df03b82c2d38b0f02784393bd39eff1a5b23d8f5308dc5e", - ), - ( - "coverage-6261.dng", - "https://raw.pixls.us/getfile.php/6261/nice/Samsung%20-%20Galaxy%20S22%20Ulra%20-%204:3.dng", - "53e7f83a94c965495ba32d3ea6021ef08ed02244ce64d111d1329bbd01fd5581", - ), - ( - "coverage-6697.dng", - "https://raw.pixls.us/getfile.php/6697/nice/Samsung%20-%20Galaxy%20S23%20-%204:3.dng", - "1c04574f32b5a139922f075291c52018b89d9981ee155a16958a231c1fdbb067", - ), - ( - "coverage-1297.dng", - "https://raw.pixls.us/getfile.php/1297/nice/Samsung%20-%20G935F%20-%2016bit%20%2816:9%29.dng", - "843617c8b572320b7badfb4ac2eda19479f5804a1dabb909c861bde23ed30561", - ), - ( - "coverage-2645.dng", - "https://raw.pixls.us/getfile.php/2645/nice/Samsung%20-%20SM-G950F%20-%2016bit%20%284:3%29.dng", - "6abdd353094964eaadddf69bba3a0d551ecce1bc0ed013a434691df4f64dcbf2", - ), - ( - "coverage-2310.dng", - "https://raw.pixls.us/getfile.php/2310/nice/Samsung%20-%20SM-G955F%20-%2016bit%20%284:3%29.dng", - "3cf5da4331d062bc6b31e0e8f5b7ff2cd21107de4262d79a2667f670516e8e8c", - ), - ( - "coverage-7273.dng", - "https://raw.pixls.us/getfile.php/7273/nice/Sigma%20-%20fp%20-%2014bit%20%283:2%29.DNG", - "0844bcf23a49e21b5c2f8489de55ce38fb7b594e3ed337d6b9af2dd9341354e9", - ), - ( - "coverage-6949.dng", - "https://raw.pixls.us/getfile.php/6949/nice/Sigma%20-%20fp%20L%20-%2014bit%20%283:2%29.DNG", - "8e7229f28be30bfa45289940eac04194af2b3474f6db286e4fa4070c145a0a70", - ), - ( - "arw-sony-a7rm4a-compressed.arw", - "https://raw.pixls.us/getfile.php/4822/nice/Sony%20-%20ILCE-7RM4A%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", - "690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67", - ), - ( - "arw-sony-a9m2-compressed.arw", - "https://raw.pixls.us/getfile.php/3989/nice/Sony%20-%20ILCE-9M2%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", - "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97", - ), - ( - "arw-sony-a7m3-compressed.arw", - "https://raw.pixls.us/getfile.php/2414/nice/Sony%20-%20ILCE-7M3%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", - "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f", - ), - ( - "arw-sony-a7m3-uncompressed.arw", - "https://raw.pixls.us/getfile.php/2418/nice/Sony%20-%20ILCE-7M3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.ARW", - "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd", - ), - ( - "arw-sony-a7m4-14bit.arw", - "https://raw.pixls.us/getfile.php/6936/nice/Sony%20-%20ILCE-7M4%20-%2014bit%20%283:2%29.ARW", - "639a6d4db881f1359e3ea7e1137b314e59417d24e6d4d862b7202111a2c823b2", - ), - // lossless compressed (Compression 7): L is 2×2 CFA cells per LJ92 sample; M and S are subsampled - ( - "arw-sony-a7m4-lossless-l.arw", - "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06674_FullFrame-LossLess-Compressed-Large.ARW", - "851b43c2116c4139104a5036f83ac3b6a148789b2142214dd7192c13972b25b6", - ), - ( - "arw-sony-a7m4-lossless-m.arw", - "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06675_FullFrame-LossLess-Compressed-Medium.ARW", - "d453005714327addd75bcb99c1c6223173dc92f2a59542d82e6761ef0e0e7571", - ), - ( - "arw-sony-a7m4-lossless-s.arw", - "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06676_FullFrame-LossLess-Compressed-Small.ARW", - "cbbd0930c7d8706dff84c68a2004454266e6fd0d8354f5f76a106b5d776e0223", - ), - // ILCE-7CR (61 MP), one scene in three codings: lossless compressed L (2×2 CFA cells per LJ92 sample) and M - // (subsampled YCbCr), and compressed ARW2 - ( - "arw-sony-a7cr-lossless-l.arw", - "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00795.ARW", - "3e1642f3a1ae7c9f93228c5f09f27e362608e6a4ca1b3e5d8dd9eb8fae576997", - ), - ( - "arw-sony-a7cr-lossless-m.arw", - "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00796.ARW", - "5bcba4acc52a5b902074581b4a2f5fb3c79ec56bd0fdc1746b9e2c0f9d3e9c88", - ), - ( - "arw-sony-a7cr-compressed.arw", - "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00798.ARW", - "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5", - ), - // ILCE-7CM2 (33 MP, issue #697): lossless compressed L, and the compressed "M-size" coding, which is Bayer at - // 4736×3132 with black 512 (only the lossless M/S codings are YCbCr with the 1024 pedestal) - ( - "arw-sony-a7cm2-lossless-l.arw", - "https://raw.pixls.us/data/Sony/ILCE-7CM2/DSC02431.ARW", - "342d893bd71fd050c273df181e3039d3f12d88947b0662de5de5325c84cc449b", - ), - ( - "arw-sony-a7cm2-compressed-m.arw", - "https://raw.pixls.us/data/Sony/ILCE-7CM2/DSC02436.ARW", - "002c86d38a16b08a2438780728b4fda8efcc745fa6c26231dad6af67b383b11e", - ), - // ILCA-99M2 (42 MP, compressed): checks that it takes its colour from the bundled ILCA-99M2 profile - ( - "arw-sony-a99m2.arw", - "https://raw.pixls.us/data/Sony/ILCA-99M2/BR_00086.ARW", - "be898847ac6da485874d95469bf274df1f8f77f8b8542460464f0f393981b2d2", - ), - // a dusk sky clipped in green behind a poplar: the clipped-highlight colour of issue #523 - ( - "arw-sony-a7rm4-14bit-compressed.arw", - "https://raw.pixls.us/getfile.php/3480/nice/Sony%20-%20ILCE-7RM4%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", - "e6dafe42643f69ab9d1fd00414b7a1f5104df354bb589201600ac934b29b5e4a", - ), - // pre-2017 bodies: white balance and black level only in the encrypted SR2SubIFD (#148) - ( - "arw-sony-rx100m3.arw", - "https://raw.pixls.us/data/Sony/DSC-RX100M3/DSC00734.ARW", - "cb81392e3a8810231ce341fb45de3e4d1b9e2f87bc47f3c314d084041ccfd138", - ), - ( - "arw-sony-rx100.arw", - "https://raw.pixls.us/data/Sony/DSC-RX100/DSC00838.ARW", - "579a485b5126a25cbd55cbd5dadfa7d09cf021c99cc7d4869f9e56e3f759390b", - ), - ( - "arw-sony-a7rm2-12bit-uncompressed.arw", - "https://raw.pixls.us/data/Sony/ILCE-7RM2/12-bit-uncompressed.ARW", - "71e0888396ef52c7e6a990b4e72ebb4d8413a1fc1c35bada449870539cef6e39", - ), - // older DSLRs whose SR2SubIFD keeps the black level elsewhere than the bodies above (#535): A500 (27152-byte - // layout, like the A450/A550), A700 (62112 bytes) - ( - "arw-sony-a500.arw", - "https://raw.pixls.us/data/Sony/DSLR-A500/DSC02421.ARW", - "1407fb596a391df67c15b90026a1a76702a2e7386dbdff2f0815b903ea49cead", - ), - ( - "arw-sony-a700.arw", - "https://raw.pixls.us/data/Sony/DSLR-A700/DSC07249.ARW", - "3159e28892bf0771d01525cd4b6190f9c15bbb19fa2fab6d2515ced0594e8f40", - ), - // SR2SubIFD white balance (#535): the A500 and A700 above, a third layout of a body that had none before - // (SLT-A33, 29000 bytes), and two shots whose maker-note gains differ from the preset applied (5600 K, Shade) - ("arw-sony-a33.arw", "https://raw.pixls.us/data/Sony/SLT-A33/DSC01867.ARW", "1a59856394f10d4fadb40f5ab9c6d1c89f473f4dbcdefd216fbbbbf1ad8d21f9"), - ( - "arw-sony-a3500-5600k.arw", - "https://raw.pixls.us/data/Sony/ILCE-3500/DSC06923.ARW", - "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8", - ), - ( - "arw-sony-a7s-shade.arw", - "https://raw.pixls.us/data/Sony/ILCE-7S/DSC04125.ARW", - "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2", - ), - // pre-2017 bodies in the camera's 16:9 mode (#535): the ILCE-7SM2 records it in FullImageSize, the DSLR-A580 only - // in the Exif image size - ( - "arw-sony-a7sm2-16x9.arw", - "https://raw.pixls.us/data/Sony/ILCE-7SM2/DSC01005.ARW", - "46044fb6a9c805f2b4915cd4970316b4d04b0566bcf5287afaad3ef648d7a3db", - ), - ( - "arw-sony-a580-16x9.arw", - "https://raw.pixls.us/data/Sony/DSLR-A580/RAW_SONY_A580.ARW", - "5b0924d39151239dce19e92e08318f4f62a5a8ac276bd7180ec8463d2cfee709", - ), - // packed 12-bit ARW (two pixels per three bytes): the DSLR-A900 - ( - "arw-sony-a900-packed12.arw", - "https://raw.pixls.us/data/Sony/DSLR-A900/_DSC7969.ARW", - "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50", - ), - // Sony ILCE-7RM6 lossless, CC0 1.0 (RawDB): the APS-C file, for its lens tables (`vendor::arw_lens_rm6`). The - // full-frame lossless file is not pinned (at most two pins per PR); the two ARW6 pins below cover Compressed files. - ( - "arw-sony-ilce7rm6-apsc-land-lossless.arw", - "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/raw_modes/ILCE-7RM6_APSC_land_RAW.ARW", - "7aaf0b3941509d7364a6f03844783155ebc912d246d1c456e5cdb4693ec09fbc", - ), - // CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples - ( - "cr2-canon-40d.cr2", - "https://raw.pixls.us/data/Canon/EOS%2040D/_MG_0153.CR2", - "775c806358fedddec7622113a5e399a3330bd5a65e35e37ae1108d8bf58067be", - ), - ( - "cr2-canon-550d.cr2", - "https://raw.pixls.us/data/Canon/EOS%20550D/IMG_4047.CR2", - "f390e0ba566f0be9af2d8e7f955b815996630c0c3cb13fcfb95d98baec1890f6", - ), - ( - "cr2-canon-5d2.cr2", - "https://raw.pixls.us/data/Canon/EOS%205D%20Mark%20II/08.canon.raw.cr2", - "12b3ad6aed9fbcd19ba3bfffcd0769aa5a041d610205eee840bf4b2c420dffae", - ), - ( - "cr2-canon-5dsr.cr2", - "https://raw.pixls.us/data/Canon/EOS%205DS%20R/_DSR2002.CR2", - "01b94b0586419d64894ccdf2e7b9f1defeedc7b51dfcd306c622fe784181a9b8", - ), - ( - "cr2-canon-6d.cr2", - "https://raw.pixls.us/data/Canon/EOS%206D/EOS_6D_RAW.CR2", - "360842779d08fe4805b4a2fe4979f06fdc6da982c85c3acb181f25bcf4c9f536", - ), - ( - "cr2-canon-7d.cr2", - "https://raw.pixls.us/data/Canon/EOS%207D/RAW_CANON_EOS_7D-raw.CR2", - "b5e47c5fcf7332ac03e0134926f17a338a42e68c1fd7f83e16f45f4b767544e8", - ), - ( - "cr2-canon-5d3-sraw2.cr2", - "https://raw.pixls.us/getfile.php/773/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%20sRAW2%20%28sRAW%29.CR2", - "a79065e78a6c5bdbc8726e786ade02d54a7d496cc3cb4504d804c924a3d999da", - ), - ( - "cr2-canon-5d3.cr2", - "https://raw.pixls.us/getfile.php/771/nice/Canon%20-%20EOS%205D%20Mark%20III.CR2", - "ec069b178b9383d80c72f8cdc1b79bd54ca451e7c20793b109d8ad73bef6bdf6", - ), - ( - "cr2-canon-80d.cr2", - "https://raw.pixls.us/getfile.php/1294/nice/Canon%20-%20EOS%2080D%20-%20RAW%20%283:2%29.CR2", - "8f28465cee09844ffad95dbd3ec91923717a329c5ec4f3f405f5b4fce83aaea3", - ), - ( - "cr3-canon-m50-craw.cr3", - "https://raw.pixls.us/getfile.php/2663/nice/Canon%20-%20EOS%20M50%20-%20CRAW%20%283:2%29.CR3", - "15384b775867ec4c42b11882837f1e368cedc0561832ffab271221e6bb80be4c", - ), - ( - "cr3-canon-m50-raw.cr3", - "https://raw.pixls.us/getfile.php/4657/nice/Canon%20-%20EOS%20M50%20-%203:2.CR3", - "1ba9ad6b51b315b88820eb0fe5fd7f52bc136ba37b1a5ca76825ebfe25b18058", - ), - ( - "cr3-canon-r100-raw.cr3", - "https://raw.pixls.us/getfile.php/7896/nice/Canon%20-%20EOS%20R100%20-%20RAW%20%283:2%29.CR3", - "6d83217d58a5e6d2dabcf470430e91a16676b60531c025b81fa7453fc74b0521", - ), - ( - "cr3-canon-r100-craw.cr3", - "https://raw.pixls.us/getfile.php/7897/nice/Canon%20-%20EOS%20R100%20-%20CRAW%20%283:2%29.CR3", - "0b66842b2fe00329ebc05fe8ab9357ddbbe1a0a2d2e69a2893102de7fdf5c942", - ), - ( - "cr3-canon-r8-raw.cr3", - "https://raw.pixls.us/getfile.php/6585/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3", - "7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c", - ), - ( - "cr3-canon-r8-craw.cr3", - "https://raw.pixls.us/getfile.php/6587/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3", - "df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812", - ), - ( - "cr3-canon-rp-raw.cr3", - "https://raw.pixls.us/getfile.php/4674/nice/Canon%20-%20EOS%20RP%20-%203:2.CR3", - "dcbd0f6f9929de2e2331b0fd10a9d01e7cfd04c5a5e4038456de1647d714cdf2", - ), - ( - "cr3-canon-rp-craw.cr3", - "https://raw.pixls.us/getfile.php/4672/nice/Canon%20-%20EOS%20RP%20-%203:2.CR3", - "bd3270610593d37ff8a399908bb9406a4e32f9151d55bf19ac1cf8f10d787b46", - ), - ( - "cr3-canon-rp-raw-crop.cr3", - "https://raw.pixls.us/getfile.php/4673/nice/Canon%20-%20EOS%20RP%20-%203:2.CR3", - "7d4ba45dc9264d598336133593c677b09774bcdf13b602ee770e72415a652a16", - ), - ( - "cr3-canon-rp-craw-crop.cr3", - "https://raw.pixls.us/getfile.php/4671/nice/Canon%20-%20EOS%20RP%20-%203:2.CR3", - "4a859ab137f792aedaf3416b0aad1497ebaab02052a49453902e226699154aa7", - ), - // EOS R5 Mark II, full frame and 1.6x crop mode: odd-height (2825- / 1787-row) Bayer planes. - ( - "cr3-canon-r5m2-raw.cr3", - "https://raw.pixls.us/getfile.php/7881/nice/Canon%20-%20EOS%20R5%20Mark%20II%20-%20RAW%20%283:2%29.CR3", - "d6e6aa3b493ea8936dff0efb0cc17c87471437e7317bce37c8c82ba08b9cab7b", - ), - ( - "cr3-canon-r5m2-craw.cr3", - "https://raw.pixls.us/getfile.php/7882/nice/Canon%20-%20EOS%20R5%20Mark%20II%20-%20CRAW%20%283:2%29.CR3", - "b3c08a5c7a97299dfd58896a12b939e7b43ec78682761812524825e6e5d087be", - ), - ( - "cr3-canon-r5m2-apsc-raw.cr3", - "https://raw.pixls.us/getfile.php/7883/nice/Canon%20-%20EOS%20R5%20Mark%20II%20-%20RAW%20%283:2%29.CR3", - "9f88c0ff012924193b7f4497559d133dfe626766d600b5d67689156721739ead", - ), - ( - "cr3-canon-r5m2-apsc-craw.cr3", - "https://raw.pixls.us/getfile.php/7884/nice/Canon%20-%20EOS%20R5%20Mark%20II%20-%20CRAW%20%283:2%29.CR3", - "6a597497776a6abbfab8fcf12d0086ac7a3f8afea2879d61accf6bbf48d357d7", - ), - ( - "cr3-canon-m200.cr3", - "https://raw.pixls.us/getfile.php/8469/nice/Canon%20-%20EOS%20M200%20-%20RAW%20%283:2%29.CR3", - "9b900c04ae37ba827b7894e66907657a0b174a74855c40039a8ca0c95583f141", - ), - ( - "cr3-canon-r10.cr3", - "https://raw.pixls.us/getfile.php/5871/nice/Canon%20-%20EOS%20R10%20-%203:2.CR3", - "5d2e076d38e7835d4c4f13dc2030a913cc573a31d7199b60777d23a25ccc3ef9", - ), - ( - "cr3-canon-r50v.cr3", - "https://raw.pixls.us/getfile.php/8403/nice/Canon%20-%20EOS%20R50%20V%20-%20RAW%20%283:2%29.CR3", - "955ff98710505498ce7f98cbcc5875793ef2d6941b3b5692ed3baf2817ee7c9f", - ), - ( - "cr3-canon-r6m3.cr3", - "https://raw.pixls.us/getfile.php/8961/nice/Canon%20-%20EOS%20R6%20Mark%20III%20-%20RAW%20%283:2%29.CR3", - "e491e4bb960961b5fa299361bf698310a80cbe7b15d30d8dad3bb21bc5457dab", - ), - ( - "cr3-canon-powershot-v1-craw.cr3", - "https://raw.pixls.us/getfile.php/8500/nice/Canon%20-%20PowerShot%20V1%20-%20CRAW%20%283:2%29.CR3", - "0a0abfd81b3a34fa513ac8ed24c809275160d65b07e892c2eb4353b77c535a31", - ), - ( - "cr3-canon-1dxm3-craw.cr3", - "https://raw.pixls.us/getfile.php/4654/nice/Canon%20-%20EOS-1D%20X%20Mark%20III%20-%203:2.CR3", - "e5755d1414a2dd51fb0120b272058f00b0199861646070c00304d927eb119560", - ), - ( - "cr3-canon-250d-craw.cr3", - "https://raw.pixls.us/getfile.php/3383/nice/Canon%20-%20EOS%20250D%20-%20C-RAW%20%283:2%29.CR3", - "69bdd267defb3d676f41f62a5e5ea65651669a04191cf4e89740917c59cedbcc", - ), - ( - "cr3-canon-850d.cr3", - "https://raw.pixls.us/getfile.php/4867/nice/Canon%20-%20EOS%20850D%20-%203:2.CR3", - "47f7747b74073515bdbd65015738bc31947504d68255c5377baa21f179df2486", - ), - ( - "cr3-canon-90d-craw.cr3", - "https://raw.pixls.us/getfile.php/4650/nice/Canon%20-%20EOS%2090D%20-%203:2.CR3", - "c8c918b2d1a53e64d7d5009ebac89a920dbba5f766c2f396bb4ec95312b21034", - ), - ( - "cr3-canon-m50m2.cr3", - "https://raw.pixls.us/getfile.php/4430/nice/Canon%20-%20EOS%20M50%20Mark%20II%20-%203:2.CR3", - "1a3c11d85f48b8c644664b505f69fbdd75eb51270bb8ab556dbc660f0487e957", - ), - ( - "cr3-canon-m6m2-craw.cr3", - "https://raw.pixls.us/getfile.php/3714/nice/Canon%20-%20EOS%20M6%20Mark%20II%20-%20C-RAW%20%283:2%29.CR3", - "94635c6f91e7d743cee6881cc2519992ae9f9031fd86b49e5d0f16edf5961b55", - ), - ( - "cr3-canon-r3.cr3", - "https://raw.pixls.us/getfile.php/5051/nice/Canon%20-%20EOS%20R3%20-%203:2.CR3", - "295d46a4d5383895e63126f67000d4352a5bb425ee4d8b16eb6b6c284bdf7994", - ), - ( - "cr3-canon-r50-craw.cr3", - "https://raw.pixls.us/getfile.php/6646/nice/Canon%20-%20EOS%20R50%20-%20CRAW%20%283:2%29.CR3", - "b79af9ffa184dd740e8af7a9f451f7150053d7e456b3435ca2e43bbbf5f65026", - ), - ( - "cr3-canon-r5-craw.cr3", - "https://raw.pixls.us/getfile.php/4698/nice/Canon%20-%20EOS%20R5%20-%203:2.CR3", - "2deae46b816e56ba91ae7a3892ca121290e12d5b22afb11f5b05a3d8f09f759f", - ), - ( - "cr3-canon-r6-craw.cr3", - "https://raw.pixls.us/getfile.php/4660/nice/Canon%20-%20EOS%20R6%20-%203:2.CR3", - "4775df76433b1e5844bc7a42b95d93efc8438c0a4cd9681e120e23fe3e3007e8", - ), - ( - "cr3-canon-r6m2-craw.cr3", - "https://raw.pixls.us/getfile.php/6409/nice/Canon%20-%20EOS%20R6%20Mark%20II%20-%203:2.cr3", - "409eb724212abde94e18fbd3170abf3264951fab13384c36cbf6fb4f22ea4201", - ), - ( - "cr3-canon-r7.cr3", - "https://raw.pixls.us/getfile.php/5633/nice/Canon%20-%20EOS%20R7%20-%203:2.CR3", - "887439cb1a45becd6a5c85fe75ae10e6c520a9f13a8b9ed077c6cf5d7c37700c", - ), - ( - "cr3-canon-r-craw.cr3", - "https://raw.pixls.us/getfile.php/4543/nice/Canon%20-%20EOS%20R%20-%203:2.CR3", - "1b3efe0664a28a0decb53e225cbfecdbc0dd27d85abdc4683fc2a4add5129b4d", - ), - ( - "cr3-canon-g5xm2-craw.cr3", - "https://raw.pixls.us/getfile.php/3506/nice/Canon%20-%20PowerShot%20G5%20X%20Mark%20II%20-%20C-RAW%20%283:2%29.CR3", - "16d7b944344004c866bf2f84c5673969e76adfe16e5fb4388da48c72bdd58058", - ), - ( - "cr3-canon-g7xm3.cr3", - "https://raw.pixls.us/getfile.php/6053/nice/Canon%20-%20PowerShot%20G7%20X%20Mark%20III%20-%203:2.CR3", - "63ce623055dd2ac7374e0b1c645a4eabc7e181cd12c2039625f64b4e63a5ee86", - ), - ( - "cr3-canon-sx70.cr3", - "https://raw.pixls.us/getfile.php/3276/nice/Canon%20-%20PowerShot%20SX70%20HS%20-%20RAW%2C%204:3%20%284:3%29.CR3", - "3b346078836f933f505338ba45965be17a39cac82bfffc7006db07e3af944839", - ), - ( - "dng-adobe-canon-5d3-linear-lj92.dng", - "https://raw.pixls.us/getfile.php/1032/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossless%20JPEG%20compression%2C%20rgb%20%283:2%29.DNG", - "0608c9f277307b745530e61b589c465540611df22f9a1fe2d01237c5b2b4c13b", - ), - ( - "dng-adobe-canon-5d3-lj92.dng", - "https://raw.pixls.us/getfile.php/1024/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%2016bit%2016bit%20Lossless%20JPEG%20compression%20%283:2%29.DNG", - "3118116735d4f6dd01fd9d6a80ee9aac52a2c2debdc6dcd112bd5e3059044a55", - ), - ( - "dng-adobe-canon-5d3-lossy.dng", - "https://raw.pixls.us/getfile.php/1023/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossy%20JPEG%20compression%20%283:2%29.DNG", - "159326856c29073e845c3c5a9ecf98c6474f43ca15798a88ad5e2baecd0664b7", - ), - // Apple ProRAW (iPhone 12 Pro, iOS 14.3): LinearRaw LJ92 tiles, ProfileToneCurve, ProfileGainTableMap, a sky matte. - ( - "dng-apple-iphone12pro-proraw.dng", - "https://raw.pixls.us/data/Apple/iPhone%2012%20Pro/IMG_1361.DNG", - "e91e77a4533ed7cce551d83330676ea5c47dd5e55fb38adda7819366afdbdfc2", - ), - ( - "dng-canon-5d3-14bit-small.dng", - "https://raw.pixls.us/getfile.php/2204/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2014bit%2014bit%20%282.3471882640587%29.dng", - "1d77dcc6cdb839b10c1948f0b4471984ab48cf15e973fb77520584cee72b603e", - ), - ( - "dng-canon-5d3-16bit-169.dng", - "https://raw.pixls.us/getfile.php/2649/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2016bit%20%2816:9%29.dng", - "bc29ac8a37c800346749b9a5a1adc2722357f7f0e49df05a7104454881604b92", - ), - ( - "dng-canon-5d3-16bit.dng", - "https://raw.pixls.us/getfile.php/885/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2016bit%2016bit%20RAW.dng", - "6275226bfa2d86ba479130999d045ea0b8da6be4b011130c9eb91cd6a0eb2233", - ), - ( - "dng-google-pixel2xl.dng", - "https://raw.pixls.us/getfile.php/2206/nice/Google%20-%20Pixel%202%20XL%20-%2016bit%20%284:3%29.dng", - "5541093a369f0487fef92bcd1f5fef1be6e4e3cec2aa4d52ad38d2211f3309d7", - ), - ( - "dng-ricoh-gr3.dng", - "https://raw.pixls.us/getfile.php/3115/nice/Ricoh%20-%20GR%20III%20-%2014bit%20%283:2%29.DNG", - "05513ee72f7cac4165534c9f64995412084f414a033c80c473c79fd94154292e", - ), - ( - "nef-nikon-d5100-lossless.nef", - "https://raw.pixls.us/getfile.php/1597/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.nef", - "3ce328830942381648be26b372a54b317fdb27b63c1aa9c2ee1fce076a2e1b4f", - ), - ( - "nef-nikon-d5100-uncompressed.nef", - "https://raw.pixls.us/getfile.php/1598/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20uncompressed%20%283:2%29.nef", - "2fb2f1b87a3b89de5ba9e56527f21a912ca624f2fa2a594cef108ee8d7180412", - ), - ( - "nef-nikon-d7000-lossy12.nef", - "https://raw.pixls.us/getfile.php/961/nice/Nikon%20-%20D7000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "986db2bf9ed08cf095bb94cfbaf02f0e89d123edad28b63c05133e7815749486", - ), - ( - "nef-nikon-d7500-lossless12.nef", - "https://raw.pixls.us/getfile.php/1532/nice/Nikon%20-%20D7500%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "1f4e58a961d3d317fa39a7e1c8948b825651e968c8cd06e49e4239f612faccad", - ), - ( - "nef-nikon-d7500-lossless14.nef", - "https://raw.pixls.us/getfile.php/1534/nice/Nikon%20-%20D7500%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "430b4f1be4e53a011861b63294ad19fdc0353ec3ea62104f77d4c193e3dd3fc8", - ), - ( - "nef-nikon-zf-he.nef", - "https://raw.pixls.us/download/data/Nikon/Z%20f/DSC_0043.NEF", - "98d6ca8e6c98048ca7ffed68ccaeda7b2b9f03807f0d320d97d5678db21748c2", - ), - ( - "nef-nikon-zf-lossless.nef", - "https://raw.pixls.us/download/data/Nikon/Z%20f/DSC_0040.NEF", - "83c82be0be8865d796096dfbcc8ef2abf5af1bd37db44dfad6715070b0c99d15", - ), - ( - // Z 6II, NIKKOR Z 24-200 at 70 mm, Vivid picture control (not used to fit the bundled Z 6II profile) - "nef-nikon-z6ii-lossless.nef", - "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%20compressed%20(Lossless)%20(3:2).NEF", - "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", - ), - ( - // Z 8, NIKKOR Z 14-24 f/2.8 S (not used to fit the bundled Z 8 profile) - "nef-nikon-z8-lossless.nef", - "https://raw.pixls.us/data/Nikon/Z%208/Nikon_Z8_raw_14_bit_lossless_compression.NEF", - "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", - ), - ( - "nrw-nikon-b700-uncompressed.nrw", - "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", - "a175d5892304e79282add07eacf4bdc498ca3f050af48cf989c080355b4735ec", - ), - ( - "orf-olympus-e1.orf", - "https://raw.pixls.us/getfile.php/1800/nice/Olympus%20-%20E-1%20-%2016bit%20%284:3%29.ORF", - "042286653fbae5b085bef4a4e626145385ea6e82e817f166b4904abcef64457c", - ), - ( - "orf-olympus-e400.orf", - "https://raw.pixls.us/getfile.php/2151/nice/Olympus%20-%20E-400%20-%2016bit%20%284:3%29.ORF", - "1354e0ac98ea90820227650031d8215637a2aaabd15de995c5a1e00b1986261b", - ), - ( - "orf-olympus-em1.orf", - "https://raw.pixls.us/getfile.php/1051/nice/Olympus%20-%20E-M1%20-%2016bit%20%284:3%29.orf", - "19ba17e778ae802d4039f0e88bb1cd68825780788b8058c479067e32b9a63eda", - ), - ( - "orf-olympus-em10iii.orf", - "https://raw.pixls.us/getfile.php/1787/nice/Olympus%20-%20E-M10%20Mark%20III%20-%2016bit%20%284:3%29.ORF", - "11d90cbb564ad2c660c7a8d5d81d99c2c018aa27fdec45ed6c461fa5d24f942b", - ), - ( - "orf-olympus-xz2.orf", - "https://raw.pixls.us/getfile.php/1432/nice/Olympus%20-%20XZ-2%20-%2012bit%20%284:3%29.orf", - "675fefe7c9e99281783b987889d723b5f46692f5c20e19fed5e422c7c6d2636b", - ), - ( - "pef-pentax-k10d.pef", - "https://raw.pixls.us/getfile.php/2239/nice/Pentax%20-%20K10D%20-%2012bit%2012bit%20compressed%20%283:2%29.PEF", - "e35ae4154a468be3154f5f462e884ba5941f010d3e8f23d347fbec14809f44d3", - ), - ( - "pef-pentax-k3.pef", - "https://raw.pixls.us/getfile.php/1075/nice/Pentax%20-%20K-3%20-%2014bit%20%283:2%29.PEF", - "abfb3907b53734b5f353e8de0c6173470c7aad07f9cecc10f723d222a49dec9a", - ), - ( - "pef-pentax-k5iis.pef", - "https://raw.pixls.us/getfile.php/1198/nice/Pentax%20-%20K-5%20II%20s%20-%2014bit%20%283:2%29.PEF", - "e579e7360c35e512f9c3a5c0eb9519575b87b489c6f86be82590f7706a7ed17b", - ), - // Samsung SRW, one file per packing: 12-bit MSB-first (NX10), 12-bit LSB-first (NX20), 16-bit words (EX1). - ( - "srw-samsung-nx10.srw", - "https://raw.pixls.us/getfile.php/5913/nice/Samsung%20-%20NX10%20-%2012bit%20%283:2%29.SRW", - "dead7b4ad83b739ff7b8a1218882589f66cb14efc09f79eeba7a1dcbf7abd29f", - ), - ( - "srw-samsung-nx20.srw", - "https://raw.pixls.us/getfile.php/5370/nice/Samsung%20-%20NX20%20-%2012bit%20%283:2%29.SRW", - "8a367db506b8f7a6661a6f33a730467cfb3bfd7e46020a9f0df702330a7b45bb", - ), - ( - "srw-samsung-ex1.srw", - "https://raw.pixls.us/getfile.php/1204/nice/Samsung%20-%20EX1%20-%2014bit%20%284:3%29.SRW", - "a084890fd9d7995ceb746c18ecf5be48c95baf14286d5c0c480cc3daa25f3e37", - ), - ( - "raf-fuji-xa2-12bit-bayer.raf", - "https://raw.pixls.us/getfile.php/2883/nice/Fujifilm%20-%20X-A2%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", - "58ad687b23138c3c49ebeb6c1f80daf13185349be364b947e2bf27e39a462d2f", - ), - ( - "raf-fuji-xa5-14bit-bayer.raf", - "https://raw.pixls.us/getfile.php/2526/nice/Fujifilm%20-%20X-A5%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "46daaf8c2b07f40bd01f737181d048a497bb0a87615a1fde65809fdd5de86bf1", - ), - ( - "raf-fuji-xe1-12bit.raf", - "https://raw.pixls.us/getfile.php/3098/nice/Fujifilm%20-%20X-E1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", - "0fa01fa1a674bcfa40f659225b4e66c5bedf15d2f78407ca1966dc8f38cd0e9f", - ), - ( - "raf-fuji-xt20-14bit.raf", - "https://raw.pixls.us/getfile.php/1177/nice/Fujifilm%20-%20X-T20%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "afe552f4a2794c7aa2376dd826165354b243dcf6f2296b01505dded8c8e4be76", - ), - ( - "raf-fuji-xt20-compressed.raf", - "https://raw.pixls.us/getfile.php/1178/nice/Fujifilm%20-%20X-T20%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "a23045101b2912e64f154100d646c228c0d16585d21e95e0d1a006e537f7784c", - ), - ( - "raw-panasonic-fz50.raw", - "https://raw.pixls.us/getfile.php/2234/nice/Panasonic%20-%20DMC-FZ50%20-%204:3.RAW", - "a776a941758214bf4d1342e2b89ed756bfcad5877e038cf9df3bdda502c432ea", - ), - ( - "raw-panasonic-fz8.raw", - "https://raw.pixls.us/getfile.php/2282/nice/Panasonic%20-%20DMC-FZ8%20-%204:3.RAW", - "a11f1534f3092a4d5f2b57d182f6b06f8d26cf766eb463c3e02b26a338b63b8b", - ), - ( - "rw2-panasonic-fz1000m2-4x3.rw2", - "https://raw.pixls.us/getfile.php/4706/nice/Panasonic%20-%20DC-FZ10002%20-%204:3.RW2", - "ab30922774bd829a992471d89eeefe1e829565a886d1477958130a2fa4fb77a1", - ), - // Fujifilm predictive compression: older X-Trans, 40 MP X-Trans, 14/16-bit GFX; both compression modes. - ( - "raf-fuji-gfx100-3773.raf", - "https://raw.pixls.us/getfile.php/3773/nice/Fujifilm%20-%20GFX%20100%20-%2016bit%2016bit%20compressed%20%284:3%29.RAF", - "018781e126842596fcf3876b3fb6cd8973dc3936ccc753ee8648618be8889287", - ), - ( - "raf-fuji-gfx100-3775.raf", - "https://raw.pixls.us/getfile.php/3775/nice/Fujifilm%20-%20GFX%20100%20-%2014bit%2014bit%20compressed%20%284:3%29.RAF", - "0e7dad9819a748bb3d3be26bbf5770d4968e2d80b9cb41d97036bf7c27f7bfe7", - ), - ( - "raf-fuji-gfx100rf-8091.raf", - "https://raw.pixls.us/getfile.php/8091/nice/Fujifilm%20-%20GFX100RF%20-%2016bit%20compressed%20%284:3%29.RAF", - "14bed5ac8191d6f2a7c82984e3bf6470ad4740df329293eac42b8018cc6a668a", - ), - ( - "raf-fuji-gfx100s-4495.raf", - "https://raw.pixls.us/getfile.php/4495/nice/Fujifilm%20-%20GFX100S%20-%2016bit%2016bit%20lossless%20compressed%20%284:3%29.RAF", - "a7bf8f061b603f7ef5fd29276eb6b2ac57355ca2f589a340117bfff0d03c065c", - ), - ( - "raf-fuji-gfx100s-4503.raf", - "https://raw.pixls.us/getfile.php/4503/nice/Fujifilm%20-%20GFX100S%20-%2016bit%2016bit%20compressed%20%284:3%29.RAF", - "1bb4608b61861a3d9e85b0ca6c00689b8ba4620cbe8579374d958c21ed08edf0", - ), - ( - "raf-fuji-gfx50s-1435.raf", - "https://raw.pixls.us/getfile.php/1435/nice/Fujifilm%20-%20GFX%2050S%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "a7124d13910e0241a6b035154bc1fcdb7be4439371d043b1e1950a0251feb51e", - ), - ( - "raf-fuji-xe5-8509.raf", - "https://raw.pixls.us/getfile.php/8509/nice/Fujifilm%20-%20X-E5%20-%2014bit%20lossy%20compressed%20%283:2%29.RAF", - "28f56cc9784164209e92fae209bdcc78267bd692aa2fb964595485f7e93692ab", - ), - ( - "raf-fuji-xh2-6001.raf", - "https://raw.pixls.us/getfile.php/6001/nice/Fujifilm%20-%20X-H2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "caeadb2666f6c5d9bbe32fbe692c2079b6a7e028f8514a13173f360656d08507", - ), - ( - "raf-fuji-xh2-6002.raf", - "https://raw.pixls.us/getfile.php/6002/nice/Fujifilm%20-%20X-H2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "109be38f59963c9b8ff289ebe1f5ec9168c52f609e5b601432bd194520b4605b", - ), - ( - "raf-fuji-xm5-7748.raf", - "https://raw.pixls.us/getfile.php/7748/nice/Fujifilm%20-%20X-M5%20-%2014bit%20compressed%20%283:2%29.RAF", - "c76ce546e15727abb4ba201471010c554403176963a1a6b8cc017462606429e5", - ), - ( - "raf-fuji-xt2-865.raf", - "https://raw.pixls.us/getfile.php/865/nice/Fujifilm%20-%20X-T2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "f756ad5be551a8b3e1b503a8a6f7e8e637fba519c0b0f8c79dd58e94b0287602", - ), - ( - "raf-fuji-xt4-3914.raf", - "https://raw.pixls.us/getfile.php/3914/nice/Fujifilm%20-%20X-T4%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.RAF", - "07ea642656074834ffe5c661870a612ffa2e80fc1218a05166ee966bbe5f6fae", - ), - ( - "raf-fuji-xt4-3918.raf", - "https://raw.pixls.us/getfile.php/3918/nice/Fujifilm%20-%20X-T4%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "871836938ffa695f4c41765ddb107c2703ce755f7f3ced7e10f9e04746f1734e", - ), - ( - "raf-fuji-xt5-6122.raf", - "https://raw.pixls.us/getfile.php/6122/nice/Fujifilm%20-%20X-T5%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "b2a067e7fda16338f15c2532caa12b238b2415dc1c75a96a6d24d3445cfd0ae3", - ), - ( - "raf-fuji-xt5-6123.raf", - "https://raw.pixls.us/getfile.php/6123/nice/Fujifilm%20-%20X-T5%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "cddd7ae0c43f9280876e5fdcbdf8878718972ebd3d034d8affc8cfc6752d33dc", - ), - ( - "raf-fuji-xt50-7807.raf", - "https://raw.pixls.us/getfile.php/7807/nice/Fujifilm%20-%20X-T50%20-%2014bit%20compressed%20%283:2%29.RAF", - "3a8a9ac6d92274969aaa1f55ea669191b98bb8f588d00ec8ec17afd8b3963dea", - ), - // Sony ILCE-7RM6 ARW6, CC0 1.0 (RawDB): one full-frame Compressed (RAW-C) and one APS-C Compressed HQ (RAW-C-HD) file; - // docs/arw6-compression.md lists the rest of the set. - ( - "arw6-sony-ilce7rm6-apsc-land-hq.arw", - "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/arw6/ILCE-7RM6_APSC_land_RAW-C-HD.ARW", - "b60a7e752d2a8102ccbe6eebcfdbf533c6b0f2545739090873e9e8eb32cc0637", - ), - ( - "arw6-sony-ilce7rm6-ff-land-c.arw", - "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/arw6/ILCE-7RM6_FF_land_RAW-C.ARW", - "d900d6cbe838be864269ad54de4a33fcac7eb3a3d4f91009739f770b8f85a23b", - ), - ( - "rw2-panasonic-g9-b.rw2", - "https://raw.pixls.us/getfile.php/2348/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2", - "16282357ce0143ff7d36737f82074662db7aa0790ecd309196f406baf0f586a7", - ), - ( - "rw2-panasonic-g9.rw2", - "https://raw.pixls.us/getfile.php/2585/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2", - "7a56babeb19f9bde3eaa532a4c8d1b11637a0f550963a1182ec845722c65583d", - ), - ( - "rw2-panasonic-gh1.rw2", - "https://raw.pixls.us/getfile.php/1323/nice/Panasonic%20-%20DMC-GH1%20-%204:3.RW2", - "2b846f932c665f196af9af97b18469730331b60b2c62811969be7db245ce6cce", - ), - ( - "rw2-panasonic-gh5.rw2", - "https://raw.pixls.us/getfile.php/1517/nice/Panasonic%20-%20DC-GH5%20-%204:3.RW2", - "dae2281df393d8c3c1b8c513d00985f1834ae0e69ce18fbd571370c25d95d22b", - ), - ( - "rw2-panasonic-gh5m2.rw2", - "https://raw.pixls.us/getfile.php/5082/nice/Panasonic%20-%20DC-GH5M2%20-%204:3.RW2", - "0ea0efd2a9442d8c68508e4196e29f1da4577407dc3df5add4cf360f70493c7b", - ), - ( - "rw2-panasonic-gh5s.rw2", - "https://raw.pixls.us/getfile.php/2603/nice/Panasonic%20-%20DC-GH5S%20-%204:3.RW2", - "b617151876e62b492641cef14b8c98ee3797e5014d24c6af1c780715a501fa53", - ), - ( - "rw2-panasonic-gh6.rw2", - "https://raw.pixls.us/getfile.php/5876/nice/Panasonic%20-%20DC-GH6%20-%204:3.RW2", - "07996bca904a178d02c5f56ee03a8dce775a0e1150c1beeaec51d72ff5e700d0", - ), - ( - "rw2-panasonic-gx80.rw2", - "https://raw.pixls.us/getfile.php/1569/nice/Panasonic%20-%20DMC-GX80%20-%204:3.RW2", - "6e9a419c70c2124630912b2934027c635d3871e3d22dd91fed0655d58cfb5382", - ), - ( - "rw2-panasonic-s1.rw2", - "https://raw.pixls.us/getfile.php/3038/nice/Panasonic%20-%20DC-S1%20-%203:2.RW2", - "45309a096d6010281e2fa310e6ec2fe1bcc02947fa37b1a2414cfc7377ce4ac6", - ), - ( - "rw2-panasonic-s5-format7.rw2", - "https://raw.pixls.us/getfile.php/6339/nice/Panasonic%20-%20DC-S5%20-%203:2.RW2", - "3615c85207097180b231f0c89d018eb3f1a71988780612921eb0a05246bca706", - ), - ( - "rw2-panasonic-s5m2.rw2", - "https://raw.pixls.us/getfile.php/7790/nice/Panasonic%20-%20DC-S5M2%20-%2014bit%20%283:2%29.RW2", - "53a171521a7f9af6910b4ab61c5a4a42de0cceb24166e15b07eee1e00675028f", - ), - ( - "rw2-panasonic-s9.rw2", - "https://raw.pixls.us/getfile.php/7702/nice/Panasonic%20-%20DC-S9%20-%203:2.RW2", - "290ad5b4e396cb241a59459b2613d2b0659a03ff654e5d5368b3376d35812e50", - ), - ( - "rwl-leica-dlux7.rwl", - "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL", - "4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c", - ), - ( - "mrw-minolta-dimage7hi-words16.mrw", - "https://raw.pixls.us/data/Minolta/DiMAGE%207Hi/RAW_MINOLTA_DIMAGE_7HI.MRW", - "2919a05757197a5424e02970c016893bca5aa29363a67b62b1a4b36326a10f6a", - ), - ( - "mrw-minolta-dimage-a1.mrw", - "https://raw.pixls.us/data/Minolta/DiMAGE%20A1/RAW_MINOLTA_A1.MRW", - "bde1e2cd788e49c9bef20dfdd41be948fb02c2ee198c6b0959a67a3f79fd41c9", - ), - ( - "mrw-minolta-dimage-a200-gbrg.mrw", - "https://raw.pixls.us/data/Minolta/DiMAGE%20A200/RAW_MINOLTA_DIMAGE_A200.MRW", - "2a95f6af2168e536dfb6d02b47e3b6119b35b497eebf179d7c20a6e496c26641", - ), - ( - "mrw-minolta-dynax7d.mrw", - "https://raw.pixls.us/data/Minolta/Dynax%207D/PICT3410.MRW", - "713819e33e5302ec864d576d57223cfc8dcf46e8d45e0ae744d1fec40290a89f", - ), - ( - "nef-nikon-d700.nef", - "https://raw.pixls.us/getfile.php/2249/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "00c3314f2ccf92aff17644943e6149e8793b517c24bb2c38a15ec41f3af109cb", - ), - ( - "raf-fujifilm-xf10.raf", - "https://raw.pixls.us/getfile.php/3124/nice/Fujifilm%20-%20XF10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "0486676fdbbdc7f3f4c63cd45aa76581cbc1316f4c9baef8781bced0fbc137db", - ), - ( - "raf-fujifilm-x-a10.raf", - "https://raw.pixls.us/getfile.php/3570/nice/Fujifilm%20-%20X-A10%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", - "05309c5434500cc53d5782072cc0dcdd947d2f0a34f0ddcd4656c82cc7803272", - ), - ( - "nef-nikon-d5300.nef", - "https://raw.pixls.us/getfile.php/8365/nice/Nikon%20-%20D5300%20-%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "0ae88fc81f082929d76dd1b932aacf7815cd6bc670b96ef55a81928e11084a6c", - ), - ( - "raf-fujifilm-x-t100.raf", - "https://raw.pixls.us/getfile.php/2676/nice/Fujifilm%20-%20X-T100%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "0af086b9569b59317da42b8e136013245486073b6b8d409fc997a71788538c4d", - ), - ( - "raf-fujifilm-x-pro1.raf", - "https://raw.pixls.us/getfile.php/1191/nice/Fujifilm%20-%20X-Pro1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", - "0b1046c299d9eb222a8f003bd047f2a34f507da0d1704ac32cefbc82f799d121", - ), - ( - "nef-nikon-1-v1.nef", - "https://raw.pixls.us/getfile.php/2210/nice/Nikon%20-%201%20V1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "0dc76bc274f9cb776eb930683a4e53a3ebede1719abafd1cba4b0c800f13f7e4", - ), - ( - "nef-nikon-1-j4.nef", - "https://raw.pixls.us/getfile.php/5922/nice/Nikon%20-%201%20J4%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "0e2887892b8cfc80891b2c510721088fcac6d77eff9ba68089f382a0115b274c", - ), - ( - "raf-fujifilm-gfx100s-ii.raf", - "https://raw.pixls.us/getfile.php/7772/nice/Fujifilm%20-%20GFX100S%20II%20-%2016bit%20uncompressed%20%284:3%29.RAF", - "10e007f3d1aef2ac9bf4da063ea1983e44515af818bf0ab63e3703cd67803810", - ), - ( - "nef-nikon-d610.nef", - "https://raw.pixls.us/getfile.php/888/nice/Nikon%20-%20D610%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "11f0a71bb4141a8ed44c7e1c64f9a12571f5f67a11571f2bde8516aa95a7bea3", - ), - ( - "nef-nikon-d5.nef", - "https://raw.pixls.us/getfile.php/1636/nice/Nikon%20-%20D5%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", - "13191560a6a9d9ed4156ec5d20863277c9618916aaba934758c5ae2be6654f6a", - ), - ( - "nef-nikon-d2h.nef", - "https://raw.pixls.us/getfile.php/5227/nice/Nikon%20-%20D2H%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", - "155edb938f884ea7372ce98d4ff5f965c3e413b43b95bc9923da6e92082cf914", - ), - ( - "nef-nikon-z-6-2.nef", - "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", - ), - ( - "nrw-nikon-coolpix-p1000.nrw", - "https://raw.pixls.us/getfile.php/3381/nice/Nikon%20-%20COOLPIX%20P1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", - "1d9470ee083f914e51aa0be782161b432f27f87dbef551a59ec435f5dbc289dd", - ), - ( - "raf-fujifilm-finepix-x100.raf", - "https://raw.pixls.us/getfile.php/2392/nice/Fujifilm%20-%20FinePix%20X100%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", - "1f4aea4fa43dd314f8b90954d7823655d4906d3e468a9ea014652b872a68db16", - ), - ( - "raf-fujifilm-x30.raf", - "https://raw.pixls.us/getfile.php/1144/nice/Fujifilm%20-%20X30%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "1f65f680600c85a6f561d2b0ad7cd1f9d93ee7f733aafe4ecd37667d984dd6c4", - ), - ( - "raf-fujifilm-finepix-x10.raf", - "https://raw.pixls.us/getfile.php/1344/nice/Fujifilm%20-%20FinePix%20X10%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "206448688c49b0a9da74dd94f12aba143c2d8842cb5bbf64d4ecdc501874451b", - ), - ( - "nef-nikon-d2xs.nef", - "https://raw.pixls.us/getfile.php/2871/nice/Nikon%20-%20D2Xs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", - "2204bc2e2178bf594e17886ca92a50a94350403a110846ca6e83bdc5f3352706", - ), - ( - "nef-nikon-d4.nef", - "https://raw.pixls.us/getfile.php/1974/nice/Nikon%20-%20D4%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", - "23aa2dd3c409b3cbbfeea3e3f7b6752180efa15430d6e059e87404e969875cd7", - ), - ( - "nef-nikon-d800.nef", - "https://raw.pixls.us/getfile.php/750/nice/Nikon%20-%20D800%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "26d58c21ed3019af5a22ef6bc02f017b863aad9eafcf84252ed375cd495c0efb", - ), - ( - "nef-nikon-d780.nef", - "https://raw.pixls.us/getfile.php/3825/nice/Nikon%20-%20D780%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "3178d0656e3b5fbe90176e38a1cd608d0b69292299445932dddc9a0b2d196b36", - ), - ( - "nef-nikon-d850.nef", - "https://raw.pixls.us/getfile.php/1835/nice/Nikon%20-%20D850%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "31b7fe298a5c91694c6ca8b3284a0a016e572a02c3964b502d4732e8e9283da8", - ), - ( - "raf-fujifilm-x-e2s.raf", - "https://raw.pixls.us/getfile.php/745/nice/Fujifilm%20-%20X-E2S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "330fc84b597e7be6d7477aad5715705efaa99d4663eb05aa47e1250d6f421549", - ), - ( - "nef-nikon-d500.nef", - "https://raw.pixls.us/getfile.php/2504/nice/Nikon%20-%20D500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "33c96a2ba7167f780928767c0545719737d601883e5f98e5edba4f71bdc99d83", - ), - ( - "raf-fujifilm-gfx-50r.raf", - "https://raw.pixls.us/getfile.php/2979/nice/Fujifilm%20-%20GFX%2050R%20-%2014bit%2014bit%20compressed%20%283:2%29.raf", - "35109fc20a849026c43c47674902431ef5a2d54b09b616995263dc75df17912f", - ), - ( - "nef-nikon-df.nef", - "https://raw.pixls.us/getfile.php/1385/nice/Nikon%20-%20Df%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", - "38ea7aba6b7e0cdbd338fe6703d3c98291f4a9a28b0d1a81bfb1f77b21712c63", - ), - ( - "raf-fujifilm-x-s10.raf", - "https://raw.pixls.us/getfile.php/4188/nice/Fujifilm%20-%20X-S10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "3d080f9ce8ac65c4beab1e9c0f49f7c72cdec71f31fe9e27dd1d88e551a1c262", - ), - ( - "nef-nikon-1-v2.nef", - "https://raw.pixls.us/getfile.php/1693/nice/Nikon%20-%201%20V2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "3d29dfa6397b0aa4e72969bb5b85d040e8bebf2e02f61f6364f580ce39f30bf2", - ), - ( - "nef-nikon-1-v3.nef", - "https://raw.pixls.us/getfile.php/2802/nice/Nikon%20-%201%20V3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "3f5376481f9ae59e9712b537dcc4eaffa72e311a8768c5396414506ae2eb1d69", - ), - ( - "nef-nikon-d3s.nef", - "https://raw.pixls.us/getfile.php/5203/nice/Nikon%20-%20D3S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "402898e57007a4176b8686019ef2878c28481f7520c6136127f00148de9960b5", - ), - ( - "nef-nikon-z-fc.nef", - "https://raw.pixls.us/getfile.php/4812/nice/Nikon%20-%20Z%20fc%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.nef", - "4099ecc46ebaca26e2a66a96a8313fed5e8c707b3836ccf0e77a134844b041c1", - ), - ( - "nef-nikon-d810.nef", - "https://raw.pixls.us/getfile.php/2029/nice/Nikon%20-%20D810%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "40a9df99865ad25a68b1131014cf954fb72da8bc32991b6a5e19d14937e21bc9", - ), - ( - "raf-fujifilm-x-a3.raf", - "https://raw.pixls.us/getfile.php/1371/nice/Fujifilm%20-%20X-A3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "4187cb5caa72320a74e9b4355db01e8e21edd43f3084873f9b9b67169d8d4ad8", - ), - ( - "nef-nikon-d40.nef", - "https://raw.pixls.us/getfile.php/3618/nice/Nikon%20-%20D40%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", - "44e88bc77b7a531b22647bcd07b9393c4568062e8f0906d3bbdecb42fbe29e03", - ), - ( - "nef-nikon-d800e.nef", - "https://raw.pixls.us/getfile.php/1303/nice/Nikon%20-%20D800E%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", - "4559b9614ab6d558192d9d30e1db4a4ec44752553e45b2bd26c1569c1820441a", - ), - ( - "nef-nikon-d3x.nef", - "https://raw.pixls.us/getfile.php/3708/nice/Nikon%20-%20D3X%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", - "48dc0339ef86541aabcc6bb68a6452f603e23a7d7a5597dad0d267f69e3e79d4", - ), - ( - "raf-fujifilm-x100v.raf", - "https://raw.pixls.us/getfile.php/3812/nice/Fujifilm%20-%20X100V%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "48f2055ab497c687347eee84103d4f48deb016fff711d2810eaf1317e506f621", - ), - ( - "nef-nikon-d4s.nef", - "https://raw.pixls.us/getfile.php/2220/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "49067c81374549465f3c621acd249bf05dbdef4e44b9201fef1fb11c93572983", - ), - ( - "raf-fujifilm-x-t30-iii.raf", - "https://raw.pixls.us/getfile.php/8769/nice/Fujifilm%20-%20X-T30%20III%20-%2014bit%20compressed%20%283:2%29.RAF", - "49f77d6162abfa5c94d2d8b90e4e926b7386c42bcf7e84a152c9ffe1ebd584da", - ), - ( - "raf-fujifilm-xq2.raf", - "https://raw.pixls.us/getfile.php/1844/nice/Fujifilm%20-%20XQ2%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "4bce88593f5ae8fc45aeeec8a6dee9a84c773493cc6db9dabdc28744c8ac4b4d", - ), - ( - "raf-fujifilm-x-t10.raf", - "https://raw.pixls.us/getfile.php/1192/nice/Fujifilm%20-%20X-T10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "4c09401975cc6f95b1284693aac39fef85498f70b55e35bf458a133c6e61885d", - ), - ( - "nef-nikon-d2x.nef", - "https://raw.pixls.us/getfile.php/9064/nice/Nikon%20-%20D2X%20-%2012bit%20uncompressed%20%283:2%29.NEF", - "4d4b89f85bdfef233ef9b8aa7ecfeb609874ab592cb44e777f5b36814df5883c", - ), - ( - "raf-fujifilm-x70.raf", - "https://raw.pixls.us/getfile.php/1301/nice/Fujifilm%20-%20X70%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "4f9c33fde578addebda991c37d26551b46fab8f592c74f7192560a05ec08d577", - ), - ( - "dng-nikon-d80.dng", - "https://raw.pixls.us/getfile.php/1988/nice/Nikon%20-%20D80%20-%2016bit%20%283:2%29.dng", - "5095ca264e763624cd3aa62940286dd1bcff4504eefa1ce61cbb72f50f8426bb", - ), - ( - "nef-nikon-1-aw1.nef", - "https://raw.pixls.us/getfile.php/1171/nice/Nikon%20-%201%20AW1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "519d354ec40907f1cd30329fc1a641ddec5b46e08e88883f3cf2fddc4ddd9974", - ), - ( - "raf-fujifilm-x-h1.raf", - "https://raw.pixls.us/getfile.php/2537/nice/Fujifilm%20-%20X-H1%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "5210bf896d2757cecda88bab0a87ef0b534d4b36c31dc08d5db7cd8f5bbf9b4e", - ), - ( - "nef-nikon-1-j5.nef", - "https://raw.pixls.us/getfile.php/1342/nice/Nikon%20-%201%20J5%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", - "56efc4f426fa489383a967102c6d30fa9d6b198ca0484c4725c91f0b0d47c063", - ), - ( - "nef-nikon-1-j1.nef", - "https://raw.pixls.us/getfile.php/1017/nice/Nikon%20-%201%20J1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "5965935729931b72ca8398041f8fc15cb0a226004db941d1f0626ccc2c5bb5f3", - ), - ( - "nef-nikon-d7200.nef", - "https://raw.pixls.us/getfile.php/832/nice/Nikon%20-%20D7200%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "5dea8ff6f95f372e4eb0852f75e728f182d7aa4057b2b76631f0fd3066c6a4b5", - ), - ( - "raf-fujifilm-x-t30-ii.raf", - "https://raw.pixls.us/getfile.php/5064/nice/Fujifilm%20-%20X-T30%20II%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "5e2678038377cc7f146ceb7693c08535fa1af162cec705d06b0446c3d3017d47", - ), - ( - "nef-nikon-z-50.nef", - "https://raw.pixls.us/getfile.php/3647/nice/Nikon%20-%20Z%2050%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", - "5e93735923229117abee2779d81f114f180f361e8ae553c9dd9bdaaedb0d9e02", - ), - ( - "nef-nikon-d5000.nef", - "https://raw.pixls.us/getfile.php/1341/nice/Nikon%20-%20D5000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "5f8e8005be87366bbf7746d35c3c88f82a12a1dd18206c939d8f1254c5922360", - ), - ( - "raf-fujifilm-xq1.raf", - "https://raw.pixls.us/getfile.php/1741/nice/Fujifilm%20-%20XQ1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "663c0bfcb8819c3931444acd9d1491de4d0c0aa1fd745b99e1faa68cc8de033b", - ), - ( - "raf-fujifilm-finepix-hs30exr.raf", - "https://raw.pixls.us/getfile.php/2613/nice/Fujifilm%20-%20FinePix%20HS30EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "6b95bb5133d026dbbb7361985e81d9a2b93000e2aea88bcd3f120412d0061da7", - ), - ( - "nef-nikon-d70s.nef", - "https://raw.pixls.us/getfile.php/1117/nice/Nikon%20-%20D70s%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", - "6c46f9854033e7623ebbb5de8c951df6762ca44f05d45035ccc5cb7e9f4e5692", - ), - ( - "nef-nikon-d5200.nef", - "https://raw.pixls.us/getfile.php/1691/nice/Nikon%20-%20D5200%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "6c49ca47d189f9d6766f94be7c9d7d779ce45c79c0c6e48a0eba7d92f320fe0e", - ), - ( - "nrw-nikon-coolpix-p1100.nrw", - "https://raw.pixls.us/getfile.php/7689/nice/Nikon%20-%20Coolpix%20P1100%20-%2012bit%20uncompressed%20%284:3%29.NRW", - "706840f7a36c3852092d4aad0465e75920e04f1aa653adf31626c0c05ab41258", - ), - ( - "raf-fujifilm-x-s20.raf", - "https://raw.pixls.us/getfile.php/6666/nice/Fujifilm%20-%20X-S20%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "737c1551843282d4963ccb351a4d6409f3eaf172928d563bbaf72fe0f1f74fdd", - ), - ( - "nef-nikon-z-30.nef", - "https://raw.pixls.us/getfile.php/5812/nice/Nikon%20-%20Z%2030%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "75249c11a3a845367c7a322a1c4e7076f33c785ec2518fc6594c34c185b41161", - ), - ( - "raf-fujifilm-x-e2.raf", - "https://raw.pixls.us/getfile.php/937/nice/Fujifilm%20-%20X-E2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "7588c70af7fd6be3abcec52546aabcd04e0aafc6541e7f7f50bdc60b60513c57", - ), - ( - "nef-nikon-d60.nef", - "https://raw.pixls.us/getfile.php/3698/nice/Nikon%20-%20D60%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", - "780ce4867e997143acbd1734a889575a5eaeeb40c6afb33b6006c266831caaa6", - ), - ( - "nef-nikon-1-j2.nef", - "https://raw.pixls.us/getfile.php/923/nice/Nikon%20-%201%20J2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "81051ee36135883acea55bd1e609d6d31212c0bf03640b7ae9fe177a1ca51219", - ), - ( - "nef-nikon-d5600.nef", - "https://raw.pixls.us/getfile.php/1415/nice/Nikon%20-%20D5600%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "82e15f02c4037820e80f14bacfacdc52ea29721b3c70d2cf08b237b50ce8db05", - ), - ( - "nef-nikon-d3.nef", - "https://raw.pixls.us/getfile.php/3516/nice/Nikon%20-%20D3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "880c60c5f611adf6b70de3d099f8433492de3a3d96866d570122f17f0a651fc8", - ), - ( - "nef-nikon-d300.nef", - "https://raw.pixls.us/getfile.php/924/nice/Nikon%20-%20D300%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "8b989f53c96995f7014ddd16124530cf6d8f3341ea9570d6312d9da1eb0a117a", - ), - ( - "raf-fujifilm-finepix-sl1000.raf", - "https://raw.pixls.us/getfile.php/5760/nice/Fujifilm%20-%20FinePix%20SL1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "8bc9903cb1bff93376eb73b64f35861dcf3c66e2ca936cfac2d7c62435689dec", - ), - ( - "raf-fujifilm-x-pro3.raf", - "https://raw.pixls.us/getfile.php/3700/nice/Fujifilm%20-%20X-Pro3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "8c50e965d7627932414e8790a5ef1d673fcb44b548b64130df6b9be42c418554", - ), - ( - "raf-fujifilm-x-pro2.raf", - "https://raw.pixls.us/getfile.php/1680/nice/Fujifilm%20-%20X-Pro2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "8cc12566e5c0fc676b713bb1612ce0510d47b1dc691e9a5a0bcf3799ad9b8af1", - ), - ( - "nef-nikon-1-s1.nef", - "https://raw.pixls.us/getfile.php/5661/nice/Nikon%20-%201%20S1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "8e540d40b842f61de69161cb0b8be72b024629c752fd82b6f8d10e460bce88cb", - ), - ( - "raf-fujifilm-x100vi.raf", - "https://raw.pixls.us/getfile.php/7300/nice/Fujifilm%20-%20X100VI%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "8e5e4d8d2e0a865ca79b3d40734b6abc2f4b00281afea8b9bd3d8c29808f2fb3", - ), - ( - "raf-fujifilm-x-t200.raf", - "https://raw.pixls.us/getfile.php/3899/nice/Fujifilm%20-%20X-T200%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "8fc8dfa5b4741246287c430010d9699fe6f1e512ac03e14abea72e63cb022051", - ), - ( - "nef-nikon-d3200.nef", - "https://raw.pixls.us/getfile.php/743/nice/Nikon%20-%20D3200%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "91657dd8e9c7a086826683f6d20799685657dd7ca2056702b145851465f57cd3", - ), - ( - "raf-fujifilm-x-t3.raf", - "https://raw.pixls.us/getfile.php/2783/nice/Fujifilm%20-%20X-T3%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "95b33021160b239ceb1a09d46a1b29cb60cb7dd47581feb126b337c219091754", - ), - ( - "raf-fujifilm-finepix-f550exr.raf", - "https://raw.pixls.us/getfile.php/3869/nice/Fujifilm%20-%20FinePix%20F550EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "95b6c8a9d5d885f60bbc965ddfd899d325f2225c99ab087d02739543a3ff7dd6", - ), - ( - "nef-nikon-d5500.nef", - "https://raw.pixls.us/getfile.php/1469/nice/Nikon%20-%20D5500%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "96d5b85dae24e190691c504781d8e7ee58cb9b63ea715760de9af3d345dc4c13", - ), - ( - "raf-fujifilm-x-a7.raf", - "https://raw.pixls.us/getfile.php/3879/nice/Fujifilm%20-%20X-A7%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "97db251dd129663da00d33e8eb4d7932d222f58d99ff7311c09cc277de5b6f18", - ), - ( - "nef-nikon-d3300.nef", - "https://raw.pixls.us/getfile.php/1734/nice/Nikon%20-%20D3300%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "97dfc95aecc00383d6da6197cb759bacb1e43c86071b4ca2d94748e498620f2f", - ), - ( - "nef-nikon-z-5.nef", - "https://raw.pixls.us/getfile.php/4136/nice/Nikon%20-%20Z%205%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "9870248c45532080c2459f3b52cdcc4a07bc8846ad50cbb81c7c1c6dc5e36d91", - ), - ( - "nef-nikon-1-j3.nef", - "https://raw.pixls.us/getfile.php/1814/nice/Nikon%20-%201%20J3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "996d65705bf1ac9f83f055817ea92618ea44c822109196d7d97a803a0268b576", - ), - ( - "nef-nikon-z-9.nef", - "https://raw.pixls.us/getfile.php/5146/nice/Nikon%20-%20Z%209%20-%208bit%208bit%20compressed%20%283:2%29.NEF", - "99d0fe24a38f0f7623aeab21440d266a5ac09f9665cf5283f590337e84323c38", - ), - ( - "nrw-nikon-coolpix-a1000.nrw", - "https://raw.pixls.us/getfile.php/4337/nice/Nikon%20-%20Coolpix%20A1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", - "9b4edd03bbb17d66eacc2a72b30f044969e67666446f3f1ed25e88bb947d4f8a", - ), - ( - "raf-fujifilm-x100s.raf", - "https://raw.pixls.us/getfile.php/2115/nice/Fujifilm%20-%20X100S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "9cea5b0d4dd3682f8850dc34e93f0045e0e28fa69f9715576d40c5a8a59e8ae9", - ), - ( - "nef-nikon-d2hs.nef", - "https://raw.pixls.us/getfile.php/2852/nice/Nikon%20-%20D2Hs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", - "9ea1c3d5d6c8a11f33239079559f1f0c86bbdb9440a2c7d2578368647abf7ea4", - ), - ( - "nef-nikon-d90.nef", - "https://raw.pixls.us/getfile.php/1325/nice/Nikon%20-%20D90%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "9eb3daa4e3d1c1614bccc4c6d136c4166960f936f17c437cc4d11a5786b2df06", - ), - ( - "nef-nikon-d50.nef", - "https://raw.pixls.us/getfile.php/1282/nice/Nikon%20-%20D50%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", - "9f94c27672a63f946c92d43910ef0c266dffaa14dae0007ae00ac72d2c684935", - ), - ( - "nef-nikon-d3000.nef", - "https://raw.pixls.us/getfile.php/2161/nice/Nikon%20-%20D3000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", - "a07ee8a0b1ff7a1ad06085306ef8f9a197d95fec639994467317c56108eeb435", - ), - ( - "raf-fujifilm-x100t.raf", - "https://raw.pixls.us/getfile.php/2137/nice/Fujifilm%20-%20X100T%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "a12bb7fa3585b636b7fa487d767ba0ba1ae10fe6aaa74db4147dc0ec2b180a0a", - ), - ( - "nef-nikon-z50-2.nef", - "https://raw.pixls.us/getfile.php/7762/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%283:2%29.NEF", - "a3314a026524396e603b461afc4fe61a8a5a3958232f3687bb1c8eca0dcbed93", - ), - ( - "nef-nikon-d600.nef", - "https://raw.pixls.us/getfile.php/916/nice/Nikon%20-%20D600%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "a70c3e54501efa7e4391ec4cf5705912a10c356a029e984ee13be5f68bc7482d", - ), - ( - "raf-fujifilm-xf1.raf", - "https://raw.pixls.us/getfile.php/3209/nice/Fujifilm%20-%20XF1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "b17c389a59228881af8d50c22bdd8ce2295a54290d9a18447dd1e56d0743e913", - ), - ( - "nef-nikon-d7100.nef", - "https://raw.pixls.us/getfile.php/1855/nice/Nikon%20-%20D7100%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "b241cb95b1008f25b8ed7d1443354e8cc7dee9c4d4e1edd0a5856d92063a48d0", - ), - ( - "raf-fujifilm-x-e3.raf", - "https://raw.pixls.us/getfile.php/1864/nice/Fujifilm%20-%20X-E3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "b2aeb7fd72a9ea116b1dcc2c8a8cf5462b15757ba3aca33bad8ce36339a1642f", - ), - ( - "raf-fujifilm-x-a1.raf", - "https://raw.pixls.us/getfile.php/1214/nice/Fujifilm%20-%20X-A1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", - "b2ddb5fe31a3b976d12961c72a2689de8742739df3d8f757614eca65f8df2a61", - ), - ( - "nef-nikon-d3500.nef", - "https://raw.pixls.us/getfile.php/2902/nice/Nikon%20-%20D3500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "b53395f74674588a63f6b667325c5a2b400f26cba72ff402a65ddd02c5f841d5", - ), - ( - "raf-fujifilm-finepix-hs50exr.raf", - "https://raw.pixls.us/getfile.php/6018/nice/Fujifilm%20-%20FinePix%20HS50EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "b5c4f3658dc686e535eeaf639729f2cb6b54e60a3e1ab206eabba1024c4dce83", - ), - ( - "raf-fujifilm-gfx100-ii.raf", - "https://raw.pixls.us/getfile.php/7174/nice/Fujifilm%20-%20GFX100%20II%20-%2014bit%2014bit%20uncompressed%20%284:3%29.RAF", - "b7b329bcbfcc07c5523a2b172a5173f4de4b841c8315078a1b69150ddaa16f30", - ), - ( - "raf-fujifilm-x100f.raf", - "https://raw.pixls.us/getfile.php/1936/nice/Fujifilm%20-%20X100F%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", - "bca8ad027b0ee0038107c01792ccb5c6b776512b77b584c6f93c6f2ced5f15a3", - ), - ( - "nef-nikon-d40x.nef", - "https://raw.pixls.us/getfile.php/804/nice/Nikon%20-%20D40X%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", - "c3e4956f7e2d1f1b3f846b6ec9552678073db0bcaeb51132ec4c60bd2229d56e", - ), - ( - "nef-nikon-coolpix-a.nef", - "https://raw.pixls.us/getfile.php/2238/nice/Nikon%20-%20Coolpix%20A%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "c5ca0333a7cda8a2fc33fb2157d18dfa89f1844548dc49c2fd2e67f502f72e25", - ), - ( - "raf-fujifilm-x20.raf", - "https://raw.pixls.us/getfile.php/1549/nice/Fujifilm%20-%20X20%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "ccb4fad836f1561b52859c3ee01db0fef7db770980d4a84e95595da1eda72b2a", - ), - ( - "raf-fujifilm-x-e4.raf", - "https://raw.pixls.us/getfile.php/4446/nice/Fujifilm%20-%20X-E4%20-%2014bit%2014bit%20compressed%20lossless%20%283:2%29.RAF", - "ce4215079e4c6c8a86d493e15f6e574e4aa7684b346ef3439026c835ed2c8948", - ), - ( - "raf-fujifilm-gfx50s-ii.raf", - "https://raw.pixls.us/getfile.php/5002/nice/Fujifilm%20-%20GFX50S%20II%20-%2014bit%2014bit%20uncompressed%20%284:3%29.RAF", - "cf53a9018d5a77cd854ea0a91afee2a35ac8fab8f814020d79f728f99ba99892", - ), - ( - "nef-nikon-d1h.nef", - "https://raw.pixls.us/getfile.php/5364/nice/Nikon%20-%20D1H%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", - "d110b80a8b10a3a67b5cd1c15ef33f1a6397557b73d5d3f4e565739f0c072571", - ), - ( - "nef-nikon-d200.nef", - "https://raw.pixls.us/getfile.php/824/nice/Nikon%20-%20D200%20-%2012bit%2012bit%20uncompressed%20%283:2%29.nef", - "d468895b0b30a60d05a7f6468d69f2f15830dd49b2d82698b34245072a22e747", - ), - ( - "raf-fujifilm-finepix-hs33exr.raf", - "https://raw.pixls.us/getfile.php/2907/nice/Fujifilm%20-%20FinePix%20HS33EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "d72d5b67d67379fa310fc5a4615016d9456d086a8bfd32c74a3b256e0dfecee8", - ), - ( - "raf-fujifilm-finepix-s1.raf", - "https://raw.pixls.us/getfile.php/1651/nice/Fujifilm%20-%20FinePix%20S1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "d77f104a9fa4ce11c78b5078ac40acf8b150d67ea837d187e0903d208bf2dbae", - ), - ( - "nef-nikon-z-7-2.nef", - "https://raw.pixls.us/getfile.php/4213/nice/Nikon%20-%20Z%207_2%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "dc83d3e49db54ca9c3a1e41dc847afd213c822d482c40158e42d01ee8968f33b", - ), - ( - "nef-nikon-d70.nef", - "https://raw.pixls.us/getfile.php/2060/nice/Nikon%20-%20D70%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", - "dd6405aeb33b0cd5bf66c98ba98ccbb478a765450cfd130810e470dab8d1f4b4", - ), - ( - "nef-nikon-d300s.nef", - "https://raw.pixls.us/getfile.php/3049/nice/Nikon%20-%20D300S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", - "de950a2263c87f085ee611a21e69fc4e0ecb6e417fff61ff6a23ea3121e21a81", - ), - ( - "raf-fujifilm-x-s1.raf", - "https://raw.pixls.us/getfile.php/997/nice/Fujifilm%20-%20X-S1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "e4652f3710cc972712a25370988ad8d72c8bf368956215348ea77b17d95f36f7", - ), - ( - "nef-nikon-d750.nef", - "https://raw.pixls.us/getfile.php/895/nice/Nikon%20-%20D750%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "e4a3f85e014275fc0a47600a373af95ec850872a5f6cf87649bcaa42b77ef2e4", - ), - ( - "nef-nikon-d3100.nef", - "https://raw.pixls.us/getfile.php/4229/nice/Nikon%20-%20D3100%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "e8113b6e7285155f9f2b88f81566dab63a59cf5559d6915113b74e8c3a8680f6", - ), - ( - "raf-fujifilm-x-t1.raf", - "https://raw.pixls.us/getfile.php/2421/nice/Fujifilm%20-%20X-T1%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "e994a1fd6e87e392432fe146a35b0b88584dc2bd50bee2c8c7e886ac2b59fcde", - ), - ( - "nef-nikon-d3400.nef", - "https://raw.pixls.us/getfile.php/1561/nice/Nikon%20-%20D3400%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", - "eced190786231f0081261b6c6b425fe23e42a53ade21dec3def5ae8551be71a5", - ), - ( - "raf-fujifilm-x-m1.raf", - "https://raw.pixls.us/getfile.php/842/nice/Fujifilm%20-%20X-M1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", - "eef4e9ae629d2d81cef5a234fb853c14c88b3c0c12c39d635a1f9fa2a871bec3", - ), - ( - "raf-fujifilm-x-t30.raf", - "https://raw.pixls.us/getfile.php/3068/nice/Fujifilm%20-%20X-T30%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", - "f0353544bc62a6816385de0850e1d37e2f3383892a7eb3928c73e4517fe47ff5", - ), - ( - "nrw-nikon-coolpix-p950.nrw", - "https://raw.pixls.us/getfile.php/3806/nice/Nikon%20-%20COOLPIX%20P950%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", - "fc6136b9e5a7e133d7dc30fe65baa04c09ff8c18ca3c7ad82b4ffdbb71ce5bef", - ), - ( - "raf-fujifilm-finepix-f770exr.raf", - "https://raw.pixls.us/getfile.php/3039/nice/Fujifilm%20-%20FinePix%20F770EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", - "ffc905e325a00c57a5335681654fdf226b724269cd5d26cd976983939f88043d", - ), - ( - "dng-leica-m-monochrom.dng", - "https://raw.pixls.us/getfile.php/974/nice/Leica%20-%20M%20Monochrom%20-%2016bit%20%283:2%29.DNG", - "149d5b3fbd5cd40d51c1f56d5e392a81870d129e047a6c8a596447590e7a0e4f", - ), - ( - "dng-leica-cl.dng", - "https://raw.pixls.us/getfile.php/2489/nice/Leica%20-%20CL%20-%2014bit%2014bit%20uncompressed%20%283:2%29.DNG", - "1f4cfa565d176d85838d96bd3eb598bbeed719cac8acb9999d5823c527635950", - ), - ( - "dng-leica-m10.dng", - "https://raw.pixls.us/getfile.php/1603/nice/Leica%20-%20M10%20-%2016bit%2016bit%20uncompressed%20%283:2%29.dng", - "26be5e06dfb53a2938dab3ca8f06024533a9c0dd0c652938f39eb48bc325f95e", - ), - ( - "rwl-leica-c-lux.rwl", - "https://raw.pixls.us/getfile.php/3863/nice/Leica%20-%20C-Lux%20-%204:3.RWL", - "286106ec2df65539912709c64e84a9b94686443fe5ea05b8ebada76daa1feb2d", - ), - ( - "arw-sony-zv-1m2.arw", - "https://raw.pixls.us/getfile.php/8461/nice/Sony%20-%20ZV-1M2%20-%2012bit%20%283:2%29.ARW", - "2a45845a93ab5e428868366d6575a264818b184541b1c114ab4de980872a7184", - ), - ( - "dng-leica-m-monochrom-typ-246.dng", - "https://raw.pixls.us/getfile.php/1087/nice/Leica%20-%20M%20Monochrom%20%28Typ%20246%29%20-%2012bit%20%283:2%29.DNG", - "3fb74d1fc402daaa48a9c3f1d6279d7e85fb58dfdaf816f036927d49ab228977", - ), - ( - "arw-sony-zv-e10m2.arw", - "https://raw.pixls.us/getfile.php/8021/nice/Sony%20-%20ZV-E10M2%20-%2012bit%20%284:3%29.ARW", - "49db06da969cf3894cf070d9171fe0ed4d0f125e16a87b5eba7eaec27cd75595", - ), - ( - "arw-sony-dsc-rx1rm3.arw", - "https://raw.pixls.us/getfile.php/8544/nice/Sony%20-%20DSC-RX1RM3%20-%2014bit%20uncompressed%20%283:2%29.ARW", - "6ef9956b343051748a6a16ba9ba7bd0d314a4ae0b4f8d8e2f1c13b4a1e50cfb8", - ), - ( - "arw-sony-ilce-1m2.arw", - "https://raw.pixls.us/getfile.php/7827/nice/Sony%20-%20ILCE-1M2%20-%2014bit%20uncompressed%20%283:2%29.ARW", - "7def3c8d80537f79af199052c6e13d86750203122dcb94888ada65291dabb793", - ), - ( - "dng-leica-m8.dng", - "https://raw.pixls.us/getfile.php/2988/nice/Leica%20-%20M8%20-%208bit%208bit%20uncompressed%20%283:2%29.DNG", - "7fdcca2371bb07fbf9483fcb08e3b5ab093eca1ca3924cbc86d6863736c31153", - ), - ( - "arw-sony-dsc-rx100m7a.arw", - "https://raw.pixls.us/getfile.php/8096/nice/Sony%20-%20DSC-RX100M7A%20-%2012bit%20%283:2%29.ARW", - "80adbc5b1cb3342058d5998cf2eda1e6b408565169462d6d26dfe02f0c140b30", - ), - ( - "arw-sony-ilce-7m5.arw", - "https://raw.pixls.us/getfile.php/8845/nice/Sony%20-%20ILCE-7M5%20-%2014bit%20%284:3%29.ARW", - "8c35cd1ab0ca36b487ae50ae5e0333a8e967122700fce5678e1e5b97561896a4", - ), - ( - "arw-sony-ilme-fx2.arw", - "https://raw.pixls.us/getfile.php/8807/nice/Sony%20-%20ILME-FX2%20-%2014bit%20uncompressed%20%283:2%29.ARW", - "924a6e4efd5c068883dd7de2bf2a2d155c7c13bdde668232077bdf9d92a201a8", - ), - ( - "dng-leica-x2.dng", - "https://raw.pixls.us/getfile.php/882/nice/Leica%20-%20X2%20-%2012bit%20%283:2%29.DNG", - "a67eca6271afb10510ffc9cf998b641d0e8a0ebfa4c779a7bc0bfed5d28b62dd", - ), - ( - "dng-leica-q2.dng", - "https://raw.pixls.us/getfile.php/3204/nice/Leica%20-%20Q2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.DNG", - "a71f98405e9e9c1e4ac0f2d2e6c52d65daae3234fc0a9f6eed2a84ee4ce794ad", - ), - ( - "arw-sony-ilce-6400a.arw", - "https://raw.pixls.us/getfile.php/7826/nice/Sony%20-%20ILCE-6400A%20-%2014bit%20%283:2%29.ARW", - "a8a45e9b6c437eba6724fcda24c3efd084c05389711b955d84b2af67635e241b", - ), - ( - "dng-leica-sl-typ-601.dng", - "https://raw.pixls.us/getfile.php/1951/nice/Leica%20-%20SL%20%28Typ%20601%29%20-%2014bit%20%283:2%29.DNG", - "aa9fd9ef937ed34467d6f91bfa2603be00cb47542b7fffd7258b79652d3b7bef", - ), - ( - "dng-leica-m10-r.dng", - "https://raw.pixls.us/getfile.php/7853/nice/Leica%20-%20M10-R%20-%2016bit%20compressed%20%283:2%29.DNG", - "ba35d25d521c7ddbc4feca69c8cdbe071ac65dcd6cf2c93374391bf6f3662b8d", - ), - ( - "dng-leica-m-typ-240.dng", - "https://raw.pixls.us/getfile.php/2317/nice/Leica%20-%20M%20%28Typ%20240%29%20-%2016bit%2016bit%20compressed%20%283:2%29.DNG", - "bf35a4da121f853cb644253b69515b441748f95fcbd6fdea2de025a76e6c24cd", - ), - ( - "dng-leica-sl2.dng", - "https://raw.pixls.us/getfile.php/7872/nice/Leica%20-%20SL2%20-%2014bit%20uncompressed%20%283:2%29.DNG", - "d00f05468793e4509b53230e90dcdc078e823b254eee95f1c68b29e217df99bf", - ), - ( - "dng-leica-m9.dng", - "https://raw.pixls.us/getfile.php/753/nice/Leica%20-%20M9%20-%208bit%208bit%20compressed%20%283:2%29.DNG", - "d1d162fce62210b8951c862189a6098329b5163b7f7492dc397c6e53bba04239", - ), - ( - "dng-leica-q-typ-116.dng", - "https://raw.pixls.us/getfile.php/1797/nice/Leica%20-%20Q%20%28Typ%20116%29%20-%2014bit%20%283:2%29.DNG", - "f7b2a6b6d7684536511cf2db2e2bfa46fae1ad7e52214093714f35e7ca60acbf", - ), - ( - "srw-samsung-nx200.srw", - "https://raw.pixls.us/getfile.php/839/nice/Samsung%20-%20NX200%20-%2012bit%20%283:2%29.srw", - "03563eadfe4ef32b5bf2a2850abb40b29fcc1f8d56b3b2ed92ef307b90d7d9dc", - ), - ( - "orf-olympus-sp550uz.orf", - "https://raw.pixls.us/getfile.php/8609/nice/Olympus%20-%20SP550UZ%20-%2012bit%20%284:3%29.ORF", - "05f49918e1b1894c3abd2b5451b356f7a00adb1c8568abc5babff33fcade1bb2", - ), - ( - "orf-olympus-e-10.orf", - "https://raw.pixls.us/getfile.php/5424/nice/Olympus%20-%20E-10%20-%2016bit%20%284:3%29.ORF", - "2bfdade72439017a60a47aad1e5bbcb1aca36f2be7a21e94a4257a678fb6f4da", - ), - ( - "orf-olympus-c5060wz.orf", - "https://raw.pixls.us/getfile.php/1799/nice/Olympus%20-%20C5060WZ%20-%2012bit%20%284:3%29.ORF", - "3fc12bc3f6320cfab456111d7805b446e329fdd1907680be55e2e163e5e084bd", - ), - ( - "dng-samsung-gx10.dng", - "https://raw.pixls.us/getfile.php/1165/nice/Samsung%20-%20GX10%20-%2012bit%20%283:2%29.DNG", - "44cbb7980b320c0920d999758eb8a3f27e6b98dd14afc3dfc135bc8526abda58", - ), - ( - "orf-olympus-sp565uz.orf", - "https://raw.pixls.us/getfile.php/3438/nice/Olympus%20-%20SP565UZ%20-%2012bit%20%284:3%29.ORF", - "5088771f83ce08a001b44672e1dd9f013e603933c37ce1bfb7030a02abbe801c", - ), - ( - "dng-samsung-gx20.dng", - "https://raw.pixls.us/getfile.php/1537/nice/Samsung%20-%20GX20%20-%2012bit%20%283:2%29.DNG", - "5557016b2712b621befdb05731923562a802beafd13e0f366737390a8f7b3286", - ), - ( - "orf-olympus-e-m5-mark-ii.orf", - "https://raw.pixls.us/getfile.php/2856/nice/Olympus%20-%20E-M5%20Mark%20II%20-%2016bit%2016bit%20%22high-res%22%20%284:3%29.ORF", - "5c42fa75d6b549514b722e2c50726c03e34fac4909ec3640e147ea7fa825fc8d", - ), - ( - "orf-olympus-e-300.orf", - "https://raw.pixls.us/getfile.php/2878/nice/Olympus%20-%20E-300%20-%2016bit%20%284:3%29.ORF", - "6d818dbff78b3e2efa4835f568d04733052b9211e6bc65b0a3748a841170e95b", - ), - ( - "orf-olympus-sh-2.orf", - "https://raw.pixls.us/getfile.php/792/nice/Olympus%20-%20SH-2%20-%2012bit%20%284:3%29.ORF", - "6e6b9b7dd9ded7c4b364b83116c817ca2d6daac15f2e9ef7796159b08c3288a1", - ), - ( - "srw-samsung-nx5.srw", - "https://raw.pixls.us/getfile.php/5684/nice/Samsung%20-%20NX5%20-%2012bit%20%283:2%29.SRW", - "908fc61d5bbc1f0d0a5cea4f4e39e7753060c1af8673f7a4a9199284b70930f7", - ), - ( - "orf-olympus-c7070wz.orf", - "https://raw.pixls.us/getfile.php/780/nice/Olympus%20-%20C7070WZ%20-%2012bit%20%284:3%29.ORF", - "9d55506f9d6ee31d2d4c4b6df0dcd1d016d358b3e5eb4ed31edd7227dfefd5f8", - ), - ( - "orf-olympus-xz-10.orf", - "https://raw.pixls.us/getfile.php/2431/nice/Olympus%20-%20XZ-10%20-%2012bit%20%284:3%29.ORF", - "a25383acad9b5bb0bb404715e0957f2eb80aa1aaad6c448a71025c03cfa631be", - ), - ( - "srw-samsung-wb2000.srw", - "https://raw.pixls.us/getfile.php/4262/nice/Samsung%20-%20WB2000%20-%2014bit%20%284:3%29.SRW", - "b9a1149fb0dda9d23aee0ad8c402bf2f9ba42832366d46c4c1b739c0b0f48497", - ), - ( - "orf-olympus-e-330.orf", - "https://raw.pixls.us/getfile.php/3624/nice/Olympus%20-%20E-330%20-%2016bit%20%284:3%29.ORF", - "ba11eb970ab32e1e9cf3d0176041e4bd11c501d445720c0a344217e785ad65a6", - ), - ( - "orf-olympus-sp570uz.orf", - "https://raw.pixls.us/getfile.php/5491/nice/Olympus%20-%20SP570UZ%20-%2012bit%20%284:3%29.ORF", - "baab92391834076b427e406e98da79aaba48fb87243d6ccae60e08e299cfccf7", - ), - ( - "srw-samsung-nx11.srw", - "https://raw.pixls.us/getfile.php/5985/nice/Samsung%20-%20NX11%20-%2012bit%20%283:2%29.SRW", - "bdce6179b4180478057f4971b512e0303da71528db454cb3f399ab10e05b68fe", - ), - ( - "srw-samsung-nx1000.srw", - "https://raw.pixls.us/getfile.php/2588/nice/Samsung%20-%20NX1000%20-%2012bit%20%283:2%29.SRW", - "beef4d1e6f25ff28fb20bb3ad00ffadb62ab57d7041237e295b0b62f7de5309c", - ), - ( - "srw-samsung-nx1100.srw", - "https://raw.pixls.us/getfile.php/4422/nice/Samsung%20-%20NX1100%20-%2012bit%20%283:2%29.srw", - "d230af65ee55314792c5337ea8e0e757626a8541e7a25ed03d63b8df202ed9cb", - ), - ( - "srw-samsung-nx210.srw", - "https://raw.pixls.us/getfile.php/1831/nice/Samsung%20-%20NX210%20-%2012bit%20%283:2%29.SRW", - "d613503ddf9bedd2d2e84708749b1a94f0cccd4fa15492e01242368c5c07cfad", - ), - ( - "orf-olympus-sp510uz.orf", - "https://raw.pixls.us/getfile.php/2420/nice/Olympus%20-%20SP510UZ%20-%2012bit%20%284:3%29.ORF", - "f0737d78bb2e588963a3d5038fab493cdff1c9c48475704de96d797bbb81e62a", - ), - ( - "orf-olympus-pen-f.orf", - "https://raw.pixls.us/getfile.php/2977/nice/Olympus%20-%20PEN-F%20-%2016bit%20%284:3%29.orf", - "f87a5f3295aeb4d6ab27bb5b41c5e0f3a211ec9b4e3b1d9f105a650bee1a2f13", - ), - ( - "orf-olympus-e-500.orf", - "https://raw.pixls.us/getfile.php/3540/nice/Olympus%20-%20E-500%20-%2016bit%20%284:3%29.ORF", - "fa5a4759b2de2b573f59629ad1baf64ad04a85fb8fe1fb3cbf68e08a42fff4f0", - ), - ( - "cr2-canon-eos-70d.cr2", - "https://raw.pixls.us/getfile.php/784/nice/Canon%20-%20EOS%2070D%20-%20RAW.CR2", - "00e42517013bd043b7145001399bbc320a8ff235a6dcd16033580d8a130bee1e", - ), - ( - "cr2-canon-eos-rebel-t7i.cr2", - "https://raw.pixls.us/getfile.php/3437/nice/Canon%20-%20EOS%20Rebel%20T7i%20-%20RAW%20%283:2%29.cr2", - "03fb55af71f384ada6b6aa0ca93279d48cf5fbd13651a323408d2fd97e1d1f7c", - ), - ( - "cr2-canon-powershot-g9-x.cr2", - "https://raw.pixls.us/getfile.php/2154/nice/Canon%20-%20PowerShot%20G9%20X%20-%20RAW%20%283:2%29.CR2", - "05c041127f4fdecfd49a45da4b12192c8dbe695c3e50427043166fef49b39e83", - ), - ( - "pef-pentax-k-1.pef", - "https://raw.pixls.us/getfile.php/2469/nice/Pentax%20-%20K-1%20-%2014bit%20%283:2%29.PEF", - "068c902e7b38ac31c7c6eca5c55f69ca395ec6aa7170489281b692a893d0fee9", - ), - ( - "cr2-canon-eos-4000d.cr2", - "https://raw.pixls.us/getfile.php/3241/nice/Canon%20-%20EOS%204000D%20-%20RAW%20%283:2%29.CR2", - "06daaf663f42d3f6ceb1a837d3ca4db2cfcb5899888ed13510bfa01e3261a896", - ), - ( - "cr2-canon-eos-1000d.cr2", - "https://raw.pixls.us/getfile.php/1616/nice/Canon%20-%20EOS%201000D%20-%20RAW%20%283:2%29.CR2", - "08b0843a7968315b68bd3c71a96c952ebf275d5b5eba4b6094ae8b103522584c", - ), - ( - "cr2-canon-powershot-g1-x-mark-ii.cr2", - "https://raw.pixls.us/getfile.php/2186/nice/Canon%20-%20PowerShot%20G1%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", - "08d861ca7fc2890f0217034970d4c900b09373b421106e4ba080f6904a48b37c", - ), - ( - "cr2-canon-eos-500d.cr2", - "https://raw.pixls.us/getfile.php/911/nice/Canon%20-%20EOS%20500D%20-%20RAW%20%283:2%29.CR2", - "0b57eb8b0dece6f63d4ea61660279488121c9a0aa250113852fa84c051c6da3e", - ), - ( - "pef-pentax-ist-dl.pef", - "https://raw.pixls.us/getfile.php/1083/nice/Pentax%20-%20%2Aist%20DL%20-%2012bit%20%283:2%29.PEF", - "0e6cd3c999a6dbf121591b2c58aaa8216e625f549db46af627f7346ccf1cf662", - ), - ( - "cr2-canon-eos-400d.cr2", - "https://raw.pixls.us/getfile.php/1764/nice/Canon%20-%20EOS%20400D%20-%20RAW%20%283:2%29.CR2", - "0e752ddaf81795885efc6443c78e5fee4d5f8f00c8d979ef20e235424f98d595", - ), - ( - "dng-ricoh-gx200.dng", - "https://raw.pixls.us/getfile.php/2589/nice/Ricoh%20-%20GX200%20-%2012bit%20%284:3%29.DNG", - "0f328905ee6f5b4823040a666103ced651178a13b8ccd0c4c60662f7265dd87f", - ), - ( - "cr2-canon-powershot-s110.cr2", - "https://raw.pixls.us/getfile.php/909/nice/Canon%20-%20PowerShot%20S110%20-%20RAW%20%284:3%29.CR2", - "14e440316c1d2b005a7f5d5c042e063b3e15d34242e38c3a0983db8ef85e4288", - ), - ( - "cr2-canon-eos-kiss-x80.cr2", - "https://raw.pixls.us/getfile.php/3099/nice/Canon%20-%20EOS%20Kiss%20X80%20-%20RAW%20%283:2%29.CR2", - "155878290528ff91f67b4f9ffed5d765e80ede5fb0c36fca40107c0b7f5c1d9d", - ), - ( - "dng-pentax-kp.dng", - "https://raw.pixls.us/getfile.php/1824/nice/Pentax%20-%20KP%20-%2014bit%20%283:2%29.DNG", - "16b09cb3032003989877659ae03cbb6985e3d7e84f1402863ccae8af10033c1c", - ), - ( - "dng-pentax-k-1-mark-ii.dng", - "https://raw.pixls.us/getfile.php/3345/nice/Pentax%20-%20K-1%20Mark%20II%20-%2014bit%20%283:2%29.DNG", - "190d5878675f4de07d246271ba411b152f3317b2d2a0e27b9c827fe7343b28ea", - ), - ( - "dng-pentax-k-3-mark-iii-monochrome.dng", - "https://raw.pixls.us/getfile.php/7081/nice/Pentax%20-%20K-3%20Mark%20III%20Monochrome%20-%2014bit%20%283:2%29.DNG", - "1afc1071451a3a3737c29ae319e1411551b6893d0e6a34db29903318266b0066", - ), - ( - "cr2-canon-eos-600d.cr2", - "https://raw.pixls.us/getfile.php/1586/nice/Canon%20-%20EOS%20600D%20-%20RAW%20%283:2%29.CR2", - "1b0892df5ba5fa418e945f0519d490aeecaaf7e74860a08b5dc47e3e9976a288", - ), - ( - "pef-pentax-k-70.pef", - "https://raw.pixls.us/getfile.php/1141/nice/Pentax%20-%20K-70%20-%2014bit%20%283:2%29.PEF", - "1d64c6aa71680642ba8f00b6f6105df3e66a4534e631f422fbf3316273c4408c", - ), - ( - "dng-ricoh-gr-iiix.dng", - "https://raw.pixls.us/getfile.php/5818/nice/Ricoh%20-%20GR%20IIIx%20-%2014bit%20%283:2%29.DNG", - "1e318af31be4baa9949f0ed4af5cfe8ffb2c180e2c12926cd75ac1d029bb0fae", - ), - ( - "cr2-canon-powershot-g1-x.cr2", - "https://raw.pixls.us/getfile.php/1588/nice/Canon%20-%20PowerShot%20G1%20X%20-%20RAW%20%284:3%29.CR2", - "1e486bbbdfb2c94accf0c5ef4558d1aa484e0ffd36fa03440e3ae34ba2aa338b", - ), - ( - "dng-ricoh-gxr.dng", - "https://raw.pixls.us/getfile.php/2756/nice/Ricoh%20-%20GXR%20-%2012bit%20%284:3%29.DNG", - "219a062f3d1eb8d66c9dd6088eecf495be082cf5c81f88622eb0dd28cc893cec", - ), - ( - "cr2-canon-powershot-g16.cr2", - "https://raw.pixls.us/getfile.php/1114/nice/Canon%20-%20PowerShot%20G16%20-%20RAW%20%284:3%29.CR2", - "2307e3cf643ab2f94d03f899467d8797ce1ef41bbffd6ad50af81bdd0ba34098", - ), - ( - "dng-pentax-k20d.dng", - "https://raw.pixls.us/getfile.php/1143/nice/Pentax%20-%20K20D%20-%2012bit%20%283:2%29.DNG", - "264465ef1d97f1599899778589f07f8aa485b88c0dfc9b1d4c006bf79d44b420", - ), - ( - "cr2-canon-powershot-g3-x.cr2", - "https://raw.pixls.us/getfile.php/1509/nice/Canon%20-%20PowerShot%20G3%20X%20-%20RAW%20%283:2%29.CR2", - "267641913613fd52532279ff16f03c135511fbcab5bbe8b276fc9cf91aebd7ca", - ), - ( - "pef-pentax-ist-ds.pef", - "https://raw.pixls.us/getfile.php/4392/nice/Pentax%20-%20%2Aist%20DS%20-%2012bit%2012bit%20uncompressed%20%283:2%29.pef", - "27809a7d252d515685ed4e6091b70ab25348502baeb75db03ce7b4f1d9549b76", - ), - ( - "cr2-canon-eos-m.cr2", - "https://raw.pixls.us/getfile.php/1224/nice/Canon%20-%20EOS%20M%20-%20RAW%20%283:2%29.CR2", - "27d151efe86f04bea8fcf6ec9f3a722ae3ee24a3f56a6ebbd27ee89b215cf569", - ), - ( - "cr2-canon-eos-1d-x-mark-ii.cr2", - "https://raw.pixls.us/getfile.php/1058/nice/Canon%20-%20EOS-1D%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", - "2e372710d546c9b8903a343696af4e7fd245794571f64258ab607a89dfa6aa97", - ), - ( - "pef-pentax-k-3-mark-iii.pef", - "https://raw.pixls.us/getfile.php/4677/nice/Pentax%20-%20K-3%20Mark%20III%20-%2014bit%20%283:2%29.PEF", - "351be88038b2dd28af39e11dbece6de74efd71119a9ef667d35cdf02dcff919f", - ), - ( - "cr2-canon-eos-750d.cr2", - "https://raw.pixls.us/getfile.php/1982/nice/Canon%20-%20EOS%20750D%20-%20RAW%20%283:2%29.CR2", - "38cdb173fe1ea083f4d475285d3080db49d7f3c7cdcb49bf1968fceeed66c85c", - ), - ( - "cr2-canon-eos-5ds.cr2", - "https://raw.pixls.us/getfile.php/2083/nice/Canon%20-%20EOS%205DS%20-%20RAW%20%283:2%29.CR2", - "3fdb8b7ada3b4a69ed5756fa814bee9bd51c1c22b40e70252a46890e2855df07", - ), - ( - "dng-canon-powershot-s2-is.dng", - "https://raw.pixls.us/getfile.php/941/nice/Canon%20-%20PowerShot%20S2%20IS%20-%2010bit%2010bit%20DNG%2C%20CHDK%20ver.%201.0.0-1504%20%284:3%29.CR2", - "445764716df165a1facd37c01d67ede44ca49671ed1a6112dd8ff7426324c757", - ), - ( - "cr2-canon-powershot-s100v.cr2", - "https://raw.pixls.us/getfile.php/2492/nice/Canon%20-%20PowerShot%20S100V%20-%20RAW%20%284:3%29.CR2", - "4775f347686f785104ee7f566df158247cdecef4de76fdbfa150e18b6b104c0a", - ), - ( - "cr2-canon-eos-kiss-digital-n.cr2", - "https://raw.pixls.us/getfile.php/1185/nice/Canon%20-%20EOS%20Kiss%20Digital%20N%20-%20RAW%20%283:2%29.CR2", - "49c78e5c36255cdd3f9fd219e86e3325bfa67be4d8d1ab5e21c21c3fd53c294e", - ), - ( - "dng-pentax-k-01.dng", - "https://raw.pixls.us/getfile.php/1008/nice/Pentax%20-%20K-01%20-%2012bit%20%283:2%29.DNG", - "4c9aac2ce2720154616ad7678385d1ec1f626d6464a1d5c6a5a4d821a78a608d", - ), - ( - "cr2-canon-eos-rebel-t5.cr2", - "https://raw.pixls.us/getfile.php/1411/nice/Canon%20-%20EOS%20Rebel%20T5%20-%20RAW%20%283:2%29.CR2", - "4f71c1416037d2212cc4847e978bffe874afc0f653260386e96dff704ca8bf51", - ), - ( - "pef-pentax-k110d.pef", - "https://raw.pixls.us/getfile.php/1309/nice/Pentax%20-%20K110D%20-%2012bit%20%283:2%29.pef", - "4f7c5574b0dbc3c1270b269126c2a3be030a86799f8b544610c74f31ef782f72", - ), - ( - "cr2-canon-powershot-g11.cr2", - "https://raw.pixls.us/getfile.php/2755/nice/Canon%20-%20PowerShot%20G11%20-%20RAW%20%284:3%29.CR2", - "50ef4d6712c7add1c65ef4c48c74ac5017f24ff1789f1d8a8d5d9accd3aab0fa", - ), - ( - "cr2-canon-eos-rebel-sl1.cr2", - "https://raw.pixls.us/getfile.php/1201/nice/Canon%20-%20EOS%20Rebel%20SL1%20-%20RAW%20%283:2%29.CR2", - "56faa7db3947c32c5e6e31bc0db027ebf95163533e043bd8c4f18166a129692c", - ), - ( - "cr2-canon-powershot-g15.cr2", - "https://raw.pixls.us/getfile.php/1986/nice/Canon%20-%20PowerShot%20G15%20-%20RAW%20%284:3%29.CR2", - "5756d1a6522134ee4833a9cbef04eb54b26a1f7935e58063733a264478385a26", - ), - ( - "dng-pentax-q7.dng", - "https://raw.pixls.us/getfile.php/1760/nice/Pentax%20-%20Q7%20-%2012bit%20%284:3%29.DNG", - "577eac8d03419bb82714169dd532d58efc82a0abf928d28e4641f1ed5d56c350", - ), - ( - "dng-pentax-k-s1.dng", - "https://raw.pixls.us/getfile.php/1365/nice/Pentax%20-%20K-S1%20-%2012bit%20%283:2%29.DNG", - "58abc43a8e025dcab0053bcee173d20197aa2b7983e5e62097147a1e8545f111", - ), - ( - "cr2-canon-eos-1d-mark-iv.cr2", - "https://raw.pixls.us/getfile.php/4258/nice/Canon%20-%20EOS-1D%20Mark%20IV%20-%20RAW%20%283:2%29.CR2", - "597bb01f2400b04bfe7ee024016992e8e897b7dffaa5e368b8375daf3b32b3ed", - ), - ( - "cr2-canon-eos-m5.cr2", - "https://raw.pixls.us/getfile.php/2935/nice/Canon%20-%20EOS%20M5%20-%20RAW%20%283:2%29.CR2", - "5af02dbad6cd90eeac2b0b1c0be0aeb9a5d2841c6dd984ee415ede9f2566667d", - ), - ( - "cr2-canon-powershot-s100.cr2", - "https://raw.pixls.us/getfile.php/1357/nice/Canon%20-%20PowerShot%20S100%20-%20RAW%20%284:3%29.CR2", - "5d1bb0597da59bc920442c55dbd7f70d090a9aa01b5642607bcdde523ae892c6", - ), - ( - "pef-pentax-ist-d.pef", - "https://raw.pixls.us/getfile.php/1843/nice/Pentax%20-%20%2Aist%20D%20-%2012bit%20%283:2%29.PEF", - "61904b594d16983070f3e56d663ae8f7b246b8bb554c4cf7bd3eb4b9f9a9f3aa", - ), - ( - "dng-pentax-k-30.dng", - "https://raw.pixls.us/getfile.php/3094/nice/Pentax%20-%20K-30%20-%2012bit%20%283:2%29.DNG", - "63eaa1ff93ad7c89947017332b10f78f17cd3853a5b495adf25410edaefbe618", - ), - ( - "cr2-canon-eos-rebel-t4i.cr2", - "https://raw.pixls.us/getfile.php/2203/nice/Canon%20-%20EOS%20Rebel%20T4i%20-%20RAW%20%283:2%29.CR2", - "65257f38bc68a11b9185ac710b3a442a19aa1c082afb01b668bf33e6836b6b2d", - ), - ( - "cr2-canon-powershot-sx50-hs.cr2", - "https://raw.pixls.us/getfile.php/3152/nice/Canon%20-%20PowerShot%20SX50%20HS%20-%20RAW%20%284:3%29.CR2", - "6c2ddd232f5432fb7bd0e28efc12d66995cedcd847f801fd854bfdfb180db50c", - ), - ( - "dng-pentax-k200d.dng", - "https://raw.pixls.us/getfile.php/5246/nice/Pentax%20-%20K200D%20-%2012bit%2012bit%20uncompressed%20%283:2%29.DNG", - "6d13579dd0a8ec784bfa3d8b53af5a3f936171bfa62d74eb96a17236a6fb9472", - ), - ( - "cr2-canon-eos-m2.cr2", - "https://raw.pixls.us/getfile.php/5067/nice/Canon%20-%20EOS%20M2%20-%20RAW%20%283:2%29.cr2", - "6d26eeb1e8a5978c7cb9d8beb6e6e34f3c27847055b76bc7f186503ca44508d0", - ), - ( - "cr2-canon-eos-m3.cr2", - "https://raw.pixls.us/getfile.php/1002/nice/Canon%20-%20EOS%20M3%20-%20RAW%20%283:2%29.CR2", - "6f12a02a21f64b284328ee6d3b2d1bab4439720cad2e881adf3da72592128b11", - ), - ( - "cr2-canon-powershot-g12.cr2", - "https://raw.pixls.us/getfile.php/1767/nice/Canon%20-%20PowerShot%20G12%20-%20RAW%20%284:3%29.cr2", - "6f2750c7cb144eee80a91431e2ce8df66dd229c3e1f1453bf80409ca669a8614", - ), - ( - "dng-pentax-k-5.dng", - "https://raw.pixls.us/getfile.php/1479/nice/Pentax%20-%20K-5%20-%2014bit%20%283:2%29.DNG", - "6f6419209340d07d597ccca66dc367d64cff3308ef2694c8f90c6259f3a5c431", - ), - ( - "cr2-canon-eos-6d-mark-ii.cr2", - "https://raw.pixls.us/getfile.php/1625/nice/Canon%20-%20EOS%206D%20Mark%20II%20-%20RAW%20%283:2%29.CR2", - "712815b910fa473f7aaeb97c0804292d996b16dd6b974a3ab9a7195a2e41d90c", - ), - ( - "cr2-canon-eos-m10.cr2", - "https://raw.pixls.us/getfile.php/1758/nice/Canon%20-%20EOS%20M10%20-%20RAW%20%283:2%29.CR2", - "71e27b9ebe7efba632e666db77b82c5129e0d1a402ff359a2b509774ebeb1b0f", - ), - ( - "dng-pentax-k-x.dng", - "https://raw.pixls.us/getfile.php/830/nice/Pentax%20-%20K-x%20-%2012bit%20%283:2%29.DNG", - "7a594b66b54f99bb5aba4627c78abfc41213d6911ea54e4f5d2dd6c8505f6968", - ), - ( - "cr2-canon-eos-rebel-t5i.cr2", - "https://raw.pixls.us/getfile.php/2651/nice/Canon%20-%20EOS%20Rebel%20T5i%20-%20RAW%20%283:2%29.CR2", - "7c3c9c3b8ec487d56040c21620aafe51e13e2c2fc1e1351929970720e10dda69", - ), - ( - "dng-ricoh-gr.dng", - "https://raw.pixls.us/getfile.php/996/nice/Ricoh%20-%20GR%20-%2012bit%20%284:3%29.DNG", - "7ceec1ea31c56b6e0414202504581b915f17818ffe018b4c8bba1393332c405e", - ), - ( - "dng-pentax-645d.dng", - "https://raw.pixls.us/getfile.php/2401/nice/Pentax%20-%20645D%20-%2014bit%2014bit%20compressed%20%284:3%29.DNG", - "7cfa4a6b3fc459157b0be94effaf98136027b1182dac7da0f91e29987ab6ae6c", - ), - ( - "cr2-canon-eos-rebel-t3.cr2", - "https://raw.pixls.us/getfile.php/3920/nice/Canon%20-%20EOS%20Rebel%20T3%20-%20RAW%20%283:2%29.CR2", - "8106f288e22ca0510b2ebe36e58833afbf847db92e9e1ec6f651605bac0beb24", - ), - ( - "cr2-canon-eos-77d.cr2", - "https://raw.pixls.us/getfile.php/1332/nice/Canon%20-%20EOS%2077D%20-%20RAW%20%283:2%29.CR2", - "811df582d97918987312fa0fceae3ad19a20532a64ebda9782764ac7e629c265", - ), - ( - "cr2-canon-eos-2000d.cr2", - "https://raw.pixls.us/getfile.php/2612/nice/Canon%20-%20EOS%202000D%20-%20RAW%20%283:2%29.CR2", - "84015493df96c877c0387071a627d6940187d75695b0598694901ac0ff83876a", - ), - ( - "cr2-canon-eos-5d-mark-iv.cr2", - "https://raw.pixls.us/getfile.php/980/nice/Canon%20-%20EOS%205D%20Mark%20IV%20-%20RAW%20%283:2%29.CR2", - "85bfec149261ffbae90797e45caae5e204e1e61ac62b92783925b5ee0f267d76", - ), - ( - "cr2-canon-eos-1d-mark-iii.cr2", - "https://raw.pixls.us/getfile.php/2037/nice/Canon%20-%20EOS-1D%20Mark%20III%20-%20RAW%20%283:2%29.CR2", - "860b5c9791548db8081823e3852c436a439e648ea863b0def05e99aac6e9e726", - ), - ( - "cr2-canon-eos-60d.cr2", - "https://raw.pixls.us/getfile.php/993/nice/Canon%20-%20EOS%2060D%20-%20RAW%20%283:2%29.CR2", - "865236ece3bf1f2f19a15001a8f0f99badf71e34b9eed1fead04d15daf87a154", - ), - ( - "pef-pentax-k100d.pef", - "https://raw.pixls.us/getfile.php/2262/nice/Pentax%20-%20K100D%20-%2012bit%20%283:2%29.PEF", - "86ec2383dbfda3eda38ca913b02874a70248efba6107aa304781c8d3779de8c9", - ), - ( - "cr2-canon-eos-1d-mark-ii-n.cr2", - "https://raw.pixls.us/getfile.php/1781/nice/Canon%20-%20EOS-1D%20Mark%20II%20N%20-%20RAW%20%283:2%29.CR2", - "8bf6591f64b0657bc6133cbd0ee1418ef8e6dd3dd595827f8e9910c0f235843c", - ), - ( - "dng-pentax-k-500.dng", - "https://raw.pixls.us/getfile.php/1458/nice/Pentax%20-%20K-500%20-%2012bit%20%283:2%29.DNG", - "91ccd6f29b8eaad88ae26d1c860ef1113e6dfc1bd2c8f5245b49f3a788295fb1", - ), - ( - "cr2-canon-eos-m100.cr2", - "https://raw.pixls.us/getfile.php/3531/nice/Canon%20-%20EOS%20M100%20-%20RAW%20%283:2%29.CR2", - "941d9e696b1d92b4b25a762474e3c5c127e7405dea099c266fa85e39a2454fcc", - ), - ( - "pef-pentax-k2000.pef", - "https://raw.pixls.us/getfile.php/5239/nice/Pentax%20-%20K2000%20-%2012bit%2012bit%20compressed%20%283:2%29.PEF", - "985003748c094b8d02f2517b4c07d66d48ea5380260235ccb3409f41ceb3ea84", - ), - ( - "dng-pentax-k-50.dng", - "https://raw.pixls.us/getfile.php/2235/nice/Pentax%20-%20K-50%20-%2012bit%20%283:2%29.DNG", - "9ea1dc1f92df19699aa8b620670c64a84d4ead0172a49cbbd47dea9257d14e67", - ), - ( - "cr2-canon-powershot-g5-x.cr2", - "https://raw.pixls.us/getfile.php/4257/nice/Canon%20-%20PowerShot%20G5%20X%20-%20RAW%20%283:2%29.CR2", - "a7d0ac7f3c7ddfa607a6d0a7433ef9ba664d260aa91616cd75cfbdf912b0da00", - ), - ( - "dng-pentax-645z.dng", - "https://raw.pixls.us/getfile.php/2403/nice/Pentax%20-%20645Z%20-%2014bit%2014bit%20compressed%20%284:3%29.DNG", - "a8203686c61962af591b374331b6eb9dace4f2b29fce9f166303fc66fd210bb0", - ), - ( - "dng-pentax-k-5-ii.dng", - "https://raw.pixls.us/getfile.php/873/nice/Pentax%20-%20K-5%20II%20-%2014bit%20%283:2%29.DNG", - "aa41f0d9ab8d3e21e468b4c61d4d1ddae6a4fbe37f629b78159246cbb94dd56b", - ), - ( - "cr2-canon-powershot-g9.cr2", - "https://raw.pixls.us/getfile.php/760/nice/Canon%20-%20PowerShot%20G9%20-%20RAW%20%284:3%29.CR2", - "ad27a4d751984cd709380e3be274a4a5243d36ca89c5fc711736ba8a9f5c932f", - ), - ( - "dng-pentax-q.dng", - "https://raw.pixls.us/getfile.php/1610/nice/Pentax%20-%20Q%20-%2012bit%20%284:3%29.DNG", - "ad79b4ce4b88e5ca8b03d0d5aeaba09f55559695234941a5a6ada0cd4825860d", - ), - ( - "dng-pentax-mx-1.dng", - "https://raw.pixls.us/getfile.php/1731/nice/Pentax%20-%20MX-1%20-%2012bit%20%284:3%29.dng", - "b00f3fd42479bbaba7a00b2b6fa3e5f5d213c51d591adff4f6eb4a768ecd1ce9", - ), - ( - "cr2-canon-eos-5d.cr2", - "https://raw.pixls.us/getfile.php/1346/nice/Canon%20-%20EOS%205D%20-%20RAW%20%283:2%29.CR2", - "b30089172973388d03e192a14a63f750e22914dfc01db5713b6b3bce3c9baeee", - ), - ( - "pef-pentax-k100d-super.pef", - "https://raw.pixls.us/getfile.php/5804/nice/Pentax%20-%20K100D%20Super%20-%2012bit%2012bit%20uncompressed%20%283:2%29.PEF", - "b391c84ff1cc8769ec5214f837d1e4b3ae80374673608601b12ee4484e003ddb", - ), - ( - "cr2-canon-eos-m6.cr2", - "https://raw.pixls.us/getfile.php/1382/nice/Canon%20-%20EOS%20M6%20-%20RAW%20%283:2%29.CR2", - "b3a7a0037600b61d4c26ad03d5cac1801e35fb43a55699db5b77708c96f7338a", - ), - ( - "cr2-canon-powershot-g7-x-mark-ii.cr2", - "https://raw.pixls.us/getfile.php/1682/nice/Canon%20-%20PowerShot%20G7%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", - "b81acebf5ac49b0389c354942d128d9423fdd59ff92217554f232f94e3e87ef5", - ), - ( - "cr2-canon-eos-7d-mark-ii.cr2", - "https://raw.pixls.us/getfile.php/1064/nice/Canon%20-%20EOS%207D%20Mark%20II%20-%20RAW%20%283:2%29.cr2", - "b9f71c56adb6e2861e6653efb312fb5cc88ba4aa17fa809c6a18b8db044360c3", - ), - ( - "dng-ricoh-gr-ii.dng", - "https://raw.pixls.us/getfile.php/1955/nice/Ricoh%20-%20GR%20II%20-%2012bit%20%283:2%29.DNG", - "bc8e00ccfd8e4753e8ee0735255bff4dcca5fb8d1a1352adfa59b86806d03f44", - ), - ( - "cr2-canon-powershot-s120.cr2", - "https://raw.pixls.us/getfile.php/1694/nice/Canon%20-%20PowerShot%20S120%20-%20RAW%20%284:3%29.CR2", - "bcbfadad8ecdbcc783864a2b769aef37588e2a6339b9682dcd9642602bd2011f", - ), - ( - "cr2-canon-powershot-s95.cr2", - "https://raw.pixls.us/getfile.php/1742/nice/Canon%20-%20PowerShot%20S95%20-%20RAW%20%284:3%29.cr2", - "c4a55c61434b8403f948518deca22e8ab755099f124d5cea5bc1cd0ea86711ad", - ), - ( - "cr2-canon-powershot-g1-x-mark-iii.cr2", - "https://raw.pixls.us/getfile.php/2695/nice/Canon%20-%20PowerShot%20G1%20X%20Mark%20III%20-%20RAW%20%283:2%29.CR2", - "ca052ed025432fdacfba7347cb4374fa3b727155912a91c42ed9a275d13bedf5", - ), - ( - "cr2-canon-eos-50d.cr2", - "https://raw.pixls.us/getfile.php/1792/nice/Canon%20-%20EOS%2050D%20-%20RAW%20%283:2%29.CR2", - "ce460cf2b6762c8c22ebeb00e9e1edccd728edcb7c4e82c78f3bce61d21fa88d", - ), - ( - "dng-pentax-k-s2.dng", - "https://raw.pixls.us/getfile.php/1626/nice/Pentax%20-%20K-S2%20-%2012bit%20%283:2%29.DNG", - "d23916a31f09c961e6050e4120bbcd92f7847080f9e40fad8dc7047ead1dec2d", - ), - ( - "cr2-canon-eos-20d.cr2", - "https://raw.pixls.us/getfile.php/1018/nice/Canon%20-%20EOS%2020D%20-%20RAW%20%283:2%29.CR2", - "db78660d48589130d99e0bc621424851eb0f84df052634fb5bc89c7a02fe7033", - ), - ( - "pef-pentax-k-r.pef", - "https://raw.pixls.us/getfile.php/8171/nice/Pentax%20-%20K-r%20-%2012bit%20compressed%20%283:2%29.PEF", - "dd0d7b76d89039995d253adddcfb44d131414fad2179610d9ea4d8d4bb876b7b", - ), - ( - "pef-pentax-kf.pef", - "https://raw.pixls.us/getfile.php/6664/nice/Pentax%20-%20KF%20-%2014bit%20%283:2%29.PEF", - "ddc9cdf399a7210b203dee1765ab9f4165a2e695da59bce6bcabf75b77174a4b", - ), - ( - "cr2-canon-powershot-g7-x.cr2", - "https://raw.pixls.us/getfile.php/1277/nice/Canon%20-%20PowerShot%20G7%20X%20-%20RAW%20%283:2%29.CR2", - "e4b0a64df6f43252ab96b4789f698ca334e86586ab151d1e9f5616b80172abcd", - ), - ( - "cr2-canon-eos-450d.cr2", - "https://raw.pixls.us/getfile.php/1353/nice/Canon%20-%20EOS%20450D%20-%20RAW%20%283:2%29.CR2", - "e975f71429b939701dd618a7bac4709c1409f4bb6aa1c3607ceeaa86031c1c4a", - ), - ( - "cr2-canon-powershot-sx60-hs.cr2", - "https://raw.pixls.us/getfile.php/4942/nice/Canon%20-%20PowerShot%20SX60%20HS%20-%20RAW%20%284:3%29.CR2", - "f005e82fe8579876d7e9d39291c57f4410412904dfff0de5bbad9f32b3189612", - ), - ( - "dng-pentax-k-3-ii.dng", - "https://raw.pixls.us/getfile.php/1363/nice/Pentax%20-%20K-3%20II%20-%2014bit%20%283:2%29.DNG", - "f43b9dc35e4d1b43918d2d4bd0973bc202af48ed24c924e4aff61250c971b171", - ), - ( - "dng-pentax-k-7.dng", - "https://raw.pixls.us/getfile.php/827/nice/Pentax%20-%20K-7%20-%2012bit%20%283:2%29.DNG", - "f441c36678dfca4f38e25a580f4f8485d78d8067a054153bc36be71e56c92386", - ), - ( - "cr2-canon-eos-kiss-x9.cr2", - "https://raw.pixls.us/getfile.php/2062/nice/Canon%20-%20EOS%20Kiss%20X9%20-%20RAW%20%283:2%29.CR2", - "f50cea4641c97c1154a214a686226ba900002f00f4f844af858ef80b9ec8dea6", - ), - ( - "cr2-canon-powershot-g9-x-mark-ii.cr2", - "https://raw.pixls.us/getfile.php/1575/nice/Canon%20-%20PowerShot%20G9%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", - "f91f1a3b6fa66b2e15fd26c1bd78e1eb19dfa7e6a3886aff36e7ab96fd1c319c", - ), - ( - "cr2-canon-eos-760d.cr2", - "https://raw.pixls.us/getfile.php/2094/nice/Canon%20-%20EOS%20760D%20-%20RAW%20%283:2%29.cr2", - "f98108c5efd57eef483009cdb490ada2071d1c1b58a82df3de997b4d86d849c2", - ), - ( - "cr2-canon-eos-30d.cr2", - "https://raw.pixls.us/getfile.php/1791/nice/Canon%20-%20EOS%2030D%20-%20RAW%20%283:2%29.CR2", - "fb216f8df8d7abca4eb9d9f8dc594c1d2b69d0a6369fb8b4107743343c37144f", - ), - ("erf-epson-rd1.erf", "https://raw.pixls.us/data/Epson/R-D1/_EPS0672.ERF", "09d8e533d93116294a9f3e161ed868e929454c7b946e083b644bd6bb75bcb5e4"), - ("erf-epson-rd1s.erf", "https://raw.pixls.us/data/Epson/R-D1s/EPSN3575.ERF", "45bb0081baf1c813c7cd9e05b3e6ae734b90aba408dd1195118dc94dc6abbbb9"), - ("erf-epson-rd1x.erf", "https://raw.pixls.us/data/Epson/R-D1x/_EPS1919.ERF", "fa1bff3e0b9b9a14f83b8db9fae022c2aca8d455896bf4f25f2c1a0034e295e6"), - ( - "mef-mamiya-zd.mef", - "https://raw.pixls.us/data/Mamiya/ZD/RAW_MAMIYA_ZD.MEF", - "bcd63507c3c4cc3ea1bad2945e3f88cb4677a1a3c762e1acc38df4445714eb7c", - ), -]; - -fn cmd_corpus(download: bool) -> Result<(), String> { - let corpus = root().join("corpus"); - println!( - "Test corpora live under {} (git-ignored, never committed). -Tests that use a corpus skip cleanly when it is absent. - - corpus/raw/ raw.pixls.us samples (CC0) — lightcraft-raw decodes every file. - corpus/images/ CC0 / public-domain JPEG/PNG/TIFF/HEIC samples (optional) -", - corpus.display() - ); - if !download { - return Ok(()); - } - let dest = corpus.join("raw"); - std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?; - let mut bad = Vec::new(); - for (name, url, sha256) in RAW_SAMPLES { - let out = dest.join(name); - if !out.exists() { - let mut curl = Command::new("curl"); - curl.args(["-fsSL", "-o"]).arg(&out).arg(url); - run(curl, &format!("curl {url}"))?; - } - let actual = file_sha256(&out)?; - if actual != *sha256 { - // delete it, so the next run downloads it again instead of trusting it - std::fs::remove_file(&out).map_err(|e| format!("remove {}: {e}", out.display()))?; - bad.push(format!("{name}: sha256 is {actual}, expected {sha256} (file deleted; source {url})")); - } - } - if bad.is_empty() { - return Ok(()); - } - Err(format!( - "{} corpus file(s) do not match their pinned sha256: - {}", - bad.len(), - bad.join( - " - " - ) - )) -} - -fn file_sha256(path: &Path) -> Result { - use sha2::{Digest, Sha256}; - let mut file = std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?; - let mut hash = Sha256::new(); - std::io::copy(&mut file, &mut hash).map_err(|e| format!("read {}: {e}", path.display()))?; - Ok(format!("{:x}", hash.finalize())) -} - -#[cfg(test)] -mod ci_jobs_tests { - use super::ci_jobs; - - #[test] - fn jobs_follow_available_ram_and_never_exceed_the_cpus() { - assert_eq!(ci_jobs(Some(3 * 1024), 8), 2, "a 4 GB machine with 3 GB free"); - assert_eq!(ci_jobs(Some(6 * 1024), 8), 4, "an 8 GB machine with 6 GB free"); - assert_eq!(ci_jobs(Some(7 * 1024), 32), 4, "a busy 32 GB machine: what is free counts"); - assert_eq!(ci_jobs(Some(24 * 1024), 32), 16); - assert_eq!(ci_jobs(Some(64 * 1024), 8), 8, "capped at the CPU count"); - assert_eq!(ci_jobs(Some(500), 8), 1, "at least one"); - assert_eq!(ci_jobs(None, 32), 4, "unknown RAM keeps the old default"); - assert_eq!(ci_jobs(None, 2), 2); - } -} - -#[cfg(test)] -mod corpus_tests { - use super::RAW_SAMPLES; - - #[test] - fn every_sample_has_a_unique_name_and_a_sha256() { - let mut names: Vec<_> = RAW_SAMPLES.iter().map(|s| s.0).collect(); - names.sort_unstable(); - names.dedup(); - assert_eq!(names.len(), RAW_SAMPLES.len(), "duplicate file name"); - for (name, _, sha256) in RAW_SAMPLES { - assert!(sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')), "{name}: not a lowercase sha256"); - } - } -} diff --git a/xtask/src/raw_samples.rs b/xtask/src/raw_samples.rs new file mode 100644 index 000000000..c9cbe7e42 --- /dev/null +++ b/xtask/src/raw_samples.rs @@ -0,0 +1,2272 @@ +/// CC0 raw samples (raw.pixls.us). Small, representative set; extend freely (CC0 only). +/// CC0 raw samples from raw.pixls.us (each verified CC0 on the site). +/// The third field is the SHA-256 of the file as downloaded; `cargo xtask corpus --download` checks it. +/// One per format / compression variant we decode or deliberately report as unsupported (preview only). +pub(crate) const RAW_SAMPLES: &[(&str, &str, &str)] = &[ + // Camera-coverage sweep: CC0 files, sensor hashes and comparisons in docs/raw/coverage-sweep.tsv. + ( + "coverage-915.dng", + "https://raw.pixls.us/getfile.php/915/nice/Apple%20-%20iPhone%206s%20Plus%20-%2016bit%20%284:3%29.DNG", + "845fee4f09c832af3728778737f5ae9b99124debb821b6e2066b94aed1daa91c", + ), + ( + "coverage-939.dng", + "https://raw.pixls.us/getfile.php/939/nice/Apple%20-%20iPhone%207%20Plus%20-%2016bit%20%284:3%29.DNG", + "e8c7ebd8f22f7281d165496b91c8fea8a8e79c8113172fb499e1d66d6045332e", + ), + ( + "coverage-3372.dng", + "https://raw.pixls.us/getfile.php/3372/nice/Apple%20-%20iPhone%20XS%20-%2016bit%20%284:3%29.dng", + "50bc64ee8cfbbfb6c0531e29ab428ff985506a16fe2d1d2e74656f21b54505f6", + ), + ( + "coverage-3009.dng", + "https://raw.pixls.us/getfile.php/3009/nice/Hasselblad%20-%20L1D-20c%20-%2016bit%20%283:2%29.DNG", + "7afecae0db80d832941eeeb3c0eb2dc03f5696637b6e78b3c592e8c002dce4ec", + ), + ( + "coverage-5140.dng", + "https://raw.pixls.us/getfile.php/5140/nice/Hasselblad%20-%20L2D-20c%20-%2016bit%20%284:3%29.DNG", + "dbe98cb202f884ff11e0144899613b26d33cf9cddd9f1a62226f4352397f18e4", + ), + ( + "coverage-8041.dng", + "https://raw.pixls.us/getfile.php/8041/nice/DJI%20-%20FC9287%20-%2016bit%20%2816:9%29.DNG", + "901b7970f02f7197c782f111363feef02108f75811342d8cc4a2b66757bb7af0", + ), + ( + "coverage-4785.dng", + "https://raw.pixls.us/getfile.php/4785/nice/DJI%20-%20FC7303%20-%2016bit%20%284:3%29.DNG", + "21a49e63966f82391ca1188c7559c3acc01a35a89a70194fda4bf44cbd64a851", + ), + ( + "coverage-4898.dng", + "https://raw.pixls.us/getfile.php/4898/nice/DJI%20-%20FC7303%20-%2016bit%20%2816:9%29.DNG", + "fb4f294e12cab7b507fd0b7c07f24e5d13e7c0dd127b534ce12c48d644c9363d", + ), + ( + "coverage-3502.dng", + "https://raw.pixls.us/getfile.php/3502/nice/Google%20-%20Pixel%204%20XL%20-%2016bit%20%284:3%29.dng", + "0c03a6c93b4b8593cd0e4426166e6389710007238b82eb23861ef191322f0c54", + ), + ( + "coverage-4176.dng", + "https://raw.pixls.us/getfile.php/4176/nice/Google%20-%20Pixel%204a%20-%2016bit%20%284:3%29.dng", + "11b38186ee3455376291bcdb3049728a3f9dcd94b9e82ff76855d9f3430e44be", + ), + ( + "coverage-5961.dng", + "https://raw.pixls.us/getfile.php/5961/nice/Google%20-%20Pixel%206%20Pro%20-%2016bit%20%284:3%29.dng", + "3a859e917a91698a3e15ce3b3721a48a1fb1296873e0697d3bf7c2793297200d", + ), + ( + "coverage-7751.dng", + "https://raw.pixls.us/getfile.php/7751/nice/Google%20-%20Pixel%208%20Pro%20-%2016bit%20%284:3%29.dng", + "45420c595401547cca950ae58d552bc82b32d31ce1d58c082df33004016197f5", + ), + ( + "coverage-7217.3fr", + "https://raw.pixls.us/getfile.php/7217/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.3FR", + "e27ff177519298fe7bae64506b975190789dc0e6ad70bea58be7c78fd094eae0", + ), + ( + "coverage-7218.fff", + "https://raw.pixls.us/getfile.php/7218/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.fff", + "c8d78912ae725a59acff42a4b4731350bbddd4a600423f319579261bd41da932", + ), + ( + "coverage-1640.fff", + "https://raw.pixls.us/getfile.php/1640/nice/Hasselblad%20-%20H5D-40%20-%2016bit%20%284:3%29.fff", + "209d464e13702de5f4fdb830ac26ac140c98a8d0dc3e81c5ba61d93677330832", + ), + ( + "coverage-1639.fff", + "https://raw.pixls.us/getfile.php/1639/nice/Hasselblad%20-%20H5D-50c%20-%2016bit%20%284:3%29.fff", + "1aad9319fcbab81b851a74c3eeaa2a07fc5013bf2c4e272fd31d6419b6b9adb0", + ), + ( + "coverage-2058.3fr", + "https://raw.pixls.us/getfile.php/2058/nice/Hasselblad%20-%20X1D%20-%2016bit%20%284:3%29.3FR", + "395acb2c706dc10270c58ec261e42a5e36f6ac8c39d2a767e86e83648aa318a3", + ), + ( + "coverage-3874.fff", + "https://raw.pixls.us/getfile.php/3874/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.fff", + "8c2451b3478fb529b4e9bd51ed3267999ce26eb1fcb5c3b93c3ae7bc56267794", + ), + ( + "coverage-3941.3fr", + "https://raw.pixls.us/getfile.php/3941/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.3FR", + "809d498263d6f07cbaa4b9a5be97933057b4ab21e7243bb7afbe3862f3b48b47", + ), + ( + "coverage-6568.fff", + "https://raw.pixls.us/getfile.php/6568/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.fff", + "44977fdaa3063eacbb2ac416dfb531bb4e3b96f3745d073dc49fb1ebcd78bedc", + ), + ( + "coverage-6565.3fr", + "https://raw.pixls.us/getfile.php/6565/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.3FR", + "dcc5a4abe3498e6f25e89bb491995fd12c3b669d9277c71b45b49210d3e56280", + ), + ( + "coverage-4726.nef", + "https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233", + ), + ( + "coverage-4720.nef", + "https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f", + ), + ( + "coverage-4721.nef", + "https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838", + ), + ( + "coverage-3584.nef", + "https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886", + ), + ( + "coverage-3581.nef", + "https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", + "8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02", + ), + ( + "coverage-3582.nef", + "https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF", + "c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31", + ), + ( + "coverage-2792.nef", + "https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded", + ), + ( + "coverage-2789.nef", + "https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e", + ), + ( + "coverage-2790.nef", + "https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114", + ), + ( + "coverage-6617.nef", + "https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF", + "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", + ), + ( + "coverage-2912.dng", + "https://raw.pixls.us/getfile.php/2912/nice/Parrot%20-%20Anafi%20-%2016bit%20%284:3%29.DNG", + "a257a296047ff2d396109fd2e6947fa36aa7986695b6815f83979d59e98a84e6", + ), + ( + "coverage-6728.dng", + "https://raw.pixls.us/getfile.php/6728/nice/Samsung%20-%20Galaxy%20S21%20Ultra%20-%204:3.dng", + "5baeb5f0c4a125647df03b82c2d38b0f02784393bd39eff1a5b23d8f5308dc5e", + ), + ( + "coverage-6261.dng", + "https://raw.pixls.us/getfile.php/6261/nice/Samsung%20-%20Galaxy%20S22%20Ulra%20-%204:3.dng", + "53e7f83a94c965495ba32d3ea6021ef08ed02244ce64d111d1329bbd01fd5581", + ), + ( + "coverage-6697.dng", + "https://raw.pixls.us/getfile.php/6697/nice/Samsung%20-%20Galaxy%20S23%20-%204:3.dng", + "1c04574f32b5a139922f075291c52018b89d9981ee155a16958a231c1fdbb067", + ), + ( + "coverage-1297.dng", + "https://raw.pixls.us/getfile.php/1297/nice/Samsung%20-%20G935F%20-%2016bit%20%2816:9%29.dng", + "843617c8b572320b7badfb4ac2eda19479f5804a1dabb909c861bde23ed30561", + ), + ( + "coverage-2645.dng", + "https://raw.pixls.us/getfile.php/2645/nice/Samsung%20-%20SM-G950F%20-%2016bit%20%284:3%29.dng", + "6abdd353094964eaadddf69bba3a0d551ecce1bc0ed013a434691df4f64dcbf2", + ), + ( + "coverage-2310.dng", + "https://raw.pixls.us/getfile.php/2310/nice/Samsung%20-%20SM-G955F%20-%2016bit%20%284:3%29.dng", + "3cf5da4331d062bc6b31e0e8f5b7ff2cd21107de4262d79a2667f670516e8e8c", + ), + ( + "coverage-7273.dng", + "https://raw.pixls.us/getfile.php/7273/nice/Sigma%20-%20fp%20-%2014bit%20%283:2%29.DNG", + "0844bcf23a49e21b5c2f8489de55ce38fb7b594e3ed337d6b9af2dd9341354e9", + ), + ( + "coverage-6949.dng", + "https://raw.pixls.us/getfile.php/6949/nice/Sigma%20-%20fp%20L%20-%2014bit%20%283:2%29.DNG", + "8e7229f28be30bfa45289940eac04194af2b3474f6db286e4fa4070c145a0a70", + ), + ( + "arw-sony-a7rm4a-compressed.arw", + "https://raw.pixls.us/getfile.php/4822/nice/Sony%20-%20ILCE-7RM4A%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", + "690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67", + ), + ( + "arw-sony-a9m2-compressed.arw", + "https://raw.pixls.us/getfile.php/3989/nice/Sony%20-%20ILCE-9M2%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", + "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97", + ), + ( + "arw-sony-a7m3-compressed.arw", + "https://raw.pixls.us/getfile.php/2414/nice/Sony%20-%20ILCE-7M3%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", + "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f", + ), + ( + "arw-sony-a7m3-uncompressed.arw", + "https://raw.pixls.us/getfile.php/2418/nice/Sony%20-%20ILCE-7M3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.ARW", + "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd", + ), + ( + "arw-sony-a7m4-14bit.arw", + "https://raw.pixls.us/getfile.php/6936/nice/Sony%20-%20ILCE-7M4%20-%2014bit%20%283:2%29.ARW", + "639a6d4db881f1359e3ea7e1137b314e59417d24e6d4d862b7202111a2c823b2", + ), + // lossless compressed (Compression 7): L is 2×2 CFA cells per LJ92 sample; M and S are subsampled + ( + "arw-sony-a7m4-lossless-l.arw", + "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06674_FullFrame-LossLess-Compressed-Large.ARW", + "851b43c2116c4139104a5036f83ac3b6a148789b2142214dd7192c13972b25b6", + ), + ( + "arw-sony-a7m4-lossless-m.arw", + "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06675_FullFrame-LossLess-Compressed-Medium.ARW", + "d453005714327addd75bcb99c1c6223173dc92f2a59542d82e6761ef0e0e7571", + ), + ( + "arw-sony-a7m4-lossless-s.arw", + "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06676_FullFrame-LossLess-Compressed-Small.ARW", + "cbbd0930c7d8706dff84c68a2004454266e6fd0d8354f5f76a106b5d776e0223", + ), + // ILCE-7CR (61 MP), one scene in three codings: lossless compressed L (2×2 CFA cells per LJ92 sample) and M + // (subsampled YCbCr), and compressed ARW2 + ( + "arw-sony-a7cr-lossless-l.arw", + "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00795.ARW", + "3e1642f3a1ae7c9f93228c5f09f27e362608e6a4ca1b3e5d8dd9eb8fae576997", + ), + ( + "arw-sony-a7cr-lossless-m.arw", + "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00796.ARW", + "5bcba4acc52a5b902074581b4a2f5fb3c79ec56bd0fdc1746b9e2c0f9d3e9c88", + ), + ( + "arw-sony-a7cr-compressed.arw", + "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00798.ARW", + "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5", + ), + // ILCE-7CM2 (33 MP, issue #697): lossless compressed L, and the compressed "M-size" coding, which is Bayer at + // 4736×3132 with black 512 (only the lossless M/S codings are YCbCr with the 1024 pedestal) + ( + "arw-sony-a7cm2-lossless-l.arw", + "https://raw.pixls.us/data/Sony/ILCE-7CM2/DSC02431.ARW", + "342d893bd71fd050c273df181e3039d3f12d88947b0662de5de5325c84cc449b", + ), + ( + "arw-sony-a7cm2-compressed-m.arw", + "https://raw.pixls.us/data/Sony/ILCE-7CM2/DSC02436.ARW", + "002c86d38a16b08a2438780728b4fda8efcc745fa6c26231dad6af67b383b11e", + ), + // ILCA-99M2 (42 MP, compressed): checks that it takes its colour from the bundled ILCA-99M2 profile + ( + "arw-sony-a99m2.arw", + "https://raw.pixls.us/data/Sony/ILCA-99M2/BR_00086.ARW", + "be898847ac6da485874d95469bf274df1f8f77f8b8542460464f0f393981b2d2", + ), + // a dusk sky clipped in green behind a poplar: the clipped-highlight colour of issue #523 + ( + "arw-sony-a7rm4-14bit-compressed.arw", + "https://raw.pixls.us/getfile.php/3480/nice/Sony%20-%20ILCE-7RM4%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", + "e6dafe42643f69ab9d1fd00414b7a1f5104df354bb589201600ac934b29b5e4a", + ), + // pre-2017 bodies: white balance and black level only in the encrypted SR2SubIFD (#148) + ( + "arw-sony-rx100m3.arw", + "https://raw.pixls.us/data/Sony/DSC-RX100M3/DSC00734.ARW", + "cb81392e3a8810231ce341fb45de3e4d1b9e2f87bc47f3c314d084041ccfd138", + ), + ( + "arw-sony-rx100.arw", + "https://raw.pixls.us/data/Sony/DSC-RX100/DSC00838.ARW", + "579a485b5126a25cbd55cbd5dadfa7d09cf021c99cc7d4869f9e56e3f759390b", + ), + ( + "arw-sony-a7rm2-12bit-uncompressed.arw", + "https://raw.pixls.us/data/Sony/ILCE-7RM2/12-bit-uncompressed.ARW", + "71e0888396ef52c7e6a990b4e72ebb4d8413a1fc1c35bada449870539cef6e39", + ), + // older DSLRs whose SR2SubIFD keeps the black level elsewhere than the bodies above (#535): A500 (27152-byte + // layout, like the A450/A550), A700 (62112 bytes) + ( + "arw-sony-a500.arw", + "https://raw.pixls.us/data/Sony/DSLR-A500/DSC02421.ARW", + "1407fb596a391df67c15b90026a1a76702a2e7386dbdff2f0815b903ea49cead", + ), + ( + "arw-sony-a700.arw", + "https://raw.pixls.us/data/Sony/DSLR-A700/DSC07249.ARW", + "3159e28892bf0771d01525cd4b6190f9c15bbb19fa2fab6d2515ced0594e8f40", + ), + // SR2SubIFD white balance (#535): the A500 and A700 above, a third layout of a body that had none before + // (SLT-A33, 29000 bytes), and two shots whose maker-note gains differ from the preset applied (5600 K, Shade) + ("arw-sony-a33.arw", "https://raw.pixls.us/data/Sony/SLT-A33/DSC01867.ARW", "1a59856394f10d4fadb40f5ab9c6d1c89f473f4dbcdefd216fbbbbf1ad8d21f9"), + ( + "arw-sony-a3500-5600k.arw", + "https://raw.pixls.us/data/Sony/ILCE-3500/DSC06923.ARW", + "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8", + ), + ( + "arw-sony-a7s-shade.arw", + "https://raw.pixls.us/data/Sony/ILCE-7S/DSC04125.ARW", + "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2", + ), + // pre-2017 bodies in the camera's 16:9 mode (#535): the ILCE-7SM2 records it in FullImageSize, the DSLR-A580 only + // in the Exif image size + ( + "arw-sony-a7sm2-16x9.arw", + "https://raw.pixls.us/data/Sony/ILCE-7SM2/DSC01005.ARW", + "46044fb6a9c805f2b4915cd4970316b4d04b0566bcf5287afaad3ef648d7a3db", + ), + ( + "arw-sony-a580-16x9.arw", + "https://raw.pixls.us/data/Sony/DSLR-A580/RAW_SONY_A580.ARW", + "5b0924d39151239dce19e92e08318f4f62a5a8ac276bd7180ec8463d2cfee709", + ), + // packed 12-bit ARW (two pixels per three bytes): the DSLR-A900 + ( + "arw-sony-a900-packed12.arw", + "https://raw.pixls.us/data/Sony/DSLR-A900/_DSC7969.ARW", + "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50", + ), + // Sony ILCE-7RM6 lossless, CC0 1.0 (RawDB): the APS-C file, for its lens tables (`vendor::arw_lens_rm6`). The + // full-frame lossless file is not pinned (at most two pins per PR); the two ARW6 pins below cover Compressed files. + ( + "arw-sony-ilce7rm6-apsc-land-lossless.arw", + "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/raw_modes/ILCE-7RM6_APSC_land_RAW.ARW", + "7aaf0b3941509d7364a6f03844783155ebc912d246d1c456e5cdb4693ec09fbc", + ), + // CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples + ( + "cr2-canon-40d.cr2", + "https://raw.pixls.us/data/Canon/EOS%2040D/_MG_0153.CR2", + "775c806358fedddec7622113a5e399a3330bd5a65e35e37ae1108d8bf58067be", + ), + ( + "cr2-canon-550d.cr2", + "https://raw.pixls.us/data/Canon/EOS%20550D/IMG_4047.CR2", + "f390e0ba566f0be9af2d8e7f955b815996630c0c3cb13fcfb95d98baec1890f6", + ), + ( + "cr2-canon-5d2.cr2", + "https://raw.pixls.us/data/Canon/EOS%205D%20Mark%20II/08.canon.raw.cr2", + "12b3ad6aed9fbcd19ba3bfffcd0769aa5a041d610205eee840bf4b2c420dffae", + ), + ( + "cr2-canon-5dsr.cr2", + "https://raw.pixls.us/data/Canon/EOS%205DS%20R/_DSR2002.CR2", + "01b94b0586419d64894ccdf2e7b9f1defeedc7b51dfcd306c622fe784181a9b8", + ), + ( + "cr2-canon-6d.cr2", + "https://raw.pixls.us/data/Canon/EOS%206D/EOS_6D_RAW.CR2", + "360842779d08fe4805b4a2fe4979f06fdc6da982c85c3acb181f25bcf4c9f536", + ), + ( + "cr2-canon-7d.cr2", + "https://raw.pixls.us/data/Canon/EOS%207D/RAW_CANON_EOS_7D-raw.CR2", + "b5e47c5fcf7332ac03e0134926f17a338a42e68c1fd7f83e16f45f4b767544e8", + ), + ( + "cr2-canon-5d3-sraw2.cr2", + "https://raw.pixls.us/getfile.php/773/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%20sRAW2%20%28sRAW%29.CR2", + "a79065e78a6c5bdbc8726e786ade02d54a7d496cc3cb4504d804c924a3d999da", + ), + ( + "cr2-canon-5d3.cr2", + "https://raw.pixls.us/getfile.php/771/nice/Canon%20-%20EOS%205D%20Mark%20III.CR2", + "ec069b178b9383d80c72f8cdc1b79bd54ca451e7c20793b109d8ad73bef6bdf6", + ), + ( + "cr2-canon-80d.cr2", + "https://raw.pixls.us/getfile.php/1294/nice/Canon%20-%20EOS%2080D%20-%20RAW%20%283:2%29.CR2", + "8f28465cee09844ffad95dbd3ec91923717a329c5ec4f3f405f5b4fce83aaea3", + ), + ( + "cr3-canon-m50-craw.cr3", + "https://raw.pixls.us/getfile.php/2663/nice/Canon%20-%20EOS%20M50%20-%20CRAW%20%283:2%29.CR3", + "15384b775867ec4c42b11882837f1e368cedc0561832ffab271221e6bb80be4c", + ), + ( + "cr3-canon-m50-raw.cr3", + "https://raw.pixls.us/getfile.php/4657/nice/Canon%20-%20EOS%20M50%20-%203:2.CR3", + "1ba9ad6b51b315b88820eb0fe5fd7f52bc136ba37b1a5ca76825ebfe25b18058", + ), + ( + "cr3-canon-r100-raw.cr3", + "https://raw.pixls.us/getfile.php/7896/nice/Canon%20-%20EOS%20R100%20-%20RAW%20%283:2%29.CR3", + "6d83217d58a5e6d2dabcf470430e91a16676b60531c025b81fa7453fc74b0521", + ), + ( + "cr3-canon-r100-craw.cr3", + "https://raw.pixls.us/getfile.php/7897/nice/Canon%20-%20EOS%20R100%20-%20CRAW%20%283:2%29.CR3", + "0b66842b2fe00329ebc05fe8ab9357ddbbe1a0a2d2e69a2893102de7fdf5c942", + ), + ( + "cr3-canon-r8-raw.cr3", + "https://raw.pixls.us/getfile.php/6585/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3", + "7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c", + ), + ( + "cr3-canon-r8-craw.cr3", + "https://raw.pixls.us/getfile.php/6587/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3", + "df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812", + ), + ( + "cr3-canon-rp-raw.cr3", + "https://raw.pixls.us/getfile.php/4674/nice/Canon%20-%20EOS%20RP%20-%203:2.CR3", + "dcbd0f6f9929de2e2331b0fd10a9d01e7cfd04c5a5e4038456de1647d714cdf2", + ), + ( + "cr3-canon-rp-craw.cr3", + "https://raw.pixls.us/getfile.php/4672/nice/Canon%20-%20EOS%20RP%20-%203:2.CR3", + "bd3270610593d37ff8a399908bb9406a4e32f9151d55bf19ac1cf8f10d787b46", + ), + ( + "cr3-canon-rp-raw-crop.cr3", + "https://raw.pixls.us/getfile.php/4673/nice/Canon%20-%20EOS%20RP%20-%203:2.CR3", + "7d4ba45dc9264d598336133593c677b09774bcdf13b602ee770e72415a652a16", + ), + ( + "cr3-canon-rp-craw-crop.cr3", + "https://raw.pixls.us/getfile.php/4671/nice/Canon%20-%20EOS%20RP%20-%203:2.CR3", + "4a859ab137f792aedaf3416b0aad1497ebaab02052a49453902e226699154aa7", + ), + // EOS R5 Mark II, full frame and 1.6x crop mode: odd-height (2825- / 1787-row) Bayer planes. + ( + "cr3-canon-r5m2-raw.cr3", + "https://raw.pixls.us/getfile.php/7881/nice/Canon%20-%20EOS%20R5%20Mark%20II%20-%20RAW%20%283:2%29.CR3", + "d6e6aa3b493ea8936dff0efb0cc17c87471437e7317bce37c8c82ba08b9cab7b", + ), + ( + "cr3-canon-r5m2-craw.cr3", + "https://raw.pixls.us/getfile.php/7882/nice/Canon%20-%20EOS%20R5%20Mark%20II%20-%20CRAW%20%283:2%29.CR3", + "b3c08a5c7a97299dfd58896a12b939e7b43ec78682761812524825e6e5d087be", + ), + ( + "cr3-canon-r5m2-apsc-raw.cr3", + "https://raw.pixls.us/getfile.php/7883/nice/Canon%20-%20EOS%20R5%20Mark%20II%20-%20RAW%20%283:2%29.CR3", + "9f88c0ff012924193b7f4497559d133dfe626766d600b5d67689156721739ead", + ), + ( + "cr3-canon-r5m2-apsc-craw.cr3", + "https://raw.pixls.us/getfile.php/7884/nice/Canon%20-%20EOS%20R5%20Mark%20II%20-%20CRAW%20%283:2%29.CR3", + "6a597497776a6abbfab8fcf12d0086ac7a3f8afea2879d61accf6bbf48d357d7", + ), + ( + "cr3-canon-m200.cr3", + "https://raw.pixls.us/getfile.php/8469/nice/Canon%20-%20EOS%20M200%20-%20RAW%20%283:2%29.CR3", + "9b900c04ae37ba827b7894e66907657a0b174a74855c40039a8ca0c95583f141", + ), + ( + "cr3-canon-r10.cr3", + "https://raw.pixls.us/getfile.php/5871/nice/Canon%20-%20EOS%20R10%20-%203:2.CR3", + "5d2e076d38e7835d4c4f13dc2030a913cc573a31d7199b60777d23a25ccc3ef9", + ), + ( + "cr3-canon-r50v.cr3", + "https://raw.pixls.us/getfile.php/8403/nice/Canon%20-%20EOS%20R50%20V%20-%20RAW%20%283:2%29.CR3", + "955ff98710505498ce7f98cbcc5875793ef2d6941b3b5692ed3baf2817ee7c9f", + ), + ( + "cr3-canon-r6m3.cr3", + "https://raw.pixls.us/getfile.php/8961/nice/Canon%20-%20EOS%20R6%20Mark%20III%20-%20RAW%20%283:2%29.CR3", + "e491e4bb960961b5fa299361bf698310a80cbe7b15d30d8dad3bb21bc5457dab", + ), + ( + "cr3-canon-powershot-v1-craw.cr3", + "https://raw.pixls.us/getfile.php/8500/nice/Canon%20-%20PowerShot%20V1%20-%20CRAW%20%283:2%29.CR3", + "0a0abfd81b3a34fa513ac8ed24c809275160d65b07e892c2eb4353b77c535a31", + ), + ( + "cr3-canon-1dxm3-craw.cr3", + "https://raw.pixls.us/getfile.php/4654/nice/Canon%20-%20EOS-1D%20X%20Mark%20III%20-%203:2.CR3", + "e5755d1414a2dd51fb0120b272058f00b0199861646070c00304d927eb119560", + ), + ( + "cr3-canon-250d-craw.cr3", + "https://raw.pixls.us/getfile.php/3383/nice/Canon%20-%20EOS%20250D%20-%20C-RAW%20%283:2%29.CR3", + "69bdd267defb3d676f41f62a5e5ea65651669a04191cf4e89740917c59cedbcc", + ), + ( + "cr3-canon-850d.cr3", + "https://raw.pixls.us/getfile.php/4867/nice/Canon%20-%20EOS%20850D%20-%203:2.CR3", + "47f7747b74073515bdbd65015738bc31947504d68255c5377baa21f179df2486", + ), + ( + "cr3-canon-90d-craw.cr3", + "https://raw.pixls.us/getfile.php/4650/nice/Canon%20-%20EOS%2090D%20-%203:2.CR3", + "c8c918b2d1a53e64d7d5009ebac89a920dbba5f766c2f396bb4ec95312b21034", + ), + ( + "cr3-canon-m50m2.cr3", + "https://raw.pixls.us/getfile.php/4430/nice/Canon%20-%20EOS%20M50%20Mark%20II%20-%203:2.CR3", + "1a3c11d85f48b8c644664b505f69fbdd75eb51270bb8ab556dbc660f0487e957", + ), + ( + "cr3-canon-m6m2-craw.cr3", + "https://raw.pixls.us/getfile.php/3714/nice/Canon%20-%20EOS%20M6%20Mark%20II%20-%20C-RAW%20%283:2%29.CR3", + "94635c6f91e7d743cee6881cc2519992ae9f9031fd86b49e5d0f16edf5961b55", + ), + ( + "cr3-canon-r3.cr3", + "https://raw.pixls.us/getfile.php/5051/nice/Canon%20-%20EOS%20R3%20-%203:2.CR3", + "295d46a4d5383895e63126f67000d4352a5bb425ee4d8b16eb6b6c284bdf7994", + ), + ( + "cr3-canon-r50-craw.cr3", + "https://raw.pixls.us/getfile.php/6646/nice/Canon%20-%20EOS%20R50%20-%20CRAW%20%283:2%29.CR3", + "b79af9ffa184dd740e8af7a9f451f7150053d7e456b3435ca2e43bbbf5f65026", + ), + ( + "cr3-canon-r5-craw.cr3", + "https://raw.pixls.us/getfile.php/4698/nice/Canon%20-%20EOS%20R5%20-%203:2.CR3", + "2deae46b816e56ba91ae7a3892ca121290e12d5b22afb11f5b05a3d8f09f759f", + ), + ( + "cr3-canon-r6-craw.cr3", + "https://raw.pixls.us/getfile.php/4660/nice/Canon%20-%20EOS%20R6%20-%203:2.CR3", + "4775df76433b1e5844bc7a42b95d93efc8438c0a4cd9681e120e23fe3e3007e8", + ), + ( + "cr3-canon-r6m2-craw.cr3", + "https://raw.pixls.us/getfile.php/6409/nice/Canon%20-%20EOS%20R6%20Mark%20II%20-%203:2.cr3", + "409eb724212abde94e18fbd3170abf3264951fab13384c36cbf6fb4f22ea4201", + ), + ( + "cr3-canon-r7.cr3", + "https://raw.pixls.us/getfile.php/5633/nice/Canon%20-%20EOS%20R7%20-%203:2.CR3", + "887439cb1a45becd6a5c85fe75ae10e6c520a9f13a8b9ed077c6cf5d7c37700c", + ), + ( + "cr3-canon-r-craw.cr3", + "https://raw.pixls.us/getfile.php/4543/nice/Canon%20-%20EOS%20R%20-%203:2.CR3", + "1b3efe0664a28a0decb53e225cbfecdbc0dd27d85abdc4683fc2a4add5129b4d", + ), + ( + "cr3-canon-g5xm2-craw.cr3", + "https://raw.pixls.us/getfile.php/3506/nice/Canon%20-%20PowerShot%20G5%20X%20Mark%20II%20-%20C-RAW%20%283:2%29.CR3", + "16d7b944344004c866bf2f84c5673969e76adfe16e5fb4388da48c72bdd58058", + ), + ( + "cr3-canon-g7xm3.cr3", + "https://raw.pixls.us/getfile.php/6053/nice/Canon%20-%20PowerShot%20G7%20X%20Mark%20III%20-%203:2.CR3", + "63ce623055dd2ac7374e0b1c645a4eabc7e181cd12c2039625f64b4e63a5ee86", + ), + ( + "cr3-canon-sx70.cr3", + "https://raw.pixls.us/getfile.php/3276/nice/Canon%20-%20PowerShot%20SX70%20HS%20-%20RAW%2C%204:3%20%284:3%29.CR3", + "3b346078836f933f505338ba45965be17a39cac82bfffc7006db07e3af944839", + ), + ( + "dng-adobe-canon-5d3-linear-lj92.dng", + "https://raw.pixls.us/getfile.php/1032/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossless%20JPEG%20compression%2C%20rgb%20%283:2%29.DNG", + "0608c9f277307b745530e61b589c465540611df22f9a1fe2d01237c5b2b4c13b", + ), + ( + "dng-adobe-canon-5d3-lj92.dng", + "https://raw.pixls.us/getfile.php/1024/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%2016bit%2016bit%20Lossless%20JPEG%20compression%20%283:2%29.DNG", + "3118116735d4f6dd01fd9d6a80ee9aac52a2c2debdc6dcd112bd5e3059044a55", + ), + ( + "dng-adobe-canon-5d3-lossy.dng", + "https://raw.pixls.us/getfile.php/1023/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossy%20JPEG%20compression%20%283:2%29.DNG", + "159326856c29073e845c3c5a9ecf98c6474f43ca15798a88ad5e2baecd0664b7", + ), + // Apple ProRAW (iPhone 12 Pro, iOS 14.3): LinearRaw LJ92 tiles, ProfileToneCurve, ProfileGainTableMap, a sky matte. + ( + "dng-apple-iphone12pro-proraw.dng", + "https://raw.pixls.us/data/Apple/iPhone%2012%20Pro/IMG_1361.DNG", + "e91e77a4533ed7cce551d83330676ea5c47dd5e55fb38adda7819366afdbdfc2", + ), + ( + "dng-canon-5d3-14bit-small.dng", + "https://raw.pixls.us/getfile.php/2204/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2014bit%2014bit%20%282.3471882640587%29.dng", + "1d77dcc6cdb839b10c1948f0b4471984ab48cf15e973fb77520584cee72b603e", + ), + ( + "dng-canon-5d3-16bit-169.dng", + "https://raw.pixls.us/getfile.php/2649/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2016bit%20%2816:9%29.dng", + "bc29ac8a37c800346749b9a5a1adc2722357f7f0e49df05a7104454881604b92", + ), + ( + "dng-canon-5d3-16bit.dng", + "https://raw.pixls.us/getfile.php/885/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2016bit%2016bit%20RAW.dng", + "6275226bfa2d86ba479130999d045ea0b8da6be4b011130c9eb91cd6a0eb2233", + ), + ( + "dng-google-pixel2xl.dng", + "https://raw.pixls.us/getfile.php/2206/nice/Google%20-%20Pixel%202%20XL%20-%2016bit%20%284:3%29.dng", + "5541093a369f0487fef92bcd1f5fef1be6e4e3cec2aa4d52ad38d2211f3309d7", + ), + ( + "dng-ricoh-gr3.dng", + "https://raw.pixls.us/getfile.php/3115/nice/Ricoh%20-%20GR%20III%20-%2014bit%20%283:2%29.DNG", + "05513ee72f7cac4165534c9f64995412084f414a033c80c473c79fd94154292e", + ), + ( + "nef-nikon-d5100-lossless.nef", + "https://raw.pixls.us/getfile.php/1597/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.nef", + "3ce328830942381648be26b372a54b317fdb27b63c1aa9c2ee1fce076a2e1b4f", + ), + ( + "nef-nikon-d5100-uncompressed.nef", + "https://raw.pixls.us/getfile.php/1598/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20uncompressed%20%283:2%29.nef", + "2fb2f1b87a3b89de5ba9e56527f21a912ca624f2fa2a594cef108ee8d7180412", + ), + ( + "nef-nikon-d7000-lossy12.nef", + "https://raw.pixls.us/getfile.php/961/nice/Nikon%20-%20D7000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "986db2bf9ed08cf095bb94cfbaf02f0e89d123edad28b63c05133e7815749486", + ), + ( + "nef-nikon-d7500-lossless12.nef", + "https://raw.pixls.us/getfile.php/1532/nice/Nikon%20-%20D7500%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "1f4e58a961d3d317fa39a7e1c8948b825651e968c8cd06e49e4239f612faccad", + ), + ( + "nef-nikon-d7500-lossless14.nef", + "https://raw.pixls.us/getfile.php/1534/nice/Nikon%20-%20D7500%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "430b4f1be4e53a011861b63294ad19fdc0353ec3ea62104f77d4c193e3dd3fc8", + ), + ( + "nef-nikon-zf-he.nef", + "https://raw.pixls.us/download/data/Nikon/Z%20f/DSC_0043.NEF", + "98d6ca8e6c98048ca7ffed68ccaeda7b2b9f03807f0d320d97d5678db21748c2", + ), + ( + "nef-nikon-zf-lossless.nef", + "https://raw.pixls.us/download/data/Nikon/Z%20f/DSC_0040.NEF", + "83c82be0be8865d796096dfbcc8ef2abf5af1bd37db44dfad6715070b0c99d15", + ), + ( + // Z 6II, NIKKOR Z 24-200 at 70 mm, Vivid picture control (not used to fit the bundled Z 6II profile) + "nef-nikon-z6ii-lossless.nef", + "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%20compressed%20(Lossless)%20(3:2).NEF", + "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", + ), + ( + // Z 8, NIKKOR Z 14-24 f/2.8 S (not used to fit the bundled Z 8 profile) + "nef-nikon-z8-lossless.nef", + "https://raw.pixls.us/data/Nikon/Z%208/Nikon_Z8_raw_14_bit_lossless_compression.NEF", + "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", + ), + ( + "nrw-nikon-b700-uncompressed.nrw", + "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", + "a175d5892304e79282add07eacf4bdc498ca3f050af48cf989c080355b4735ec", + ), + ( + "orf-olympus-e1.orf", + "https://raw.pixls.us/getfile.php/1800/nice/Olympus%20-%20E-1%20-%2016bit%20%284:3%29.ORF", + "042286653fbae5b085bef4a4e626145385ea6e82e817f166b4904abcef64457c", + ), + ( + "orf-olympus-e400.orf", + "https://raw.pixls.us/getfile.php/2151/nice/Olympus%20-%20E-400%20-%2016bit%20%284:3%29.ORF", + "1354e0ac98ea90820227650031d8215637a2aaabd15de995c5a1e00b1986261b", + ), + ( + "orf-olympus-em1.orf", + "https://raw.pixls.us/getfile.php/1051/nice/Olympus%20-%20E-M1%20-%2016bit%20%284:3%29.orf", + "19ba17e778ae802d4039f0e88bb1cd68825780788b8058c479067e32b9a63eda", + ), + ( + "orf-olympus-em10iii.orf", + "https://raw.pixls.us/getfile.php/1787/nice/Olympus%20-%20E-M10%20Mark%20III%20-%2016bit%20%284:3%29.ORF", + "11d90cbb564ad2c660c7a8d5d81d99c2c018aa27fdec45ed6c461fa5d24f942b", + ), + ( + "orf-olympus-xz2.orf", + "https://raw.pixls.us/getfile.php/1432/nice/Olympus%20-%20XZ-2%20-%2012bit%20%284:3%29.orf", + "675fefe7c9e99281783b987889d723b5f46692f5c20e19fed5e422c7c6d2636b", + ), + ( + "pef-pentax-k10d.pef", + "https://raw.pixls.us/getfile.php/2239/nice/Pentax%20-%20K10D%20-%2012bit%2012bit%20compressed%20%283:2%29.PEF", + "e35ae4154a468be3154f5f462e884ba5941f010d3e8f23d347fbec14809f44d3", + ), + ( + "pef-pentax-k3.pef", + "https://raw.pixls.us/getfile.php/1075/nice/Pentax%20-%20K-3%20-%2014bit%20%283:2%29.PEF", + "abfb3907b53734b5f353e8de0c6173470c7aad07f9cecc10f723d222a49dec9a", + ), + ( + "pef-pentax-k5iis.pef", + "https://raw.pixls.us/getfile.php/1198/nice/Pentax%20-%20K-5%20II%20s%20-%2014bit%20%283:2%29.PEF", + "e579e7360c35e512f9c3a5c0eb9519575b87b489c6f86be82590f7706a7ed17b", + ), + // Samsung SRW, one file per packing: 12-bit MSB-first (NX10), 12-bit LSB-first (NX20), 16-bit words (EX1). + ( + "srw-samsung-nx10.srw", + "https://raw.pixls.us/getfile.php/5913/nice/Samsung%20-%20NX10%20-%2012bit%20%283:2%29.SRW", + "dead7b4ad83b739ff7b8a1218882589f66cb14efc09f79eeba7a1dcbf7abd29f", + ), + ( + "srw-samsung-nx20.srw", + "https://raw.pixls.us/getfile.php/5370/nice/Samsung%20-%20NX20%20-%2012bit%20%283:2%29.SRW", + "8a367db506b8f7a6661a6f33a730467cfb3bfd7e46020a9f0df702330a7b45bb", + ), + ( + "srw-samsung-ex1.srw", + "https://raw.pixls.us/getfile.php/1204/nice/Samsung%20-%20EX1%20-%2014bit%20%284:3%29.SRW", + "a084890fd9d7995ceb746c18ecf5be48c95baf14286d5c0c480cc3daa25f3e37", + ), + ( + "raf-fuji-xa2-12bit-bayer.raf", + "https://raw.pixls.us/getfile.php/2883/nice/Fujifilm%20-%20X-A2%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "58ad687b23138c3c49ebeb6c1f80daf13185349be364b947e2bf27e39a462d2f", + ), + ( + "raf-fuji-xa5-14bit-bayer.raf", + "https://raw.pixls.us/getfile.php/2526/nice/Fujifilm%20-%20X-A5%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "46daaf8c2b07f40bd01f737181d048a497bb0a87615a1fde65809fdd5de86bf1", + ), + ( + "raf-fuji-xe1-12bit.raf", + "https://raw.pixls.us/getfile.php/3098/nice/Fujifilm%20-%20X-E1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "0fa01fa1a674bcfa40f659225b4e66c5bedf15d2f78407ca1966dc8f38cd0e9f", + ), + ( + "raf-fuji-xt20-14bit.raf", + "https://raw.pixls.us/getfile.php/1177/nice/Fujifilm%20-%20X-T20%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "afe552f4a2794c7aa2376dd826165354b243dcf6f2296b01505dded8c8e4be76", + ), + ( + "raf-fuji-xt20-compressed.raf", + "https://raw.pixls.us/getfile.php/1178/nice/Fujifilm%20-%20X-T20%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "a23045101b2912e64f154100d646c228c0d16585d21e95e0d1a006e537f7784c", + ), + ( + "raw-panasonic-fz50.raw", + "https://raw.pixls.us/getfile.php/2234/nice/Panasonic%20-%20DMC-FZ50%20-%204:3.RAW", + "a776a941758214bf4d1342e2b89ed756bfcad5877e038cf9df3bdda502c432ea", + ), + ( + "raw-panasonic-fz8.raw", + "https://raw.pixls.us/getfile.php/2282/nice/Panasonic%20-%20DMC-FZ8%20-%204:3.RAW", + "a11f1534f3092a4d5f2b57d182f6b06f8d26cf766eb463c3e02b26a338b63b8b", + ), + ( + "rw2-panasonic-fz1000m2-4x3.rw2", + "https://raw.pixls.us/getfile.php/4706/nice/Panasonic%20-%20DC-FZ10002%20-%204:3.RW2", + "ab30922774bd829a992471d89eeefe1e829565a886d1477958130a2fa4fb77a1", + ), + // Fujifilm predictive compression: older X-Trans, 40 MP X-Trans, 14/16-bit GFX; both compression modes. + ( + "raf-fuji-gfx100-3773.raf", + "https://raw.pixls.us/getfile.php/3773/nice/Fujifilm%20-%20GFX%20100%20-%2016bit%2016bit%20compressed%20%284:3%29.RAF", + "018781e126842596fcf3876b3fb6cd8973dc3936ccc753ee8648618be8889287", + ), + ( + "raf-fuji-gfx100-3775.raf", + "https://raw.pixls.us/getfile.php/3775/nice/Fujifilm%20-%20GFX%20100%20-%2014bit%2014bit%20compressed%20%284:3%29.RAF", + "0e7dad9819a748bb3d3be26bbf5770d4968e2d80b9cb41d97036bf7c27f7bfe7", + ), + ( + "raf-fuji-gfx100rf-8091.raf", + "https://raw.pixls.us/getfile.php/8091/nice/Fujifilm%20-%20GFX100RF%20-%2016bit%20compressed%20%284:3%29.RAF", + "14bed5ac8191d6f2a7c82984e3bf6470ad4740df329293eac42b8018cc6a668a", + ), + ( + "raf-fuji-gfx100s-4495.raf", + "https://raw.pixls.us/getfile.php/4495/nice/Fujifilm%20-%20GFX100S%20-%2016bit%2016bit%20lossless%20compressed%20%284:3%29.RAF", + "a7bf8f061b603f7ef5fd29276eb6b2ac57355ca2f589a340117bfff0d03c065c", + ), + ( + "raf-fuji-gfx100s-4503.raf", + "https://raw.pixls.us/getfile.php/4503/nice/Fujifilm%20-%20GFX100S%20-%2016bit%2016bit%20compressed%20%284:3%29.RAF", + "1bb4608b61861a3d9e85b0ca6c00689b8ba4620cbe8579374d958c21ed08edf0", + ), + ( + "raf-fuji-gfx50s-1435.raf", + "https://raw.pixls.us/getfile.php/1435/nice/Fujifilm%20-%20GFX%2050S%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "a7124d13910e0241a6b035154bc1fcdb7be4439371d043b1e1950a0251feb51e", + ), + ( + "raf-fuji-xe5-8509.raf", + "https://raw.pixls.us/getfile.php/8509/nice/Fujifilm%20-%20X-E5%20-%2014bit%20lossy%20compressed%20%283:2%29.RAF", + "28f56cc9784164209e92fae209bdcc78267bd692aa2fb964595485f7e93692ab", + ), + ( + "raf-fuji-xh2-6001.raf", + "https://raw.pixls.us/getfile.php/6001/nice/Fujifilm%20-%20X-H2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "caeadb2666f6c5d9bbe32fbe692c2079b6a7e028f8514a13173f360656d08507", + ), + ( + "raf-fuji-xh2-6002.raf", + "https://raw.pixls.us/getfile.php/6002/nice/Fujifilm%20-%20X-H2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "109be38f59963c9b8ff289ebe1f5ec9168c52f609e5b601432bd194520b4605b", + ), + ( + "raf-fuji-xm5-7748.raf", + "https://raw.pixls.us/getfile.php/7748/nice/Fujifilm%20-%20X-M5%20-%2014bit%20compressed%20%283:2%29.RAF", + "c76ce546e15727abb4ba201471010c554403176963a1a6b8cc017462606429e5", + ), + ( + "raf-fuji-xt2-865.raf", + "https://raw.pixls.us/getfile.php/865/nice/Fujifilm%20-%20X-T2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "f756ad5be551a8b3e1b503a8a6f7e8e637fba519c0b0f8c79dd58e94b0287602", + ), + ( + "raf-fuji-xt4-3914.raf", + "https://raw.pixls.us/getfile.php/3914/nice/Fujifilm%20-%20X-T4%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.RAF", + "07ea642656074834ffe5c661870a612ffa2e80fc1218a05166ee966bbe5f6fae", + ), + ( + "raf-fuji-xt4-3918.raf", + "https://raw.pixls.us/getfile.php/3918/nice/Fujifilm%20-%20X-T4%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "871836938ffa695f4c41765ddb107c2703ce755f7f3ced7e10f9e04746f1734e", + ), + ( + "raf-fuji-xt5-6122.raf", + "https://raw.pixls.us/getfile.php/6122/nice/Fujifilm%20-%20X-T5%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "b2a067e7fda16338f15c2532caa12b238b2415dc1c75a96a6d24d3445cfd0ae3", + ), + ( + "raf-fuji-xt5-6123.raf", + "https://raw.pixls.us/getfile.php/6123/nice/Fujifilm%20-%20X-T5%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "cddd7ae0c43f9280876e5fdcbdf8878718972ebd3d034d8affc8cfc6752d33dc", + ), + ( + "raf-fuji-xt50-7807.raf", + "https://raw.pixls.us/getfile.php/7807/nice/Fujifilm%20-%20X-T50%20-%2014bit%20compressed%20%283:2%29.RAF", + "3a8a9ac6d92274969aaa1f55ea669191b98bb8f588d00ec8ec17afd8b3963dea", + ), + // Sony ILCE-7RM6 ARW6, CC0 1.0 (RawDB): one full-frame Compressed (RAW-C) and one APS-C Compressed HQ (RAW-C-HD) file; + // docs/arw6-compression.md lists the rest of the set. + ( + "arw6-sony-ilce7rm6-apsc-land-hq.arw", + "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/arw6/ILCE-7RM6_APSC_land_RAW-C-HD.ARW", + "b60a7e752d2a8102ccbe6eebcfdbf533c6b0f2545739090873e9e8eb32cc0637", + ), + ( + "arw6-sony-ilce7rm6-ff-land-c.arw", + "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/arw6/ILCE-7RM6_FF_land_RAW-C.ARW", + "d900d6cbe838be864269ad54de4a33fcac7eb3a3d4f91009739f770b8f85a23b", + ), + ( + "rw2-panasonic-g9-b.rw2", + "https://raw.pixls.us/getfile.php/2348/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2", + "16282357ce0143ff7d36737f82074662db7aa0790ecd309196f406baf0f586a7", + ), + ( + "rw2-panasonic-g9.rw2", + "https://raw.pixls.us/getfile.php/2585/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2", + "7a56babeb19f9bde3eaa532a4c8d1b11637a0f550963a1182ec845722c65583d", + ), + ( + "rw2-panasonic-gh1.rw2", + "https://raw.pixls.us/getfile.php/1323/nice/Panasonic%20-%20DMC-GH1%20-%204:3.RW2", + "2b846f932c665f196af9af97b18469730331b60b2c62811969be7db245ce6cce", + ), + ( + "rw2-panasonic-gh5.rw2", + "https://raw.pixls.us/getfile.php/1517/nice/Panasonic%20-%20DC-GH5%20-%204:3.RW2", + "dae2281df393d8c3c1b8c513d00985f1834ae0e69ce18fbd571370c25d95d22b", + ), + ( + "rw2-panasonic-gh5m2.rw2", + "https://raw.pixls.us/getfile.php/5082/nice/Panasonic%20-%20DC-GH5M2%20-%204:3.RW2", + "0ea0efd2a9442d8c68508e4196e29f1da4577407dc3df5add4cf360f70493c7b", + ), + ( + "rw2-panasonic-gh5s.rw2", + "https://raw.pixls.us/getfile.php/2603/nice/Panasonic%20-%20DC-GH5S%20-%204:3.RW2", + "b617151876e62b492641cef14b8c98ee3797e5014d24c6af1c780715a501fa53", + ), + ( + "rw2-panasonic-gh6.rw2", + "https://raw.pixls.us/getfile.php/5876/nice/Panasonic%20-%20DC-GH6%20-%204:3.RW2", + "07996bca904a178d02c5f56ee03a8dce775a0e1150c1beeaec51d72ff5e700d0", + ), + ( + "rw2-panasonic-gx80.rw2", + "https://raw.pixls.us/getfile.php/1569/nice/Panasonic%20-%20DMC-GX80%20-%204:3.RW2", + "6e9a419c70c2124630912b2934027c635d3871e3d22dd91fed0655d58cfb5382", + ), + ( + "rw2-panasonic-s1.rw2", + "https://raw.pixls.us/getfile.php/3038/nice/Panasonic%20-%20DC-S1%20-%203:2.RW2", + "45309a096d6010281e2fa310e6ec2fe1bcc02947fa37b1a2414cfc7377ce4ac6", + ), + ( + "rw2-panasonic-s5-format7.rw2", + "https://raw.pixls.us/getfile.php/6339/nice/Panasonic%20-%20DC-S5%20-%203:2.RW2", + "3615c85207097180b231f0c89d018eb3f1a71988780612921eb0a05246bca706", + ), + ( + "rw2-panasonic-s5m2.rw2", + "https://raw.pixls.us/getfile.php/7790/nice/Panasonic%20-%20DC-S5M2%20-%2014bit%20%283:2%29.RW2", + "53a171521a7f9af6910b4ab61c5a4a42de0cceb24166e15b07eee1e00675028f", + ), + ( + "rw2-panasonic-s9.rw2", + "https://raw.pixls.us/getfile.php/7702/nice/Panasonic%20-%20DC-S9%20-%203:2.RW2", + "290ad5b4e396cb241a59459b2613d2b0659a03ff654e5d5368b3376d35812e50", + ), + ( + "rwl-leica-dlux7.rwl", + "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL", + "4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c", + ), + ( + "mrw-minolta-dimage7hi-words16.mrw", + "https://raw.pixls.us/data/Minolta/DiMAGE%207Hi/RAW_MINOLTA_DIMAGE_7HI.MRW", + "2919a05757197a5424e02970c016893bca5aa29363a67b62b1a4b36326a10f6a", + ), + ( + "mrw-minolta-dimage-a1.mrw", + "https://raw.pixls.us/data/Minolta/DiMAGE%20A1/RAW_MINOLTA_A1.MRW", + "bde1e2cd788e49c9bef20dfdd41be948fb02c2ee198c6b0959a67a3f79fd41c9", + ), + ( + "mrw-minolta-dimage-a200-gbrg.mrw", + "https://raw.pixls.us/data/Minolta/DiMAGE%20A200/RAW_MINOLTA_DIMAGE_A200.MRW", + "2a95f6af2168e536dfb6d02b47e3b6119b35b497eebf179d7c20a6e496c26641", + ), + ( + "mrw-minolta-dynax7d.mrw", + "https://raw.pixls.us/data/Minolta/Dynax%207D/PICT3410.MRW", + "713819e33e5302ec864d576d57223cfc8dcf46e8d45e0ae744d1fec40290a89f", + ), + ( + "nef-nikon-d700.nef", + "https://raw.pixls.us/getfile.php/2249/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "00c3314f2ccf92aff17644943e6149e8793b517c24bb2c38a15ec41f3af109cb", + ), + ( + "raf-fujifilm-xf10.raf", + "https://raw.pixls.us/getfile.php/3124/nice/Fujifilm%20-%20XF10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "0486676fdbbdc7f3f4c63cd45aa76581cbc1316f4c9baef8781bced0fbc137db", + ), + ( + "raf-fujifilm-x-a10.raf", + "https://raw.pixls.us/getfile.php/3570/nice/Fujifilm%20-%20X-A10%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "05309c5434500cc53d5782072cc0dcdd947d2f0a34f0ddcd4656c82cc7803272", + ), + ( + "nef-nikon-d5300.nef", + "https://raw.pixls.us/getfile.php/8365/nice/Nikon%20-%20D5300%20-%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "0ae88fc81f082929d76dd1b932aacf7815cd6bc670b96ef55a81928e11084a6c", + ), + ( + "raf-fujifilm-x-t100.raf", + "https://raw.pixls.us/getfile.php/2676/nice/Fujifilm%20-%20X-T100%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "0af086b9569b59317da42b8e136013245486073b6b8d409fc997a71788538c4d", + ), + ( + "raf-fujifilm-x-pro1.raf", + "https://raw.pixls.us/getfile.php/1191/nice/Fujifilm%20-%20X-Pro1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "0b1046c299d9eb222a8f003bd047f2a34f507da0d1704ac32cefbc82f799d121", + ), + ( + "nef-nikon-1-v1.nef", + "https://raw.pixls.us/getfile.php/2210/nice/Nikon%20-%201%20V1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "0dc76bc274f9cb776eb930683a4e53a3ebede1719abafd1cba4b0c800f13f7e4", + ), + ( + "nef-nikon-1-j4.nef", + "https://raw.pixls.us/getfile.php/5922/nice/Nikon%20-%201%20J4%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "0e2887892b8cfc80891b2c510721088fcac6d77eff9ba68089f382a0115b274c", + ), + ( + "raf-fujifilm-gfx100s-ii.raf", + "https://raw.pixls.us/getfile.php/7772/nice/Fujifilm%20-%20GFX100S%20II%20-%2016bit%20uncompressed%20%284:3%29.RAF", + "10e007f3d1aef2ac9bf4da063ea1983e44515af818bf0ab63e3703cd67803810", + ), + ( + "nef-nikon-d610.nef", + "https://raw.pixls.us/getfile.php/888/nice/Nikon%20-%20D610%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "11f0a71bb4141a8ed44c7e1c64f9a12571f5f67a11571f2bde8516aa95a7bea3", + ), + ( + "nef-nikon-d5.nef", + "https://raw.pixls.us/getfile.php/1636/nice/Nikon%20-%20D5%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "13191560a6a9d9ed4156ec5d20863277c9618916aaba934758c5ae2be6654f6a", + ), + ( + "nef-nikon-d2h.nef", + "https://raw.pixls.us/getfile.php/5227/nice/Nikon%20-%20D2H%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "155edb938f884ea7372ce98d4ff5f965c3e413b43b95bc9923da6e92082cf914", + ), + ( + "nef-nikon-z-6-2.nef", + "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", + ), + ( + "nrw-nikon-coolpix-p1000.nrw", + "https://raw.pixls.us/getfile.php/3381/nice/Nikon%20-%20COOLPIX%20P1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", + "1d9470ee083f914e51aa0be782161b432f27f87dbef551a59ec435f5dbc289dd", + ), + ( + "raf-fujifilm-finepix-x100.raf", + "https://raw.pixls.us/getfile.php/2392/nice/Fujifilm%20-%20FinePix%20X100%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "1f4aea4fa43dd314f8b90954d7823655d4906d3e468a9ea014652b872a68db16", + ), + ( + "raf-fujifilm-x30.raf", + "https://raw.pixls.us/getfile.php/1144/nice/Fujifilm%20-%20X30%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "1f65f680600c85a6f561d2b0ad7cd1f9d93ee7f733aafe4ecd37667d984dd6c4", + ), + ( + "raf-fujifilm-finepix-x10.raf", + "https://raw.pixls.us/getfile.php/1344/nice/Fujifilm%20-%20FinePix%20X10%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "206448688c49b0a9da74dd94f12aba143c2d8842cb5bbf64d4ecdc501874451b", + ), + ( + "nef-nikon-d2xs.nef", + "https://raw.pixls.us/getfile.php/2871/nice/Nikon%20-%20D2Xs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "2204bc2e2178bf594e17886ca92a50a94350403a110846ca6e83bdc5f3352706", + ), + ( + "nef-nikon-d4.nef", + "https://raw.pixls.us/getfile.php/1974/nice/Nikon%20-%20D4%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "23aa2dd3c409b3cbbfeea3e3f7b6752180efa15430d6e059e87404e969875cd7", + ), + ( + "nef-nikon-d800.nef", + "https://raw.pixls.us/getfile.php/750/nice/Nikon%20-%20D800%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "26d58c21ed3019af5a22ef6bc02f017b863aad9eafcf84252ed375cd495c0efb", + ), + ( + "nef-nikon-d780.nef", + "https://raw.pixls.us/getfile.php/3825/nice/Nikon%20-%20D780%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "3178d0656e3b5fbe90176e38a1cd608d0b69292299445932dddc9a0b2d196b36", + ), + ( + "nef-nikon-d850.nef", + "https://raw.pixls.us/getfile.php/1835/nice/Nikon%20-%20D850%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "31b7fe298a5c91694c6ca8b3284a0a016e572a02c3964b502d4732e8e9283da8", + ), + ( + "raf-fujifilm-x-e2s.raf", + "https://raw.pixls.us/getfile.php/745/nice/Fujifilm%20-%20X-E2S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "330fc84b597e7be6d7477aad5715705efaa99d4663eb05aa47e1250d6f421549", + ), + ( + "nef-nikon-d500.nef", + "https://raw.pixls.us/getfile.php/2504/nice/Nikon%20-%20D500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "33c96a2ba7167f780928767c0545719737d601883e5f98e5edba4f71bdc99d83", + ), + ( + "raf-fujifilm-gfx-50r.raf", + "https://raw.pixls.us/getfile.php/2979/nice/Fujifilm%20-%20GFX%2050R%20-%2014bit%2014bit%20compressed%20%283:2%29.raf", + "35109fc20a849026c43c47674902431ef5a2d54b09b616995263dc75df17912f", + ), + ( + "nef-nikon-df.nef", + "https://raw.pixls.us/getfile.php/1385/nice/Nikon%20-%20Df%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "38ea7aba6b7e0cdbd338fe6703d3c98291f4a9a28b0d1a81bfb1f77b21712c63", + ), + ( + "raf-fujifilm-x-s10.raf", + "https://raw.pixls.us/getfile.php/4188/nice/Fujifilm%20-%20X-S10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "3d080f9ce8ac65c4beab1e9c0f49f7c72cdec71f31fe9e27dd1d88e551a1c262", + ), + ( + "nef-nikon-1-v2.nef", + "https://raw.pixls.us/getfile.php/1693/nice/Nikon%20-%201%20V2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3d29dfa6397b0aa4e72969bb5b85d040e8bebf2e02f61f6364f580ce39f30bf2", + ), + ( + "nef-nikon-1-v3.nef", + "https://raw.pixls.us/getfile.php/2802/nice/Nikon%20-%201%20V3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3f5376481f9ae59e9712b537dcc4eaffa72e311a8768c5396414506ae2eb1d69", + ), + ( + "nef-nikon-d3s.nef", + "https://raw.pixls.us/getfile.php/5203/nice/Nikon%20-%20D3S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "402898e57007a4176b8686019ef2878c28481f7520c6136127f00148de9960b5", + ), + ( + "nef-nikon-z-fc.nef", + "https://raw.pixls.us/getfile.php/4812/nice/Nikon%20-%20Z%20fc%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.nef", + "4099ecc46ebaca26e2a66a96a8313fed5e8c707b3836ccf0e77a134844b041c1", + ), + ( + "nef-nikon-d810.nef", + "https://raw.pixls.us/getfile.php/2029/nice/Nikon%20-%20D810%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "40a9df99865ad25a68b1131014cf954fb72da8bc32991b6a5e19d14937e21bc9", + ), + ( + "raf-fujifilm-x-a3.raf", + "https://raw.pixls.us/getfile.php/1371/nice/Fujifilm%20-%20X-A3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "4187cb5caa72320a74e9b4355db01e8e21edd43f3084873f9b9b67169d8d4ad8", + ), + ( + "nef-nikon-d40.nef", + "https://raw.pixls.us/getfile.php/3618/nice/Nikon%20-%20D40%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "44e88bc77b7a531b22647bcd07b9393c4568062e8f0906d3bbdecb42fbe29e03", + ), + ( + "nef-nikon-d800e.nef", + "https://raw.pixls.us/getfile.php/1303/nice/Nikon%20-%20D800E%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "4559b9614ab6d558192d9d30e1db4a4ec44752553e45b2bd26c1569c1820441a", + ), + ( + "nef-nikon-d3x.nef", + "https://raw.pixls.us/getfile.php/3708/nice/Nikon%20-%20D3X%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "48dc0339ef86541aabcc6bb68a6452f603e23a7d7a5597dad0d267f69e3e79d4", + ), + ( + "raf-fujifilm-x100v.raf", + "https://raw.pixls.us/getfile.php/3812/nice/Fujifilm%20-%20X100V%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "48f2055ab497c687347eee84103d4f48deb016fff711d2810eaf1317e506f621", + ), + ( + "nef-nikon-d4s.nef", + "https://raw.pixls.us/getfile.php/2220/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "49067c81374549465f3c621acd249bf05dbdef4e44b9201fef1fb11c93572983", + ), + ( + "raf-fujifilm-x-t30-iii.raf", + "https://raw.pixls.us/getfile.php/8769/nice/Fujifilm%20-%20X-T30%20III%20-%2014bit%20compressed%20%283:2%29.RAF", + "49f77d6162abfa5c94d2d8b90e4e926b7386c42bcf7e84a152c9ffe1ebd584da", + ), + ( + "raf-fujifilm-xq2.raf", + "https://raw.pixls.us/getfile.php/1844/nice/Fujifilm%20-%20XQ2%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "4bce88593f5ae8fc45aeeec8a6dee9a84c773493cc6db9dabdc28744c8ac4b4d", + ), + ( + "raf-fujifilm-x-t10.raf", + "https://raw.pixls.us/getfile.php/1192/nice/Fujifilm%20-%20X-T10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "4c09401975cc6f95b1284693aac39fef85498f70b55e35bf458a133c6e61885d", + ), + ( + "nef-nikon-d2x.nef", + "https://raw.pixls.us/getfile.php/9064/nice/Nikon%20-%20D2X%20-%2012bit%20uncompressed%20%283:2%29.NEF", + "4d4b89f85bdfef233ef9b8aa7ecfeb609874ab592cb44e777f5b36814df5883c", + ), + ( + "raf-fujifilm-x70.raf", + "https://raw.pixls.us/getfile.php/1301/nice/Fujifilm%20-%20X70%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "4f9c33fde578addebda991c37d26551b46fab8f592c74f7192560a05ec08d577", + ), + ( + "dng-nikon-d80.dng", + "https://raw.pixls.us/getfile.php/1988/nice/Nikon%20-%20D80%20-%2016bit%20%283:2%29.dng", + "5095ca264e763624cd3aa62940286dd1bcff4504eefa1ce61cbb72f50f8426bb", + ), + ( + "nef-nikon-1-aw1.nef", + "https://raw.pixls.us/getfile.php/1171/nice/Nikon%20-%201%20AW1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "519d354ec40907f1cd30329fc1a641ddec5b46e08e88883f3cf2fddc4ddd9974", + ), + ( + "raf-fujifilm-x-h1.raf", + "https://raw.pixls.us/getfile.php/2537/nice/Fujifilm%20-%20X-H1%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "5210bf896d2757cecda88bab0a87ef0b534d4b36c31dc08d5db7cd8f5bbf9b4e", + ), + ( + "nef-nikon-1-j5.nef", + "https://raw.pixls.us/getfile.php/1342/nice/Nikon%20-%201%20J5%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", + "56efc4f426fa489383a967102c6d30fa9d6b198ca0484c4725c91f0b0d47c063", + ), + ( + "nef-nikon-1-j1.nef", + "https://raw.pixls.us/getfile.php/1017/nice/Nikon%20-%201%20J1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "5965935729931b72ca8398041f8fc15cb0a226004db941d1f0626ccc2c5bb5f3", + ), + ( + "nef-nikon-d7200.nef", + "https://raw.pixls.us/getfile.php/832/nice/Nikon%20-%20D7200%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "5dea8ff6f95f372e4eb0852f75e728f182d7aa4057b2b76631f0fd3066c6a4b5", + ), + ( + "raf-fujifilm-x-t30-ii.raf", + "https://raw.pixls.us/getfile.php/5064/nice/Fujifilm%20-%20X-T30%20II%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "5e2678038377cc7f146ceb7693c08535fa1af162cec705d06b0446c3d3017d47", + ), + ( + "nef-nikon-z-50.nef", + "https://raw.pixls.us/getfile.php/3647/nice/Nikon%20-%20Z%2050%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", + "5e93735923229117abee2779d81f114f180f361e8ae553c9dd9bdaaedb0d9e02", + ), + ( + "nef-nikon-d5000.nef", + "https://raw.pixls.us/getfile.php/1341/nice/Nikon%20-%20D5000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "5f8e8005be87366bbf7746d35c3c88f82a12a1dd18206c939d8f1254c5922360", + ), + ( + "raf-fujifilm-xq1.raf", + "https://raw.pixls.us/getfile.php/1741/nice/Fujifilm%20-%20XQ1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "663c0bfcb8819c3931444acd9d1491de4d0c0aa1fd745b99e1faa68cc8de033b", + ), + ( + "raf-fujifilm-finepix-hs30exr.raf", + "https://raw.pixls.us/getfile.php/2613/nice/Fujifilm%20-%20FinePix%20HS30EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "6b95bb5133d026dbbb7361985e81d9a2b93000e2aea88bcd3f120412d0061da7", + ), + ( + "nef-nikon-d70s.nef", + "https://raw.pixls.us/getfile.php/1117/nice/Nikon%20-%20D70s%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "6c46f9854033e7623ebbb5de8c951df6762ca44f05d45035ccc5cb7e9f4e5692", + ), + ( + "nef-nikon-d5200.nef", + "https://raw.pixls.us/getfile.php/1691/nice/Nikon%20-%20D5200%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "6c49ca47d189f9d6766f94be7c9d7d779ce45c79c0c6e48a0eba7d92f320fe0e", + ), + ( + "nrw-nikon-coolpix-p1100.nrw", + "https://raw.pixls.us/getfile.php/7689/nice/Nikon%20-%20Coolpix%20P1100%20-%2012bit%20uncompressed%20%284:3%29.NRW", + "706840f7a36c3852092d4aad0465e75920e04f1aa653adf31626c0c05ab41258", + ), + ( + "raf-fujifilm-x-s20.raf", + "https://raw.pixls.us/getfile.php/6666/nice/Fujifilm%20-%20X-S20%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "737c1551843282d4963ccb351a4d6409f3eaf172928d563bbaf72fe0f1f74fdd", + ), + ( + "nef-nikon-z-30.nef", + "https://raw.pixls.us/getfile.php/5812/nice/Nikon%20-%20Z%2030%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "75249c11a3a845367c7a322a1c4e7076f33c785ec2518fc6594c34c185b41161", + ), + ( + "raf-fujifilm-x-e2.raf", + "https://raw.pixls.us/getfile.php/937/nice/Fujifilm%20-%20X-E2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "7588c70af7fd6be3abcec52546aabcd04e0aafc6541e7f7f50bdc60b60513c57", + ), + ( + "nef-nikon-d60.nef", + "https://raw.pixls.us/getfile.php/3698/nice/Nikon%20-%20D60%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "780ce4867e997143acbd1734a889575a5eaeeb40c6afb33b6006c266831caaa6", + ), + ( + "nef-nikon-1-j2.nef", + "https://raw.pixls.us/getfile.php/923/nice/Nikon%20-%201%20J2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "81051ee36135883acea55bd1e609d6d31212c0bf03640b7ae9fe177a1ca51219", + ), + ( + "nef-nikon-d5600.nef", + "https://raw.pixls.us/getfile.php/1415/nice/Nikon%20-%20D5600%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "82e15f02c4037820e80f14bacfacdc52ea29721b3c70d2cf08b237b50ce8db05", + ), + ( + "nef-nikon-d3.nef", + "https://raw.pixls.us/getfile.php/3516/nice/Nikon%20-%20D3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "880c60c5f611adf6b70de3d099f8433492de3a3d96866d570122f17f0a651fc8", + ), + ( + "nef-nikon-d300.nef", + "https://raw.pixls.us/getfile.php/924/nice/Nikon%20-%20D300%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "8b989f53c96995f7014ddd16124530cf6d8f3341ea9570d6312d9da1eb0a117a", + ), + ( + "raf-fujifilm-finepix-sl1000.raf", + "https://raw.pixls.us/getfile.php/5760/nice/Fujifilm%20-%20FinePix%20SL1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "8bc9903cb1bff93376eb73b64f35861dcf3c66e2ca936cfac2d7c62435689dec", + ), + ( + "raf-fujifilm-x-pro3.raf", + "https://raw.pixls.us/getfile.php/3700/nice/Fujifilm%20-%20X-Pro3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8c50e965d7627932414e8790a5ef1d673fcb44b548b64130df6b9be42c418554", + ), + ( + "raf-fujifilm-x-pro2.raf", + "https://raw.pixls.us/getfile.php/1680/nice/Fujifilm%20-%20X-Pro2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8cc12566e5c0fc676b713bb1612ce0510d47b1dc691e9a5a0bcf3799ad9b8af1", + ), + ( + "nef-nikon-1-s1.nef", + "https://raw.pixls.us/getfile.php/5661/nice/Nikon%20-%201%20S1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "8e540d40b842f61de69161cb0b8be72b024629c752fd82b6f8d10e460bce88cb", + ), + ( + "raf-fujifilm-x100vi.raf", + "https://raw.pixls.us/getfile.php/7300/nice/Fujifilm%20-%20X100VI%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8e5e4d8d2e0a865ca79b3d40734b6abc2f4b00281afea8b9bd3d8c29808f2fb3", + ), + ( + "raf-fujifilm-x-t200.raf", + "https://raw.pixls.us/getfile.php/3899/nice/Fujifilm%20-%20X-T200%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8fc8dfa5b4741246287c430010d9699fe6f1e512ac03e14abea72e63cb022051", + ), + ( + "nef-nikon-d3200.nef", + "https://raw.pixls.us/getfile.php/743/nice/Nikon%20-%20D3200%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "91657dd8e9c7a086826683f6d20799685657dd7ca2056702b145851465f57cd3", + ), + ( + "raf-fujifilm-x-t3.raf", + "https://raw.pixls.us/getfile.php/2783/nice/Fujifilm%20-%20X-T3%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "95b33021160b239ceb1a09d46a1b29cb60cb7dd47581feb126b337c219091754", + ), + ( + "raf-fujifilm-finepix-f550exr.raf", + "https://raw.pixls.us/getfile.php/3869/nice/Fujifilm%20-%20FinePix%20F550EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "95b6c8a9d5d885f60bbc965ddfd899d325f2225c99ab087d02739543a3ff7dd6", + ), + ( + "nef-nikon-d5500.nef", + "https://raw.pixls.us/getfile.php/1469/nice/Nikon%20-%20D5500%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "96d5b85dae24e190691c504781d8e7ee58cb9b63ea715760de9af3d345dc4c13", + ), + ( + "raf-fujifilm-x-a7.raf", + "https://raw.pixls.us/getfile.php/3879/nice/Fujifilm%20-%20X-A7%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "97db251dd129663da00d33e8eb4d7932d222f58d99ff7311c09cc277de5b6f18", + ), + ( + "nef-nikon-d3300.nef", + "https://raw.pixls.us/getfile.php/1734/nice/Nikon%20-%20D3300%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "97dfc95aecc00383d6da6197cb759bacb1e43c86071b4ca2d94748e498620f2f", + ), + ( + "nef-nikon-z-5.nef", + "https://raw.pixls.us/getfile.php/4136/nice/Nikon%20-%20Z%205%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "9870248c45532080c2459f3b52cdcc4a07bc8846ad50cbb81c7c1c6dc5e36d91", + ), + ( + "nef-nikon-1-j3.nef", + "https://raw.pixls.us/getfile.php/1814/nice/Nikon%20-%201%20J3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "996d65705bf1ac9f83f055817ea92618ea44c822109196d7d97a803a0268b576", + ), + ( + "nef-nikon-z-9.nef", + "https://raw.pixls.us/getfile.php/5146/nice/Nikon%20-%20Z%209%20-%208bit%208bit%20compressed%20%283:2%29.NEF", + "99d0fe24a38f0f7623aeab21440d266a5ac09f9665cf5283f590337e84323c38", + ), + ( + "nrw-nikon-coolpix-a1000.nrw", + "https://raw.pixls.us/getfile.php/4337/nice/Nikon%20-%20Coolpix%20A1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", + "9b4edd03bbb17d66eacc2a72b30f044969e67666446f3f1ed25e88bb947d4f8a", + ), + ( + "raf-fujifilm-x100s.raf", + "https://raw.pixls.us/getfile.php/2115/nice/Fujifilm%20-%20X100S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "9cea5b0d4dd3682f8850dc34e93f0045e0e28fa69f9715576d40c5a8a59e8ae9", + ), + ( + "nef-nikon-d2hs.nef", + "https://raw.pixls.us/getfile.php/2852/nice/Nikon%20-%20D2Hs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "9ea1c3d5d6c8a11f33239079559f1f0c86bbdb9440a2c7d2578368647abf7ea4", + ), + ( + "nef-nikon-d90.nef", + "https://raw.pixls.us/getfile.php/1325/nice/Nikon%20-%20D90%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "9eb3daa4e3d1c1614bccc4c6d136c4166960f936f17c437cc4d11a5786b2df06", + ), + ( + "nef-nikon-d50.nef", + "https://raw.pixls.us/getfile.php/1282/nice/Nikon%20-%20D50%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "9f94c27672a63f946c92d43910ef0c266dffaa14dae0007ae00ac72d2c684935", + ), + ( + "nef-nikon-d3000.nef", + "https://raw.pixls.us/getfile.php/2161/nice/Nikon%20-%20D3000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "a07ee8a0b1ff7a1ad06085306ef8f9a197d95fec639994467317c56108eeb435", + ), + ( + "raf-fujifilm-x100t.raf", + "https://raw.pixls.us/getfile.php/2137/nice/Fujifilm%20-%20X100T%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "a12bb7fa3585b636b7fa487d767ba0ba1ae10fe6aaa74db4147dc0ec2b180a0a", + ), + ( + "nef-nikon-z50-2.nef", + "https://raw.pixls.us/getfile.php/7762/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%283:2%29.NEF", + "a3314a026524396e603b461afc4fe61a8a5a3958232f3687bb1c8eca0dcbed93", + ), + ( + "nef-nikon-d600.nef", + "https://raw.pixls.us/getfile.php/916/nice/Nikon%20-%20D600%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "a70c3e54501efa7e4391ec4cf5705912a10c356a029e984ee13be5f68bc7482d", + ), + ( + "raf-fujifilm-xf1.raf", + "https://raw.pixls.us/getfile.php/3209/nice/Fujifilm%20-%20XF1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "b17c389a59228881af8d50c22bdd8ce2295a54290d9a18447dd1e56d0743e913", + ), + ( + "nef-nikon-d7100.nef", + "https://raw.pixls.us/getfile.php/1855/nice/Nikon%20-%20D7100%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "b241cb95b1008f25b8ed7d1443354e8cc7dee9c4d4e1edd0a5856d92063a48d0", + ), + ( + "raf-fujifilm-x-e3.raf", + "https://raw.pixls.us/getfile.php/1864/nice/Fujifilm%20-%20X-E3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "b2aeb7fd72a9ea116b1dcc2c8a8cf5462b15757ba3aca33bad8ce36339a1642f", + ), + ( + "raf-fujifilm-x-a1.raf", + "https://raw.pixls.us/getfile.php/1214/nice/Fujifilm%20-%20X-A1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "b2ddb5fe31a3b976d12961c72a2689de8742739df3d8f757614eca65f8df2a61", + ), + ( + "nef-nikon-d3500.nef", + "https://raw.pixls.us/getfile.php/2902/nice/Nikon%20-%20D3500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "b53395f74674588a63f6b667325c5a2b400f26cba72ff402a65ddd02c5f841d5", + ), + ( + "raf-fujifilm-finepix-hs50exr.raf", + "https://raw.pixls.us/getfile.php/6018/nice/Fujifilm%20-%20FinePix%20HS50EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "b5c4f3658dc686e535eeaf639729f2cb6b54e60a3e1ab206eabba1024c4dce83", + ), + ( + "raf-fujifilm-gfx100-ii.raf", + "https://raw.pixls.us/getfile.php/7174/nice/Fujifilm%20-%20GFX100%20II%20-%2014bit%2014bit%20uncompressed%20%284:3%29.RAF", + "b7b329bcbfcc07c5523a2b172a5173f4de4b841c8315078a1b69150ddaa16f30", + ), + ( + "raf-fujifilm-x100f.raf", + "https://raw.pixls.us/getfile.php/1936/nice/Fujifilm%20-%20X100F%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "bca8ad027b0ee0038107c01792ccb5c6b776512b77b584c6f93c6f2ced5f15a3", + ), + ( + "nef-nikon-d40x.nef", + "https://raw.pixls.us/getfile.php/804/nice/Nikon%20-%20D40X%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "c3e4956f7e2d1f1b3f846b6ec9552678073db0bcaeb51132ec4c60bd2229d56e", + ), + ( + "nef-nikon-coolpix-a.nef", + "https://raw.pixls.us/getfile.php/2238/nice/Nikon%20-%20Coolpix%20A%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "c5ca0333a7cda8a2fc33fb2157d18dfa89f1844548dc49c2fd2e67f502f72e25", + ), + ( + "raf-fujifilm-x20.raf", + "https://raw.pixls.us/getfile.php/1549/nice/Fujifilm%20-%20X20%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "ccb4fad836f1561b52859c3ee01db0fef7db770980d4a84e95595da1eda72b2a", + ), + ( + "raf-fujifilm-x-e4.raf", + "https://raw.pixls.us/getfile.php/4446/nice/Fujifilm%20-%20X-E4%20-%2014bit%2014bit%20compressed%20lossless%20%283:2%29.RAF", + "ce4215079e4c6c8a86d493e15f6e574e4aa7684b346ef3439026c835ed2c8948", + ), + ( + "raf-fujifilm-gfx50s-ii.raf", + "https://raw.pixls.us/getfile.php/5002/nice/Fujifilm%20-%20GFX50S%20II%20-%2014bit%2014bit%20uncompressed%20%284:3%29.RAF", + "cf53a9018d5a77cd854ea0a91afee2a35ac8fab8f814020d79f728f99ba99892", + ), + ( + "nef-nikon-d1h.nef", + "https://raw.pixls.us/getfile.php/5364/nice/Nikon%20-%20D1H%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "d110b80a8b10a3a67b5cd1c15ef33f1a6397557b73d5d3f4e565739f0c072571", + ), + ( + "nef-nikon-d200.nef", + "https://raw.pixls.us/getfile.php/824/nice/Nikon%20-%20D200%20-%2012bit%2012bit%20uncompressed%20%283:2%29.nef", + "d468895b0b30a60d05a7f6468d69f2f15830dd49b2d82698b34245072a22e747", + ), + ( + "raf-fujifilm-finepix-hs33exr.raf", + "https://raw.pixls.us/getfile.php/2907/nice/Fujifilm%20-%20FinePix%20HS33EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "d72d5b67d67379fa310fc5a4615016d9456d086a8bfd32c74a3b256e0dfecee8", + ), + ( + "raf-fujifilm-finepix-s1.raf", + "https://raw.pixls.us/getfile.php/1651/nice/Fujifilm%20-%20FinePix%20S1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "d77f104a9fa4ce11c78b5078ac40acf8b150d67ea837d187e0903d208bf2dbae", + ), + ( + "nef-nikon-z-7-2.nef", + "https://raw.pixls.us/getfile.php/4213/nice/Nikon%20-%20Z%207_2%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "dc83d3e49db54ca9c3a1e41dc847afd213c822d482c40158e42d01ee8968f33b", + ), + ( + "nef-nikon-d70.nef", + "https://raw.pixls.us/getfile.php/2060/nice/Nikon%20-%20D70%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "dd6405aeb33b0cd5bf66c98ba98ccbb478a765450cfd130810e470dab8d1f4b4", + ), + ( + "nef-nikon-d300s.nef", + "https://raw.pixls.us/getfile.php/3049/nice/Nikon%20-%20D300S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "de950a2263c87f085ee611a21e69fc4e0ecb6e417fff61ff6a23ea3121e21a81", + ), + ( + "raf-fujifilm-x-s1.raf", + "https://raw.pixls.us/getfile.php/997/nice/Fujifilm%20-%20X-S1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "e4652f3710cc972712a25370988ad8d72c8bf368956215348ea77b17d95f36f7", + ), + ( + "nef-nikon-d750.nef", + "https://raw.pixls.us/getfile.php/895/nice/Nikon%20-%20D750%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "e4a3f85e014275fc0a47600a373af95ec850872a5f6cf87649bcaa42b77ef2e4", + ), + ( + "nef-nikon-d3100.nef", + "https://raw.pixls.us/getfile.php/4229/nice/Nikon%20-%20D3100%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "e8113b6e7285155f9f2b88f81566dab63a59cf5559d6915113b74e8c3a8680f6", + ), + ( + "raf-fujifilm-x-t1.raf", + "https://raw.pixls.us/getfile.php/2421/nice/Fujifilm%20-%20X-T1%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "e994a1fd6e87e392432fe146a35b0b88584dc2bd50bee2c8c7e886ac2b59fcde", + ), + ( + "nef-nikon-d3400.nef", + "https://raw.pixls.us/getfile.php/1561/nice/Nikon%20-%20D3400%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "eced190786231f0081261b6c6b425fe23e42a53ade21dec3def5ae8551be71a5", + ), + ( + "raf-fujifilm-x-m1.raf", + "https://raw.pixls.us/getfile.php/842/nice/Fujifilm%20-%20X-M1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "eef4e9ae629d2d81cef5a234fb853c14c88b3c0c12c39d635a1f9fa2a871bec3", + ), + ( + "raf-fujifilm-x-t30.raf", + "https://raw.pixls.us/getfile.php/3068/nice/Fujifilm%20-%20X-T30%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "f0353544bc62a6816385de0850e1d37e2f3383892a7eb3928c73e4517fe47ff5", + ), + ( + "nrw-nikon-coolpix-p950.nrw", + "https://raw.pixls.us/getfile.php/3806/nice/Nikon%20-%20COOLPIX%20P950%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", + "fc6136b9e5a7e133d7dc30fe65baa04c09ff8c18ca3c7ad82b4ffdbb71ce5bef", + ), + ( + "raf-fujifilm-finepix-f770exr.raf", + "https://raw.pixls.us/getfile.php/3039/nice/Fujifilm%20-%20FinePix%20F770EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "ffc905e325a00c57a5335681654fdf226b724269cd5d26cd976983939f88043d", + ), + ( + "dng-leica-m-monochrom.dng", + "https://raw.pixls.us/getfile.php/974/nice/Leica%20-%20M%20Monochrom%20-%2016bit%20%283:2%29.DNG", + "149d5b3fbd5cd40d51c1f56d5e392a81870d129e047a6c8a596447590e7a0e4f", + ), + ( + "dng-leica-cl.dng", + "https://raw.pixls.us/getfile.php/2489/nice/Leica%20-%20CL%20-%2014bit%2014bit%20uncompressed%20%283:2%29.DNG", + "1f4cfa565d176d85838d96bd3eb598bbeed719cac8acb9999d5823c527635950", + ), + ( + "dng-leica-m10.dng", + "https://raw.pixls.us/getfile.php/1603/nice/Leica%20-%20M10%20-%2016bit%2016bit%20uncompressed%20%283:2%29.dng", + "26be5e06dfb53a2938dab3ca8f06024533a9c0dd0c652938f39eb48bc325f95e", + ), + ( + "rwl-leica-c-lux.rwl", + "https://raw.pixls.us/getfile.php/3863/nice/Leica%20-%20C-Lux%20-%204:3.RWL", + "286106ec2df65539912709c64e84a9b94686443fe5ea05b8ebada76daa1feb2d", + ), + ( + "arw-sony-zv-1m2.arw", + "https://raw.pixls.us/getfile.php/8461/nice/Sony%20-%20ZV-1M2%20-%2012bit%20%283:2%29.ARW", + "2a45845a93ab5e428868366d6575a264818b184541b1c114ab4de980872a7184", + ), + ( + "dng-leica-m-monochrom-typ-246.dng", + "https://raw.pixls.us/getfile.php/1087/nice/Leica%20-%20M%20Monochrom%20%28Typ%20246%29%20-%2012bit%20%283:2%29.DNG", + "3fb74d1fc402daaa48a9c3f1d6279d7e85fb58dfdaf816f036927d49ab228977", + ), + ( + "arw-sony-zv-e10m2.arw", + "https://raw.pixls.us/getfile.php/8021/nice/Sony%20-%20ZV-E10M2%20-%2012bit%20%284:3%29.ARW", + "49db06da969cf3894cf070d9171fe0ed4d0f125e16a87b5eba7eaec27cd75595", + ), + ( + "arw-sony-dsc-rx1rm3.arw", + "https://raw.pixls.us/getfile.php/8544/nice/Sony%20-%20DSC-RX1RM3%20-%2014bit%20uncompressed%20%283:2%29.ARW", + "6ef9956b343051748a6a16ba9ba7bd0d314a4ae0b4f8d8e2f1c13b4a1e50cfb8", + ), + ( + "arw-sony-ilce-1m2.arw", + "https://raw.pixls.us/getfile.php/7827/nice/Sony%20-%20ILCE-1M2%20-%2014bit%20uncompressed%20%283:2%29.ARW", + "7def3c8d80537f79af199052c6e13d86750203122dcb94888ada65291dabb793", + ), + ( + "dng-leica-m8.dng", + "https://raw.pixls.us/getfile.php/2988/nice/Leica%20-%20M8%20-%208bit%208bit%20uncompressed%20%283:2%29.DNG", + "7fdcca2371bb07fbf9483fcb08e3b5ab093eca1ca3924cbc86d6863736c31153", + ), + ( + "arw-sony-dsc-rx100m7a.arw", + "https://raw.pixls.us/getfile.php/8096/nice/Sony%20-%20DSC-RX100M7A%20-%2012bit%20%283:2%29.ARW", + "80adbc5b1cb3342058d5998cf2eda1e6b408565169462d6d26dfe02f0c140b30", + ), + ( + "arw-sony-ilce-7m5.arw", + "https://raw.pixls.us/getfile.php/8845/nice/Sony%20-%20ILCE-7M5%20-%2014bit%20%284:3%29.ARW", + "8c35cd1ab0ca36b487ae50ae5e0333a8e967122700fce5678e1e5b97561896a4", + ), + ( + "arw-sony-ilme-fx2.arw", + "https://raw.pixls.us/getfile.php/8807/nice/Sony%20-%20ILME-FX2%20-%2014bit%20uncompressed%20%283:2%29.ARW", + "924a6e4efd5c068883dd7de2bf2a2d155c7c13bdde668232077bdf9d92a201a8", + ), + ( + "dng-leica-x2.dng", + "https://raw.pixls.us/getfile.php/882/nice/Leica%20-%20X2%20-%2012bit%20%283:2%29.DNG", + "a67eca6271afb10510ffc9cf998b641d0e8a0ebfa4c779a7bc0bfed5d28b62dd", + ), + ( + "dng-leica-q2.dng", + "https://raw.pixls.us/getfile.php/3204/nice/Leica%20-%20Q2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.DNG", + "a71f98405e9e9c1e4ac0f2d2e6c52d65daae3234fc0a9f6eed2a84ee4ce794ad", + ), + ( + "arw-sony-ilce-6400a.arw", + "https://raw.pixls.us/getfile.php/7826/nice/Sony%20-%20ILCE-6400A%20-%2014bit%20%283:2%29.ARW", + "a8a45e9b6c437eba6724fcda24c3efd084c05389711b955d84b2af67635e241b", + ), + ( + "dng-leica-sl-typ-601.dng", + "https://raw.pixls.us/getfile.php/1951/nice/Leica%20-%20SL%20%28Typ%20601%29%20-%2014bit%20%283:2%29.DNG", + "aa9fd9ef937ed34467d6f91bfa2603be00cb47542b7fffd7258b79652d3b7bef", + ), + ( + "dng-leica-m10-r.dng", + "https://raw.pixls.us/getfile.php/7853/nice/Leica%20-%20M10-R%20-%2016bit%20compressed%20%283:2%29.DNG", + "ba35d25d521c7ddbc4feca69c8cdbe071ac65dcd6cf2c93374391bf6f3662b8d", + ), + ( + "dng-leica-m-typ-240.dng", + "https://raw.pixls.us/getfile.php/2317/nice/Leica%20-%20M%20%28Typ%20240%29%20-%2016bit%2016bit%20compressed%20%283:2%29.DNG", + "bf35a4da121f853cb644253b69515b441748f95fcbd6fdea2de025a76e6c24cd", + ), + ( + "dng-leica-sl2.dng", + "https://raw.pixls.us/getfile.php/7872/nice/Leica%20-%20SL2%20-%2014bit%20uncompressed%20%283:2%29.DNG", + "d00f05468793e4509b53230e90dcdc078e823b254eee95f1c68b29e217df99bf", + ), + ( + "dng-leica-m9.dng", + "https://raw.pixls.us/getfile.php/753/nice/Leica%20-%20M9%20-%208bit%208bit%20compressed%20%283:2%29.DNG", + "d1d162fce62210b8951c862189a6098329b5163b7f7492dc397c6e53bba04239", + ), + ( + "dng-leica-q-typ-116.dng", + "https://raw.pixls.us/getfile.php/1797/nice/Leica%20-%20Q%20%28Typ%20116%29%20-%2014bit%20%283:2%29.DNG", + "f7b2a6b6d7684536511cf2db2e2bfa46fae1ad7e52214093714f35e7ca60acbf", + ), + ( + "srw-samsung-nx200.srw", + "https://raw.pixls.us/getfile.php/839/nice/Samsung%20-%20NX200%20-%2012bit%20%283:2%29.srw", + "03563eadfe4ef32b5bf2a2850abb40b29fcc1f8d56b3b2ed92ef307b90d7d9dc", + ), + ( + "orf-olympus-sp550uz.orf", + "https://raw.pixls.us/getfile.php/8609/nice/Olympus%20-%20SP550UZ%20-%2012bit%20%284:3%29.ORF", + "05f49918e1b1894c3abd2b5451b356f7a00adb1c8568abc5babff33fcade1bb2", + ), + ( + "orf-olympus-e-10.orf", + "https://raw.pixls.us/getfile.php/5424/nice/Olympus%20-%20E-10%20-%2016bit%20%284:3%29.ORF", + "2bfdade72439017a60a47aad1e5bbcb1aca36f2be7a21e94a4257a678fb6f4da", + ), + ( + "orf-olympus-c5060wz.orf", + "https://raw.pixls.us/getfile.php/1799/nice/Olympus%20-%20C5060WZ%20-%2012bit%20%284:3%29.ORF", + "3fc12bc3f6320cfab456111d7805b446e329fdd1907680be55e2e163e5e084bd", + ), + ( + "dng-samsung-gx10.dng", + "https://raw.pixls.us/getfile.php/1165/nice/Samsung%20-%20GX10%20-%2012bit%20%283:2%29.DNG", + "44cbb7980b320c0920d999758eb8a3f27e6b98dd14afc3dfc135bc8526abda58", + ), + ( + "orf-olympus-sp565uz.orf", + "https://raw.pixls.us/getfile.php/3438/nice/Olympus%20-%20SP565UZ%20-%2012bit%20%284:3%29.ORF", + "5088771f83ce08a001b44672e1dd9f013e603933c37ce1bfb7030a02abbe801c", + ), + ( + "dng-samsung-gx20.dng", + "https://raw.pixls.us/getfile.php/1537/nice/Samsung%20-%20GX20%20-%2012bit%20%283:2%29.DNG", + "5557016b2712b621befdb05731923562a802beafd13e0f366737390a8f7b3286", + ), + ( + "orf-olympus-e-m5-mark-ii.orf", + "https://raw.pixls.us/getfile.php/2856/nice/Olympus%20-%20E-M5%20Mark%20II%20-%2016bit%2016bit%20%22high-res%22%20%284:3%29.ORF", + "5c42fa75d6b549514b722e2c50726c03e34fac4909ec3640e147ea7fa825fc8d", + ), + ( + "orf-olympus-e-300.orf", + "https://raw.pixls.us/getfile.php/2878/nice/Olympus%20-%20E-300%20-%2016bit%20%284:3%29.ORF", + "6d818dbff78b3e2efa4835f568d04733052b9211e6bc65b0a3748a841170e95b", + ), + ( + "orf-olympus-sh-2.orf", + "https://raw.pixls.us/getfile.php/792/nice/Olympus%20-%20SH-2%20-%2012bit%20%284:3%29.ORF", + "6e6b9b7dd9ded7c4b364b83116c817ca2d6daac15f2e9ef7796159b08c3288a1", + ), + ( + "srw-samsung-nx5.srw", + "https://raw.pixls.us/getfile.php/5684/nice/Samsung%20-%20NX5%20-%2012bit%20%283:2%29.SRW", + "908fc61d5bbc1f0d0a5cea4f4e39e7753060c1af8673f7a4a9199284b70930f7", + ), + ( + "orf-olympus-c7070wz.orf", + "https://raw.pixls.us/getfile.php/780/nice/Olympus%20-%20C7070WZ%20-%2012bit%20%284:3%29.ORF", + "9d55506f9d6ee31d2d4c4b6df0dcd1d016d358b3e5eb4ed31edd7227dfefd5f8", + ), + ( + "orf-olympus-xz-10.orf", + "https://raw.pixls.us/getfile.php/2431/nice/Olympus%20-%20XZ-10%20-%2012bit%20%284:3%29.ORF", + "a25383acad9b5bb0bb404715e0957f2eb80aa1aaad6c448a71025c03cfa631be", + ), + ( + "srw-samsung-wb2000.srw", + "https://raw.pixls.us/getfile.php/4262/nice/Samsung%20-%20WB2000%20-%2014bit%20%284:3%29.SRW", + "b9a1149fb0dda9d23aee0ad8c402bf2f9ba42832366d46c4c1b739c0b0f48497", + ), + ( + "orf-olympus-e-330.orf", + "https://raw.pixls.us/getfile.php/3624/nice/Olympus%20-%20E-330%20-%2016bit%20%284:3%29.ORF", + "ba11eb970ab32e1e9cf3d0176041e4bd11c501d445720c0a344217e785ad65a6", + ), + ( + "orf-olympus-sp570uz.orf", + "https://raw.pixls.us/getfile.php/5491/nice/Olympus%20-%20SP570UZ%20-%2012bit%20%284:3%29.ORF", + "baab92391834076b427e406e98da79aaba48fb87243d6ccae60e08e299cfccf7", + ), + ( + "srw-samsung-nx11.srw", + "https://raw.pixls.us/getfile.php/5985/nice/Samsung%20-%20NX11%20-%2012bit%20%283:2%29.SRW", + "bdce6179b4180478057f4971b512e0303da71528db454cb3f399ab10e05b68fe", + ), + ( + "srw-samsung-nx1000.srw", + "https://raw.pixls.us/getfile.php/2588/nice/Samsung%20-%20NX1000%20-%2012bit%20%283:2%29.SRW", + "beef4d1e6f25ff28fb20bb3ad00ffadb62ab57d7041237e295b0b62f7de5309c", + ), + ( + "srw-samsung-nx1100.srw", + "https://raw.pixls.us/getfile.php/4422/nice/Samsung%20-%20NX1100%20-%2012bit%20%283:2%29.srw", + "d230af65ee55314792c5337ea8e0e757626a8541e7a25ed03d63b8df202ed9cb", + ), + ( + "srw-samsung-nx210.srw", + "https://raw.pixls.us/getfile.php/1831/nice/Samsung%20-%20NX210%20-%2012bit%20%283:2%29.SRW", + "d613503ddf9bedd2d2e84708749b1a94f0cccd4fa15492e01242368c5c07cfad", + ), + ( + "orf-olympus-sp510uz.orf", + "https://raw.pixls.us/getfile.php/2420/nice/Olympus%20-%20SP510UZ%20-%2012bit%20%284:3%29.ORF", + "f0737d78bb2e588963a3d5038fab493cdff1c9c48475704de96d797bbb81e62a", + ), + ( + "orf-olympus-pen-f.orf", + "https://raw.pixls.us/getfile.php/2977/nice/Olympus%20-%20PEN-F%20-%2016bit%20%284:3%29.orf", + "f87a5f3295aeb4d6ab27bb5b41c5e0f3a211ec9b4e3b1d9f105a650bee1a2f13", + ), + ( + "orf-olympus-e-500.orf", + "https://raw.pixls.us/getfile.php/3540/nice/Olympus%20-%20E-500%20-%2016bit%20%284:3%29.ORF", + "fa5a4759b2de2b573f59629ad1baf64ad04a85fb8fe1fb3cbf68e08a42fff4f0", + ), + ( + "cr2-canon-eos-70d.cr2", + "https://raw.pixls.us/getfile.php/784/nice/Canon%20-%20EOS%2070D%20-%20RAW.CR2", + "00e42517013bd043b7145001399bbc320a8ff235a6dcd16033580d8a130bee1e", + ), + ( + "cr2-canon-eos-rebel-t7i.cr2", + "https://raw.pixls.us/getfile.php/3437/nice/Canon%20-%20EOS%20Rebel%20T7i%20-%20RAW%20%283:2%29.cr2", + "03fb55af71f384ada6b6aa0ca93279d48cf5fbd13651a323408d2fd97e1d1f7c", + ), + ( + "cr2-canon-powershot-g9-x.cr2", + "https://raw.pixls.us/getfile.php/2154/nice/Canon%20-%20PowerShot%20G9%20X%20-%20RAW%20%283:2%29.CR2", + "05c041127f4fdecfd49a45da4b12192c8dbe695c3e50427043166fef49b39e83", + ), + ( + "pef-pentax-k-1.pef", + "https://raw.pixls.us/getfile.php/2469/nice/Pentax%20-%20K-1%20-%2014bit%20%283:2%29.PEF", + "068c902e7b38ac31c7c6eca5c55f69ca395ec6aa7170489281b692a893d0fee9", + ), + ( + "cr2-canon-eos-4000d.cr2", + "https://raw.pixls.us/getfile.php/3241/nice/Canon%20-%20EOS%204000D%20-%20RAW%20%283:2%29.CR2", + "06daaf663f42d3f6ceb1a837d3ca4db2cfcb5899888ed13510bfa01e3261a896", + ), + ( + "cr2-canon-eos-1000d.cr2", + "https://raw.pixls.us/getfile.php/1616/nice/Canon%20-%20EOS%201000D%20-%20RAW%20%283:2%29.CR2", + "08b0843a7968315b68bd3c71a96c952ebf275d5b5eba4b6094ae8b103522584c", + ), + ( + "cr2-canon-powershot-g1-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/2186/nice/Canon%20-%20PowerShot%20G1%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "08d861ca7fc2890f0217034970d4c900b09373b421106e4ba080f6904a48b37c", + ), + ( + "cr2-canon-eos-500d.cr2", + "https://raw.pixls.us/getfile.php/911/nice/Canon%20-%20EOS%20500D%20-%20RAW%20%283:2%29.CR2", + "0b57eb8b0dece6f63d4ea61660279488121c9a0aa250113852fa84c051c6da3e", + ), + ( + "pef-pentax-ist-dl.pef", + "https://raw.pixls.us/getfile.php/1083/nice/Pentax%20-%20%2Aist%20DL%20-%2012bit%20%283:2%29.PEF", + "0e6cd3c999a6dbf121591b2c58aaa8216e625f549db46af627f7346ccf1cf662", + ), + ( + "cr2-canon-eos-400d.cr2", + "https://raw.pixls.us/getfile.php/1764/nice/Canon%20-%20EOS%20400D%20-%20RAW%20%283:2%29.CR2", + "0e752ddaf81795885efc6443c78e5fee4d5f8f00c8d979ef20e235424f98d595", + ), + ( + "dng-ricoh-gx200.dng", + "https://raw.pixls.us/getfile.php/2589/nice/Ricoh%20-%20GX200%20-%2012bit%20%284:3%29.DNG", + "0f328905ee6f5b4823040a666103ced651178a13b8ccd0c4c60662f7265dd87f", + ), + ( + "cr2-canon-powershot-s110.cr2", + "https://raw.pixls.us/getfile.php/909/nice/Canon%20-%20PowerShot%20S110%20-%20RAW%20%284:3%29.CR2", + "14e440316c1d2b005a7f5d5c042e063b3e15d34242e38c3a0983db8ef85e4288", + ), + ( + "cr2-canon-eos-kiss-x80.cr2", + "https://raw.pixls.us/getfile.php/3099/nice/Canon%20-%20EOS%20Kiss%20X80%20-%20RAW%20%283:2%29.CR2", + "155878290528ff91f67b4f9ffed5d765e80ede5fb0c36fca40107c0b7f5c1d9d", + ), + ( + "dng-pentax-kp.dng", + "https://raw.pixls.us/getfile.php/1824/nice/Pentax%20-%20KP%20-%2014bit%20%283:2%29.DNG", + "16b09cb3032003989877659ae03cbb6985e3d7e84f1402863ccae8af10033c1c", + ), + ( + "dng-pentax-k-1-mark-ii.dng", + "https://raw.pixls.us/getfile.php/3345/nice/Pentax%20-%20K-1%20Mark%20II%20-%2014bit%20%283:2%29.DNG", + "190d5878675f4de07d246271ba411b152f3317b2d2a0e27b9c827fe7343b28ea", + ), + ( + "dng-pentax-k-3-mark-iii-monochrome.dng", + "https://raw.pixls.us/getfile.php/7081/nice/Pentax%20-%20K-3%20Mark%20III%20Monochrome%20-%2014bit%20%283:2%29.DNG", + "1afc1071451a3a3737c29ae319e1411551b6893d0e6a34db29903318266b0066", + ), + ( + "cr2-canon-eos-600d.cr2", + "https://raw.pixls.us/getfile.php/1586/nice/Canon%20-%20EOS%20600D%20-%20RAW%20%283:2%29.CR2", + "1b0892df5ba5fa418e945f0519d490aeecaaf7e74860a08b5dc47e3e9976a288", + ), + ( + "pef-pentax-k-70.pef", + "https://raw.pixls.us/getfile.php/1141/nice/Pentax%20-%20K-70%20-%2014bit%20%283:2%29.PEF", + "1d64c6aa71680642ba8f00b6f6105df3e66a4534e631f422fbf3316273c4408c", + ), + ( + "dng-ricoh-gr-iiix.dng", + "https://raw.pixls.us/getfile.php/5818/nice/Ricoh%20-%20GR%20IIIx%20-%2014bit%20%283:2%29.DNG", + "1e318af31be4baa9949f0ed4af5cfe8ffb2c180e2c12926cd75ac1d029bb0fae", + ), + ( + "cr2-canon-powershot-g1-x.cr2", + "https://raw.pixls.us/getfile.php/1588/nice/Canon%20-%20PowerShot%20G1%20X%20-%20RAW%20%284:3%29.CR2", + "1e486bbbdfb2c94accf0c5ef4558d1aa484e0ffd36fa03440e3ae34ba2aa338b", + ), + ( + "dng-ricoh-gxr.dng", + "https://raw.pixls.us/getfile.php/2756/nice/Ricoh%20-%20GXR%20-%2012bit%20%284:3%29.DNG", + "219a062f3d1eb8d66c9dd6088eecf495be082cf5c81f88622eb0dd28cc893cec", + ), + ( + "cr2-canon-powershot-g16.cr2", + "https://raw.pixls.us/getfile.php/1114/nice/Canon%20-%20PowerShot%20G16%20-%20RAW%20%284:3%29.CR2", + "2307e3cf643ab2f94d03f899467d8797ce1ef41bbffd6ad50af81bdd0ba34098", + ), + ( + "dng-pentax-k20d.dng", + "https://raw.pixls.us/getfile.php/1143/nice/Pentax%20-%20K20D%20-%2012bit%20%283:2%29.DNG", + "264465ef1d97f1599899778589f07f8aa485b88c0dfc9b1d4c006bf79d44b420", + ), + ( + "cr2-canon-powershot-g3-x.cr2", + "https://raw.pixls.us/getfile.php/1509/nice/Canon%20-%20PowerShot%20G3%20X%20-%20RAW%20%283:2%29.CR2", + "267641913613fd52532279ff16f03c135511fbcab5bbe8b276fc9cf91aebd7ca", + ), + ( + "pef-pentax-ist-ds.pef", + "https://raw.pixls.us/getfile.php/4392/nice/Pentax%20-%20%2Aist%20DS%20-%2012bit%2012bit%20uncompressed%20%283:2%29.pef", + "27809a7d252d515685ed4e6091b70ab25348502baeb75db03ce7b4f1d9549b76", + ), + ( + "cr2-canon-eos-m.cr2", + "https://raw.pixls.us/getfile.php/1224/nice/Canon%20-%20EOS%20M%20-%20RAW%20%283:2%29.CR2", + "27d151efe86f04bea8fcf6ec9f3a722ae3ee24a3f56a6ebbd27ee89b215cf569", + ), + ( + "cr2-canon-eos-1d-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1058/nice/Canon%20-%20EOS-1D%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "2e372710d546c9b8903a343696af4e7fd245794571f64258ab607a89dfa6aa97", + ), + ( + "pef-pentax-k-3-mark-iii.pef", + "https://raw.pixls.us/getfile.php/4677/nice/Pentax%20-%20K-3%20Mark%20III%20-%2014bit%20%283:2%29.PEF", + "351be88038b2dd28af39e11dbece6de74efd71119a9ef667d35cdf02dcff919f", + ), + ( + "cr2-canon-eos-750d.cr2", + "https://raw.pixls.us/getfile.php/1982/nice/Canon%20-%20EOS%20750D%20-%20RAW%20%283:2%29.CR2", + "38cdb173fe1ea083f4d475285d3080db49d7f3c7cdcb49bf1968fceeed66c85c", + ), + ( + "cr2-canon-eos-5ds.cr2", + "https://raw.pixls.us/getfile.php/2083/nice/Canon%20-%20EOS%205DS%20-%20RAW%20%283:2%29.CR2", + "3fdb8b7ada3b4a69ed5756fa814bee9bd51c1c22b40e70252a46890e2855df07", + ), + ( + "dng-canon-powershot-s2-is.dng", + "https://raw.pixls.us/getfile.php/941/nice/Canon%20-%20PowerShot%20S2%20IS%20-%2010bit%2010bit%20DNG%2C%20CHDK%20ver.%201.0.0-1504%20%284:3%29.CR2", + "445764716df165a1facd37c01d67ede44ca49671ed1a6112dd8ff7426324c757", + ), + ( + "cr2-canon-powershot-s100v.cr2", + "https://raw.pixls.us/getfile.php/2492/nice/Canon%20-%20PowerShot%20S100V%20-%20RAW%20%284:3%29.CR2", + "4775f347686f785104ee7f566df158247cdecef4de76fdbfa150e18b6b104c0a", + ), + ( + "cr2-canon-eos-kiss-digital-n.cr2", + "https://raw.pixls.us/getfile.php/1185/nice/Canon%20-%20EOS%20Kiss%20Digital%20N%20-%20RAW%20%283:2%29.CR2", + "49c78e5c36255cdd3f9fd219e86e3325bfa67be4d8d1ab5e21c21c3fd53c294e", + ), + ( + "dng-pentax-k-01.dng", + "https://raw.pixls.us/getfile.php/1008/nice/Pentax%20-%20K-01%20-%2012bit%20%283:2%29.DNG", + "4c9aac2ce2720154616ad7678385d1ec1f626d6464a1d5c6a5a4d821a78a608d", + ), + ( + "cr2-canon-eos-rebel-t5.cr2", + "https://raw.pixls.us/getfile.php/1411/nice/Canon%20-%20EOS%20Rebel%20T5%20-%20RAW%20%283:2%29.CR2", + "4f71c1416037d2212cc4847e978bffe874afc0f653260386e96dff704ca8bf51", + ), + ( + "pef-pentax-k110d.pef", + "https://raw.pixls.us/getfile.php/1309/nice/Pentax%20-%20K110D%20-%2012bit%20%283:2%29.pef", + "4f7c5574b0dbc3c1270b269126c2a3be030a86799f8b544610c74f31ef782f72", + ), + ( + "cr2-canon-powershot-g11.cr2", + "https://raw.pixls.us/getfile.php/2755/nice/Canon%20-%20PowerShot%20G11%20-%20RAW%20%284:3%29.CR2", + "50ef4d6712c7add1c65ef4c48c74ac5017f24ff1789f1d8a8d5d9accd3aab0fa", + ), + ( + "cr2-canon-eos-rebel-sl1.cr2", + "https://raw.pixls.us/getfile.php/1201/nice/Canon%20-%20EOS%20Rebel%20SL1%20-%20RAW%20%283:2%29.CR2", + "56faa7db3947c32c5e6e31bc0db027ebf95163533e043bd8c4f18166a129692c", + ), + ( + "cr2-canon-powershot-g15.cr2", + "https://raw.pixls.us/getfile.php/1986/nice/Canon%20-%20PowerShot%20G15%20-%20RAW%20%284:3%29.CR2", + "5756d1a6522134ee4833a9cbef04eb54b26a1f7935e58063733a264478385a26", + ), + ( + "dng-pentax-q7.dng", + "https://raw.pixls.us/getfile.php/1760/nice/Pentax%20-%20Q7%20-%2012bit%20%284:3%29.DNG", + "577eac8d03419bb82714169dd532d58efc82a0abf928d28e4641f1ed5d56c350", + ), + ( + "dng-pentax-k-s1.dng", + "https://raw.pixls.us/getfile.php/1365/nice/Pentax%20-%20K-S1%20-%2012bit%20%283:2%29.DNG", + "58abc43a8e025dcab0053bcee173d20197aa2b7983e5e62097147a1e8545f111", + ), + ( + "cr2-canon-eos-1d-mark-iv.cr2", + "https://raw.pixls.us/getfile.php/4258/nice/Canon%20-%20EOS-1D%20Mark%20IV%20-%20RAW%20%283:2%29.CR2", + "597bb01f2400b04bfe7ee024016992e8e897b7dffaa5e368b8375daf3b32b3ed", + ), + ( + "cr2-canon-eos-m5.cr2", + "https://raw.pixls.us/getfile.php/2935/nice/Canon%20-%20EOS%20M5%20-%20RAW%20%283:2%29.CR2", + "5af02dbad6cd90eeac2b0b1c0be0aeb9a5d2841c6dd984ee415ede9f2566667d", + ), + ( + "cr2-canon-powershot-s100.cr2", + "https://raw.pixls.us/getfile.php/1357/nice/Canon%20-%20PowerShot%20S100%20-%20RAW%20%284:3%29.CR2", + "5d1bb0597da59bc920442c55dbd7f70d090a9aa01b5642607bcdde523ae892c6", + ), + ( + "pef-pentax-ist-d.pef", + "https://raw.pixls.us/getfile.php/1843/nice/Pentax%20-%20%2Aist%20D%20-%2012bit%20%283:2%29.PEF", + "61904b594d16983070f3e56d663ae8f7b246b8bb554c4cf7bd3eb4b9f9a9f3aa", + ), + ( + "dng-pentax-k-30.dng", + "https://raw.pixls.us/getfile.php/3094/nice/Pentax%20-%20K-30%20-%2012bit%20%283:2%29.DNG", + "63eaa1ff93ad7c89947017332b10f78f17cd3853a5b495adf25410edaefbe618", + ), + ( + "cr2-canon-eos-rebel-t4i.cr2", + "https://raw.pixls.us/getfile.php/2203/nice/Canon%20-%20EOS%20Rebel%20T4i%20-%20RAW%20%283:2%29.CR2", + "65257f38bc68a11b9185ac710b3a442a19aa1c082afb01b668bf33e6836b6b2d", + ), + ( + "cr2-canon-powershot-sx50-hs.cr2", + "https://raw.pixls.us/getfile.php/3152/nice/Canon%20-%20PowerShot%20SX50%20HS%20-%20RAW%20%284:3%29.CR2", + "6c2ddd232f5432fb7bd0e28efc12d66995cedcd847f801fd854bfdfb180db50c", + ), + ( + "dng-pentax-k200d.dng", + "https://raw.pixls.us/getfile.php/5246/nice/Pentax%20-%20K200D%20-%2012bit%2012bit%20uncompressed%20%283:2%29.DNG", + "6d13579dd0a8ec784bfa3d8b53af5a3f936171bfa62d74eb96a17236a6fb9472", + ), + ( + "cr2-canon-eos-m2.cr2", + "https://raw.pixls.us/getfile.php/5067/nice/Canon%20-%20EOS%20M2%20-%20RAW%20%283:2%29.cr2", + "6d26eeb1e8a5978c7cb9d8beb6e6e34f3c27847055b76bc7f186503ca44508d0", + ), + ( + "cr2-canon-eos-m3.cr2", + "https://raw.pixls.us/getfile.php/1002/nice/Canon%20-%20EOS%20M3%20-%20RAW%20%283:2%29.CR2", + "6f12a02a21f64b284328ee6d3b2d1bab4439720cad2e881adf3da72592128b11", + ), + ( + "cr2-canon-powershot-g12.cr2", + "https://raw.pixls.us/getfile.php/1767/nice/Canon%20-%20PowerShot%20G12%20-%20RAW%20%284:3%29.cr2", + "6f2750c7cb144eee80a91431e2ce8df66dd229c3e1f1453bf80409ca669a8614", + ), + ( + "dng-pentax-k-5.dng", + "https://raw.pixls.us/getfile.php/1479/nice/Pentax%20-%20K-5%20-%2014bit%20%283:2%29.DNG", + "6f6419209340d07d597ccca66dc367d64cff3308ef2694c8f90c6259f3a5c431", + ), + ( + "cr2-canon-eos-6d-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1625/nice/Canon%20-%20EOS%206D%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "712815b910fa473f7aaeb97c0804292d996b16dd6b974a3ab9a7195a2e41d90c", + ), + ( + "cr2-canon-eos-m10.cr2", + "https://raw.pixls.us/getfile.php/1758/nice/Canon%20-%20EOS%20M10%20-%20RAW%20%283:2%29.CR2", + "71e27b9ebe7efba632e666db77b82c5129e0d1a402ff359a2b509774ebeb1b0f", + ), + ( + "dng-pentax-k-x.dng", + "https://raw.pixls.us/getfile.php/830/nice/Pentax%20-%20K-x%20-%2012bit%20%283:2%29.DNG", + "7a594b66b54f99bb5aba4627c78abfc41213d6911ea54e4f5d2dd6c8505f6968", + ), + ( + "cr2-canon-eos-rebel-t5i.cr2", + "https://raw.pixls.us/getfile.php/2651/nice/Canon%20-%20EOS%20Rebel%20T5i%20-%20RAW%20%283:2%29.CR2", + "7c3c9c3b8ec487d56040c21620aafe51e13e2c2fc1e1351929970720e10dda69", + ), + ( + "dng-ricoh-gr.dng", + "https://raw.pixls.us/getfile.php/996/nice/Ricoh%20-%20GR%20-%2012bit%20%284:3%29.DNG", + "7ceec1ea31c56b6e0414202504581b915f17818ffe018b4c8bba1393332c405e", + ), + ( + "dng-pentax-645d.dng", + "https://raw.pixls.us/getfile.php/2401/nice/Pentax%20-%20645D%20-%2014bit%2014bit%20compressed%20%284:3%29.DNG", + "7cfa4a6b3fc459157b0be94effaf98136027b1182dac7da0f91e29987ab6ae6c", + ), + ( + "cr2-canon-eos-rebel-t3.cr2", + "https://raw.pixls.us/getfile.php/3920/nice/Canon%20-%20EOS%20Rebel%20T3%20-%20RAW%20%283:2%29.CR2", + "8106f288e22ca0510b2ebe36e58833afbf847db92e9e1ec6f651605bac0beb24", + ), + ( + "cr2-canon-eos-77d.cr2", + "https://raw.pixls.us/getfile.php/1332/nice/Canon%20-%20EOS%2077D%20-%20RAW%20%283:2%29.CR2", + "811df582d97918987312fa0fceae3ad19a20532a64ebda9782764ac7e629c265", + ), + ( + "cr2-canon-eos-2000d.cr2", + "https://raw.pixls.us/getfile.php/2612/nice/Canon%20-%20EOS%202000D%20-%20RAW%20%283:2%29.CR2", + "84015493df96c877c0387071a627d6940187d75695b0598694901ac0ff83876a", + ), + ( + "cr2-canon-eos-5d-mark-iv.cr2", + "https://raw.pixls.us/getfile.php/980/nice/Canon%20-%20EOS%205D%20Mark%20IV%20-%20RAW%20%283:2%29.CR2", + "85bfec149261ffbae90797e45caae5e204e1e61ac62b92783925b5ee0f267d76", + ), + ( + "cr2-canon-eos-1d-mark-iii.cr2", + "https://raw.pixls.us/getfile.php/2037/nice/Canon%20-%20EOS-1D%20Mark%20III%20-%20RAW%20%283:2%29.CR2", + "860b5c9791548db8081823e3852c436a439e648ea863b0def05e99aac6e9e726", + ), + ( + "cr2-canon-eos-60d.cr2", + "https://raw.pixls.us/getfile.php/993/nice/Canon%20-%20EOS%2060D%20-%20RAW%20%283:2%29.CR2", + "865236ece3bf1f2f19a15001a8f0f99badf71e34b9eed1fead04d15daf87a154", + ), + ( + "pef-pentax-k100d.pef", + "https://raw.pixls.us/getfile.php/2262/nice/Pentax%20-%20K100D%20-%2012bit%20%283:2%29.PEF", + "86ec2383dbfda3eda38ca913b02874a70248efba6107aa304781c8d3779de8c9", + ), + ( + "cr2-canon-eos-1d-mark-ii-n.cr2", + "https://raw.pixls.us/getfile.php/1781/nice/Canon%20-%20EOS-1D%20Mark%20II%20N%20-%20RAW%20%283:2%29.CR2", + "8bf6591f64b0657bc6133cbd0ee1418ef8e6dd3dd595827f8e9910c0f235843c", + ), + ( + "dng-pentax-k-500.dng", + "https://raw.pixls.us/getfile.php/1458/nice/Pentax%20-%20K-500%20-%2012bit%20%283:2%29.DNG", + "91ccd6f29b8eaad88ae26d1c860ef1113e6dfc1bd2c8f5245b49f3a788295fb1", + ), + ( + "cr2-canon-eos-m100.cr2", + "https://raw.pixls.us/getfile.php/3531/nice/Canon%20-%20EOS%20M100%20-%20RAW%20%283:2%29.CR2", + "941d9e696b1d92b4b25a762474e3c5c127e7405dea099c266fa85e39a2454fcc", + ), + ( + "pef-pentax-k2000.pef", + "https://raw.pixls.us/getfile.php/5239/nice/Pentax%20-%20K2000%20-%2012bit%2012bit%20compressed%20%283:2%29.PEF", + "985003748c094b8d02f2517b4c07d66d48ea5380260235ccb3409f41ceb3ea84", + ), + ( + "dng-pentax-k-50.dng", + "https://raw.pixls.us/getfile.php/2235/nice/Pentax%20-%20K-50%20-%2012bit%20%283:2%29.DNG", + "9ea1dc1f92df19699aa8b620670c64a84d4ead0172a49cbbd47dea9257d14e67", + ), + ( + "cr2-canon-powershot-g5-x.cr2", + "https://raw.pixls.us/getfile.php/4257/nice/Canon%20-%20PowerShot%20G5%20X%20-%20RAW%20%283:2%29.CR2", + "a7d0ac7f3c7ddfa607a6d0a7433ef9ba664d260aa91616cd75cfbdf912b0da00", + ), + ( + "dng-pentax-645z.dng", + "https://raw.pixls.us/getfile.php/2403/nice/Pentax%20-%20645Z%20-%2014bit%2014bit%20compressed%20%284:3%29.DNG", + "a8203686c61962af591b374331b6eb9dace4f2b29fce9f166303fc66fd210bb0", + ), + ( + "dng-pentax-k-5-ii.dng", + "https://raw.pixls.us/getfile.php/873/nice/Pentax%20-%20K-5%20II%20-%2014bit%20%283:2%29.DNG", + "aa41f0d9ab8d3e21e468b4c61d4d1ddae6a4fbe37f629b78159246cbb94dd56b", + ), + ( + "cr2-canon-powershot-g9.cr2", + "https://raw.pixls.us/getfile.php/760/nice/Canon%20-%20PowerShot%20G9%20-%20RAW%20%284:3%29.CR2", + "ad27a4d751984cd709380e3be274a4a5243d36ca89c5fc711736ba8a9f5c932f", + ), + ( + "dng-pentax-q.dng", + "https://raw.pixls.us/getfile.php/1610/nice/Pentax%20-%20Q%20-%2012bit%20%284:3%29.DNG", + "ad79b4ce4b88e5ca8b03d0d5aeaba09f55559695234941a5a6ada0cd4825860d", + ), + ( + "dng-pentax-mx-1.dng", + "https://raw.pixls.us/getfile.php/1731/nice/Pentax%20-%20MX-1%20-%2012bit%20%284:3%29.dng", + "b00f3fd42479bbaba7a00b2b6fa3e5f5d213c51d591adff4f6eb4a768ecd1ce9", + ), + ( + "cr2-canon-eos-5d.cr2", + "https://raw.pixls.us/getfile.php/1346/nice/Canon%20-%20EOS%205D%20-%20RAW%20%283:2%29.CR2", + "b30089172973388d03e192a14a63f750e22914dfc01db5713b6b3bce3c9baeee", + ), + ( + "pef-pentax-k100d-super.pef", + "https://raw.pixls.us/getfile.php/5804/nice/Pentax%20-%20K100D%20Super%20-%2012bit%2012bit%20uncompressed%20%283:2%29.PEF", + "b391c84ff1cc8769ec5214f837d1e4b3ae80374673608601b12ee4484e003ddb", + ), + ( + "cr2-canon-eos-m6.cr2", + "https://raw.pixls.us/getfile.php/1382/nice/Canon%20-%20EOS%20M6%20-%20RAW%20%283:2%29.CR2", + "b3a7a0037600b61d4c26ad03d5cac1801e35fb43a55699db5b77708c96f7338a", + ), + ( + "cr2-canon-powershot-g7-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1682/nice/Canon%20-%20PowerShot%20G7%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "b81acebf5ac49b0389c354942d128d9423fdd59ff92217554f232f94e3e87ef5", + ), + ( + "cr2-canon-eos-7d-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1064/nice/Canon%20-%20EOS%207D%20Mark%20II%20-%20RAW%20%283:2%29.cr2", + "b9f71c56adb6e2861e6653efb312fb5cc88ba4aa17fa809c6a18b8db044360c3", + ), + ( + "dng-ricoh-gr-ii.dng", + "https://raw.pixls.us/getfile.php/1955/nice/Ricoh%20-%20GR%20II%20-%2012bit%20%283:2%29.DNG", + "bc8e00ccfd8e4753e8ee0735255bff4dcca5fb8d1a1352adfa59b86806d03f44", + ), + ( + "cr2-canon-powershot-s120.cr2", + "https://raw.pixls.us/getfile.php/1694/nice/Canon%20-%20PowerShot%20S120%20-%20RAW%20%284:3%29.CR2", + "bcbfadad8ecdbcc783864a2b769aef37588e2a6339b9682dcd9642602bd2011f", + ), + ( + "cr2-canon-powershot-s95.cr2", + "https://raw.pixls.us/getfile.php/1742/nice/Canon%20-%20PowerShot%20S95%20-%20RAW%20%284:3%29.cr2", + "c4a55c61434b8403f948518deca22e8ab755099f124d5cea5bc1cd0ea86711ad", + ), + ( + "cr2-canon-powershot-g1-x-mark-iii.cr2", + "https://raw.pixls.us/getfile.php/2695/nice/Canon%20-%20PowerShot%20G1%20X%20Mark%20III%20-%20RAW%20%283:2%29.CR2", + "ca052ed025432fdacfba7347cb4374fa3b727155912a91c42ed9a275d13bedf5", + ), + ( + "cr2-canon-eos-50d.cr2", + "https://raw.pixls.us/getfile.php/1792/nice/Canon%20-%20EOS%2050D%20-%20RAW%20%283:2%29.CR2", + "ce460cf2b6762c8c22ebeb00e9e1edccd728edcb7c4e82c78f3bce61d21fa88d", + ), + ( + "dng-pentax-k-s2.dng", + "https://raw.pixls.us/getfile.php/1626/nice/Pentax%20-%20K-S2%20-%2012bit%20%283:2%29.DNG", + "d23916a31f09c961e6050e4120bbcd92f7847080f9e40fad8dc7047ead1dec2d", + ), + ( + "cr2-canon-eos-20d.cr2", + "https://raw.pixls.us/getfile.php/1018/nice/Canon%20-%20EOS%2020D%20-%20RAW%20%283:2%29.CR2", + "db78660d48589130d99e0bc621424851eb0f84df052634fb5bc89c7a02fe7033", + ), + ( + "pef-pentax-k-r.pef", + "https://raw.pixls.us/getfile.php/8171/nice/Pentax%20-%20K-r%20-%2012bit%20compressed%20%283:2%29.PEF", + "dd0d7b76d89039995d253adddcfb44d131414fad2179610d9ea4d8d4bb876b7b", + ), + ( + "pef-pentax-kf.pef", + "https://raw.pixls.us/getfile.php/6664/nice/Pentax%20-%20KF%20-%2014bit%20%283:2%29.PEF", + "ddc9cdf399a7210b203dee1765ab9f4165a2e695da59bce6bcabf75b77174a4b", + ), + ( + "cr2-canon-powershot-g7-x.cr2", + "https://raw.pixls.us/getfile.php/1277/nice/Canon%20-%20PowerShot%20G7%20X%20-%20RAW%20%283:2%29.CR2", + "e4b0a64df6f43252ab96b4789f698ca334e86586ab151d1e9f5616b80172abcd", + ), + ( + "cr2-canon-eos-450d.cr2", + "https://raw.pixls.us/getfile.php/1353/nice/Canon%20-%20EOS%20450D%20-%20RAW%20%283:2%29.CR2", + "e975f71429b939701dd618a7bac4709c1409f4bb6aa1c3607ceeaa86031c1c4a", + ), + ( + "cr2-canon-powershot-sx60-hs.cr2", + "https://raw.pixls.us/getfile.php/4942/nice/Canon%20-%20PowerShot%20SX60%20HS%20-%20RAW%20%284:3%29.CR2", + "f005e82fe8579876d7e9d39291c57f4410412904dfff0de5bbad9f32b3189612", + ), + ( + "dng-pentax-k-3-ii.dng", + "https://raw.pixls.us/getfile.php/1363/nice/Pentax%20-%20K-3%20II%20-%2014bit%20%283:2%29.DNG", + "f43b9dc35e4d1b43918d2d4bd0973bc202af48ed24c924e4aff61250c971b171", + ), + ( + "dng-pentax-k-7.dng", + "https://raw.pixls.us/getfile.php/827/nice/Pentax%20-%20K-7%20-%2012bit%20%283:2%29.DNG", + "f441c36678dfca4f38e25a580f4f8485d78d8067a054153bc36be71e56c92386", + ), + ( + "cr2-canon-eos-kiss-x9.cr2", + "https://raw.pixls.us/getfile.php/2062/nice/Canon%20-%20EOS%20Kiss%20X9%20-%20RAW%20%283:2%29.CR2", + "f50cea4641c97c1154a214a686226ba900002f00f4f844af858ef80b9ec8dea6", + ), + ( + "cr2-canon-powershot-g9-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1575/nice/Canon%20-%20PowerShot%20G9%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "f91f1a3b6fa66b2e15fd26c1bd78e1eb19dfa7e6a3886aff36e7ab96fd1c319c", + ), + ( + "cr2-canon-eos-760d.cr2", + "https://raw.pixls.us/getfile.php/2094/nice/Canon%20-%20EOS%20760D%20-%20RAW%20%283:2%29.cr2", + "f98108c5efd57eef483009cdb490ada2071d1c1b58a82df3de997b4d86d849c2", + ), + ( + "cr2-canon-eos-30d.cr2", + "https://raw.pixls.us/getfile.php/1791/nice/Canon%20-%20EOS%2030D%20-%20RAW%20%283:2%29.CR2", + "fb216f8df8d7abca4eb9d9f8dc594c1d2b69d0a6369fb8b4107743343c37144f", + ), + ("erf-epson-rd1.erf", "https://raw.pixls.us/data/Epson/R-D1/_EPS0672.ERF", "09d8e533d93116294a9f3e161ed868e929454c7b946e083b644bd6bb75bcb5e4"), + ("erf-epson-rd1s.erf", "https://raw.pixls.us/data/Epson/R-D1s/EPSN3575.ERF", "45bb0081baf1c813c7cd9e05b3e6ae734b90aba408dd1195118dc94dc6abbbb9"), + ("erf-epson-rd1x.erf", "https://raw.pixls.us/data/Epson/R-D1x/_EPS1919.ERF", "fa1bff3e0b9b9a14f83b8db9fae022c2aca8d455896bf4f25f2c1a0034e295e6"), + ( + "mef-mamiya-zd.mef", + "https://raw.pixls.us/data/Mamiya/ZD/RAW_MAMIYA_ZD.MEF", + "bcd63507c3c4cc3ea1bad2945e3f88cb4677a1a3c762e1acc38df4445714eb7c", + ), +]; diff --git a/xtask/src/wasm.rs b/xtask/src/wasm.rs new file mode 100644 index 000000000..4f16b2d75 --- /dev/null +++ b/xtask/src/wasm.rs @@ -0,0 +1,37 @@ +use super::*; + +/// Workspace packages that must build for wasm32: all L0–L5 crates plus +/// the egui shell and the web app. +fn wasm_set() -> Result, String> { + let crates = layers::from_metadata(&metadata()?)?; + Ok(crates + .into_iter() + .filter(|c| match layers::classify(&c.name) { + Some(layers::Class::Layer(l)) => l <= 5, + Some(layers::Class::Standalone) => true, + _ => false, + }) + .map(|c| c.name) + .chain(std::iter::once("lightcraft-web".to_string())) + .collect()) +} + +/// One `cargo check` over the whole set; `--keep-going` reports every crate that fails. +pub(crate) fn cmd_wasm() -> Result<(), String> { + cmd_wasm_for(None) +} + +/// The wasm check of the wasm-safe crates, or only those of them in `only` (`ci --quick`). +pub(crate) fn cmd_wasm_for(only: Option<&std::collections::BTreeSet>) -> Result<(), String> { + let set: Vec = wasm_set()?.into_iter().filter(|p| only.is_none_or(|o| o.contains(p))).collect(); + if set.is_empty() { + println!("wasm32-unknown-unknown check: no changed crate builds for wasm32"); + return Ok(()); + } + let mut c = cargo(); + c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); + for pkg in &set { + c.args(["-p", pkg]); + } + run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p "))) +} From 59d1d6f0f8461d04e1ac0fa49e45c83e226cfc60 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:24:24 +0000 Subject: [PATCH 117/191] refactor(xtask): point the raw corpus test and ci-quick map at the moved sources --- crates/raw/tests/it/corpus.rs | 4 ++-- xtask/src/ci_quick.rs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/raw/tests/it/corpus.rs b/crates/raw/tests/it/corpus.rs index 94d4647d6..7f7074f91 100644 --- a/crates/raw/tests/it/corpus.rs +++ b/crates/raw/tests/it/corpus.rs @@ -1142,7 +1142,7 @@ fn nikon_in_file_distortion_is_limited_to_validated_bodies() { eprintln!("Nikon distortion boundary: {checked} corpus files checked"); } -/// The Sony samples of the corpus manifest (`RAW_SAMPLES` in `xtask/src/main.rs`) with their pinned SHA-256. +/// The Sony samples of the corpus manifest (`RAW_SAMPLES` in `xtask/src/raw_samples.rs`) with their pinned SHA-256. const SONY_SAMPLES: &[(&str, &str)] = &[ ("arw-sony-a7rm4a-compressed.arw", "690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67"), ("arw-sony-a9m2-compressed.arw", "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97"), @@ -1203,7 +1203,7 @@ fn sony_pins_match_the_corpus_manifest() { lines.sort(); let digest: String = Sha256::digest(lines.concat()).iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(digest, SONY_PINS_SHA256, "the pinned Sony SHA-256 table changed: check every changed pin against its file, then update the anchor"); - let manifest = include_str!("../../../../xtask/src/main.rs"); + let manifest = include_str!("../../../../xtask/src/raw_samples.rs"); for (name, sha) in SONY_SAMPLES { let entry = manifest.find(&format!("\"{name}\"")).unwrap_or_else(|| panic!("{name} is not in the manifest")); assert!(manifest[entry..].split_once("),").is_some_and(|(e, _)| e.contains(sha)), "{name}: pinned SHA-256 differs from the manifest"); diff --git a/xtask/src/ci_quick.rs b/xtask/src/ci_quick.rs index 3242c95af..f187daf59 100644 --- a/xtask/src/ci_quick.rs +++ b/xtask/src/ci_quick.rs @@ -41,8 +41,9 @@ const EMBEDDED: &[(&str, &[&str])] = &[ ("docs/whats-new.md", &["lightcraft-ui-egui"]), ("docs/raw/coverage-sweep.tsv", &["lightcraft-raw"]), ("docs/raf-corpus.sha256", &["lightcraft-raw"]), - ("xtask/src/main.rs", &["lightcraft-raw"]), + ("xtask/src/raw_samples.rs", &["lightcraft-raw"]), ("crates/ui-egui/src/menus.rs", &["xtask"]), + ("crates/ui-egui/src/menus/", &["xtask"]), ]; /// The packages that compile in `path` from outside their own directory. From 2e5597d48a751d19fc217233f9afd6f1ae605872 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:09:26 +0000 Subject: [PATCH 118/191] refactor(lightcraft): split main.rs into crate-root modules --- apps/lightcraft/src/gpu_crash.rs | 97 ++++ apps/lightcraft/src/launch.rs | 138 +++++ apps/lightcraft/src/main.rs | 845 +------------------------------ apps/lightcraft/src/platform.rs | 57 +++ apps/lightcraft/src/prefs.rs | 245 +++++++++ apps/lightcraft/src/session.rs | 297 +++++++++++ 6 files changed, 853 insertions(+), 826 deletions(-) create mode 100644 apps/lightcraft/src/gpu_crash.rs create mode 100644 apps/lightcraft/src/launch.rs create mode 100644 apps/lightcraft/src/platform.rs create mode 100644 apps/lightcraft/src/prefs.rs create mode 100644 apps/lightcraft/src/session.rs diff --git a/apps/lightcraft/src/gpu_crash.rs b/apps/lightcraft/src/gpu_crash.rs new file mode 100644 index 000000000..1656d88e3 --- /dev/null +++ b/apps/lightcraft/src/gpu_crash.rs @@ -0,0 +1,97 @@ +use super::prefs::config_dir; +use super::*; + +/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12 +/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136: +/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12 +/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471). +/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`. +#[cfg(test)] +pub(crate) fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { + window_gpu::base_options() +} + +/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions +/// and `LIGHTCRAFT_NO_PREFS`. +pub(crate) fn window_files(in_memory: bool) -> window_gpu::Files { + window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none())) +} + +/// Written while the GPU compute device is created, removed once that returned +/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts). +fn gpu_marker_path() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("gpu-init.marker")) +} + +/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU +/// driver while creating the device), the notice to show — GPU rendering then starts off. +/// +/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would +/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU +/// starts off there too, and the next ordinary launch reports and clears it. +pub(crate) fn gpu_crash_check(in_memory: bool) -> Option { + gpu_crash_check_at(gpu_marker_path(), in_memory) +} + +pub(crate) fn gpu_crash_check_at(marker: Option, in_memory: bool) -> Option { + use lightcraft_engine::gpu::backend; + let left = if in_memory { + marker.as_deref().and_then(backend::read_init_marker) + } else { + let left = marker.as_deref().and_then(backend::take_init_marker); + backend::set_init_marker(marker); + left + }; + left.map(|what| gpu_crash_notice(&what)) +} + +pub(crate) fn gpu_crash_notice(what: &str) -> String { + format!( + "LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \ + To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \ + LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)." + ) +} + +/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next +/// launch renders on the CPU and says why. +pub(crate) fn gpu_lost_marker_path() -> Option { + gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker")) +} + +/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a +/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it +/// never failed. +pub(crate) fn gpu_lost_reason() -> Option { + lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string)) +} + +/// Record that LightCraft stopped after the GPU was lost, for the next launch. +pub(crate) fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) { + let Some(m) = marker else { return }; + if let Some(d) = m.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Err(e) = std::fs::write(m, format!("{reason}\n")) { + log::warn!("could not write {}: {e}", m.display()); + } +} + +/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a +/// `--memory` session, which leaves the disk as it found it). +pub(crate) fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option { + let m = marker?; + let reason = std::fs::read_to_string(m).ok()?; + if !in_memory { + let _ = std::fs::remove_file(m); + } + Some(format!( + "LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \ + is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \ + an up-to-date graphics driver may help.", + reason.trim() + )) +} diff --git a/apps/lightcraft/src/launch.rs b/apps/lightcraft/src/launch.rs new file mode 100644 index 000000000..a7b8904d6 --- /dev/null +++ b/apps/lightcraft/src/launch.rs @@ -0,0 +1,138 @@ +use super::prefs::config_dir; + +pub(crate) const HELP: &str = "\ +lightcraft — photo library + raw developer + +USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported) + +OPTIONS: + --library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or + a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held, + else ~/Pictures/LightCraft Library) + --no-demo don't seed a new library with the procedural demo photos + --memory throwaway in-memory session (alias --demo); nothing is saved + --control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT; + see docs/control-protocol.md) + --version, --help + +ENVIRONMENT: + LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder). + Without it, a portable.txt file next to the program puts the folder beside it (LightCraftData) + LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS, + Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering. + LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: /models/sam3; + optional: LightCraft offers to download it when first needed) + LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order) + LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings + from other crates); else RUST_LOG=. Records go to stderr and to + /logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log) +"; + +/// The launch arguments' files and folders to import (absolute), and a notice for each one that +/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374). +pub(crate) fn launch_imports(args: &[String]) -> (Vec, Vec) { + let cwd = std::env::current_dir().ok(); + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); + launch_imports_in(args, cwd.as_deref(), home.as_deref()) +} + +pub(crate) fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec, Vec) { + use lightcraft_engine::import::{LaunchPath, launch_path}; + let (mut files, mut notices) = (Vec::new(), Vec::new()); + for a in args { + match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) { + Some(LaunchPath::Import(p)) => files.push(p), + Some(LaunchPath::Refused { path, why }) => { + log::warn!("not importing {path} given at launch: {why}"); + notices + .push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…")); + } + None => {} + } + } + (files, notices) +} + +/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`). +/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs. +pub(crate) fn log_dir() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("logs")) +} + +/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is +/// the option or variable it came from). Logged before the log file opens, so it is kept for it. +pub(crate) fn control_port_from(what: &str, value: Option) -> Option { + let value = value?; + let port = value.trim().parse().ok(); + if port.is_none() { + log::warn!("{what} {value:?} is not a port number; no control server"); + } + port +} + +/// An isolated profile keeps the emergency crash log with its other app data. With no +/// override (or an empty override), retain the historical system-temp location. +pub(crate) fn panic_log_path_with(env: impl Fn(&str) -> Option, temp: std::path::PathBuf) -> std::path::PathBuf { + env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log") +} + +/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU +/// was lost (`lost`): GPU rendering is off from the next start. +pub(crate) fn stopped_text(error: &str, lost: Option<&str>) -> String { + match lost { + Some(reason) => format!( + "{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \ + Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on" + ), + None => error.to_string(), + } +} + +/// LightCraft stopped while its window was up: say so in the log and in a native message box +/// naming the log file (as [`startup_failed`] does for a start that failed). +pub(crate) fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) { + log::error!("LightCraft stopped: {text}"); + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return; // tests and scripts: no dialog to click away + } + let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref()); + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title(title) + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + +/// The window could not start (e.g. no usable graphics device): say so in the log and in a native +/// message box naming the log file, instead of exiting without a trace — the release builds have +/// no console on Windows, and an app started from Finder shows no standard error (issue #260). +pub(crate) fn startup_failed(error: &str, log_file: Option<&std::path::Path>) { + log::error!("LightCraft could not open its window: {error}"); + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return; // tests and scripts: no dialog to click away + } + let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref()); + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title(title) + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + +#[cfg(test)] +mod config_path_tests { + use super::*; + + #[test] + fn config_override_keeps_panic_log_in_isolated_profile() { + let temp = std::path::PathBuf::from("temp"); + assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log")); + assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log")); + assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log")); + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 2d11a3375..7b572fa0e 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -44,6 +44,25 @@ use lightcraft_engine::Session; use lightcraft_ui_egui::panels::library_problem::{Launch, LibraryProblem}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState}; +mod gpu_crash; +mod launch; +mod platform; +mod prefs; +mod session; + +use gpu_crash::{gpu_crash_check, gpu_lost_check_at, gpu_lost_marker_path, gpu_lost_reason, record_gpu_lost, window_files}; +#[cfg(test)] +use gpu_crash::{gpu_crash_check_at, gpu_crash_notice, window_wgpu_options}; +#[cfg(test)] +use launch::launch_imports_in; +use launch::{HELP, control_port_from, launch_imports, log_dir, panic_log_path_with, startup_failed, stopped_text, stopped_while_running}; +#[cfg(test)] +use platform::{explorer_select_arg, explorer_select_command, windows_open_url_command}; +use prefs::{PrefsWriter, as_remembered, load_prefs, prefs_path}; +#[cfg(test)] +use prefs::{load_prefs_at, write_atomic}; +use session::{open_session, services}; + /// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name. const APP_ID: &str = "ai.storyteller.lightcraft"; @@ -114,774 +133,6 @@ impl eframe::App for App { /// a second; anything slower gives way to quitting. const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2); -/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12 -/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136: -/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12 -/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471). -/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`. -#[cfg(test)] -fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { - window_gpu::base_options() -} - -/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions -/// and `LIGHTCRAFT_NO_PREFS`. -fn window_files(in_memory: bool) -> window_gpu::Files { - window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none())) -} - -/// Written while the GPU compute device is created, removed once that returned -/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts). -fn gpu_marker_path() -> Option { - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return None; - } - config_dir().map(|d| d.join("gpu-init.marker")) -} - -/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU -/// driver while creating the device), the notice to show — GPU rendering then starts off. -/// -/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would -/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU -/// starts off there too, and the next ordinary launch reports and clears it. -fn gpu_crash_check(in_memory: bool) -> Option { - gpu_crash_check_at(gpu_marker_path(), in_memory) -} - -fn gpu_crash_check_at(marker: Option, in_memory: bool) -> Option { - use lightcraft_engine::gpu::backend; - let left = if in_memory { - marker.as_deref().and_then(backend::read_init_marker) - } else { - let left = marker.as_deref().and_then(backend::take_init_marker); - backend::set_init_marker(marker); - left - }; - left.map(|what| gpu_crash_notice(&what)) -} - -fn gpu_crash_notice(what: &str) -> String { - format!( - "LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \ - To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \ - LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)." - ) -} - -/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next -/// launch renders on the CPU and says why. -fn gpu_lost_marker_path() -> Option { - gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker")) -} - -/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a -/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it -/// never failed. -fn gpu_lost_reason() -> Option { - lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string)) -} - -/// Record that LightCraft stopped after the GPU was lost, for the next launch. -fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) { - let Some(m) = marker else { return }; - if let Some(d) = m.parent() { - let _ = std::fs::create_dir_all(d); - } - if let Err(e) = std::fs::write(m, format!("{reason}\n")) { - log::warn!("could not write {}: {e}", m.display()); - } -} - -/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a -/// `--memory` session, which leaves the disk as it found it). -fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option { - let m = marker?; - let reason = std::fs::read_to_string(m).ok()?; - if !in_memory { - let _ = std::fs::remove_file(m); - } - Some(format!( - "LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \ - is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \ - an up-to-date graphics driver may help.", - reason.trim() - )) -} - -/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's -/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's. -fn display_profile_dir() -> Option { - let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); - let user = |rel: &str| home.as_ref().map(|h| h.join(rel)); - let candidates: Vec> = if cfg!(target_os = "macos") { - vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())] - } else if cfg!(windows) { - let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); - vec![Some(root.join("System32").join("spool").join("drivers").join("color"))] - } else { - let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share")); - vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())] - }; - candidates.into_iter().flatten().find(|d| d.is_dir()) -} - -fn config_dir() -> Option { - lightcraft_engine::config::config_dir() -} - -/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size -/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it -/// opens is the system's to say. -fn as_remembered(builder: egui::ViewportBuilder, window: Option) -> egui::ViewportBuilder { - let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size)); - // (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen - // to a size of its own choosing) - if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder } -} - -/// `/ui.json`, the saved UI state and app settings — `None` when nothing is read or -/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder. -fn prefs_path() -> Option { - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return None; - } - config_dir().map(|d| d.join("ui.json")) -} - -/// The saved UI state and app settings (`/ui.json`), if any, and a warning for the user -/// when the file exists but can't be used (issue #103): a damaged file is kept as -/// `ui.json.corrupt-` first, so the next save can't lose the library location in it; -/// one that can't be read at all is not written this session (`keep_file`). -/// -/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing -/// (issue #164), so a damaged file is left as it is there, not set aside. -fn load_prefs(in_memory: bool) -> (Option, Option, bool) { - let Some(path) = prefs_path() else { return (None, None, false) }; - load_prefs_at(&path, !in_memory) -} - -/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. An -/// arrangement from an older docking version starts again from the default without a warning. -fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> { - let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; - let fields = saved.as_object_mut().ok_or("settings must be an object")?; - lightcraft_ui_egui::docking::drop_outdated(fields); - let recovered = - fields.get("docking").is_some_and(|value| match serde_json::from_value::>(value.clone()) { - Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(), - Ok(None) => false, - Err(_) => true, - }); - if recovered { - fields.remove("docking"); - } - let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?; - Ok((ui, recovered)) -} - -fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option, Option, bool) { - let bytes = match std::fs::read(path) { - Ok(b) => b, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false), - Err(e) => { - let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display()); - return (None, Some(msg), true); - } - }; - match decode_saved_ui(&bytes) { - Ok((ui, false)) => (Some(ui.sanitized()), None, false), - Ok((ui, true)) => { - let msg = format!( - "The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.", - path.display() - ); - (Some(ui.sanitized()), Some(msg), true) - } - Err(e) if !set_aside_damaged => { - let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display()); - (None, Some(msg), true) - } - Err(e) => { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let keep = path.with_file_name(format!("ui.json.corrupt-{secs}")); - let kept = std::fs::rename(path, &keep); - let what = match &kept { - Ok(()) => format!("it was kept as {}", keep.display()), - Err(r) => format!("it couldn't be set aside ({r})"), - }; - let msg = format!( - "The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.", - path.display() - ); - (None, Some(msg), kept.is_err()) - } - } -} - -/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full -/// disk leaves the old file or the new one, never a truncated one). -fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { - use std::io::Write; - let tmp = path.with_extension("json.tmp"); - let written = (|| { - let mut f = std::fs::File::create(&tmp)?; - f.write_all(bytes)?; - f.sync_all() - })(); - if let Err(e) = written { - let _ = std::fs::remove_file(&tmp); - return Err(e); - } - std::fs::rename(&tmp, path)?; - if let Some(dir) = path.parent() - && let Ok(d) = std::fs::File::open(dir) - { - let _ = d.sync_all(); - } - Ok(()) -} - -/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library -/// changes, every few seconds when anything else changed, and at exit. -/// -/// Nothing is written while the session is one that promises to save nothing: `--memory` -/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the -/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the -/// library it opened is saved then). -#[derive(Default)] -struct PrefsWriter { - /// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder). - path: Option, - written: Vec, - library: String, - checked: f64, - /// The last write failed (reported once until a write works). - failing: bool, - /// `ui.json` couldn't be read at launch: never overwrite it this session. - keep_file: bool, - /// Follows the window, to remember the size it rests at (`UiState::window`). - window: lightcraft_ui_egui::window_place::WindowWatch, - /// When the window was first looked at: its watch goes by the clock from then on, not by - /// the interface's time, which stands still while the window is hidden. - window_since: Option, -} - -impl PrefsWriter { - fn save(&mut self, app: &LightcraftApp) -> Result<(), String> { - self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing)) - } - - /// Write `ui` if it changed since the last write; `temporary` (the library couldn't be - /// opened) saves nothing. - fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> { - if self.keep_file || temporary { - return Ok(()); - } - let Some(path) = self.path.clone() else { return Ok(()) }; - if let Some(workspace) = &ui.docking { - lightcraft_ui_egui::docking::validate(workspace)?; - } - let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?; - if bytes == self.written { - return Ok(()); - } - path.parent() - .map_or(Ok(()), std::fs::create_dir_all) - .and_then(|()| write_atomic(&path, &bytes)) - .map_err(|e| format!("saving the app settings failed: {e}"))?; - self.written = bytes; - self.library = ui.settings.library_path.clone(); - Ok(()) - } - - /// What to remember of the window as the app quits ([`WindowWatch::at_exit`]). - /// - /// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit - fn window_at_exit( - &mut self, - remembered: Option, - ) -> Option { - match self.window_since { - Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()), - None => remembered, - } - } - - /// Per-frame host tick: records the window's placement while visible, asks for a repaint when - /// a remembered size is due, and saves settings/library at most every three seconds or - /// immediately after the library path changes; a failed save is reported once and retried. - fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { - // (asked for before the input is read: the context is not asked from inside its own lock) - let zoom = ctx.zoom_factor(); - // The monotonic app clock: `i.time` stands still while the window is hidden. - let now = app.tick_now(); - let (hidden, seen) = ctx.input(|i| { - let hidden = i.viewport().visible() == Some(false); - (hidden, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom)) - }); - // how the window is, to open like that next time (written with the settings below). What - // the settings hold is checked first: a script may have set it (`ui.set`). A hidden - // `run_logic` pass has no fresh content rect: keep the last known placement. - let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked); - let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64(); - if !hidden { - app.ui.window = self.window.note(remembered, &seen, at); - } - if let Some(wait) = self.window.waits(at) { - // (a size is only remembered once the window has rested at it: look again then, though - // nothing else asks for a frame) - ctx.request_repaint_after(wait); - } - let moved = app.ui.settings.library_path != self.library; - if !moved && now - self.checked < 3.0 { - return; - } - self.checked = now; - match self.save(app) { - Ok(()) => self.failing = false, - Err(e) => { - if !self.failing { - log::warn!("{e}"); - app.notices.push(format!("{e}. LightCraft keeps trying.")); - } - self.failing = true; - // don't retry every frame - self.library = app.ui.settings.library_path.clone(); - } - } - } -} - -/// The Windows command that opens `url` in the default browser. `explorer ` opened File -/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link -/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so -/// `?`, `&` and `#` need no escaping. -fn windows_open_url_command(url: &str) -> std::process::Command { - let mut c = std::process::Command::new("rundll32"); - c.args(["url.dll,FileProtocolHandler", url]); - c -} - -/// Explorer's `/select,` switch (issue #272). Explorer parses its own command line and only -/// selects the file when the path after the comma is quoted (`/select,"C:\My Photos\a.jpg"`), -/// spelled with backslashes and free of the verbatim prefix (`\\?\`, `\\?\UNC\`) that -/// `canonicalize` adds; given anything else it opens Documents. Windows file names can't contain -/// `"`; a path that does anyway (a hostile catalog) has them dropped, so the raw command line -/// always holds exactly this one argument. -fn explorer_select_arg(path: &str) -> String { - let path = path.replace('/', "\\").replace('"', ""); - let path = match path.strip_prefix(r"\\?\UNC\") { - Some(unc) => format!(r"\\{unc}"), - None => path.strip_prefix(r"\\?\").unwrap_or(path.as_str()).to_string(), - }; - format!("/select,\"{path}\"") -} - -/// `explorer /select,""`. Rust's own argument quoting wraps the whole switch in quotes -/// (`"/select,C:\My Photos\a.jpg"`) whenever the path has a space, which Explorer doesn't -/// recognise, so on Windows the switch goes on the command line exactly as built. -fn explorer_select_command(path: &str) -> std::process::Command { - let mut c = std::process::Command::new("explorer"); - let arg = explorer_select_arg(path); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - c.raw_arg(arg); - } - #[cfg(not(windows))] - { - c.arg(arg); - } - c -} - -/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is -/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray -/// top-level ("Failed to associate portal window with parent window" in the portal's log): a -/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so -/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself; -/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including -/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog -/// becomes a sheet and on Windows a modal, a behaviour change this does not make. -static DIALOG_PARENT: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// A file dialog owned by the app's window, or a plain one before the window exists. -fn file_dialog() -> rfd::FileDialog { - DIALOG_PARENT.get().cloned().unwrap_or_default() -} - -/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints -/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder. -fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services { - let ctx = cc.egui_ctx.clone(); - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc)); - Services { - // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread - // stopped the window answering the compositor, which then reported the app as hung. - picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| { - use lightcraft_ui_egui::pick::PickKind; - let (tx, rx) = std::sync::mpsc::channel(); - let repaint = ctx.clone(); - std::thread::Builder::new() - .name("file-dialog".into()) - .spawn(move || { - let mut d = file_dialog(); - if let Some(t) = &req.title { - d = d.set_title(t); - } - if let Some((name, exts)) = &req.filter { - d = d.add_filter_nocase(name.clone(), exts); - } - if let Some(n) = &req.file_name { - d = d.set_file_name(n); - } - let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); - let paths: Vec = match req.kind { - PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(), - PickKind::File => d.pick_file().map(s).into_iter().collect(), - PickKind::Folder => d.pick_folder().map(s).into_iter().collect(), - PickKind::Save => d.save_file().map(s).into_iter().collect(), - }; - // the app may have quit meanwhile: nobody to tell - let _ = tx.send(paths); - repaint.request_repaint(); - }) - .map_err(|e| format!("could not start the file dialog: {e}"))?; - Ok(rx) - })), - log_file: log_file.map(|p| p.display().to_string()), - pick_lightroom_catalog: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })), - pick_folder: Some(Box::new(|| { - file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) - })), - #[cfg(target_os = "linux")] - open_model_folder: Some(std::sync::Arc::new(|path: &str| { - std::process::Command::new("xdg-open") - .arg(path) - .status() - .map_err(|e| e.to_string()) - .and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) }) - })), - open_with: Some(Box::new(editors::open_with)), - open_all_with: Some(Box::new(editors::open_all_with)), - check_editor: Some(Box::new(editors::check)), - open_url: Some(Box::new(|url: &str| { - if !url.starts_with("https://") { - return Err("only https links are opened".into()); - } - let status = if cfg!(target_os = "macos") { - std::process::Command::new("open").arg(url).status() - } else if cfg!(target_os = "windows") { - windows_open_url_command(url).status() - } else { - std::process::Command::new("xdg-open").arg(url).status() - }; - status.map(|_| ()).map_err(|e| e.to_string()) - })), - reveal: Some(Box::new(|path: &str| { - let status = if cfg!(target_os = "macos") { - std::process::Command::new("open").args(["-R", path]).status() - } else if cfg!(target_os = "windows") { - explorer_select_command(path).status() - } else { - let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into()); - std::process::Command::new("xdg-open").arg(dir).status() - }; - status - .map_err(|e| e.to_string()) - .and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) }) - })), - pick_files: Some(Box::new(|| { - file_dialog() - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })), - pick_denoise_model: Some(Box::new(|| { - file_dialog() - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"]) - .pick_file() - .map(|p| vec![p.to_string_lossy().into_owned()]) - .unwrap_or_default() - })), - pick_preset_files: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Import Presets")) - .add_filter_nocase( - lightcraft_ui_egui::i18n::tr("Presets & Profiles"), - &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"], - ) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })), - pick_model_file: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"]) - .pick_file() - .map(|p| vec![p.to_string_lossy().to_string()]) - .unwrap_or_default() - })), - pick_tracklog: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"]) - .pick_file() - .map(|p| vec![p.to_string_lossy().to_string()]) - .unwrap_or_default() - })), - // macOS doesn't colour-manage the window: previews follow the screen's profile (#787) - #[cfg(target_os = "macos")] - screen_profile: Some(Box::new(screen_profile::icc)), - // Linux: the profile the desktop assigns in colord, read off the UI thread - #[cfg(target_os = "linux")] - screen_profile: Some(colord::service()), - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - screen_profile: None, - pick_display_profile: Some(Box::new(|| { - let mut d = file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]); - if let Some(dir) = display_profile_dir() { - d = d.set_directory(dir); - } - d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default() - })), - save_preset_file: Some(Box::new(|name: &str| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Export Presets")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"]) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), - pick_keyword_list: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Import Keywords")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"]) - .pick_file() - .map(|p| vec![p.to_string_lossy().to_string()]) - .unwrap_or_default() - })), - save_keyword_list: Some(Box::new(|name: &str| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Export Keywords")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"]) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), - pick_curve_preset_files: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"]) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })), - save_curve_preset_file: Some(Box::new(|name: &str| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"]) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), - pick_slideshow_templates: Some(Box::new(|| { - rfd::FileDialog::new() - .set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })), - save_slideshow_template: Some(Box::new(|name: &str| { - rfd::FileDialog::new() - .set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), - pick_print_templates: Some(Box::new(|| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Import Print Templates")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT]) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })), - save_print_template: Some(Box::new(|name: &str| { - file_dialog() - .set_title(lightcraft_ui_egui::i18n::tr("Export Print Template")) - .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT]) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), - // atomic (temp file + sync + rename): a failed write never leaves a truncated file - write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), - write: Some(Box::new(lightcraft_engine::export::write_file)), - write_new: Some(Box::new(lightcraft_engine::export::write_file_new)), - png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { - lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() - })), - // the library is a folder on disk: backed up with the user's other files - backup_library: None, - restore_library: None, - system_theme: appearance::service(), - } -} - -/// The library session with the folders face models (`/models`) and slideshow templates -/// (`/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet: -/// the window lists the recent ones. -fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option) { - let (mut s, problem) = match launch { - Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None), - Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo), - }; - s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); - s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); - // Control requests are answered on the UI thread: Add to Apple Photos must not wait there - s.apple_photos_background = true; - (s, problem) -} - -/// The persistent library session, or with `--memory` an in-memory one (demo photos). -/// -/// If the library can't be opened the session is empty and in memory — never seeded with the -/// demo photos, never written anywhere — and the problem is returned: the window then says so -/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100). -fn open_library_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { - if in_memory { - return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None); - } - let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock(); - let Some(dir) = dir else { - log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)"); - return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it."))); - }; - let t0 = std::time::Instant::now(); - let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock(); - match s.open_library(&dir, seed_demo) { - Ok(r) => { - let (replayed, torn) = (r.replayed, r.torn_bytes); - log::info!( - "library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)", - dir.display(), - s.catalog.len(), - if torn > 0 { ", torn tail repaired" } else { "" }, - t0.elapsed().as_secs_f64() * 1000.0 - ); - (s, None) - } - Err(e) => { - log::error!("can't open library {}: {e}", dir.display()); - (unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string()))) - } - } -} - -const HELP: &str = "\ -lightcraft — photo library + raw developer - -USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported) - -OPTIONS: - --library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or - a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held, - else ~/Pictures/LightCraft Library) - --no-demo don't seed a new library with the procedural demo photos - --memory throwaway in-memory session (alias --demo); nothing is saved - --control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT; - see docs/control-protocol.md) - --version, --help - -ENVIRONMENT: - LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder). - Without it, a portable.txt file next to the program puts the folder beside it (LightCraftData) - LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS, - Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering. - LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: /models/sam3; - optional: LightCraft offers to download it when first needed) - LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order) - LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings - from other crates); else RUST_LOG=. Records go to stderr and to - /logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log) -"; - -/// The launch arguments' files and folders to import (absolute), and a notice for each one that -/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374). -fn launch_imports(args: &[String]) -> (Vec, Vec) { - let cwd = std::env::current_dir().ok(); - let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); - launch_imports_in(args, cwd.as_deref(), home.as_deref()) -} - -fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec, Vec) { - use lightcraft_engine::import::{LaunchPath, launch_path}; - let (mut files, mut notices) = (Vec::new(), Vec::new()); - for a in args { - match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) { - Some(LaunchPath::Import(p)) => files.push(p), - Some(LaunchPath::Refused { path, why }) => { - log::warn!("not importing {path} given at launch: {why}"); - notices - .push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…")); - } - None => {} - } - } - (files, notices) -} - -/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`). -/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs. -fn log_dir() -> Option { - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return None; - } - config_dir().map(|d| d.join("logs")) -} - -/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is -/// the option or variable it came from). Logged before the log file opens, so it is kept for it. -fn control_port_from(what: &str, value: Option) -> Option { - let value = value?; - let port = value.trim().parse().ok(); - if port.is_none() { - log::warn!("{what} {value:?} is not a port number; no control server"); - } - port -} - -/// An isolated profile keeps the emergency crash log with its other app data. With no -/// override (or an empty override), retain the historical system-temp location. -fn panic_log_path_with(env: impl Fn(&str) -> Option, temp: std::path::PathBuf) -> std::path::PathBuf { - env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log") -} - fn main() -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] @@ -1119,64 +370,6 @@ fn main() -> eframe::Result { started } -/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU -/// was lost (`lost`): GPU rendering is off from the next start. -fn stopped_text(error: &str, lost: Option<&str>) -> String { - match lost { - Some(reason) => format!( - "{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \ - Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on" - ), - None => error.to_string(), - } -} - -/// LightCraft stopped while its window was up: say so in the log and in a native message box -/// naming the log file (as [`startup_failed`] does for a start that failed). -fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) { - log::error!("LightCraft stopped: {text}"); - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return; // tests and scripts: no dialog to click away - } - let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref()); - let _ = rfd::MessageDialog::new() - .set_level(rfd::MessageLevel::Error) - .set_title(title) - .set_description(text) - .set_buttons(rfd::MessageButtons::Ok) - .show(); -} - -/// The window could not start (e.g. no usable graphics device): say so in the log and in a native -/// message box naming the log file, instead of exiting without a trace — the release builds have -/// no console on Windows, and an app started from Finder shows no standard error (issue #260). -fn startup_failed(error: &str, log_file: Option<&std::path::Path>) { - log::error!("LightCraft could not open its window: {error}"); - if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return; // tests and scripts: no dialog to click away - } - let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref()); - let _ = rfd::MessageDialog::new() - .set_level(rfd::MessageLevel::Error) - .set_title(title) - .set_description(text) - .set_buttons(rfd::MessageButtons::Ok) - .show(); -} - #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests; - -#[cfg(test)] -mod config_path_tests { - use super::*; - - #[test] - fn config_override_keeps_panic_log_in_isolated_profile() { - let temp = std::path::PathBuf::from("temp"); - assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log")); - assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log")); - assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log")); - } -} diff --git a/apps/lightcraft/src/platform.rs b/apps/lightcraft/src/platform.rs new file mode 100644 index 000000000..3d4d87896 --- /dev/null +++ b/apps/lightcraft/src/platform.rs @@ -0,0 +1,57 @@ +/// The Windows command that opens `url` in the default browser. `explorer ` opened File +/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link +/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so +/// `?`, `&` and `#` need no escaping. +pub(crate) fn windows_open_url_command(url: &str) -> std::process::Command { + let mut c = std::process::Command::new("rundll32"); + c.args(["url.dll,FileProtocolHandler", url]); + c +} + +/// Explorer's `/select,` switch (issue #272). Explorer parses its own command line and only +/// selects the file when the path after the comma is quoted (`/select,"C:\My Photos\a.jpg"`), +/// spelled with backslashes and free of the verbatim prefix (`\\?\`, `\\?\UNC\`) that +/// `canonicalize` adds; given anything else it opens Documents. Windows file names can't contain +/// `"`; a path that does anyway (a hostile catalog) has them dropped, so the raw command line +/// always holds exactly this one argument. +pub(crate) fn explorer_select_arg(path: &str) -> String { + let path = path.replace('/', "\\").replace('"', ""); + let path = match path.strip_prefix(r"\\?\UNC\") { + Some(unc) => format!(r"\\{unc}"), + None => path.strip_prefix(r"\\?\").unwrap_or(path.as_str()).to_string(), + }; + format!("/select,\"{path}\"") +} + +/// `explorer /select,""`. Rust's own argument quoting wraps the whole switch in quotes +/// (`"/select,C:\My Photos\a.jpg"`) whenever the path has a space, which Explorer doesn't +/// recognise, so on Windows the switch goes on the command line exactly as built. +pub(crate) fn explorer_select_command(path: &str) -> std::process::Command { + let mut c = std::process::Command::new("explorer"); + let arg = explorer_select_arg(path); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.raw_arg(arg); + } + #[cfg(not(windows))] + { + c.arg(arg); + } + c +} + +/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is +/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray +/// top-level ("Failed to associate portal window with parent window" in the portal's log): a +/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so +/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself; +/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including +/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog +/// becomes a sheet and on Windows a modal, a behaviour change this does not make. +pub(crate) static DIALOG_PARENT: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// A file dialog owned by the app's window, or a plain one before the window exists. +pub(crate) fn file_dialog() -> rfd::FileDialog { + DIALOG_PARENT.get().cloned().unwrap_or_default() +} diff --git a/apps/lightcraft/src/prefs.rs b/apps/lightcraft/src/prefs.rs new file mode 100644 index 000000000..0800e6f3e --- /dev/null +++ b/apps/lightcraft/src/prefs.rs @@ -0,0 +1,245 @@ +use super::*; + +/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's +/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's. +pub(crate) fn display_profile_dir() -> Option { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); + let user = |rel: &str| home.as_ref().map(|h| h.join(rel)); + let candidates: Vec> = if cfg!(target_os = "macos") { + vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())] + } else if cfg!(windows) { + let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + vec![Some(root.join("System32").join("spool").join("drivers").join("color"))] + } else { + let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share")); + vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())] + }; + candidates.into_iter().flatten().find(|d| d.is_dir()) +} + +pub(crate) fn config_dir() -> Option { + lightcraft_engine::config::config_dir() +} + +/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size +/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it +/// opens is the system's to say. +pub(crate) fn as_remembered(builder: egui::ViewportBuilder, window: Option) -> egui::ViewportBuilder { + let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size)); + // (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen + // to a size of its own choosing) + if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder } +} + +/// `/ui.json`, the saved UI state and app settings — `None` when nothing is read or +/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder. +pub(crate) fn prefs_path() -> Option { + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return None; + } + config_dir().map(|d| d.join("ui.json")) +} + +/// The saved UI state and app settings (`/ui.json`), if any, and a warning for the user +/// when the file exists but can't be used (issue #103): a damaged file is kept as +/// `ui.json.corrupt-` first, so the next save can't lose the library location in it; +/// one that can't be read at all is not written this session (`keep_file`). +/// +/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing +/// (issue #164), so a damaged file is left as it is there, not set aside. +pub(crate) fn load_prefs(in_memory: bool) -> (Option, Option, bool) { + let Some(path) = prefs_path() else { return (None, None, false) }; + load_prefs_at(&path, !in_memory) +} + +/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. An +/// arrangement from an older docking version starts again from the default without a warning. +fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> { + let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; + let fields = saved.as_object_mut().ok_or("settings must be an object")?; + lightcraft_ui_egui::docking::drop_outdated(fields); + let recovered = + fields.get("docking").is_some_and(|value| match serde_json::from_value::>(value.clone()) { + Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(), + Ok(None) => false, + Err(_) => true, + }); + if recovered { + fields.remove("docking"); + } + let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?; + Ok((ui, recovered)) +} + +pub(crate) fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option, Option, bool) { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false), + Err(e) => { + let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display()); + return (None, Some(msg), true); + } + }; + match decode_saved_ui(&bytes) { + Ok((ui, false)) => (Some(ui.sanitized()), None, false), + Ok((ui, true)) => { + let msg = format!( + "The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.", + path.display() + ); + (Some(ui.sanitized()), Some(msg), true) + } + Err(e) if !set_aside_damaged => { + let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display()); + (None, Some(msg), true) + } + Err(e) => { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let keep = path.with_file_name(format!("ui.json.corrupt-{secs}")); + let kept = std::fs::rename(path, &keep); + let what = match &kept { + Ok(()) => format!("it was kept as {}", keep.display()), + Err(r) => format!("it couldn't be set aside ({r})"), + }; + let msg = format!( + "The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.", + path.display() + ); + (None, Some(msg), kept.is_err()) + } + } +} + +/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full +/// disk leaves the old file or the new one, never a truncated one). +pub(crate) fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let tmp = path.with_extension("json.tmp"); + let written = (|| { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(bytes)?; + f.sync_all() + })(); + if let Err(e) = written { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + std::fs::rename(&tmp, path)?; + if let Some(dir) = path.parent() + && let Ok(d) = std::fs::File::open(dir) + { + let _ = d.sync_all(); + } + Ok(()) +} + +/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library +/// changes, every few seconds when anything else changed, and at exit. +/// +/// Nothing is written while the session is one that promises to save nothing: `--memory` +/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the +/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the +/// library it opened is saved then). +#[derive(Default)] +pub(crate) struct PrefsWriter { + /// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder). + pub(crate) path: Option, + pub(crate) written: Vec, + pub(crate) library: String, + pub(crate) checked: f64, + /// The last write failed (reported once until a write works). + pub(crate) failing: bool, + /// `ui.json` couldn't be read at launch: never overwrite it this session. + pub(crate) keep_file: bool, + /// Follows the window, to remember the size it rests at (`UiState::window`). + pub(crate) window: lightcraft_ui_egui::window_place::WindowWatch, + /// When the window was first looked at: its watch goes by the clock from then on, not by + /// the interface's time, which stands still while the window is hidden. + pub(crate) window_since: Option, +} + +impl PrefsWriter { + pub(crate) fn save(&mut self, app: &LightcraftApp) -> Result<(), String> { + self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing)) + } + + /// Write `ui` if it changed since the last write; `temporary` (the library couldn't be + /// opened) saves nothing. + pub(crate) fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> { + if self.keep_file || temporary { + return Ok(()); + } + let Some(path) = self.path.clone() else { return Ok(()) }; + if let Some(workspace) = &ui.docking { + lightcraft_ui_egui::docking::validate(workspace)?; + } + let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?; + if bytes == self.written { + return Ok(()); + } + path.parent() + .map_or(Ok(()), std::fs::create_dir_all) + .and_then(|()| write_atomic(&path, &bytes)) + .map_err(|e| format!("saving the app settings failed: {e}"))?; + self.written = bytes; + self.library = ui.settings.library_path.clone(); + Ok(()) + } + + /// What to remember of the window as the app quits ([`WindowWatch::at_exit`]). + /// + /// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit + pub(crate) fn window_at_exit( + &mut self, + remembered: Option, + ) -> Option { + match self.window_since { + Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()), + None => remembered, + } + } + + /// Per-frame host tick: records the window's placement while visible, asks for a repaint when + /// a remembered size is due, and saves settings/library at most every three seconds or + /// immediately after the library path changes; a failed save is reported once and retried. + pub(crate) fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { + // (asked for before the input is read: the context is not asked from inside its own lock) + let zoom = ctx.zoom_factor(); + // The monotonic app clock: `i.time` stands still while the window is hidden. + let now = app.tick_now(); + let (hidden, seen) = ctx.input(|i| { + let hidden = i.viewport().visible() == Some(false); + (hidden, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom)) + }); + // how the window is, to open like that next time (written with the settings below). What + // the settings hold is checked first: a script may have set it (`ui.set`). A hidden + // `run_logic` pass has no fresh content rect: keep the last known placement. + let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked); + let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64(); + if !hidden { + app.ui.window = self.window.note(remembered, &seen, at); + } + if let Some(wait) = self.window.waits(at) { + // (a size is only remembered once the window has rested at it: look again then, though + // nothing else asks for a frame) + ctx.request_repaint_after(wait); + } + let moved = app.ui.settings.library_path != self.library; + if !moved && now - self.checked < 3.0 { + return; + } + self.checked = now; + match self.save(app) { + Ok(()) => self.failing = false, + Err(e) => { + if !self.failing { + log::warn!("{e}"); + app.notices.push(format!("{e}. LightCraft keeps trying.")); + } + self.failing = true; + // don't retry every frame + self.library = app.ui.settings.library_path.clone(); + } + } + } +} diff --git a/apps/lightcraft/src/session.rs b/apps/lightcraft/src/session.rs new file mode 100644 index 000000000..ce3ab95b7 --- /dev/null +++ b/apps/lightcraft/src/session.rs @@ -0,0 +1,297 @@ +#[cfg(any(target_os = "freebsd", target_os = "linux"))] +use super::platform::DIALOG_PARENT; +use super::platform::{explorer_select_command, file_dialog, windows_open_url_command}; +use super::prefs::display_profile_dir; +use super::*; + +/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints +/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder. +pub(crate) fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services { + let ctx = cc.egui_ctx.clone(); + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc)); + Services { + // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread + // stopped the window answering the compositor, which then reported the app as hung. + picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| { + use lightcraft_ui_egui::pick::PickKind; + let (tx, rx) = std::sync::mpsc::channel(); + let repaint = ctx.clone(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + let mut d = file_dialog(); + if let Some(t) = &req.title { + d = d.set_title(t); + } + if let Some((name, exts)) = &req.filter { + d = d.add_filter_nocase(name.clone(), exts); + } + if let Some(n) = &req.file_name { + d = d.set_file_name(n); + } + let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); + let paths: Vec = match req.kind { + PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(), + PickKind::File => d.pick_file().map(s).into_iter().collect(), + PickKind::Folder => d.pick_folder().map(s).into_iter().collect(), + PickKind::Save => d.save_file().map(s).into_iter().collect(), + }; + // the app may have quit meanwhile: nobody to tell + let _ = tx.send(paths); + repaint.request_repaint(); + }) + .map_err(|e| format!("could not start the file dialog: {e}"))?; + Ok(rx) + })), + log_file: log_file.map(|p| p.display().to_string()), + pick_lightroom_catalog: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_folder: Some(Box::new(|| { + file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) + })), + #[cfg(target_os = "linux")] + open_model_folder: Some(std::sync::Arc::new(|path: &str| { + std::process::Command::new("xdg-open") + .arg(path) + .status() + .map_err(|e| e.to_string()) + .and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) }) + })), + open_with: Some(Box::new(editors::open_with)), + open_all_with: Some(Box::new(editors::open_all_with)), + check_editor: Some(Box::new(editors::check)), + open_url: Some(Box::new(|url: &str| { + if !url.starts_with("https://") { + return Err("only https links are opened".into()); + } + let status = if cfg!(target_os = "macos") { + std::process::Command::new("open").arg(url).status() + } else if cfg!(target_os = "windows") { + windows_open_url_command(url).status() + } else { + std::process::Command::new("xdg-open").arg(url).status() + }; + status.map(|_| ()).map_err(|e| e.to_string()) + })), + reveal: Some(Box::new(|path: &str| { + let status = if cfg!(target_os = "macos") { + std::process::Command::new("open").args(["-R", path]).status() + } else if cfg!(target_os = "windows") { + explorer_select_command(path).status() + } else { + let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into()); + std::process::Command::new("xdg-open").arg(dir).status() + }; + status + .map_err(|e| e.to_string()) + .and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) }) + })), + pick_files: Some(Box::new(|| { + file_dialog() + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + pick_denoise_model: Some(Box::new(|| { + file_dialog() + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().into_owned()]) + .unwrap_or_default() + })), + pick_preset_files: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Presets")) + .add_filter_nocase( + lightcraft_ui_egui::i18n::tr("Presets & Profiles"), + &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"], + ) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + pick_model_file: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().to_string()]) + .unwrap_or_default() + })), + pick_tracklog: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().to_string()]) + .unwrap_or_default() + })), + // macOS doesn't colour-manage the window: previews follow the screen's profile (#787) + #[cfg(target_os = "macos")] + screen_profile: Some(Box::new(screen_profile::icc)), + // Linux: the profile the desktop assigns in colord, read off the UI thread + #[cfg(target_os = "linux")] + screen_profile: Some(colord::service()), + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + screen_profile: None, + pick_display_profile: Some(Box::new(|| { + let mut d = file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]); + if let Some(dir) = display_profile_dir() { + d = d.set_directory(dir); + } + d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default() + })), + save_preset_file: Some(Box::new(|name: &str| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Export Presets")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_keyword_list: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Keywords")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().to_string()]) + .unwrap_or_default() + })), + save_keyword_list: Some(Box::new(|name: &str| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Export Keywords")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_curve_preset_files: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_curve_preset_file: Some(Box::new(|name: &str| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_slideshow_templates: Some(Box::new(|| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_slideshow_template: Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_print_templates: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Print Templates")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_print_template: Some(Box::new(|name: &str| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Export Print Template")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), + // atomic (temp file + sync + rename): a failed write never leaves a truncated file + write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), + write: Some(Box::new(lightcraft_engine::export::write_file)), + write_new: Some(Box::new(lightcraft_engine::export::write_file_new)), + png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + })), + // the library is a folder on disk: backed up with the user's other files + backup_library: None, + restore_library: None, + system_theme: appearance::service(), + } +} + +/// The library session with the folders face models (`/models`) and slideshow templates +/// (`/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet: +/// the window lists the recent ones. +pub(crate) fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option) { + let (mut s, problem) = match launch { + Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None), + Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo), + }; + s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); + s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); + // Control requests are answered on the UI thread: Add to Apple Photos must not wait there + s.apple_photos_background = true; + (s, problem) +} + +/// The persistent library session, or with `--memory` an in-memory one (demo photos). +/// +/// If the library can't be opened the session is empty and in memory — never seeded with the +/// demo photos, never written anywhere — and the problem is returned: the window then says so +/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100). +fn open_library_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { + if in_memory { + return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None); + } + let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock(); + let Some(dir) = dir else { + log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)"); + return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it."))); + }; + let t0 = std::time::Instant::now(); + let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock(); + match s.open_library(&dir, seed_demo) { + Ok(r) => { + let (replayed, torn) = (r.replayed, r.torn_bytes); + log::info!( + "library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)", + dir.display(), + s.catalog.len(), + if torn > 0 { ", torn tail repaired" } else { "" }, + t0.elapsed().as_secs_f64() * 1000.0 + ); + (s, None) + } + Err(e) => { + log::error!("can't open library {}: {e}", dir.display()); + (unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string()))) + } + } +} From 401e047accbb3b9378dcb4fbe59fd77d01fa38d9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:11:14 +0000 Subject: [PATCH 119/191] refactor(lightcraft-ui-egui): split panels/detail.rs into panels/detail/ --- crates/ui-egui/src/panels/detail.rs | 2779 +---------------- crates/ui-egui/src/panels/detail/crop.rs | 582 ++++ crates/ui-egui/src/panels/detail/filmstrip.rs | 227 ++ crates/ui-egui/src/panels/detail/geometry.rs | 385 +++ crates/ui-egui/src/panels/detail/info.rs | 547 ++++ crates/ui-egui/src/panels/detail/masks.rs | 657 ++++ crates/ui-egui/src/panels/detail/retouch.rs | 249 ++ crates/ui-egui/src/panels/detail/window.rs | 119 + 8 files changed, 2803 insertions(+), 2742 deletions(-) create mode 100644 crates/ui-egui/src/panels/detail/crop.rs create mode 100644 crates/ui-egui/src/panels/detail/filmstrip.rs create mode 100644 crates/ui-egui/src/panels/detail/geometry.rs create mode 100644 crates/ui-egui/src/panels/detail/info.rs create mode 100644 crates/ui-egui/src/panels/detail/masks.rs create mode 100644 crates/ui-egui/src/panels/detail/retouch.rs create mode 100644 crates/ui-egui/src/panels/detail/window.rs diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 5e610b098..25f3baa24 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -14,6 +14,43 @@ use crate::state::{BeforeAfter, RightPanel, ToolOverlay, Zoom}; use crate::theme::Tokens; use crate::widgets::{register, register_geometry}; +mod crop; +mod filmstrip; +mod geometry; +mod info; +mod masks; +mod retouch; +mod window; + +pub(crate) use crop::crop_angle_label; +use crop::crop_overlay; +#[cfg(test)] +pub(crate) use crop::readout_rect; +#[cfg(test)] +use crop::{flipped_corners, outside_crop_mesh, rotate_cursor_turn}; +#[cfg(test)] +use filmstrip::film_label; +#[cfg(test)] +pub(crate) use filmstrip::film_spoken; +pub(crate) use filmstrip::filmstrip; +use filmstrip::navigator; +pub use geometry::CanvasMap; +#[cfg(test)] +pub(crate) use geometry::focus_box; +use geometry::{animated_rect, middle_pan}; +pub(crate) use geometry::{fit_rect, fit_texture_rect, focus_pan, navigate_gesture, output_px, pan_image, snap_fit_rect, texture_side}; +#[cfg(test)] +use info::RENDERING_AFTER_SECS; +use info::{ + FILTER_PILL_HEIGHT, FILTER_PILL_INSET, brief_info, filter_pill, filter_pill_shown, info_overlay, info_overlay_extent, label_badge_rect, + preview_only_pill, quick_name, +}; +pub(crate) use info::{draw_focus_point, failed_pill, loupe_pill, rendering_overdue}; +use masks::{RegionEdit, color_range_pick, mask_overlay, region_overlay}; +use retouch::{eye_overlay, remove_overlay}; +pub(crate) use window::overlay_supports_window; +use window::{WindowCtx, WindowView, draw_window, request_window}; + /// An in-progress on-canvas gesture. #[derive(Clone, Debug)] pub enum Gesture { @@ -85,419 +122,6 @@ pub enum Gesture { }, } -/// Screen ↔ normalized-image mapping for the displayed frame. -#[derive(Clone, Copy)] -pub struct CanvasMap { - rect: Rect, - to_norm: Affine, - from_norm: Affine, - k: f32, -} - -impl CanvasMap { - fn new(frame: &Frame, rect: Rect) -> CanvasMap { - let k = 8.0f32; - let (w, h) = ((rect.width() * k).round().max(1.0) as usize, (rect.height() * k).round().max(1.0) as usize); - let to_norm = frame.out_to_norm(w, h); - CanvasMap { rect, to_norm, from_norm: to_norm.inverse().unwrap_or(Affine::IDENTITY), k } - } - pub fn norm(&self, p: Pos2) -> Point { - self.to_norm.apply(Point::new(((p.x - self.rect.left()) * self.k) as f64, ((p.y - self.rect.top()) * self.k) as f64)) - } - pub fn screen(&self, n: Point) -> Pos2 { - let q = self.from_norm.apply(n); - pos2(self.rect.left() + q.x as f32 / self.k, self.rect.top() + q.y as f32 / self.k) - } -} - -pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { - let zoom = match zoom { - Zoom::Percent(p) if !p.is_finite() || p <= 0.0 => Zoom::Fit, - _ => zoom, - }; - let (aw, ah) = (area.width(), area.height()); - let (w, h) = match zoom { - Zoom::Fit => { - if aw / ah > aspect { - (ah * aspect, ah) - } else { - (aw, aw / aspect) - } - } - Zoom::Fill => { - if aw / ah > aspect { - (aw, aw / aspect) - } else { - (ah * aspect, ah) - } - } - Zoom::Percent(p) => { - // the photo's own pixels at p% (its width as shown: cropped and rotated) - let w = img_px[0] as f32 * p / 100.0 / ppp; - (w, w / aspect) - } - }; - let pan = bounded_pan(area, vec2(w, h), pan); - let c = pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h); - let rect = Rect::from_center_size(c, vec2(w, h)); - // At 100 % the texture maps texel for texel to screen pixels, so its corner must sit on a whole - // physical pixel: half a pixel off and the GPU's bilinear filter blurs the view (an odd-width - // canvas on a 2x display does this). Other zooms resample anyway, and a pinch must not jump. - let one_to_one = ppp.is_finite() && ppp > 0.0 && (w * ppp - img_px[0] as f32).abs() < 0.5; - if matches!(zoom, Zoom::Percent(_)) && one_to_one { - let snap = |v: f32| (v * ppp).round() / ppp; - return Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), rect.size()); - } - rect -} - -/// Fit a decoded image inside the frame calculated from catalog metadata. Unsupported RAWs can -/// only show their embedded camera JPEG, whose crop and aspect ratio can differ from that metadata. -pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect { - let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); - let scale = (area.width() / w).min(area.height() / h); - Rect::from_center_size(area.center(), vec2(w * scale, h * scale)) -} - -/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is -/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred -/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its -/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to -/// the sampler. -pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect { - let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; - let (w, h) = (size[0] as f32, size[1] as f32); - if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 { - return rect; - } - let snap = |v: f32| (v * ppp).round() / ppp; - Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp)) -} - -/// Keep the viewport inside the photo; axes smaller than the viewport stay centred. -fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) { - let bound = |visible: f32, image: f32, centre: f32| { - let centre = if centre.is_finite() { centre } else { 0.5 }; - if image > visible.max(0.0) { - let half = visible.max(0.0) / image / 2.0; - centre.clamp(half, 1.0 - half) - } else { - 0.5 - } - }; - (bound(area.width(), size.x, pan.0), bound(area.height(), size.y, pan.1)) -} - -pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: egui::Vec2) { - let centre = area.center() - img.min - delta; - let pan = bounded_pan(area, img.size(), (centre.x / img.width().max(1.0), centre.y / img.height().max(1.0))); - let _ = app.run("view.navigate", json!({"pan": pan})); -} - -/// A drag with the middle button (the mouse wheel pressed) pans the zoomed photo whatever tool is -/// active, so the healing brush, mask brushes and crop never paint or move while you look around; -/// the hand cursor shows while it does. -fn middle_pan(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, area: Rect, img: Rect) -> bool { - if !resp.dragged_by(egui::PointerButton::Middle) { - return false; - } - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - if img.width() > area.width() + 1.0 || img.height() > area.height() + 1.0 { - pan_image(app, area, img, resp.drag_delta()); - } - true -} - -/// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad. -fn wheel_notches(events: &[egui::Event]) -> f32 { - events - .iter() - .filter_map(|e| match e { - egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page, delta, modifiers, .. } - if *modifiers == egui::Modifiers::NONE && delta.y.is_finite() => - { - Some(delta.y) - } - _ => None, - }) - .sum() -} - -/// Native pinch (also modifier-wheel zoom) and two-finger scroll, scoped to this image view. -pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, area: Rect, img: Rect, native: [usize; 2]) -> bool { - if !resp.hovered() { - return false; - } - let (mut factor, mut delta, anchor) = - ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos()))); - // A notched mouse wheel (line/page units, no modifier) zooms around the pointer, through egui's smoothed wheel - // delta like Ctrl+wheel (so it glides instead of jumping); trackpads report points and still pan. - let wheel_id = egui::Id::unique("loupe-wheel-zoom"); - if ui.input(|i| wheel_notches(&i.events)) != 0.0 { - ui.data_mut(|d| d.insert_temp(wheel_id, true)); - } - if ui.data(|d| d.get_temp::(wheel_id)).unwrap_or(false) { - let smooth = ui.input(|i| i.smooth_scroll_delta.y); - let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed); - if smooth == 0.0 { - ui.data_mut(|d| d.remove_temp::(wheel_id)); - } else { - factor *= (speed * smooth).exp(); - delta = egui::Vec2::ZERO; - } - } - if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) { - return false; - } - let Some(anchor) = anchor.filter(|p| area.contains(*p)) else { return false }; - let ppp = ui.ctx().pixels_per_point(); - let aspect = img.width() / img.height().max(1.0); - let fit = fit_rect(area, aspect, Zoom::Fit, native, ppp, (0.5, 0.5)); - let fit_pct = fit.width() * ppp * 100.0 / native[0].max(1) as f32; - let mut zoom = app.ui.zoom; - let size = if factor != 1.0 { - // Start at the displayed scale (including Fit/Fill and an interrupted click animation). - let pct = (img.width() * ppp * 100.0 / native[0].max(1) as f32 * factor).clamp(fit_pct.min(800.0), 800.0); - zoom = if pct <= fit_pct { Zoom::Fit } else { Zoom::Percent(pct) }; - fit_rect(area, aspect, zoom, native, ppp, (0.5, 0.5)).size() - } else { - img.size() - }; - // The image point under the pointer remains there as its size changes, then follows the pan. - let point = (anchor - img.min) / img.size(); - let offset = area.center() - anchor - delta; - let pan = bounded_pan(area, size, (point.x + offset.x / size.x.max(1.0), point.y + offset.y / size.y.max(1.0))); - let _ = app.run("view.navigate", json!({"zoom": zoom, "pan": pan})); - ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); - ui.ctx().request_repaint(); - true -} - -/// The largest texture the GPU behind `ctx` takes (egui reports it; 2048 until it does, the -/// smallest limit WebGL devices have). -/// -/// The value egui keeps, not the frame's raw input: a native host (egui-winit) reports the limit -/// in the first frame's input only, so the raw value is `None` from the second frame on -/// (issue #652: every window render was cut to 2048 px, with blurry strips beside it). -pub(crate) fn texture_side(ctx: &egui::Context) -> usize { - ctx.input(|i| i.max_texture_side) -} - -/// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what -/// 100 % zoom and the render size limit are measured against), at least 1 × 1. -pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { - let (w, h) = frame.native_size(); - let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 1e9) as usize } else { 1 }; - [px(w), px(h)] -} - -/// The camera's focus point of `photo` in the displayed frame (its crop, rotation and flips), as the -/// normalized box over the drawn image: `None` when the photo has none or it is cropped away. -pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option { - let f = photo.meta.focus.as_ref()?; - let d = &photo.develop; - let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref()); - let [w, h] = output_px(&frame); - let to_out = frame.out_to_norm(w, h).inverse()?; - // stored on the upright photo: the user's Rotate / Flip on top, then the crop - let r = d.orientation.map_norm_rect(f.rect); - let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| { - let q = to_out.apply(c); - (q.x / w as f64, q.y / h as f64) - }); - let xs = corners.map(|c| c.0); - let ys = corners.map(|c| c.1); - let out = lightcraft_geom::Rect { - x0: xs.iter().copied().fold(f64::INFINITY, f64::min), - y0: ys.iter().copied().fold(f64::INFINITY, f64::min), - x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - if ![out.x0, out.y0, out.x1, out.y1].iter().all(|v| v.is_finite()) { - return None; - } - // its centre must be in the frame; the box may reach past the edge (it is clipped when drawn) - let (cx, cy) = ((out.x0 + out.x1) / 2.0, (out.y0 + out.y1) / 2.0); - ((0.0..=1.0).contains(&cx) && (0.0..=1.0).contains(&cy)).then_some(out) -} - -/// Where Toggle Zoom centres the view on the photo's focus point (`app.ui.pan`), if it has one in frame. -pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> { - let r = focus_box(app.session.catalog.photo(id)?)?; - Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) -} - -/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Focus Point): green corner -/// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. -/// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:`). -pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) { - let Some(r) = focus_box(photo) else { return }; - let at = |x: f64, y: f64| pos2(img.left() + x as f32 * img.width(), img.top() + y as f32 * img.height()); - let b = Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1)); - let b = Rect::from_center_size(b.center(), b.size().max(vec2(14.0, 14.0))); - let arm = (b.width().min(b.height()) * 0.3).max(5.0); - let green = Color32::from_rgb(90, 230, 110); - for (c, dx, dy) in [(b.left_top(), 1.0, 1.0), (b.right_top(), -1.0, 1.0), (b.left_bottom(), 1.0, -1.0), (b.right_bottom(), -1.0, -1.0)] { - let pts = vec![c + vec2(dx * arm, 0.0), c, c + vec2(0.0, dy * arm)]; - p.add(egui::Shape::line(pts.clone(), Stroke::new(4.0, Color32::from_black_alpha(170)))); - p.add(egui::Shape::line(pts, Stroke::new(2.0, green))); - } - let c = b.center(); - p.circle_filled(c, 2.5, Color32::from_black_alpha(170)); - p.circle_filled(c, 1.5, green); - register_geometry(p.ctx(), key, b); -} - -/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. -fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { - let t = if *anim { 0.22 } else { 0.0 }; - let id = egui::Id::unique("loupe_anim"); - let v = |k: &str, x: f32| ctx.animate_value_with_time(id.with(k), x, t); - let (c, s) = (target.center(), target.size()); - let r = Rect::from_center_size(pos2(v("cx", c.x), v("cy", c.y)), vec2(v("w", s.x), v("h", s.y))); - if *anim && (r.center() - c).abs().max_elem() < 0.5 && (r.size() - s).abs().max_elem() < 0.5 { - *anim = false; - } - r -} - -/// View Options' Show Briefly When Photo Changes: note when the loupe changes to photo `id`, and -/// whether the brief info overlay shows this frame (until [`BRIEF_INFO_SECS`] have passed; a -/// repaint is asked for then, so it hides without input). -/// -/// [`BRIEF_INFO_SECS`]: crate::state::BRIEF_INFO_SECS -fn brief_info(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { - let now = ctx.input(|i| i.time); - let brief = &mut app.ui.brief_info; - if brief.photo != Some(id) { - brief.photo = Some(id); - brief.since = now; - } - let left = crate::state::BRIEF_INFO_SECS - (now - brief.since); - brief.shown = app.ui.settings.loupe_info.briefly != crate::state::InfoOverlay::Off && left > 0.0; - if brief.shown { - ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); - } -} - -/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all) -/// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize -/// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high -/// pass reads about 3σ = 1.8 % of the long edge, inside the window's margin -/// ([`crate::region::margin_for`]: 4.5 %). Each is tested equal to the whole-frame render's pixels. -pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { - use lightcraft_pipeline::Overlay as O; - match overlay { - O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true, - } -} - -/// What a window render of the loupe needs to know about the view it belongs to. -struct WindowCtx { - id: PhotoId, - overlay: lightcraft_pipeline::Overlay, - /// Long edge of the frame the windows are cut from (`None`: no windows). - frame_edge: Option, - drawn_long: f32, - aspect: f32, - ppp: f32, - texture_side: usize, - crop_tool: bool, - interacting: bool, - /// Hash of the photo's develop settings. - look: u64, - /// A pinch or two-finger scroll is running: keep the windows there are. - holding: bool, -} - -/// One side of the loupe that gets a window render. -#[derive(Clone, Copy)] -struct WindowView { - slot: Slot, - /// The Before side (the photo without its edits). - before: bool, - /// The rect the photo is drawn in. - img: Rect, - /// The rect it is requested for (the drawn rect without a click-zoom animation). - target: Rect, - /// The area whose pixels are on screen. - visible: Rect, -} - -/// Ask for the window of `v` that holds what is on screen; remember it so its texture can be -/// drawn where it belongs. `None`: no window is wanted (or possible) for this view. -fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option { - let frame_edge = c.frame_edge?; - // while a pinch or scroll runs the windows there are keep being drawn, magnified: no new one - // per frame (zooming out would ask for ever wider ones) - if c.holding { - return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); - } - let (fw, fh) = - if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) }; - let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); - // what is on screen, in pixels of the window's frame (the drawn frame, scaled) - let to_px = c.ppp * frame_edge as f32 / c.drawn_long.max(1.0); - let visible = ( - to_px * (v.visible.left() - v.target.left()), - to_px * (v.visible.top() - v.target.top()), - to_px * (v.visible.right() - v.target.left()), - to_px * (v.visible.bottom() - v.target.top()), - ); - let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; - // a window waits for the original another window job is decoding (see `defer_window`) - let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh))); - let window_pending = app.renderer.is_pending(Slot::Region) || app.renderer.is_pending(Slot::RegionBefore); - if crate::region::defer_window(window_pending, original_held) { - return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); - } - let job = if v.before { - app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool) - } else { - app.session.region_job(c.id, fw, fh, win, !c.crop_tool) - }; - // refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one - // render, so the whole frame is rendered at the drawn size instead (see `region::plan`) - let Some(job) = job else { - app.window_refused = Some((c.id, c.look, frame_edge)); - return None; - }; - // a drag renders drafts of the window, as it does of the whole frame. The Before tile stays - // plain; the After tile carries the same mask overlay as the whole-frame render. - let job = if c.interacting { job.draft() } else { job }; - let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay }; - let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) }; - // the After window is proofed like the whole-frame render under it; the Before side is - // never proofed (its whole-frame render isn't either) - let proof = app.ui.soft_proof.then_some(app.ui.proof); - let job = job.with_overlay(overlay).with_proof(if v.before { None } else { proof }); - // keyed as the renderer keys the texture (a display profile changes the key) - let job = app.renderer.job_for(v.slot, job); - // The overlay and the proof are part of the tile's identity too, so changing mask view/colour - // or the proof never leaves a stale region tile on screen while its replacement renders. - let proof_key = if v.before { 0 } else { proof.map_or(0, |p| p.key()) }; - let look = job.settings.hash64() ^ overlay.key().rotate_left(17) ^ proof_key.rotate_left(41); - let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; - // windows of other photos, looks and zooms of this side can't be drawn any more - app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); - app.region_tiles.insert((v.before, job.key), view); - app.renderer.request(v.slot, job, 99); - Some(view) -} - -/// Draw `v`'s window texture over the whole-frame render, if it is the one asked for (or, in a -/// drag, an earlier draft of it). -fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &WindowView, wanted: &crate::region::RegionView) { - let Some(tex) = app.renderer.textures.get(&v.slot).filter(|t| t.photo == c.id) else { return }; - let Some(tile) = app.region_tiles.get(&(v.before, tex.key)).filter(|t| t.is_current(c.id, wanted.full, wanted.settings, c.interacting)) else { - return; - }; - let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32); - let at = v.img.min + vec2(tile.window.x as f32 / fw * v.img.width(), tile.window.y as f32 / fh * v.img.height()); - let dst = Rect::from_min_size(at, vec2(tile.window.w as f32 / fw * v.img.width(), tile.window.h as f32 / fh * v.img.height())); - p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); -} - /// Draw the Develop loupe: the optional filmstrip, the fitted or zoomed photo (before/after and /// full-screen aware), crop overlays and the render requests for the current view, or, when no /// photo is active, the empty-library message if the library is empty and "No photo selected" @@ -1026,516 +650,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } -/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the -/// photo's top left, inset 8 points, with the `text` size plus padding. Zoomed in, the photo -/// reaches past its `pane` (the canvas): the pill then sits at the pane's top left, so the label -/// stays in view (issue #2267: at 100 % it left the screen with the photo's corner), below the -/// `top_inset` the filter pill takes in that corner, and never past the pane's right edge. -fn label_badge_rect(photo: Rect, pane: Rect, text: egui::Vec2, top_inset: f32) -> Rect { - let size = text + vec2(16.0, 8.0); - let left = photo.left().max(pane.left()).min(pane.right() - size.x - 16.0).max(pane.left()); - let corner = pos2(left, photo.top().max(pane.top() + top_inset)); - Rect::from_min_size(corner + vec2(8.0, 8.0), size) -} - -/// The info overlay at the canvas' top left (`view.infoOverlay`): the lines View Options chose for -/// Info 1 or Info 2 (`view.viewOptions`). -fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { - let Some(lines) = info_overlay_lines(app, photo) else { return }; - let t = Tokens::get(p.ctx()); - let mut y = canvas.top() + info_overlay_top(app, &t); - for (i, l) in lines.iter().enumerate() { - // laid out uncoloured: `galley` only fills placeholder text with its colour, so a white - // layout made the shadow a second white copy (#1690) - let g = p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER); - let at = pos2(canvas.left() + 14.0, y); - // a soft shadow keeps the text readable on bright photos - p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); - p.galley(at, g.clone(), Color32::WHITE); - y += g.size().y + INFO_OVERLAY_GAP; - } - y = overlay_badges(p, &t, pos2(canvas.left() + 14.0, y), photo); - register_geometry(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); -} - -/// Where the info overlay's text starts below the canvas' top (without window controls over it), -/// and the gap after each line. -const INFO_OVERLAY_TOP: f32 = 12.0; -const INFO_OVERLAY_GAP: f32 = 3.0; - -/// The info overlay's font for line `i` (the file name is the first, larger line). -fn info_overlay_font(t: &Tokens, i: usize) -> egui::FontId { - if i == 0 { t.semibold(15.0) } else { t.font(12.5) } -} - -/// How far below the canvas' top the info overlay's text and badges reach (0 when it is off), so a -/// label drawn in that corner can sit below it. -fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraft_catalog::Photo) -> f32 { - let Some(lines) = info_overlay_lines(app, photo) else { return 0.0 }; - let t = Tokens::get(p.ctx()); - let text = lines.iter().enumerate().fold(info_overlay_top(app, &t), |y, (i, l)| { - y + p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER).size().y + INFO_OVERLAY_GAP - }); - text + if has_overlay_badges(photo) { OVERLAY_BADGES_H } else { 0.0 } -} - -/// Where the info overlay's text starts below the canvas' top: the full-screen preview has no top -/// bar, so on macOS it starts below the window controls that sit over the photo there (#2460). -fn info_overlay_top(app: &LightcraftApp, t: &Tokens) -> f32 { - let controls = app.ui.fullscreen && app.integrated_titlebar && !app.window_is_fullscreen; - if controls { t.top_bar_h } else { INFO_OVERLAY_TOP } -} - -/// The info overlay's lines for `photo`, or `None` when it is off. -fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Option> { - use crate::state::InfoOverlay; - // with the overlay off, Show Briefly When Photo Changes shows its Info for a moment - let brief = app.ui.info_overlay == InfoOverlay::Off && app.ui.brief_info.shown; - let mode = if brief { app.ui.settings.loupe_info.briefly } else { app.ui.info_overlay }; - let fields = match mode { - InfoOverlay::Off => return None, - InfoOverlay::Basic => &app.ui.settings.loupe_info.info1, - InfoOverlay::Exposure => &app.ui.settings.loupe_info.info2, - }; - let lines: Vec = fields.iter().filter_map(|f| info_line(*f, photo)).collect(); - if lines.is_empty() { - return None; - } - Some(lines) -} - -/// The height of the info overlay's rating and flag row. -const OVERLAY_BADGES_H: f32 = 17.0; - -fn has_overlay_badges(photo: &lightcraft_catalog::Photo) -> bool { - photo.rating > 0 || photo.flag != lightcraft_catalog::Flag::None -} - -/// The star rating and flag under the info overlay's lines, when the photo has either; returns -/// the y below them. -fn overlay_badges(p: &egui::Painter, t: &Tokens, at: Pos2, photo: &lightcraft_catalog::Photo) -> f32 { - use crate::icons::{Icon, paint}; - use lightcraft_catalog::Flag; - let flag = match photo.flag { - Flag::Pick => Some((Icon::FlagPickFilled, Color32::WHITE)), - Flag::Reject => Some((Icon::FlagReject, t.reject)), - Flag::None => None, - }; - if !has_overlay_badges(photo) { - return at.y; - } - let stars = photo.rating.min(5); - let mut icons: Vec<(Icon, Color32)> = vec![(Icon::StarFilled, Color32::WHITE); stars as usize]; - icons.extend(flag); - let side = 12.0; - let mut x = at.x; - for (i, (icon, color)) in icons.into_iter().enumerate() { - // the flag stands a little apart from the stars - if i > 0 && i == stars as usize { - x += 5.0; - } - let r = Rect::from_min_size(pos2(x, at.y + 1.0), vec2(side, side)); - paint(p, r.translate(vec2(1.0, 1.0)), icon, Color32::from_black_alpha(200)); - paint(p, r, icon, color); - x += side + 2.0; - } - register_geometry(p.ctx(), "canvas:infoOverlayBadges", Rect::from_min_max(at, pos2(x, at.y + side + 2.0))); - at.y + OVERLAY_BADGES_H -} - -/// What the user did to a face box this frame. -enum RegionEdit { - /// The × was clicked. - Remove(usize), - /// A handle drag ended: the region's new box (normalized, upright frame). - Resize(usize, lightcraft_geom::Rect), - /// A name was typed or picked for the face. - Name(usize, String), - /// The name box's offer to set up face recognition was pressed. - SetUp, -} - -/// Handles of a box: (x, y) as fractions of its width and height, and the cursor they show. -const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ - (0.0, 0.0, egui::CursorIcon::ResizeNwSe), - (0.5, 0.0, egui::CursorIcon::ResizeVertical), - (1.0, 0.0, egui::CursorIcon::ResizeNeSw), - (1.0, 0.5, egui::CursorIcon::ResizeHorizontal), - (1.0, 1.0, egui::CursorIcon::ResizeNwSe), - (0.5, 1.0, egui::CursorIcon::ResizeVertical), - (0.0, 1.0, egui::CursorIcon::ResizeNeSw), - (0.0, 0.5, egui::CursorIcon::ResizeHorizontal), -]; - -/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows -/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once, -/// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: -/// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo; -/// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes. -fn region_overlay( - ui: &egui::Ui, - p: &egui::Painter, - map: &CanvasMap, - photo: &lightcraft_catalog::Photo, - orient: lightcraft_geom::Orientation, - hints: Option<&super::faces::Hints>, - people: &[String], - editing: &mut Option, - setup: super::faces::Setup, -) -> Option { - let t = Tokens::get(p.ctx()); - let clip = p.clip_rect(); - let pointer = ui.input(|i| i.pointer.hover_pos()); - // the box being dragged: (region index, its box so far) - let drag_key = egui::Id::unique("region-drag"); - let live: Option<(usize, lightcraft_geom::Rect)> = ui.data(|d| d.get_temp(drag_key)); - let mut edit = None; - for (index, r) in photo.meta.regions.iter().enumerate() { - let dragging = live.filter(|(i, _)| *i == index); - let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n)); - let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1))); - // white with a black keyline just outside it, so the box shows on any background - p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside); - p.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Outside); - if dragging.is_some() || (live.is_none() && pointer.is_some_and(|h| rect.expand(8.0).contains(h))) { - for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() { - let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height()); - let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::unique(("region-handle", index, h)), Sense::drag()); - crate::widgets::announce(&resp, crate::i18n::tr("Resize region")); - if resp.hovered() || resp.dragged() { - ui.ctx().set_cursor_icon(*cursor); - } - let sq = Rect::from_center_size(c, vec2(7.0, 7.0)); - p.rect_filled(sq, 0.0, if resp.hovered() || resp.dragged() { Color32::from_rgb(120, 190, 255) } else { Color32::WHITE }); - p.rect_stroke(sq, 0.0, Stroke::new(1.0, Color32::from_black_alpha(220)), StrokeKind::Outside); - if resp.dragged() - && let Some(pp) = resp.interact_pointer_pos() - { - // move the dragged edge(s) to the pointer, never closer than 12 px to the opposite one - let mut n = rect; - if *fx == 0.0 { - n.min.x = pp.x.min(n.max.x - 12.0); - } else if *fx == 1.0 { - n.max.x = pp.x.max(n.min.x + 12.0); - } - if *fy == 0.0 { - n.min.y = pp.y.min(n.max.y - 12.0); - } else if *fy == 1.0 { - n.max.y = pp.y.max(n.min.y + 12.0); - } - let (a, b) = (map.norm(n.min), map.norm(n.max)); - let shown = lightcraft_geom::Rect { - x0: a.x.min(b.x).clamp(0.0, 1.0), - y0: a.y.min(b.y).clamp(0.0, 1.0), - x1: a.x.max(b.x).clamp(0.0, 1.0), - y1: a.y.max(b.y).clamp(0.0, 1.0), - }; - // back to the upright frame the region is stored in - let new = orient.inverse().map_norm_rect(shown); - ui.data_mut(|d| d.insert_temp(drag_key, (index, new))); - ui.ctx().request_repaint(); - } - if resp.drag_stopped() { - if let Some((i, new)) = ui.data(|d| d.get_temp::<(usize, lightcraft_geom::Rect)>(drag_key)) { - edit = Some(RegionEdit::Resize(i, new)); - } - ui.data_mut(|d| d.remove_temp::<(usize, lightcraft_geom::Rect)>(drag_key)); - } - } - } - if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { - let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0)); - let resp = ui.interact(xr, egui::Id::unique(("region-x", index)), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr("Remove region")); - register(ui, format!("regionRemove:{index}"), xr); - let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 }); - p.rect_filled(xr, 2.0, fill); - p.rect_stroke(xr, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside); - let (c, m) = (xr.center(), 3.5); - let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) }); - p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); - p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); - if resp.on_hover_text(crate::i18n::tr("Remove this face box (undo with Edit ▸ Undo)")).clicked() { - edit = Some(RegionEdit::Remove(index)); - } - } - // what the label says: the name, a guess to confirm ("Jane Doe?"), or on hover an invitation to name the face - let is_face = r.kind == lightcraft_meta::RegionKind::Face; - let hint = hints.and_then(|h| h.by_index.get(&index)); - let hovered = pointer.is_some_and(|h| rect.expand(3.0).contains(h)); - let editing_this = editing.as_ref().is_some_and(|e| e.photo == photo.id.0 && e.index == index); - let (text, text_color, fill_alpha, invitation) = match (&r.name, hint.and_then(|h| h.suggestion.as_ref())) { - (Some(n), _) => (n.clone(), Color32::from_gray(225), 235, false), - (None, Some((n, _))) if is_face => (format!("{n}?"), Color32::from_gray(185), 200, false), - (None, None) if is_face && live.is_none() => ("Add name".to_string(), Color32::from_gray(150), 190, true), - _ => continue, - }; - // the name in a dark label with a caret, centred above the box (below it when there is no room) - let name = &text; - let g = p.layout_no_wrap(name.clone(), t.font(13.0), text_color); - let (pad, caret) = (vec2(14.0, 7.0), 5.0); - let size = g.size() + pad * 2.0; - let above = rect.top() - caret - size.y >= clip.top(); - let (top, tip, base) = if above { - (rect.top() - caret - size.y, rect.top() - 1.0, rect.top() - caret - 1.0) - } else { - (rect.bottom() + caret, rect.bottom() + 1.0, rect.bottom() + caret + 1.0) - }; - let left = (rect.center().x - size.x / 2.0).clamp(clip.left(), (clip.right() - size.x).max(clip.left())); - let label = Rect::from_min_size(pos2(left, top), size); - // the invitation shows while the pointer is on the box *or on the label* (and the gap between), so it can be reached - if invitation && !(hovered || editing_this || pointer.is_some_and(|h| rect.union(label).expand(3.0).contains(h))) { - continue; - } - let fill = Color32::from_rgba_unmultiplied(56, 56, 56, fill_alpha); - p.rect_filled(label, 3.0, fill); - p.rect_stroke(label, 3.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Inside); - let cx = rect.center().x.clamp(label.left() + caret + 4.0, label.right() - caret - 4.0); - p.add(egui::Shape::convex_polygon(vec![pos2(cx - caret, base), pos2(cx + caret, base), pos2(cx, tip)], fill, Stroke::NONE)); - p.galley(label.min + pad, g, text_color); - if is_face && live.is_none() { - // a click on the label opens the name box, filled with the name or the guess - let resp = ui.interact(label, egui::Id::unique(("region-label", index)), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr("Click to name this face")); - register(ui, format!("regionLabel:{index}"), label); - if resp.on_hover_text(crate::i18n::tr("Click to name this face")).clicked() { - let start = r.name.clone().or_else(|| hint.and_then(|h| h.suggestion.as_ref().map(|(n, _)| n.clone()))).unwrap_or_default(); - *editing = Some(crate::state::NameEdit { photo: photo.id.0, index, text: start, fresh: true }); - } - } - if editing_this && let Some(e) = editing.as_mut() { - let candidates = hint.map(|h| h.candidates.as_slice()).unwrap_or(&[]); - match super::faces::name_editor(ui.ctx(), pos2(label.left(), rect.bottom() + 8.0), e, candidates, people, setup) { - super::faces::Editor::Submit(name) => edit = Some(RegionEdit::Name(index, name)), - super::faces::Editor::Setup => edit = Some(RegionEdit::SetUp), - super::faces::Editor::Cancel => *editing = None, - super::faces::Editor::Open => {} - } - } - } - edit -} - -/// The filter pill's inset from the canvas' top-left corner and its height (the same inset from -/// the left as from the top, so the pill sits in the corner, #1645). -const FILTER_PILL_INSET: f32 = 3.0; -const FILTER_PILL_HEIGHT: f32 = 20.0; - -/// Whether [`filter_pill`] has something to show (a filter is active). -fn filter_pill_shown(app: &LightcraftApp) -> bool { - !lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).is_empty() -} - -/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous -/// follow them, so a filter must never be invisible here. A click clears them all. It sits in the -/// canvas margin, so the photo does not move. -fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { - let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); - if chips.is_empty() { - return; - } - let t = Tokens::get(ui.ctx()); - let p = ui.painter_at(canvas); - let label = - chips.iter().map(|c| crate::panels::chips::display_label(c, &app.session.filter, &app.session.catalog)).collect::>().join(" · "); - let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225)); - let inset = FILTER_PILL_INSET; - let w = (g.size().x + 40.0).min((canvas.width() - 2.0 * inset).max(60.0)); - let r = Rect::from_min_size(canvas.min + vec2(inset, inset), vec2(w, FILTER_PILL_HEIGHT)); - let resp = ui.interact(r, egui::Id::unique("loupe-filter-pill"), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr("Filters")); - register(ui, "loupe:filters", r); - p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 })); - let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y)); - p.with_clip_rect(text_clip).galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); - let (c, m) = (pos2(r.right() - 13.0, r.center().y), 3.0); - let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) }); - p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); - p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); - if resp.on_hover_text(crate::i18n::tr("Click to clear these filters")).clicked() { - let _ = app.run("library.clearFilter", json!({})); - } -} - -/// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so; -/// hovering it explains (registered as `notice:previewOnly:loupe`). -fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { - let t = Tokens::get(ui.ctx()); - let p = ui.painter_at(canvas); - let text = crate::i18n::tr_format!("Preview only — editing the camera's embedded JPEG ({})", crate::widgets::preview_only_variant(reason)); - let g = p.layout_no_wrap(text, t.font(12.0), Color32::WHITE); - let size = vec2(g.size().x + 40.0, 26.0); - let r = Rect::from_min_size(pos2(canvas.center().x - size.x / 2.0, canvas.top() + 12.0), size); - p.rect_filled(r, 13.0, Color32::from_black_alpha(185)); - crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(15.0, 15.0)), crate::icons::Icon::Info, t.caution); - p.galley(pos2(r.left() + 29.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); - register(ui, "notice:previewOnly:loupe", r); - ui.interact(r, egui::Id::unique("preview-only-pill"), Sense::hover()).on_hover_text(crate::widgets::preview_only_explanation(reason)); -} - -/// One line of the info overlay for `photo`; `None` when the field is None or the photo has -/// nothing to show for it. -fn info_line(field: crate::state::LoupeInfoField, photo: &lightcraft_catalog::Photo) -> Option { - use crate::state::LoupeInfoField as F; - let m = &photo.meta; - let text = match field { - F::None => return None, - F::FileName => photo.file_name.clone(), - F::CaptureDate => pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)), - F::Dimensions => format!("{} × {}", photo.width, photo.height), - F::Exposure => { - let mut exp = Vec::new(); - if !m.shutter.is_empty() { - exp.push(format!("{} s", m.shutter)); - } - if let Some(a) = m.aperture { - exp.push(format!("f/{a:.1}")); - } - if let Some(i) = m.iso { - exp.push(format!("ISO {i}")); - } - if let Some(f) = m.focal_mm { - exp.push(format!("{f:.0} mm")); - } - if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") } - } - F::Camera => m.camera.clone(), - F::Lens => m.lens.clone(), - F::CameraAndLens => [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" · "), - }; - (!text.is_empty()).then_some(text) -} - -/// `2026-09-30T12:00:00` → `2026-09-30 12:00`. -fn pretty_date(iso: &str) -> String { - let d = iso.replacen('T', " ", 1); - d.get(..16).map(str::to_string).unwrap_or(d) -} - -/// Size of the Navigator mini map (points). -const NAV_W: f32 = 180.0; - -/// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the -/// visible region outlined; click or drag on it to pan. Returns the area it covers. -fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) -> Option { - let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; - if !app.ui.navigator || !zoomed || app.ui.fullscreen { - return None; - } - let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)); - let aspect = img.width() / img.height().max(1.0); - let (w, h) = if aspect >= 1.0 { (NAV_W, NAV_W / aspect) } else { (NAV_W * aspect, NAV_W) }; - let frame = Rect::from_min_size(pos2(canvas.right() - w - 16.0, canvas.bottom() - h - 16.0), vec2(w, h)); - let p = ui.painter_at(canvas); - p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190)); - match tex { - Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE), - None => p.rect_filled(frame, 0.0, Tokens::get(ui.ctx()).skeleton), - }; - // the visible part of the photo, in normalized image coordinates - let vis = canvas.intersect(img); - let n = |q: Pos2| pos2((q.x - img.left()) / img.width(), (q.y - img.top()) / img.height()); - let (a, b) = (n(vis.min), n(vis.max)); - let to_nav = |q: Pos2| pos2(frame.left() + q.x * frame.width(), frame.top() + q.y * frame.height()); - let view = Rect::from_min_max(to_nav(a), to_nav(b)); - p.rect_stroke(view, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); - p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); - register(ui, "canvas:navigator", frame); - let resp = ui.interact(frame.expand(5.0), egui::Id::unique("navigator"), Sense::click_and_drag()); - crate::widgets::announce(&resp, crate::i18n::tr("Navigator")); - if (resp.clicked() || resp.dragged()) - && let Some(q) = resp.interact_pointer_pos() - { - // centre the view on the point under the pointer - let u = ((q.x - frame.left()) / frame.width()).clamp(0.0, 1.0); - let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0); - app.ui.pan = (u, v); - } - Some(frame.expand(5.0)) -} - /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. const PREFETCH_PRIORITY: u32 = 4; -/// How long a render may be on its way, with nothing new on screen, before the loupe says -/// "Rendering…" (s): as long as the activity stack waits before it shows a task. -const RENDERING_AFTER_SECS: f64 = 0.5; - -/// What [`rendering_overdue`] keeps between frames while a render is coming: the texture keys on -/// screen, since when, and whether the pill is up. -type RenderingWait = Option<([Option; 4], f64, bool)>; - -/// Whether the loupe says "Rendering…": a render has been `coming` for [`RENDERING_AFTER_SECS`] with -/// no new texture `keys` on screen. The wait starts when the render starts coming (an idle app draws -/// no frames, so the last one may be long ago), and once up the pill stays until nothing is coming: -/// drafts slower than the wait don't make it blink. -pub(crate) fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: bool, now: f64) -> bool { - if !coming { - *wait = None; - return false; - } - let (seen, since, up) = wait.get_or_insert((keys, now, false)); - if !*up && *seen != keys { - (*seen, *since) = (keys, now); - } - *up |= now - *since > RENDERING_AFTER_SECS; - *up -} - -/// A pill over the photo, 12 pt above the bottom of `over` or just above the Navigator when that -/// is in the way, registered as `id`; `caution` adds the preview-only pill's caution icon. The -/// loupe's "Rendering…" says the pixels on screen aren't final, so it isn't an activity row (see -/// docs/background-tasks.md); "This photo can't be opened" says the stand-in on screen is all -/// there will be. -pub(crate) fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option, id: &str, text: &str, caution: bool) -> Rect { - let t = Tokens::get(ui.ctx()); - let p = ui.painter_at(canvas); - let g = p.layout_no_wrap(text.to_string(), t.font(12.0), Color32::from_gray(225)); - let inset = if caution { 29.0 } else { 10.0 }; - let size = vec2(g.size().x + inset + 10.0, 20.0); - let mut r = Rect::from_center_size(pos2(over.center().x, over.bottom() - 12.0 - size.y / 2.0), size); - if let Some(nav) = navigator.filter(|n| n.expand(8.0).intersects(r)) { - r = Rect::from_min_size(pos2(nav.right() - size.x, nav.top() - 8.0 - size.y), size); - } - p.rect_filled(r, 10.0, Color32::from_black_alpha(185)); - if caution { - crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(13.0, 13.0)), crate::icons::Icon::Info, t.caution); - } - p.galley(pos2(r.left() + inset, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); - register(ui, id, r); - r -} - -/// The caution pill saying a render failed behind the stand-in on screen (see [`loupe_pill`]): -/// "Original offline · no smart preview" when that is why, else "This photo can't be opened", with -/// the error on hover and for screen readers. -#[allow(clippy::too_many_arguments)] -pub(crate) fn failed_pill( - ui: &egui::Ui, - app: &LightcraftApp, - photo: &lightcraft_catalog::Photo, - canvas: Rect, - over: Rect, - navigator: Option, - id: &str, - error: &str, -) { - let offline = matches!(&photo.source, lightcraft_catalog::Source::File { path } if app.session.media.availability.is_offline(path)); - let text = crate::i18n::tr(if offline { "Original offline · no smart preview" } else { "This photo can't be opened" }); - let r = loupe_pill(ui, canvas, over, navigator, id, text, true); - let resp = ui.interact(r, egui::Id::unique(("render-failed", id)), Sense::hover()).on_hover_text(error); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, format!("{text}: {error}"))); -} - -fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { - use lightcraft_engine::media::QuickSource; - match q { - QuickSource::Cached => "cached", - QuickSource::Embedded => "embedded", - QuickSource::Small => "small", - } -} - /// Targeted adjustment tool: dragging up/down on the photo raises/lowers the tone-curve region or /// the colour-mixer bands under the press point (`develop.targeted`, one call per whole step, all /// in one interaction = one undo step). @@ -1701,1526 +818,6 @@ fn general_interaction( let _ = (native, aspect); } -// ------------------------------------------------------------------------ crop - -fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings, id: PhotoId) { - // the selected guide belongs to this photo and to the Guided Upright tool, and to a guide - // that still exists (a Delete after the last one leaves the selection behind) - if app.ui.tool != "guidedUpright" || app.ui.selected_guide.is_some_and(|(photo, i)| photo != id.0 || i >= d.geometry.guides.len()) { - app.ui.selected_guide = None; - } - if app.ui.tool == "guidedUpright" { - guided_overlay(app, ui, resp, map, frame, d, id); - return; - } - if app.ui.tool == "straighten" { - straighten_overlay(app, ui, resp, false); - return; - } - // hold ⌘ and drag: draw a straighten line without leaving the crop tool - let cmd_drag = resp.drag_started() && ui.input(|i| i.modifiers.command); - if cmd_drag || matches!(app.gesture, Some(Gesture::StraightenLine { .. })) { - straighten_overlay(app, ui, resp, true); - return; - } - if resp.hovered() && ui.input(|i| i.modifiers.command) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - let quad = frame_crop_quad(d, frame); - let pts: Vec = quad.iter().map(|q| map.screen(*q)).collect(); - let p = ui.painter(); - // darken outside the crop (the box's corners in screen order: a flipped photo shows its - // top-left corner elsewhere) - let quad = flipped_corners(&pts, frame.flip_h, frame.flip_v); - if pts.len() == 4 { - p.add(outside_crop_mesh(map.rect, &quad, Color32::from_black_alpha(150))); - } - p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, Color32::WHITE))); - // overlay guides - let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t; - let guide = Stroke::new(0.8, Color32::from_white_alpha(150)); - // (u, v) across / down the (rotated) crop frame - let at = |(u, v): (f32, f32)| pts[0] + (pts[1] - pts[0]) * u + (pts[3] - pts[0]) * v; - let (cw, ch) = (pts[0].distance(pts[1]), pts[0].distance(pts[3])); - for line in super::crop_overlay::paths(app.ui.crop_overlay, app.ui.crop_overlay_orient, cw, ch) { - p.add(egui::Shape::line(line.into_iter().map(at).collect(), guide)); - } - // handles: 0..3 corners, 4..7 edges (top, right, bottom, left) - let handles: Vec = (0..8).map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) }).collect(); - for (i, h) in handles.iter().enumerate() { - register(ui, format!("cropHandle:{i}"), Rect::from_center_size(*h, vec2(14.0, 14.0))); - let s = if i < 4 { 12.0 } else { 9.0 }; - p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE); - p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE); - } - // the keyboard: each handle (and the box itself) a stand-in Tab reaches and the arrows move - if crop_keys(app, ui, map, &handles, &pts, d, frame) { - return; - } - let inside = |q: Pos2| { - let n = map.norm(q); - let s = to_straight(n, d.crop.geometry.angle, frame); - d.crop.geometry.rect.contains(Point::new(s.x.clamp(-1.0, 2.0), s.y)) - }; - // a crop drag cut short (the tool closed before the release) leaves its gesture behind: with no - // button held it is over - if matches!(app.gesture, Some(Gesture::CropRotate { .. } | Gesture::CropHandle { .. })) - && !resp.drag_stopped() - && !ui.input(|i| i.pointer.any_down()) - { - app.gesture = None; - } - // while rotating, the pointer may leave the canvas: it still shows rotation and the angle - let rotating = matches!(app.gesture, Some(Gesture::CropRotate { .. })); - let pointer = resp.hover_pos().or_else(|| if rotating { ui.input(|i| i.pointer.latest_pos()) } else { None }); - // with ⌘ held a drag draws a level line (the straighten crosshair, set above), not a rotation - let straightening = !rotating && ui.input(|i| i.modifiers.command); - if let Some(hq) = pointer.filter(|_| !straightening) { - // a move or resize drag keeps its pointer wherever it goes, even past the box - let held = match app.gesture { - Some(Gesture::CropHandle { handle, .. }) => Some(usize::from(handle)), - _ => None, - }; - let near = held.or_else(|| handles.iter().position(|h| h.distance(hq) < 12.0)).filter(|_| !rotating); - let cursor = match near { - Some(0 | 2) => egui::CursorIcon::ResizeNwSe, - Some(1 | 3) => egui::CursorIcon::ResizeNeSw, - Some(4 | 6) => egui::CursorIcon::ResizeVertical, - Some(5 | 7) => egui::CursorIcon::ResizeHorizontal, - // 8: the whole box - Some(_) => egui::CursorIcon::Move, - None if inside(hq) && !rotating => egui::CursorIcon::Move, - // a drag here rotates: no system cursor shows that, so draw a curved double arrow - None => { - rotate_cursor(ui, hq, rotate_cursor_turn(&pts, hq, inside(hq))); - egui::CursorIcon::None - } - }; - ui.ctx().set_cursor_icon(cursor); - } - // the angle next to the pointer, drawn once this frame's rotation is applied (below) - let readout_at = pointer.filter(|_| rotating); - let mut shown_angle = d.crop.geometry.angle; - // double-click inside the crop box applies the crop (same as Return / Done) - if resp.double_clicked() - && let Some(q) = resp.interact_pointer_pos() - && inside(q) - && !handles.iter().any(|h| h.distance(q) < 12.0) - { - let _ = app.run("tool.done", json!({})); - return; - } - // egui reports a drag only once the pointer has moved a few px: the zone is where the button - // went down, where the cursor showed it, not where the pointer is by then - if resp.drag_started() - && let Some(q) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) - { - if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)) - { - app.ui.status = e; - let _ = app.session.cancel_interaction(); - return; - } - let d = app.session.develop_of(id).unwrap_or_default(); - app.gesture = Some(match handles.iter().position(|h| h.distance(q) < 12.0) { - Some(h) => Gesture::CropHandle { handle: h as u8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, - None if inside(q) => Gesture::CropHandle { handle: 8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, - None => { - let c = map.screen(Point::new(0.5, 0.5)); - Gesture::CropRotate { start_angle: d.crop.geometry.angle, a0: (q - c).angle() } - } - }); - } - if resp.dragged() - && let Some(q) = resp.interact_pointer_pos() - { - match app.gesture.clone() { - Some(Gesture::CropHandle { handle, start, angle }) => { - // The crop model decides what the drag does (anchor, aspect lock, image bounds): see - // `lightcraft_geom::drag_crop`; the UI only reports where the pointer started and is. - let n = to_straight(map.norm(q), angle, frame); - let orig = ui.input(|i| i.pointer.press_origin()).map(|q0| to_straight(map.norm(q0), angle, frame)).unwrap_or(n); - // Shift keeps the current proportions for this drag only; the Lock toggle is untouched. - let keep_ratio = ui.input(|i| i.modifiers.shift); - let _ = app.run( - "crop.drag", - json!({"handle": handle, "from": [orig.x, orig.y], "to": [n.x, n.y], "start": [start.x0, start.y0, start.x1, start.y1], "keepRatio": keep_ratio}), - ); - } - Some(Gesture::CropRotate { start_angle, a0 }) => { - let c = map.screen(Point::new(0.5, 0.5)); - let a = (q - c).angle(); - let ang = (start_angle + (a - a0).to_degrees() as f64).clamp(-45.0, 45.0); - shown_angle = (ang * 100.0).round() / 100.0; - let _ = app.run("crop.straighten", json!({"angle": shown_angle})); - } - _ => {} - } - } - if resp.drag_stopped() { - app.gesture = None; - let _ = app.run("develop.endInteraction", json!({})); - } - if let Some(at) = readout_at { - // the canvas (the loupe's own rect), not the photo: rotating happens in the margin around it - angle_readout(ui, at, shown_angle, resp.rect); - } -} - -/// The crop handles' names for screen readers, in `crop.drag`'s numbering: 0..3 corners, 4..7 -/// edges (top, right, bottom, left), 8 the whole box. -const CROP_HANDLE_NAMES: [&str; 9] = [ - "Crop top-left corner", - "Crop top-right corner", - "Crop bottom-right corner", - "Crop bottom-left corner", - "Crop top edge", - "Crop right edge", - "Crop bottom edge", - "Crop left edge", - "Crop box", -]; - -/// Keyboard stand-ins for the crop handles: an arrow press drags the focused one by a step (one -/// undo step each), as a pointer drag from its place would. Return applies the crop. True when the -/// frame is over (the crop was applied). -fn crop_keys( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - map: &CanvasMap, - handles: &[Pos2], - pts: &[Pos2], - d: &DevelopSettings, - frame: &Frame, -) -> bool { - let r = d.crop.geometry.rect; - let centre = if pts.len() == 4 { pts.iter().fold(Pos2::ZERO, |acc, q| acc + q.to_vec2() / 4.0) } else { return false }; - let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 }; - let value = format!("left {}%, top {}%, right {}%, bottom {}%", pct(r.x0), pct(r.y0), pct(r.x1), pct(r.y1)); - for (i, name) in CROP_HANDLE_NAMES.iter().enumerate() { - let Some(at) = handles.get(i).copied().or_else(|| (i == 8).then_some(centre)) else { continue }; - let keys = crate::canvas_keys::handle(ui, &format!("crop:{i}"), crate::i18n::tr(name), value.clone(), at); - if keys.activated { - let _ = app.run("tool.done", json!({})); - return true; - } - let Some((dx, dy)) = keys.delta else { continue }; - // the drag runs in the straightened crop frame, as a pointer drag from the handle would; - // the arrows follow the screen - let angle = d.crop.geometry.angle; - let to_q = at + vec2(dx as f32 * map.rect.width(), dy as f32 * map.rect.height()); - let (from, to) = (to_straight(map.norm(at), angle, frame), to_straight(map.norm(to_q), angle, frame)); - let started = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)); - if let Err(e) = started { - app.ui.status = e; - let _ = app.session.cancel_interaction(); - return false; - } - let _ = app.run( - "crop.drag", - json!({"handle": i, "from": [from.x, from.y], "to": [to.x, to.y], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false}), - ); - let _ = app.run("develop.endInteraction", json!({})); - } - false -} - -/// The crop angle as the rotation readout shows it: like the Straighten value, in degrees. -pub(crate) fn crop_angle_label(angle: f64) -> String { - let shown = - lightcraft_develop::controls::find("crop.angle").map_or_else(|| format!("{angle:.2}"), |spec| crate::widgets::shown_value(spec, angle)); - // the slider's format turns a rounded −0.00 into "0": every zero reads "0.00", the angle's - // usual two decimals - let shown = if shown == "0" { "0.00".to_string() } else { shown }; - format!("{shown}°") -} - -/// Where the angle readout of `size` goes for a pointer `at`: below right of it, flipped to the -/// left / above where that would leave `bounds`, then kept inside them. -pub(crate) fn readout_rect(at: Pos2, size: egui::Vec2, bounds: Rect) -> Rect { - // without a real pointer or bounds (NaN, an empty rect) there is nothing to keep it in; and - // f32::clamp panics on NaN, so only finite numbers get there - if !at.is_finite() || !bounds.is_finite() || !bounds.is_positive() { - let at = if at.is_finite() { at } else { bounds.min.max(Pos2::ZERO) }; - let at = if at.is_finite() { at } else { Pos2::ZERO }; - return Rect::from_min_size(at + vec2(18.0, 14.0), size); - } - let x = if at.x + 18.0 + size.x > bounds.right() { at.x - 18.0 - size.x } else { at.x + 18.0 }; - let y = if at.y + 14.0 + size.y > bounds.bottom() { at.y - 14.0 - size.y } else { at.y + 14.0 }; - let x = x.clamp(bounds.left(), (bounds.right() - size.x).max(bounds.left())); - let y = y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top())); - Rect::from_min_size(pos2(x, y), size) -} - -/// On top of everything (the tooltip layer), so neither the photo nor a panel covers it. -fn top_painter(ui: &egui::Ui, name: &'static str) -> egui::Painter { - ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique(name))) -} - -/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo, -/// turned `turn` radians clockwise ([`rotate_cursor_turn`]). -fn rotate_cursor(ui: &egui::Ui, at: Pos2, turn: f32) { - let p = top_painter(ui, "crop-rotate-cursor"); - let r = Rect::from_center_size(at, vec2(22.0, 22.0)); - p.circle_filled(at, 12.0, Color32::from_black_alpha(150)); - crate::icons::paint_turned(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE, turn); - register(ui, "cropRotateCursor", r); -} - -/// How far to turn the rotation pointer (radians, clockwise) so its arc, drawn bulging up, bulges -/// away from the crop box `pts` (its four corners on screen, maybe rotated): up above the top edge, -/// right beside the right edge, down below it, diagonally off a corner. Measured from the nearest -/// point of the box's outline; `inside` (a rotate drag that has come back over the box) faces it -/// the other way, still away from that edge. -fn rotate_cursor_turn(pts: &[Pos2], q: Pos2, inside: bool) -> f32 { - let nearest = (0..pts.len()) - .map(|i| { - let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); - let ab = b - a; - let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; - a + ab * t - }) - .min_by(|a, b| a.distance_sq(q).total_cmp(&b.distance_sq(q))); - let Some(nearest) = nearest else { return 0.0 }; - let away = if inside { nearest - q } else { q - nearest }; - if away.length() < 0.5 { - return 0.0; - } - away.y.atan2(away.x) + std::f32::consts::FRAC_PI_2 -} - -/// While rotating: the angle, next to the pointer, kept inside `bounds` (the canvas): on the -/// pointer's other side when it would run past an edge. -fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) { - let t = Tokens::get(ui.ctx()); - let p = top_painter(ui, "crop-angle-readout"); - let galley = p.layout_no_wrap(crop_angle_label(angle), t.font(12.5), Color32::WHITE); - let rect = readout_rect(at, galley.size() + vec2(12.0, 6.0), bounds); - p.rect_filled(rect, 4.0, Color32::from_black_alpha(170)); - p.galley(rect.min + vec2(6.0, 3.0), galley, Color32::WHITE); - register(ui, "cropAngleReadout", rect); -} - -/// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are -/// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps. -fn guided_overlay( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - resp: &egui::Response, - map: &CanvasMap, - frame: &Frame, - d: &DevelopSettings, - id: PhotoId, -) { - let p = ui.painter_at(map.rect.expand(8.0)); - let col = Color32::from_rgb(255, 196, 40); - // the selected guide: brighter, so the Delete key has a visible target - let selected = app.ui.selected_guide.filter(|(photo, _)| *photo == id.0).map(|(_, i)| i); - let draw = |a: Pos2, b: Pos2, sel: bool| { - let (edge, core, at) = if sel { (3.5, 2.5, 5.0) } else { (3.0, 1.5, 4.0) }; - p.line_segment([a, b], Stroke::new(edge, Color32::from_black_alpha(120))); - p.line_segment([a, b], Stroke::new(core, if sel { Color32::WHITE } else { col })); - for q in [a, b] { - p.circle_filled(q, at, if sel { Color32::WHITE } else { col }); - p.circle_stroke(q, at, Stroke::new(1.0, Color32::BLACK)); - } - }; - let on_screen = |guide: &(Point, Point)| { - let (sa, sb) = (map.screen(frame.corrected_to_transformed(guide.0)), map.screen(frame.corrected_to_transformed(guide.1))); - (sa, sb) - }; - for (i, guide) in d.geometry.guides.iter().enumerate() { - let (sa, sb) = on_screen(guide); - register(ui, format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb)); - draw(sa, sb, selected == Some(i)); - } - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - // A press starts what it means, right away (the pointer's press point, not where a drag - // threshold would have been crossed): on an endpoint it drags that endpoint (the guide is - // selected when it lands), on a line it selects the guide (a press on the selected one - // deselects), anywhere else it starts a new guide from the press point. The primary button - // only, never on the navigator — it pans the photo, it draws no guide — and only where the - // loupe itself takes the press: `contains_pointer` is layer-aware over the current pointer - // (the raw pointer is not), and `layer_id_at` asks the same question at the press point — - // a menu can receive the press and the pointer can leave it within the same frame, so the - // press itself must belong to the loupe's layer. Anything floating above the photo — a - // context menu, a dropdown, a dialog — owns its own press; the guide under it stays put. - // A slow frame can also swallow the whole press-move-release into one batch, and egui - // clears `press_origin` on the release: then the press position is read back from the - // frame's own events, so the gesture still starts where the pointer went down. - let nav = app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r); - if ui.input(|i| i.pointer.primary_pressed()) - && let Some(q) = ui - .input(|i| { - i.pointer.press_origin().or_else(|| { - i.events.iter().rev().find_map(|e| match e { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } => Some(*pos), - _ => None, - }) - }) - }) - .or(resp.interact_pointer_pos()) - && resp.contains_pointer() - && ui.ctx().layer_id_at(q) == Some(resp.layer_id) - && resp.rect.contains(q) - && !nav.is_some_and(|n| n.expand(5.0).contains(q)) - { - let endpoint = d.geometry.guides.iter().enumerate().find_map(|(i, guide)| { - let (a, b) = on_screen(guide); - if a.distance(q) <= 7.0 { - Some((i, 0)) - } else if b.distance(q) <= 7.0 { - Some((i, 1)) - } else { - None - } - }); - let line = d.geometry.guides.iter().position(|guide| { - let (a, b) = on_screen(guide); - point_segment_dist(q, a, b) <= 6.0 - }); - match (endpoint, line) { - (Some((i, end)), _) => app.gesture = Some(Gesture::GuideEndpoint { i, end, from: q }), - (None, Some(i)) => app.ui.selected_guide = if selected == Some(i) { None } else { Some((id.0, i)) }, - // drawing a new guide leaves nothing selected - (None, None) => { - app.ui.selected_guide = None; - app.gesture = Some(Gesture::Guide { a: map.norm(q) }); - } - } - } - let q = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())); - if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, q) { - draw(map.screen(*a), q, false); - } - // the endpoint being dragged: the guide follows the pointer, bright - if let (Some(Gesture::GuideEndpoint { i, end, .. }), Some(q)) = (&app.gesture, q) - && let Some(guide) = d.geometry.guides.get(*i) - { - let fixed = if *end == 0 { guide.1 } else { guide.0 }; - draw(map.screen(frame.corrected_to_transformed(fixed)), q, true); - } - if ui.input(|i| i.pointer.any_released()) { - match app.gesture.take() { - // the endpoint lands: the guide stays (one undo step) and is selected. A press - // without a real drag only selects — no no-op undo entry for a click. - Some(Gesture::GuideEndpoint { i, end, from }) => { - app.ui.selected_guide = Some((id.0, i)); - if let (Some(q), Some(guide)) = (q, d.geometry.guides.get(i)) { - let fixed_t = if end == 0 { guide.1 } else { guide.0 }; - let new_t = frame.transformed_to_corrected(map.norm(q)); - let (a, b) = if end == 0 { (new_t, fixed_t) } else { (fixed_t, new_t) }; - let (na, nb) = (frame.corrected_to_transformed(a), frame.corrected_to_transformed(b)); - // a press that only selected (it landed within the endpoint's grab radius - // and never moved) writes no guide: compare against where it went down - let moved = from.distance(q) > 2.0; - if moved && na.dist(nb) > 0.02 { - let all: Vec<[f64; 4]> = d - .geometry - .guides - .iter() - .enumerate() - .map(|(j, g)| { - let (x, y) = if j == i { (a, b) } else { *g }; - [x.x, x.y, y.x, y.y] - }) - .collect(); - let _ = app.run("geometry.guides", json!({"guides": all})); - } - } - } - Some(Gesture::Guide { a }) => { - if let Some(q) = q - && map.norm(q).dist(a) > 0.02 - { - let b = map.norm(q); - let (ca, cb) = (frame.transformed_to_corrected(a), frame.transformed_to_corrected(b)); - let _ = app.run("geometry.guides", json!({"guides": [[ca.x, ca.y, cb.x, cb.y]], "add": true})); - } - } - other => app.gesture = other, - } - } -} - -/// Normalized oriented coords → the straightened (rotated) frame the crop rect lives in. -fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point { - let (w, h) = (frame.ow, frame.oh); - let px = Point::new(n.x * w, n.y * h); - let r = Affine::rotate_about(angle.to_radians(), Point::new(w / 2.0, h / 2.0)).apply(px); - Point::new(r.x / w, r.y / h) -} - -/// How far `q` is from the segment `a`–`b` (screen pixels): the click that selects a guide. -fn point_segment_dist(q: Pos2, a: Pos2, b: Pos2) -> f32 { - let ab = b - a; - let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; - (a + ab * t).distance(q) -} - -/// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo) -/// in the order they appear on screen when the photo is shown flipped. -fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] { - let at = |i: usize| pts.get(i).copied().unwrap_or(Pos2::ZERO); - let order = match (flip_h, flip_v) { - (false, false) => [0, 1, 2, 3], - (true, false) => [1, 0, 3, 2], - (false, true) => [3, 2, 1, 0], - (true, true) => [2, 3, 0, 1], - }; - order.map(at) -} - -/// The dimming outside the crop box: the photo's rectangle minus the crop quad, as one mesh of -/// non-overlapping convex pieces. The quad is a rotated rectangle, so its opposite sides are -/// parallel and the plane outside it splits into four disjoint half-strips: outside edge `i` -/// and inside edge `i + 1`. Each is clipped to the photo and fanned into triangles. -/// -/// Issue #731: joining photo corner `i` to crop corner `i` with a quad per side only works while -/// the two rectangles are aligned. Rotated, those quads cross themselves and each other, which -/// showed as dimmed wedges inside the crop and doubly dark ones outside, growing with the angle. -/// A degenerate quad (a point, a line, a non-finite corner) dims nothing. -fn outside_crop_mesh(img: Rect, quad: &[Pos2; 4], color: Color32) -> egui::epaint::Mesh { - let mut mesh = egui::epaint::Mesh::default(); - if !quad.iter().all(|q| q.x.is_finite() && q.y.is_finite()) || !img.is_finite() { - return mesh; - } - let centre = pos2((quad[0].x + quad[1].x + quad[2].x + quad[3].x) / 4.0, (quad[0].y + quad[1].y + quad[2].y + quad[3].y) / 4.0); - // signed distance from edge i's line, positive towards the crop's centre - let side = |i: usize, q: Pos2| -> Option { - let (a, b) = (quad[i % 4], quad[(i + 1) % 4]); - let n = vec2(a.y - b.y, b.x - a.x); - let orient = n.dot(centre - a); - // an edge the centre lies on: the box is flat - (orient.abs() > 1e-6 * n.length().max(1.0)).then(|| n.dot(q - a) * orient.signum()) - }; - // Sutherland–Hodgman: the part of a convex polygon where `keep` is at or above zero - let clip = |poly: &[Pos2], keep: &dyn Fn(Pos2) -> Option| -> Option> { - let mut out = Vec::with_capacity(poly.len() + 2); - for (k, &a) in poly.iter().enumerate() { - let b = *poly.get((k + 1) % poly.len())?; - let (da, db) = (keep(a)?, keep(b)?); - if da >= 0.0 { - out.push(a); - } - if (da < 0.0) != (db < 0.0) && (da - db).abs() > f32::EPSILON { - let t = da / (da - db); - out.push(a + (b - a) * t); - } - } - Some(out) - }; - let photo = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()]; - for i in 0..4 { - let outside = |q: Pos2| side(i, q).map(|d| -d); - let inside_next = |q: Pos2| side(i + 1, q); - let Some(strip) = clip(&photo, &outside).and_then(|poly| clip(&poly, &inside_next)) else { return egui::epaint::Mesh::default() }; - if strip.len() < 3 { - continue; - } - let base = mesh.vertices.len() as u32; - mesh.vertices.extend(strip.iter().map(|&pos| egui::epaint::Vertex { pos, uv: Pos2::ZERO, color })); - for k in 1..(strip.len() as u32).saturating_sub(1) { - mesh.indices.extend([base, base + k, base + k + 1]); - } - } - mesh -} - -fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] { - let f = Frame { crop: d.crop.geometry, ..frame.clone() }; - f.crop_quad_norm() -} - -// ------------------------------------------------------------------------ masks - -/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo. -pub(crate) fn component_pin(shape: &MaskShape) -> Option { - match shape { - MaskShape::Radial { center, .. } => Some(*center), - MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)), - MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(), - _ => None, - } -} - -/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. -fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape { - let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); - match shape.clone() { - MaskShape::Radial { center, rx, ry, angle, feather, invert } => match handle { - 1 | 3 => { - let l = frame_long_norm(map); - let dx = (at.x - center.x) / l.0; - let dy = (at.y - center.y) / l.1; - let (s, co) = angle.to_radians().sin_cos(); - let new_rx = (dx * co + dy * s).abs().max(0.01); - let mut new_ry = ry; - if alt { - new_ry = ry * (new_rx / rx.max(0.01)); - } - if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { - new_ry = new_rx; - } - MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } - } - 2 | 4 => { - let l = frame_long_norm(map); - let dx = (at.x - center.x) / l.0; - let dy = (at.y - center.y) / l.1; - let (s, co) = angle.to_radians().sin_cos(); - let new_ry = (dx * (-s) + dy * co).abs().max(0.01); - let mut new_rx = rx; - if alt { - new_rx = rx * (new_ry / ry.max(0.01)); - } - if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { - new_rx = new_ry; - } - MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } - } - 5 => { - let l = frame_long_norm(map); - let dx = (at.x - center.x) / l.0; - let dy = (at.y - center.y) / l.1; - let new_angle = dy.atan2(dx).to_degrees(); - MaskShape::Radial { center, rx, ry, angle: new_angle, feather, invert } - } - _ => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, - }, - MaskShape::Linear { start, end } => match handle { - 1 => MaskShape::Linear { start: at, end }, - 2 => MaskShape::Linear { start, end: at }, - _ => MaskShape::Linear { start: mv(start), end: mv(end) }, - }, - MaskShape::Brush { mut strokes } => { - for st in &mut strokes { - st.points.iter_mut().for_each(|p| *p = mv(*p)); - } - MaskShape::Brush { strokes } - } - s => s, - } -} - -/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units, -/// so undo the same scaling and rotation used to draw its outline before testing containment. -fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool { - let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false }; - if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 { - return false; - } - let l = frame_long_norm(map); - let dx = (at.x - center.x) / l.0; - let dy = (at.y - center.y) / l.1; - let (s, co) = angle.to_radians().sin_cos(); - let x = (dx * co + dy * s) / rx; - let y = (-dx * s + dy * co) / ry; - x * x + y * y <= 1.0 -} - -/// Colour Range eyedropper: a click samples the colour under it for the selected mask's -/// colour range, at normalized frame coordinates (crop, rotation and zoom aware); -/// ⇧-click adds a sample (up to 5). -fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, map: &CanvasMap) { - if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - { - let n = map.norm(q); - let add = ui.input(|i| i.modifiers.shift); - if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) { - app.toast(ui.ctx(), e); - } - } -} - -/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask -/// component (click selects its mask, drag moves the component), and brush painting. The mask -/// itself shows as a rendered overlay ([`view_overlay`]). -fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - let long = (map.rect.width().max(map.rect.height())) as f64; - let active = app.session.active_mask; - let clip = app.canvas_rect.unwrap_or(map.rect); - let p = &ui.painter_at(clip); - // (mask, component, handle, screen position) of everything that can be grabbed - let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new(); - // pins follow Show Pins and the Tool Overlay mode (Shift+H); Auto hides them while brushing - let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); - let mut pins = p.clone(); - if mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Brush { .. })) { - pins.set_invisible(); - } - for m in &d.masks { - let sel = Some(m.id) == active; - // Ctrl+Shift+O (`mask_overlay_all`) shows every visible mask's region; outline each one - // too, not just the selected mask. Handles and the rotation grip stay on the selected - // mask so nothing else becomes draggable. The component check keeps this in step with the - // painted region (`view_overlay` at detail.rs:1354 only covers masks with components). - let show_outline = sel || (app.ui.mask_overlay_all && m.visible && !m.components.is_empty()); - let stroke_a: u8 = if sel { 230 } else { 170 }; - for (ci, c) in m.components.iter().enumerate() { - match &c.shape { - MaskShape::Radial { center, rx, ry, angle, .. } if show_outline => { - let pts: Vec = (0..64) - .map(|i| { - let a = i as f64 / 64.0 * std::f64::consts::TAU; - let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a.cos(), ry * a.sin()); - let l = frame_long_norm(map); - map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) - }) - .collect(); - p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); - - if sel { - let l = frame_long_norm(map); - for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { - let a_local = a_deg.to_radians(); - let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); - let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); - p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, q)); - } - - let (s, co) = angle.to_radians().sin_cos(); - let rot_r = rx + 0.08; - let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); - let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); - p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); - p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); - grips.push((m.id, ci, 5, rot_q)); - } - } - MaskShape::Linear { start, end } if show_outline => { - let (a, b) = (map.screen(*start), map.screen(*end)); - let dir = (b - a).normalized(); - let perp = vec2(-dir.y, dir.x) * 3000.0; - let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; - for (q, alpha) in [(a, a1), (b, a2)] { - p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); - } - if sel { - for (i, q) in [a, b].iter().enumerate() { - p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, *q)); - } - } - } - _ => {} - } - if app.ui.mask_pins - && mode.shows(sel, live) - && let Some(at) = component_pin(&c.shape) - { - let q = map.screen(at); - pin(&pins, q, sel); - register(ui, format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0))); - grips.push((m.id, ci, 0, q)); - } - } - } - mask_keys(app, ui, map, d, &grips); - // Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude - // (clicks still on their way to the model included) - let pending = app.session.segmenter.pending_clicks().cloned(); - for m in d.masks.iter().filter(|m| Some(m.id) == active) { - for (k, c) in m.components.iter().enumerate() { - if let MaskShape::Object { hint, exclude, .. } = &c.shape { - let (hint, exclude) = match pending.as_ref().filter(|q| (q.mask, q.comp) == (m.id, k)) { - Some(q) => (&q.hint, &q.exclude), - None => (hint, exclude), - }; - for (pts, col) in [(hint, Color32::from_rgb(40, 200, 90)), (exclude, Color32::from_rgb(230, 60, 60))] { - for q in pts { - let q = map.screen(*q); - p.circle_filled(q, 5.0, col); - p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); - } - } - } - } - } - // Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction - // never runs while it is open), so the eyedropper is handled here too. - if app.ui.tool == "colorRange" { - if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - color_range_pick(app, ui, resp, map); - return; - } - // Object tool: a click includes what's under it, ⌥-click leaves it out - if app.ui.tool == "object" { - if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - { - let n = map.norm(q); - let exclude = ui.input(|i| i.modifiers.alt); - match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { - Ok(_) => { - if let Some(mid) = app.session.active_mask { - app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); - } - } - Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), - } - } - return; - } - // brush tool - if app.ui.tool == "brush" { - let r = (app.ui.brush_size as f64 * long) as f32; - if let Some(h) = resp.hover_pos() { - ui.ctx().set_cursor_icon(egui::CursorIcon::None); - p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); - p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); - } - if let Some(q) = resp.interact_pointer_pos() - && (resp.drag_started() || resp.dragged() || resp.clicked()) - { - let n = map.norm(q); - let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; - match &mut app.gesture { - // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) - Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} - Some(Gesture::Brush { points, pressure }) => { - record_pressure(pressure, points.len(), force); - points.push(n); - } - _ => { - let mut pressure = Vec::new(); - record_pressure(&mut pressure, 0, force); - app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); - } - } - } - // the stroke being painted (the render shows it once it's committed), with the committed - // stroke's dab spacing, feather and flow (issue #517) - if let Some(Gesture::Brush { points, pressure }) = &app.gesture { - let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); - let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; - let params = crate::brush_live::LiveParams { - clip, - scale: ui.ctx().pixels_per_point(), - r, - hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), - flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), - density: 1.0, - color: app.ui.mask_overlay_color, - opacity: opacity.clamp(0.0, 1.0), - }; - crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); - } else { - app.brush_live = None; - } - if (resp.drag_stopped() || resp.clicked()) - && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() - { - app.brush_live = None; - let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing - let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); - let erase = app.ui.brush_erase != alt; - // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom - let connect = shift && resp.clicked() && pts.len() == 1; - let _ = app.run( - "mask.brushStroke", - json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, - "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), - ); - } - return; - } - // the grip under the pointer: the selected mask's first, then the nearest - let hit = |q: Pos2| { - grips - .iter() - .filter(|g| g.3.distance(q) < 10.0) - .min_by(|a, b| { - (Some(a.0) != active, a.3.distance(q)).partial_cmp(&(Some(b.0) != active, b.3.distance(q))).unwrap_or(std::cmp::Ordering::Equal) - }) - .copied() - }; - if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - && let Some((mid, ..)) = hit(q) - && Some(mid) != active - { - let _ = app.run("mask.select", json!({"id": mid})); - } - if resp.drag_started() - && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) - { - let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| { - let m = d.masks.iter().find(|m| Some(m.id) == active)?; - // Handles take precedence; otherwise grab the selected radial component under - // the press, including components after the first in compound masks. - let at = map.norm(q); - if let Some(ci) = m.components.iter().position(|c| radial_body_contains(&c.shape, at, map)) { - return Some((m.id, ci, 0)); - } - // anywhere on the photo drags the selected linear gradient - matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) - }); - if let Some((mask, comp, handle)) = grip - && let Some(original_shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone()) - { - if Some(mask) != active { - let _ = app.run("mask.select", json!({"id": mask})); - } - let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); - app.gesture = Some(Gesture::MaskHandle { mask, comp, handle, original_shape }); - } - } - if resp.dragged() - && let (Some(Gesture::MaskHandle { mask, comp, handle, original_shape }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) - { - let n = map.norm(q); - let press_origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(q); - let n0 = map.norm(press_origin); - let dn = Point::new(n.x - n0.x, n.y - n0.y); - let alt = ui.input(|i| i.modifiers.alt); - let new_shape = moved_shape(&original_shape, handle, dn, n, map, alt); - let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape})); - } - if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) { - app.gesture = None; - let _ = app.run("develop.endInteraction", json!({})); - } -} - -/// A mask handle's name for screen readers: `handle` 0 is the component's pin, 1..5 a radial -/// gradient's right / bottom / left / top edge and rotation, 1..2 a linear gradient's start and end. -fn mask_handle_name(mask: &str, shape: &MaskShape, handle: u8) -> String { - let part = match (shape, handle) { - (_, 0) => "pin", - (MaskShape::Radial { .. }, 1) => "right edge", - (MaskShape::Radial { .. }, 2) => "bottom edge", - (MaskShape::Radial { .. }, 3) => "left edge", - (MaskShape::Radial { .. }, 4) => "top edge", - (MaskShape::Radial { .. }, _) => "rotation", - (MaskShape::Linear { .. }, 1) => "start", - (MaskShape::Linear { .. }, _) => "end", - _ => "handle", - }; - format!("{mask} {}", crate::i18n::tr(part)) -} - -/// Keyboard stand-ins for the mask handles and pins: an arrow press moves the focused one by a -/// step (one undo step), as dragging it would; Return on a pin selects its mask. -fn mask_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(u32, usize, u8, Pos2)]) { - for &(mid, ci, handle, q) in grips { - let Some(m) = d.masks.iter().find(|m| m.id == mid) else { continue }; - let Some(shape) = m.components.get(ci).map(|c| &c.shape) else { continue }; - let at = map.norm(q); - let mask_name = if m.name.trim().is_empty() { format!("{} {mid}", crate::i18n::tr("Mask")) } else { m.name.clone() }; - let name = mask_handle_name(&mask_name, shape, handle); - let keys = crate::canvas_keys::handle(ui, &format!("mask:{mid}:{ci}:{handle}"), &name, crate::canvas_keys::position_text(at.x, at.y), q); - if keys.activated && app.session.active_mask != Some(mid) { - let _ = app.run("mask.select", json!({"id": mid})); - } - let Some((dx, dy)) = keys.delta else { continue }; - if app.session.active_mask != Some(mid) { - let _ = app.run("mask.select", json!({"id": mid})); - } - let to = Point::new(at.x + dx, at.y + dy); - let new_shape = moved_shape(shape, handle, Point::new(dx, dy), to, map, false); - let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); - let _ = app.run("mask.update", json!({"id": mid, "component": ci, "shape": new_shape})); - let _ = app.run("develop.endInteraction", json!({})); - } -} - -/// Long-edge units → normalized units for x and y. -fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { - // derive from the mapping: 1 normalized unit in x and y in screen points - let o = map.screen(Point::new(0.0, 0.0)); - let x = map.screen(Point::new(1.0, 0.0)).distance(o) as f64; - let y = map.screen(Point::new(0.0, 1.0)).distance(o) as f64; - let l = x.max(y); - (l / x.max(1e-9), l / y.max(1e-9)) -} - -/// Auto's condition for the Tool Overlay (Shift+H): the pointer is over the photo, or a pin is -/// being dragged. -fn pins_live(app: &LightcraftApp, resp: &egui::Response, map: &CanvasMap) -> bool { - resp.hover_pos().is_some_and(|q| map.rect.contains(q)) - || matches!(app.gesture, Some(Gesture::SpotMove { .. } | Gesture::MaskHandle { .. })) - // a handle moved with the keyboard stays shown while it has the focus - || crate::canvas_keys::has_focus(&resp.ctx) -} - -fn pin(p: &egui::Painter, c: Pos2, sel: bool) { - p.circle_filled(c, 6.0, if sel { Color32::from_rgb(1, 101, 221) } else { Color32::from_gray(200) }); - p.circle_stroke(c, 6.0, Stroke::new(1.5, Color32::WHITE)); -} - -// ------------------------------------------------------------------------ remove - -/// The Remove tool on the photo: every spot's outline and pin (the selected one with its source), -/// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one. -/// The Tool Overlay mode (Shift+H) says which spots show; a hidden spot can't be grabbed, so a -/// click there adds a new one. -fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - let long = (map.rect.width().max(map.rect.height())) as f64; - let active = app.session.active_spot.filter(|i| *i < d.spots.len()); - let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); - // Auto also hides them while a new spot is painted - let painting = mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Spot { .. })); - let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect)); - let mut pins = p.clone(); - if painting { - pins.set_invisible(); - } - // (spot, is source, screen centre, radius) of everything that can be grabbed - let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new(); - for (i, s) in d.spots.iter().enumerate() { - let r = (s.size * long) as f32; - let sel = Some(i) == active; - if !mode.shows(sel, live) { - continue; - } - let p = &pins; - let col = Color32::from_white_alpha(if sel { 230 } else { 150 }); - for q in &s.points { - p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col)); - } - let Some(&t) = s.points.first() else { continue }; - let tq = map.screen(t); - if let Some(o) = s.source_offset.filter(|_| sel) { - let sq = map.screen(Point::new(t.x + o.x, t.y + o.y)); - for q in &s.points { - p.circle_stroke(map.screen(Point::new(q.x + o.x, q.y + o.y)), r, Stroke::new(1.0, Color32::from_white_alpha(170))); - } - let dir = (tq - sq).normalized(); - p.arrow(sq + dir * r, (tq - sq) - dir * (2.0 * r).min((tq - sq).length()), Stroke::new(1.0, Color32::from_white_alpha(200))); - register(ui, format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0))); - grips.push((i, true, sq, r)); - } - pin(p, tq, sel); - register(ui, format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); - grips.push((i, false, tq, r)); - } - if spot_keys(app, ui, map, d, &grips) { - return; - } - let r = (app.ui.remove_size as f64 * long) as f32; - // a target or source under `q`: the selected spot's first, then the nearest pin - let hit = |q: Pos2| { - grips - .iter() - .filter(|g| g.2.distance(q) < g.3.max(8.0)) - .min_by(|a, b| { - (Some(a.0) != active, a.2.distance(q)).partial_cmp(&(Some(b.0) != active, b.2.distance(q))).unwrap_or(std::cmp::Ordering::Equal) - }) - .copied() - }; - let hover = resp.hover_pos().and_then(hit); - if let Some(h) = resp.hover_pos() { - if hover.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } else { - ui.ctx().set_cursor_icon(egui::CursorIcon::None); - p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); - p.circle_stroke(h, r * (1.0 - app.ui.remove_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(110))); - } - } - let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); - if resp.drag_started() - && let Some((spot, source, ..)) = press.and_then(hit) - { - if Some(spot) != active { - let _ = app.run("spot.select", json!({"index": spot})); - } - let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); - app.gesture = Some(Gesture::SpotMove { spot, source }); - } - if let Some(Gesture::SpotMove { spot, source }) = app.gesture.clone() { - if resp.dragged() - && let Some(q) = resp.interact_pointer_pos() - { - let (n, n0) = (map.norm(q), map.norm(q - resp.drag_delta())); - let dn = [n.x - n0.x, n.y - n0.y]; - if dn != [0.0, 0.0] { - let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: dn})); - } - } - if resp.drag_stopped() { - app.gesture = None; - let _ = app.run("develop.endInteraction", json!({})); - } - return; - } - if resp.clicked() - && let Some((spot, ..)) = press.and_then(hit) - { - let _ = app.run("spot.select", json!({"index": spot})); - return; - } - if let Some(q) = resp.interact_pointer_pos() - && (resp.drag_started() || resp.dragged() || resp.clicked()) - { - let n = map.norm(q); - match &mut app.gesture { - Some(Gesture::Spot { points }) => points.push(n), - _ => app.gesture = Some(Gesture::Spot { points: vec![n] }), - } - } - if let Some(Gesture::Spot { points }) = &app.gesture { - for q in points { - p.circle_filled(map.screen(*q), r, Color32::from_white_alpha(60)); - } - } - if (resp.drag_stopped() || resp.clicked()) - && let Some(Gesture::Spot { points }) = app.gesture.take() - { - let mode = match app.ui.tool.as_str() { - "heal" => "heal", - "clone" => "clone", - "fill" => "fill", - _ => "remove", - }; - let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - let _ = app.run( - "spot.add", - json!({"mode": mode, "points": pts, "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), - ); - } -} - -/// Keyboard stand-ins for the Remove tool: each spot's target and source (an arrow press moves -/// it a step, one undo step; Return selects the spot), and a placement point that the arrows move -/// and Return turns into a new spot of the brush's size there. True when a spot was added. -fn spot_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(usize, bool, Pos2, f32)]) -> bool { - for &(spot, source, q, _) in grips { - let at = map.norm(q); - let name = - format!("{} {}{}", crate::i18n::tr("Spot"), spot + 1, if source { format!(" {}", crate::i18n::tr("source")) } else { String::new() }); - let size = d.spots.get(spot).map(|s| s.size).unwrap_or_default(); - let value = format!("{}, {} {:.0}%", crate::canvas_keys::position_text(at.x, at.y), crate::i18n::tr("size"), size * 100.0); - let keys = crate::canvas_keys::handle(ui, &format!("spot:{spot}:{}", if source { "source" } else { "target" }), &name, value, q); - if (keys.activated || keys.delta.is_some()) && app.session.active_spot != Some(spot) { - let _ = app.run("spot.select", json!({"index": spot})); - } - if let Some((dx, dy)) = keys.delta { - let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); - let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: [dx, dy]})); - let _ = app.run("develop.endInteraction", json!({})); - } - } - // where a new spot goes: the photo's centre at first, then wherever the arrows took it - let key = egui::Id::unique("lc-spot-placement"); - let place: (f64, f64) = ui.data(|m| m.get_temp(key)).unwrap_or((0.5, 0.5)); - let q = map.screen(Point::new(place.0, place.1)); - let keys = crate::canvas_keys::handle( - ui, - "spot:new", - crate::i18n::tr("New spot (Return adds it here)"), - crate::canvas_keys::position_text(place.0, place.1), - q, - ); - if let Some((dx, dy)) = keys.delta { - let moved = ((place.0 + dx).clamp(0.0, 1.0), (place.1 + dy).clamp(0.0, 1.0)); - ui.data_mut(|m| m.insert_temp(key, moved)); - } - if crate::canvas_keys::has_focus(ui.ctx()) && ui.memory(|m| m.has_focus(crate::canvas_keys::id("spot:new"))) { - let r = (app.ui.remove_size as f64 * (map.rect.width().max(map.rect.height())) as f64) as f32; - ui.painter().circle_stroke(q, r, Stroke::new(1.0, Color32::WHITE)); - } - if keys.activated { - let mode = match app.ui.tool.as_str() { - "heal" => "heal", - "clone" => "clone", - "fill" => "fill", - _ => "remove", - }; - let _ = app.run( - "spot.add", - json!({"mode": mode, "points": [[place.0, place.1]], "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), - ); - return true; - } - false -} - -// ------------------------------------------------------------------------ red eye - -/// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it. -fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - // screen points per long-edge unit - let o = map.screen(Point::new(0.0, 0.0)); - let (sx, sy) = (map.screen(Point::new(1.0, 0.0)).distance(o), map.screen(Point::new(0.0, 1.0)).distance(o)); - let per_long = sx.max(sy); - let p = ui.painter(); - for (i, e) in d.red_eye.iter().enumerate() { - let c = map.screen(e.center); - let r = vec2(e.rx as f32 * per_long, e.ry as f32 * per_long); - let sel = i == app.ui.eye; - let col = if sel { Color32::WHITE } else { Color32::from_white_alpha(150) }; - p.add(egui::Shape::ellipse_stroke(c, r, Stroke::new(if sel { 1.5 } else { 1.0 }, col))); - p.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.0, col)); - p.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.0, col)); - } - if resp.hover_pos().is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.drag_started() - && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) - { - // from where the button went down (a drag is recognized only after some movement) - app.gesture = Some(Gesture::Eye { a: map.norm(q) }); - } - if let Some(Gesture::Eye { a }) = app.gesture - && let Some(q) = resp.interact_pointer_pos() - { - let (a, b) = (map.screen(a), q); - p.add(egui::Shape::ellipse_stroke(a.lerp(b, 0.5), ((b - a) / 2.0).abs(), Stroke::new(1.0, Color32::WHITE))); - } - if resp.drag_stopped() - && let Some(Gesture::Eye { a }) = app.gesture.take() - && let Some(q) = resp.interact_pointer_pos() - { - let (sa, sb) = (map.screen(a), q); - let half = ((sb - sa) / 2.0).abs() / per_long; - if half.x > 0.002 && half.y > 0.002 { - let c = map.norm(sa.lerp(sb, 0.5)); - let r = app.run("redeye.add", json!({"center": [c.x, c.y], "rx": half.x, "ry": half.y, "pet": app.ui.eye_pet})); - if let Ok(v) = r { - app.ui.eye = v["index"].as_u64().unwrap_or(0) as usize; - } - } - } else if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - && let Some(i) = d.red_eye.iter().position(|e| { - let c = map.screen(e.center); - let (dx, dy) = ((q.x - c.x) / (e.rx as f32 * per_long).max(4.0), (q.y - c.y) / (e.ry as f32 * per_long).max(4.0)); - dx * dx + dy * dy <= 1.0 - }) - { - app.ui.eye = i; - } -} - -// ------------------------------------------------------------------------ filmstrip - -/// A filmstrip cell's file-name label: names longer than 14 characters are cut to 13 and an -/// ellipsis. Counts characters, not bytes, so a CJK name is never cut inside a character (#266). -fn film_label(name: &str) -> String { - if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::()) } else { name.to_string() } -} - -/// Draw the horizontal filmstrip in `r`: selectable thumbnail cells with names and format when -/// film names are on, badges when film badges are on, and stack counts, following the active -/// photo and scrolling sideways with the wheel. Clicks select: Command toggles and Shift extends -/// the range; in Compare they adjust the pair's selection, and a plain click replaces the active -/// side. -pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_filled(r, 0.0, t.canvas); - ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20)); - let ids = app.session.visible_cloned(); - let active = app.session.selection.active; - let cell_w = 120.0; - let ppp = ui.ctx().pixels_per_point(); - if ids.is_empty() { - let why = if app.session.filter != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" }; - ui.painter().text(r.center(), Align2::CENTER_CENTER, why, t.font(12.5), t.text_dim); - return; - } - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0)))); - // a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work) - child.style_mut().always_scroll_the_only_direction = true; - // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` - let follow = super::grid::follow_active(child.ctx(), egui::Id::unique("film-follow-active"), active, app.session.source); - // a wheel notch scrolls a third of its width; see `grid::wheel_step` - register(ui, "filmstrip", child.max_rect()); - let wheel = super::grid::wheel_step(&child, egui::Id::unique("film-wheel"), child.max_rect(), child.max_rect().width(), true); - egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| { - if wheel != egui::Vec2::ZERO { - ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none()); - } - let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover()); - app.film_scroll = Some(vp.left()); - for (i, id) in ids.iter().enumerate() { - let cr = Rect::from_min_size(pos2(area.left() + i as f32 * cell_w, area.top()), vec2(cell_w, area.height())); - let local = Rect::from_min_size(pos2(i as f32 * cell_w, 0.0), cr.size()); - // centred when it is (partly) off screen; a click on a visible thumbnail leaves the strip alone - if follow && Some(*id) == active && !(local.left() >= vp.left() && local.right() <= vp.right()) { - ui.scroll_to_rect(cr, Some(egui::Align::Center)); - } - if !local.intersects(vp.expand2(vec2(cell_w * 4.0, 0.0))) { - continue; - } - let resp = ui.interact(cr, egui::Id::unique(("film", id.0)), Sense::click()); - let failed = app.renderer.thumb(*id).is_none() && app.renderer.failure(Slot::Thumb(*id)).is_some(); - crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed)); - register(ui, format!("film:{}", id.0), cr); - let state = app.session.selection.state_of(*id); - let sel = state == lightcraft_engine::SelectionState::Active; - let p = ui.painter(); - let label = app.session.catalog.photo(*id).and_then(|ph| ph.label); - let selected = state != lightcraft_engine::SelectionState::NotSelected; - let base = if selected { - t.cell_selected - } else if resp.hovered() { - t.cell_selected.gamma_multiply(0.6) - } else { - t.canvas - }; - p.rect_filled(cr, 0.0, crate::theme::label_background(base, label, selected)); - let names = app.ui.settings.film_names; - if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) { - let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name); - p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, film_label(name), t.font(10.0), t.text_dim); - p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim); - } - let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0)); - super::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8); - let thumb = app.renderer.thumb(*id); - if thumb.is_none() { - p.rect_filled(img_area, 0.0, t.skeleton); - if failed { - p.text(img_area.center(), Align2::CENTER_CENTER, "!", t.semibold(14.0), t.text); - } - } - if let Some(tex) = thumb { - let [tw, th] = tex.size; - let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32); - let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s)); - p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - if sel { - p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); - } else if state == lightcraft_engine::SelectionState::Selected { - p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::from_gray(170)), StrokeKind::Outside); - } - if app.ui.settings.film_badges - && let Some(ph) = app.session.catalog.photo(*id) - { - film_badges(p, &t, fr, ph); - } - if let Some(st) = app.session.catalog.stack_of(*id) { - let collapsed = st.collapsed; - let text = - if collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; - let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE); - let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0)); - // like the grid's badge: a click expands / collapses the stack - let badge = ui.interact(br, egui::Id::unique(("film-stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed { - "Stack — click to expand" - } else { - "Stack — click to collapse" - }); - crate::widgets::announce(&badge, crate::i18n::tr(if collapsed { "Expand Stack" } else { "Collapse Stack" })); - register(ui, format!("filmstack:{}", id.0), br); - let p = ui.painter(); - let fill = if badge.hovered() { Color32::from_black_alpha(230) } else { Color32::from_black_alpha(170) }; - p.rect_filled(br, 7.5, fill); - crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE); - p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - if badge.clicked() { - super::grid::toggle_stack(app, ui.ctx(), *id); - } - } - } - if resp.clicked() { - let m = ui.input(|i| i.modifiers); - let compare = app.ui.view == crate::state::ViewMode::Compare; - let r = if compare && !m.command && !m.shift { - // Compare: the clicked photo replaces the active side (or swaps with the other one) - app.run("compare.set", json!({"id": id.0})) - } else if compare { - // ⌘/Ctrl- and Shift-clicks change the selection; the pair on screen stays (#187) - super::compare::select_with_modifier(app, *id, !m.command) - } else { - let mode = if m.command { - "toggle" - } else if m.shift { - "range" - } else { - "replace" - }; - app.run("library.select", json!({"ids": [id.0], "mode": mode})) - }; - if compare && let Err(e) = r { - app.toast(ui.ctx(), e); - } - } - // the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted) - crate::widgets::context_menu(&resp, |ui| super::grid::context_menu(app, ui, *id)); - } - }); -} - -/// What a screen reader says for a filmstrip thumbnail: the file, and why there is no picture -/// when the thumbnail failed (the painted `!` has no name of its own). The name is empty when the -/// catalog no longer has the photo (or its file name is blank); the reason then stands alone. -pub(crate) fn film_spoken(file_name: &str, failed: bool) -> String { - match (file_name.is_empty(), failed) { - (_, false) => file_name.to_string(), - (true, true) => crate::i18n::tr("This photo can't be opened").to_string(), - (false, true) => format!("{file_name}, {}", crate::i18n::tr("This photo can't be opened")), - } -} - -/// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface). -fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) { - use crate::icons::{Icon, paint}; - use lightcraft_catalog::Flag; - let edited = ph.is_edited(); - if ph.rating == 0 && ph.flag == Flag::None && !edited { - return; - } - let bar = Rect::from_min_max(pos2(fr.left(), fr.bottom() - 15.0), fr.right_bottom()); - p.rect_filled(bar, 0.0, Color32::from_black_alpha(130)); - let y = bar.center().y; - let mut x = bar.left() + 3.0; - x += crate::rating::paint_rating(p, pos2(x, y), ph.rating, crate::rating::RatingSize::Small, t.star) + 2.0; - match ph.flag { - Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), - Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), - Flag::None => {} - } - if edited { - paint(p, Rect::from_min_size(pos2(bar.right() - 13.0, y - 5.0), vec2(10.0, 10.0)), Icon::Sliders, t.text_label); - } -} - -#[cfg(test)] -mod preview_geometry_tests { - use super::{fit_rect, fit_texture_rect, label_badge_rect}; - use crate::state::Zoom; - use egui::{Rect, pos2, vec2}; - - /// The "Before" label sits on a dark pill that wraps the text with padding on every side. - #[test] - fn before_label_badge_wraps_the_text() { - let text = vec2(40.0, 14.0); - let pane = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); - let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0)); - let bg = label_badge_rect(photo, pane, text, 0.0); - assert_eq!(bg.min, pos2(28.0, 38.0)); - assert_eq!(bg.size(), vec2(56.0, 22.0)); - assert!(photo.contains_rect(bg)); - } - - /// Zoomed in, the photo's corner is off the canvas: the pill moves to the canvas' corner - /// instead of leaving with it (issue #2267), on whichever axes the photo reaches past. - #[test] - fn before_label_badge_stays_in_the_pane_when_the_photo_reaches_past_it() { - let text = vec2(40.0, 14.0); - let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); - let both = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, 0.0); - assert_eq!(both.min, pos2(108.0, 58.0)); - assert!(pane.contains_rect(both)); - let left_only = label_badge_rect(Rect::from_min_size(pos2(-900.0, 120.0), vec2(3000.0, 400.0)), pane, text, 0.0); - assert_eq!(left_only.min, pos2(108.0, 128.0)); - assert!(pane.contains_rect(left_only)); - } - - /// With a filter active, the pill in the canvas' corner has that corner: the label sits below - /// it, whether the photo is past the pane or just under the pill. A photo lower down is left - /// alone. A wide label near the pane's right edge stays inside the pane. - #[test] - fn before_label_badge_keeps_clear_of_the_filter_pill_and_the_right_edge() { - let text = vec2(40.0, 14.0); - let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); - let inset = super::FILTER_PILL_INSET + super::FILTER_PILL_HEIGHT; - let past = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, inset); - assert_eq!(past.min, pos2(108.0, 50.0 + inset + 8.0)); - let under = label_badge_rect(Rect::from_min_size(pos2(200.0, 60.0), vec2(400.0, 300.0)), pane, text, inset); - assert_eq!(under.min.y, 50.0 + inset + 8.0, "a photo whose corner is under the pill moves down too"); - let lower = label_badge_rect(Rect::from_min_size(pos2(200.0, 200.0), vec2(400.0, 300.0)), pane, text, inset); - assert_eq!(lower.min, pos2(208.0, 208.0)); - let wide = label_badge_rect(Rect::from_min_size(pos2(860.0, 200.0), vec2(400.0, 300.0)), pane, text, 0.0); - assert!(pane.contains_rect(wide), "{wide:?}"); - assert_eq!(wide.max.x, pane.right() - 8.0, "8 points from the pane's right edge, like the left inset"); - } - - #[test] - fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() { - let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); - let image = fit_texture_rect(frame, [100, 200]); - - assert!((image.width() / image.height() - 0.5).abs() < 1e-6); - assert_eq!(image.center(), frame.center()); - assert!(frame.contains_rect(image)); - } - - /// A 100 % view must put the photo's corner on a whole physical pixel, or the GPU's bilinear - /// filter softens it (a 1279.5 pt canvas on a 2x display centred the photo half a pixel off). - #[test] - fn percent_zoom_lands_on_whole_physical_pixels() { - let area = Rect::from_min_size(pos2(0.0, 42.0), vec2(1279.5, 726.0)); - for ppp in [1.0, 1.25, 2.0, 3.0] { - for pan in [(0.5, 0.5), (0.3, 0.7), (0.4999, 0.5001)] { - let image = fit_rect(area, 5152.0 / 7728.0, Zoom::Percent(100.0), [5152, 7728], ppp, pan); - for v in [image.min.x, image.min.y] { - assert!((v * ppp - (v * ppp).round()).abs() < 1e-3, "{v} pt is not a whole pixel at {ppp}x, pan {pan:?}"); - } - assert!((image.width() * ppp - 5152.0).abs() < 1e-3, "size stays the photo's own pixels"); - } - } - } - - #[test] - fn portrait_preview_aspect_fits_detail_area_without_stretching() { - let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); - let image = fit_rect(area, 0.5, Zoom::Fit, [4000, 6000], 1.0, (0.5, 0.5)); - - assert!((image.width() / image.height() - 0.5).abs() < 1e-6); - assert_eq!(image.size(), vec2(100.0, 200.0)); - assert_eq!(image.center(), area.center()); - } -} - -/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto. -/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle. -/// `held`: drawn with ⌘ held in the crop tool, which stays active afterwards. -fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, held: bool) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - if !held && resp.double_clicked() { - app.auto_straighten(ui.ctx()); - app.ui.tool.clear(); - app.gesture = None; - return; - } - if resp.drag_started() - && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) - { - app.gesture = Some(Gesture::StraightenLine { a: q }); - } - let Some(Gesture::StraightenLine { a }) = app.gesture.clone() else { return }; - let Some(b) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; - let p = ui.painter(); - p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(140))); - p.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); - if resp.drag_stopped() { - app.gesture = None; - let v = b - a; - if v.length() > 8.0 { - let mut deg = v.y.atan2(v.x).to_degrees() as f64; - // fold to the nearest axis: a line closer to vertical straightens plumb lines - while deg > 45.0 { - deg -= 90.0; - } - while deg < -45.0 { - deg += 90.0; - } - let _ = app.run("crop.straighten", json!({"angle": (-deg * 100.0).round() / 100.0})); - } - if !held { - app.ui.tool.clear(); - } - } -} - #[cfg(test)] mod tests { /// The rotation pointer's arc bulges away from the side of the crop box it is on, and follows @@ -3411,305 +1008,3 @@ mod tests { assert_eq!(flipped_corners(&hv, true, true), screen); } } - -#[cfg(test)] -mod wheel_zoom_tests { - use super::*; - - fn wheel(unit: egui::MouseWheelUnit, y: f32, modifiers: egui::Modifiers) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers } - } - - #[test] - fn only_notched_unmodified_wheels_count() { - let m = egui::Modifiers::NONE; - assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, m), wheel(egui::MouseWheelUnit::Line, 2.0, m)]), 3.0); - assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Point, 40.0, m)]), 0.0); - assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL)]), 0.0); - assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, f32::NAN, m)]), 0.0); - } -} - -/// The pen pressure (0..1) reported this frame: the force of the latest touch or pen event that carries one (winit -/// reports a pen's pressure as a touch force, on the platforms that give it), `None` for a mouse. -pub(crate) fn pen_force(i: &egui::InputState) -> Option { - i.events.iter().rev().find_map(|e| match e { - egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), - _ => None, - }) -} - -/// Record the pressure of the stroke's point `index`: `force` when the pen reported one this frame, else the last -/// known one. Points before the first reported force take it too; a stroke without any keeps no pressure list. -pub(crate) fn record_pressure(pressure: &mut Vec, index: usize, force: Option) { - match (force, pressure.last().copied()) { - (Some(f), _) if pressure.is_empty() => pressure.extend(std::iter::repeat_n(f, index + 1)), - (Some(f), _) => pressure.push(f), - (None, Some(last)) => pressure.push(last), - (None, None) => {} - } -} - -#[cfg(test)] -mod pen_tests { - use super::*; - - #[test] - fn pen_pressure_is_recorded_per_point_and_absent_for_a_mouse() { - let mut p = Vec::new(); - record_pressure(&mut p, 0, None); - record_pressure(&mut p, 1, None); - assert!(p.is_empty(), "a mouse: no pressure list"); - // the first force covers the points before it, a frame without one repeats the last - record_pressure(&mut p, 2, Some(0.4)); - record_pressure(&mut p, 3, None); - record_pressure(&mut p, 4, Some(0.9)); - assert_eq!(p, vec![0.4, 0.4, 0.4, 0.4, 0.9]); - - let mut i = egui::InputState::default(); - assert_eq!(pen_force(&i), None); - let touch = |force| egui::Event::Touch { - device_id: egui::TouchDeviceId(1), - id: egui::TouchId(1), - phase: egui::TouchPhase::Move, - pos: egui::pos2(1.0, 1.0), - force, - }; - i.events = vec![touch(Some(0.3)), touch(Some(f32::NAN)), touch(None)]; - assert_eq!(pen_force(&i), Some(0.3), "the latest usable force"); - i.events = vec![touch(Some(4.0))]; - assert_eq!(pen_force(&i), Some(1.0)); - } -} - -#[cfg(test)] -mod snap_tests { - use super::*; - - /// A texture drawn at its own size lands on whole physical pixels at exactly that size (no - /// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given. - #[test] - fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() { - let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2)); - let s = snap_fit_rect(r, [1088, 720], 1.0); - assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0)); - // at 1.5 points per pixel the snap is to physical pixels and the size stays in points - let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5); - assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}"); - let other = snap_fit_rect(r, [2560, 1694], 1.0); - assert_eq!(other, r); - let bad = snap_fit_rect(r, [1088, 720], f32::NAN); - assert_eq!((bad.width(), bad.height()), (1088.0, 720.0)); - } -} - -#[cfg(test)] -mod filter_pill_tests { - use super::*; - - /// #1645: the filter pill sits in the loupe's top-left corner as far from the left edge as - /// from the top one. - #[test] - fn the_filter_pill_is_inset_equally_from_both_edges() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); - app.session.filter.text = "717".into(); - let ctx = egui::Context::default(); - let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); - let mut out = ctx.run_ui(raw, |ui| filter_pill(&mut app, ui, canvas)); - out.textures_delta.clear(); - let widgets = crate::widgets::take_registry(&ctx); - let pill = widgets.iter().find(|(w, _)| w == "loupe:filters").map(|(_, r)| *r).expect("the pill shows with a search"); - let (left, top) = (pill.left() - canvas.left(), pill.top() - canvas.top()); - assert!(top > 0.0, "inside the canvas: {pill:?}"); - assert_eq!(left, top, "left and top insets: {pill:?} in {canvas:?}"); - } -} - -#[cfg(test)] -mod info_overlay_tests { - use super::*; - - /// #1690: the info overlay's shadow is a dark copy under the text. Drawn as a second white - /// copy one point off, each line read doubled. - #[test] - fn the_info_overlay_shadow_is_dark() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); - app.ui.info_overlay = crate::state::InfoOverlay::Basic; - let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); - let mut out = ctx.run_ui(raw, |ui| info_overlay(&app, ui.painter(), canvas, &photo)); - out.textures_delta.clear(); - // the colour a text shape paints its glyphs in, as epaint resolves it - let ink = |s: &egui::epaint::TextShape| { - let c = s.galley.job.sections.first().map_or(Color32::PLACEHOLDER, |sec| sec.format.color); - s.override_text_color.unwrap_or(if c == Color32::PLACEHOLDER { s.fallback_color } else { c }) - }; - fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { - match shape { - egui::epaint::Shape::Text(t) => out.push(t.clone()), - egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut all = Vec::new(); - out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); - let copies: Vec = all.iter().filter(|t| t.galley.job.text == photo.file_name).map(ink).collect(); - assert_eq!(copies.len(), 2, "the file name and its shadow: {copies:?}"); - assert_eq!(copies[1], Color32::WHITE, "the text on top"); - assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]); - } - - /// View Options' Show Briefly When Photo Changes: with the overlay off, the chosen Info shows - /// for `BRIEF_INFO_SECS` after the loupe changes photo, then hides; an overlay that is on stays. - #[test] - fn show_briefly_shows_the_info_after_the_photo_changes() { - use crate::state::{BRIEF_INFO_SECS, InfoOverlay}; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); - let photos: Vec = app.session.catalog.photos().take(2).map(|p| (**p).clone()).collect(); - assert_eq!(photos.len(), 2, "the demo catalog has two photos"); - let ctx = egui::Context::default(); - // the loupe shows photo `i` at `time`: the lines the overlay paints then - let at = |app: &mut LightcraftApp, i: usize, time: f64| { - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, time, vec![]); - let mut out = ctx.run_ui(raw, |ui| brief_info(app, ui.ctx(), photos[i].id)); - out.textures_delta.clear(); - painted_lines(app, &photos[i]) - }; - let name = |i: usize| photos[i].file_name.clone(); - assert!(at(&mut app, 0, 1.0).is_empty(), "off by default"); - app.ui.settings.loupe_info.briefly = InfoOverlay::Exposure; - let info2 = at(&mut app, 1, 2.0); - assert_eq!(info2.first(), Some(&name(1)), "Info 2 shows when the photo changes: {info2:?}"); - assert!(!at(&mut app, 1, 2.0 + BRIEF_INFO_SECS - 0.5).is_empty(), "still up"); - assert!(at(&mut app, 1, 2.0 + BRIEF_INFO_SECS + 0.1).is_empty(), "then hidden"); - assert_eq!(at(&mut app, 0, 10.0).first(), Some(&name(0)), "and shown again for the next photo"); - app.ui.settings.loupe_info.briefly = InfoOverlay::Basic; - let info1 = at(&mut app, 1, 20.0); - assert_eq!(info1.len(), 3, "Info 1's three lines: {info1:?}"); - assert_ne!(info1, info2, "Info 1, not Info 2"); - app.ui.info_overlay = InfoOverlay::Exposure; - assert_eq!(at(&mut app, 1, 30.0), info2, "an overlay that is on shows its own Info, and stays"); - } - - /// The lines the overlay paints, top to bottom (each once: the shadow copies left out). - fn painted_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Vec { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); - let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); - out.textures_delta.clear(); - let mut lines = Vec::new(); - for c in &out.shapes { - if let egui::epaint::Shape::Text(t) = &c.shape - && t.override_text_color.unwrap_or(t.fallback_color) == Color32::WHITE - { - lines.push(t.galley.job.text.clone()); - } - } - lines - } - - /// View ▸ View Options: Info 1 and Info 2 each show the three lines chosen for them, in that - /// order, without the lines set to None. - #[test] - fn the_info_overlay_shows_the_lines_chosen_in_view_options() { - use crate::state::{InfoOverlay, LoupeInfoField as F}; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); - let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); - let size = format!("{} × {}", photo.width, photo.height); - let date = pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)); - app.ui.settings.loupe_info.info1 = [F::Dimensions, F::None, F::FileName]; - app.ui.settings.loupe_info.info2 = [F::CaptureDate, F::FileName, F::None]; - app.ui.info_overlay = InfoOverlay::Basic; - assert_eq!(painted_lines(&app, &photo), vec![size.clone(), photo.file_name.clone()], "Info 1"); - app.ui.info_overlay = InfoOverlay::Exposure; - assert_eq!(painted_lines(&app, &photo), vec![date, photo.file_name.clone()], "Info 2"); - app.ui.settings.loupe_info.info1 = [F::None; 3]; - app.ui.info_overlay = InfoOverlay::Basic; - assert_eq!(painted_lines(&app, &photo), Vec::::new(), "nothing chosen, nothing shown"); - } - - fn demo_app() -> (LightcraftApp, lightcraft_catalog::Photo) { - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); - app.ui.info_overlay = crate::state::InfoOverlay::Basic; - let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); - (app, photo) - } - - /// The overlay's text shapes and the rects it registered, painted on a canvas at the window's corner. - fn paint_overlay(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> (Vec, Vec<(String, Rect)>) { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let canvas = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); - let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); - out.textures_delta.clear(); - fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { - match shape { - egui::epaint::Shape::Text(t) => out.push(t.clone()), - egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let mut all = Vec::new(); - out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); - (all, crate::widgets::take_registry(&ctx)) - } - - fn name_top(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> f32 { - let (texts, _) = paint_overlay(app, photo); - texts.iter().filter(|t| t.galley.job.text == photo.file_name).map(|t| t.pos.y).fold(f32::INFINITY, f32::min) - } - - /// #2460: in the full-screen preview on macOS the window controls stay at the top-left, so the - /// file name starts below them; in macOS full screen they are gone and it keeps the corner. - #[test] - fn the_file_name_clears_the_macos_window_controls_in_the_full_screen_preview() { - let (mut app, photo) = demo_app(); - app.ui.fullscreen = true; - assert_eq!(name_top(&app, &photo), 12.0, "no window controls over the photo"); - app.integrated_titlebar = true; - let bar = Tokens::get(&egui::Context::default()).top_bar_h; - assert!(name_top(&app, &photo) >= bar, "below the window controls"); - app.window_is_fullscreen = true; - assert_eq!(name_top(&app, &photo), 12.0, "macOS full screen hides the window controls"); - app.window_is_fullscreen = false; - app.ui.fullscreen = false; - assert_eq!(name_top(&app, &photo), 12.0, "the loupe's canvas starts below the top bar"); - } - - /// #2460: a rated or flagged photo shows its stars and flag under the date and size. - #[test] - fn the_info_overlay_shows_the_rating_and_flag_under_the_date() { - let (_, mut photo) = demo_app(); - let (app, _) = demo_app(); - photo.rating = 0; - photo.flag = lightcraft_catalog::Flag::None; - let badges = |photo: &lightcraft_catalog::Photo| { - paint_overlay(&app, photo).1.into_iter().find(|(id, _)| id == "canvas:infoOverlayBadges").map(|(_, r)| r) - }; - assert_eq!(badges(&photo), None, "nothing to show"); - photo.rating = 3; - let stars = badges(&photo).expect("three stars"); - photo.flag = lightcraft_catalog::Flag::Pick; - let flagged = badges(&photo).expect("three stars and a flag"); - assert!(flagged.width() > stars.width(), "the flag follows the stars: {stars:?} {flagged:?}"); - let (texts, _) = paint_overlay(&app, &photo); - let date_bottom = texts.iter().map(|t| t.pos.y + t.galley.size().y).fold(0.0, f32::max); - assert!(flagged.top() >= date_bottom, "under the date and size: {flagged:?}, text ends at {date_bottom}"); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); - let mut extent = 0.0; - ctx.run_ui(raw, |ui| extent = info_overlay_extent(&app, ui.painter(), &photo)).textures_delta.clear(); - assert!(extent >= flagged.bottom(), "a label below the overlay clears the badges: {extent} < {flagged:?}"); - photo.rating = 0; - photo.flag = lightcraft_catalog::Flag::Reject; - assert!(badges(&photo).is_some(), "a rejected photo shows its flag alone"); - } -} diff --git a/crates/ui-egui/src/panels/detail/crop.rs b/crates/ui-egui/src/panels/detail/crop.rs new file mode 100644 index 000000000..295b8f819 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/crop.rs @@ -0,0 +1,582 @@ +use super::geometry::CanvasMap; +use super::*; + +// ------------------------------------------------------------------------ crop + +pub(super) fn crop_overlay( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + frame: &Frame, + d: &DevelopSettings, + id: PhotoId, +) { + // the selected guide belongs to this photo and to the Guided Upright tool, and to a guide + // that still exists (a Delete after the last one leaves the selection behind) + if app.ui.tool != "guidedUpright" || app.ui.selected_guide.is_some_and(|(photo, i)| photo != id.0 || i >= d.geometry.guides.len()) { + app.ui.selected_guide = None; + } + if app.ui.tool == "guidedUpright" { + guided_overlay(app, ui, resp, map, frame, d, id); + return; + } + if app.ui.tool == "straighten" { + straighten_overlay(app, ui, resp, false); + return; + } + // hold ⌘ and drag: draw a straighten line without leaving the crop tool + let cmd_drag = resp.drag_started() && ui.input(|i| i.modifiers.command); + if cmd_drag || matches!(app.gesture, Some(Gesture::StraightenLine { .. })) { + straighten_overlay(app, ui, resp, true); + return; + } + if resp.hovered() && ui.input(|i| i.modifiers.command) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + let quad = frame_crop_quad(d, frame); + let pts: Vec = quad.iter().map(|q| map.screen(*q)).collect(); + let p = ui.painter(); + // darken outside the crop (the box's corners in screen order: a flipped photo shows its + // top-left corner elsewhere) + let quad = flipped_corners(&pts, frame.flip_h, frame.flip_v); + if pts.len() == 4 { + p.add(outside_crop_mesh(map.rect, &quad, Color32::from_black_alpha(150))); + } + p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, Color32::WHITE))); + // overlay guides + let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t; + let guide = Stroke::new(0.8, Color32::from_white_alpha(150)); + // (u, v) across / down the (rotated) crop frame + let at = |(u, v): (f32, f32)| pts[0] + (pts[1] - pts[0]) * u + (pts[3] - pts[0]) * v; + let (cw, ch) = (pts[0].distance(pts[1]), pts[0].distance(pts[3])); + for line in crate::panels::crop_overlay::paths(app.ui.crop_overlay, app.ui.crop_overlay_orient, cw, ch) { + p.add(egui::Shape::line(line.into_iter().map(at).collect(), guide)); + } + // handles: 0..3 corners, 4..7 edges (top, right, bottom, left) + let handles: Vec = (0..8).map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) }).collect(); + for (i, h) in handles.iter().enumerate() { + register(ui, format!("cropHandle:{i}"), Rect::from_center_size(*h, vec2(14.0, 14.0))); + let s = if i < 4 { 12.0 } else { 9.0 }; + p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE); + p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE); + } + // the keyboard: each handle (and the box itself) a stand-in Tab reaches and the arrows move + if crop_keys(app, ui, map, &handles, &pts, d, frame) { + return; + } + let inside = |q: Pos2| { + let n = map.norm(q); + let s = to_straight(n, d.crop.geometry.angle, frame); + d.crop.geometry.rect.contains(Point::new(s.x.clamp(-1.0, 2.0), s.y)) + }; + // a crop drag cut short (the tool closed before the release) leaves its gesture behind: with no + // button held it is over + if matches!(app.gesture, Some(Gesture::CropRotate { .. } | Gesture::CropHandle { .. })) + && !resp.drag_stopped() + && !ui.input(|i| i.pointer.any_down()) + { + app.gesture = None; + } + // while rotating, the pointer may leave the canvas: it still shows rotation and the angle + let rotating = matches!(app.gesture, Some(Gesture::CropRotate { .. })); + let pointer = resp.hover_pos().or_else(|| if rotating { ui.input(|i| i.pointer.latest_pos()) } else { None }); + // with ⌘ held a drag draws a level line (the straighten crosshair, set above), not a rotation + let straightening = !rotating && ui.input(|i| i.modifiers.command); + if let Some(hq) = pointer.filter(|_| !straightening) { + // a move or resize drag keeps its pointer wherever it goes, even past the box + let held = match app.gesture { + Some(Gesture::CropHandle { handle, .. }) => Some(usize::from(handle)), + _ => None, + }; + let near = held.or_else(|| handles.iter().position(|h| h.distance(hq) < 12.0)).filter(|_| !rotating); + let cursor = match near { + Some(0 | 2) => egui::CursorIcon::ResizeNwSe, + Some(1 | 3) => egui::CursorIcon::ResizeNeSw, + Some(4 | 6) => egui::CursorIcon::ResizeVertical, + Some(5 | 7) => egui::CursorIcon::ResizeHorizontal, + // 8: the whole box + Some(_) => egui::CursorIcon::Move, + None if inside(hq) && !rotating => egui::CursorIcon::Move, + // a drag here rotates: no system cursor shows that, so draw a curved double arrow + None => { + rotate_cursor(ui, hq, rotate_cursor_turn(&pts, hq, inside(hq))); + egui::CursorIcon::None + } + }; + ui.ctx().set_cursor_icon(cursor); + } + // the angle next to the pointer, drawn once this frame's rotation is applied (below) + let readout_at = pointer.filter(|_| rotating); + let mut shown_angle = d.crop.geometry.angle; + // double-click inside the crop box applies the crop (same as Return / Done) + if resp.double_clicked() + && let Some(q) = resp.interact_pointer_pos() + && inside(q) + && !handles.iter().any(|h| h.distance(q) < 12.0) + { + let _ = app.run("tool.done", json!({})); + return; + } + // egui reports a drag only once the pointer has moved a few px: the zone is where the button + // went down, where the cursor showed it, not where the pointer is by then + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) + { + if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)) + { + app.ui.status = e; + let _ = app.session.cancel_interaction(); + return; + } + let d = app.session.develop_of(id).unwrap_or_default(); + app.gesture = Some(match handles.iter().position(|h| h.distance(q) < 12.0) { + Some(h) => Gesture::CropHandle { handle: h as u8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, + None if inside(q) => Gesture::CropHandle { handle: 8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, + None => { + let c = map.screen(Point::new(0.5, 0.5)); + Gesture::CropRotate { start_angle: d.crop.geometry.angle, a0: (q - c).angle() } + } + }); + } + if resp.dragged() + && let Some(q) = resp.interact_pointer_pos() + { + match app.gesture.clone() { + Some(Gesture::CropHandle { handle, start, angle }) => { + // The crop model decides what the drag does (anchor, aspect lock, image bounds): see + // `lightcraft_geom::drag_crop`; the UI only reports where the pointer started and is. + let n = to_straight(map.norm(q), angle, frame); + let orig = ui.input(|i| i.pointer.press_origin()).map(|q0| to_straight(map.norm(q0), angle, frame)).unwrap_or(n); + // Shift keeps the current proportions for this drag only; the Lock toggle is untouched. + let keep_ratio = ui.input(|i| i.modifiers.shift); + let _ = app.run( + "crop.drag", + json!({"handle": handle, "from": [orig.x, orig.y], "to": [n.x, n.y], "start": [start.x0, start.y0, start.x1, start.y1], "keepRatio": keep_ratio}), + ); + } + Some(Gesture::CropRotate { start_angle, a0 }) => { + let c = map.screen(Point::new(0.5, 0.5)); + let a = (q - c).angle(); + let ang = (start_angle + (a - a0).to_degrees() as f64).clamp(-45.0, 45.0); + shown_angle = (ang * 100.0).round() / 100.0; + let _ = app.run("crop.straighten", json!({"angle": shown_angle})); + } + _ => {} + } + } + if resp.drag_stopped() { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } + if let Some(at) = readout_at { + // the canvas (the loupe's own rect), not the photo: rotating happens in the margin around it + angle_readout(ui, at, shown_angle, resp.rect); + } +} + +/// The crop handles' names for screen readers, in `crop.drag`'s numbering: 0..3 corners, 4..7 +/// edges (top, right, bottom, left), 8 the whole box. +const CROP_HANDLE_NAMES: [&str; 9] = [ + "Crop top-left corner", + "Crop top-right corner", + "Crop bottom-right corner", + "Crop bottom-left corner", + "Crop top edge", + "Crop right edge", + "Crop bottom edge", + "Crop left edge", + "Crop box", +]; + +/// Keyboard stand-ins for the crop handles: an arrow press drags the focused one by a step (one +/// undo step each), as a pointer drag from its place would. Return applies the crop. True when the +/// frame is over (the crop was applied). +fn crop_keys( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + map: &CanvasMap, + handles: &[Pos2], + pts: &[Pos2], + d: &DevelopSettings, + frame: &Frame, +) -> bool { + let r = d.crop.geometry.rect; + let centre = if pts.len() == 4 { pts.iter().fold(Pos2::ZERO, |acc, q| acc + q.to_vec2() / 4.0) } else { return false }; + let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 }; + let value = format!("left {}%, top {}%, right {}%, bottom {}%", pct(r.x0), pct(r.y0), pct(r.x1), pct(r.y1)); + for (i, name) in CROP_HANDLE_NAMES.iter().enumerate() { + let Some(at) = handles.get(i).copied().or_else(|| (i == 8).then_some(centre)) else { continue }; + let keys = crate::canvas_keys::handle(ui, &format!("crop:{i}"), crate::i18n::tr(name), value.clone(), at); + if keys.activated { + let _ = app.run("tool.done", json!({})); + return true; + } + let Some((dx, dy)) = keys.delta else { continue }; + // the drag runs in the straightened crop frame, as a pointer drag from the handle would; + // the arrows follow the screen + let angle = d.crop.geometry.angle; + let to_q = at + vec2(dx as f32 * map.rect.width(), dy as f32 * map.rect.height()); + let (from, to) = (to_straight(map.norm(at), angle, frame), to_straight(map.norm(to_q), angle, frame)); + let started = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)); + if let Err(e) = started { + app.ui.status = e; + let _ = app.session.cancel_interaction(); + return false; + } + let _ = app.run( + "crop.drag", + json!({"handle": i, "from": [from.x, from.y], "to": [to.x, to.y], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false}), + ); + let _ = app.run("develop.endInteraction", json!({})); + } + false +} + +/// The crop angle as the rotation readout shows it: like the Straighten value, in degrees. +pub(crate) fn crop_angle_label(angle: f64) -> String { + let shown = + lightcraft_develop::controls::find("crop.angle").map_or_else(|| format!("{angle:.2}"), |spec| crate::widgets::shown_value(spec, angle)); + // the slider's format turns a rounded −0.00 into "0": every zero reads "0.00", the angle's + // usual two decimals + let shown = if shown == "0" { "0.00".to_string() } else { shown }; + format!("{shown}°") +} + +/// Where the angle readout of `size` goes for a pointer `at`: below right of it, flipped to the +/// left / above where that would leave `bounds`, then kept inside them. +pub(crate) fn readout_rect(at: Pos2, size: egui::Vec2, bounds: Rect) -> Rect { + // without a real pointer or bounds (NaN, an empty rect) there is nothing to keep it in; and + // f32::clamp panics on NaN, so only finite numbers get there + if !at.is_finite() || !bounds.is_finite() || !bounds.is_positive() { + let at = if at.is_finite() { at } else { bounds.min.max(Pos2::ZERO) }; + let at = if at.is_finite() { at } else { Pos2::ZERO }; + return Rect::from_min_size(at + vec2(18.0, 14.0), size); + } + let x = if at.x + 18.0 + size.x > bounds.right() { at.x - 18.0 - size.x } else { at.x + 18.0 }; + let y = if at.y + 14.0 + size.y > bounds.bottom() { at.y - 14.0 - size.y } else { at.y + 14.0 }; + let x = x.clamp(bounds.left(), (bounds.right() - size.x).max(bounds.left())); + let y = y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top())); + Rect::from_min_size(pos2(x, y), size) +} + +/// On top of everything (the tooltip layer), so neither the photo nor a panel covers it. +fn top_painter(ui: &egui::Ui, name: &'static str) -> egui::Painter { + ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique(name))) +} + +/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo, +/// turned `turn` radians clockwise ([`rotate_cursor_turn`]). +fn rotate_cursor(ui: &egui::Ui, at: Pos2, turn: f32) { + let p = top_painter(ui, "crop-rotate-cursor"); + let r = Rect::from_center_size(at, vec2(22.0, 22.0)); + p.circle_filled(at, 12.0, Color32::from_black_alpha(150)); + crate::icons::paint_turned(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE, turn); + register(ui, "cropRotateCursor", r); +} + +/// How far to turn the rotation pointer (radians, clockwise) so its arc, drawn bulging up, bulges +/// away from the crop box `pts` (its four corners on screen, maybe rotated): up above the top edge, +/// right beside the right edge, down below it, diagonally off a corner. Measured from the nearest +/// point of the box's outline; `inside` (a rotate drag that has come back over the box) faces it +/// the other way, still away from that edge. +pub(super) fn rotate_cursor_turn(pts: &[Pos2], q: Pos2, inside: bool) -> f32 { + let nearest = (0..pts.len()) + .map(|i| { + let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); + let ab = b - a; + let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + a + ab * t + }) + .min_by(|a, b| a.distance_sq(q).total_cmp(&b.distance_sq(q))); + let Some(nearest) = nearest else { return 0.0 }; + let away = if inside { nearest - q } else { q - nearest }; + if away.length() < 0.5 { + return 0.0; + } + away.y.atan2(away.x) + std::f32::consts::FRAC_PI_2 +} + +/// While rotating: the angle, next to the pointer, kept inside `bounds` (the canvas): on the +/// pointer's other side when it would run past an edge. +fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) { + let t = Tokens::get(ui.ctx()); + let p = top_painter(ui, "crop-angle-readout"); + let galley = p.layout_no_wrap(crop_angle_label(angle), t.font(12.5), Color32::WHITE); + let rect = readout_rect(at, galley.size() + vec2(12.0, 6.0), bounds); + p.rect_filled(rect, 4.0, Color32::from_black_alpha(170)); + p.galley(rect.min + vec2(6.0, 3.0), galley, Color32::WHITE); + register(ui, "cropAngleReadout", rect); +} + +/// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are +/// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps. +fn guided_overlay( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + frame: &Frame, + d: &DevelopSettings, + id: PhotoId, +) { + let p = ui.painter_at(map.rect.expand(8.0)); + let col = Color32::from_rgb(255, 196, 40); + // the selected guide: brighter, so the Delete key has a visible target + let selected = app.ui.selected_guide.filter(|(photo, _)| *photo == id.0).map(|(_, i)| i); + let draw = |a: Pos2, b: Pos2, sel: bool| { + let (edge, core, at) = if sel { (3.5, 2.5, 5.0) } else { (3.0, 1.5, 4.0) }; + p.line_segment([a, b], Stroke::new(edge, Color32::from_black_alpha(120))); + p.line_segment([a, b], Stroke::new(core, if sel { Color32::WHITE } else { col })); + for q in [a, b] { + p.circle_filled(q, at, if sel { Color32::WHITE } else { col }); + p.circle_stroke(q, at, Stroke::new(1.0, Color32::BLACK)); + } + }; + let on_screen = |guide: &(Point, Point)| { + let (sa, sb) = (map.screen(frame.corrected_to_transformed(guide.0)), map.screen(frame.corrected_to_transformed(guide.1))); + (sa, sb) + }; + for (i, guide) in d.geometry.guides.iter().enumerate() { + let (sa, sb) = on_screen(guide); + register(ui, format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb)); + draw(sa, sb, selected == Some(i)); + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + // A press starts what it means, right away (the pointer's press point, not where a drag + // threshold would have been crossed): on an endpoint it drags that endpoint (the guide is + // selected when it lands), on a line it selects the guide (a press on the selected one + // deselects), anywhere else it starts a new guide from the press point. The primary button + // only, never on the navigator — it pans the photo, it draws no guide — and only where the + // loupe itself takes the press: `contains_pointer` is layer-aware over the current pointer + // (the raw pointer is not), and `layer_id_at` asks the same question at the press point — + // a menu can receive the press and the pointer can leave it within the same frame, so the + // press itself must belong to the loupe's layer. Anything floating above the photo — a + // context menu, a dropdown, a dialog — owns its own press; the guide under it stays put. + // A slow frame can also swallow the whole press-move-release into one batch, and egui + // clears `press_origin` on the release: then the press position is read back from the + // frame's own events, so the gesture still starts where the pointer went down. + let nav = app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r); + if ui.input(|i| i.pointer.primary_pressed()) + && let Some(q) = ui + .input(|i| { + i.pointer.press_origin().or_else(|| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } => Some(*pos), + _ => None, + }) + }) + }) + .or(resp.interact_pointer_pos()) + && resp.contains_pointer() + && ui.ctx().layer_id_at(q) == Some(resp.layer_id) + && resp.rect.contains(q) + && !nav.is_some_and(|n| n.expand(5.0).contains(q)) + { + let endpoint = d.geometry.guides.iter().enumerate().find_map(|(i, guide)| { + let (a, b) = on_screen(guide); + if a.distance(q) <= 7.0 { + Some((i, 0)) + } else if b.distance(q) <= 7.0 { + Some((i, 1)) + } else { + None + } + }); + let line = d.geometry.guides.iter().position(|guide| { + let (a, b) = on_screen(guide); + point_segment_dist(q, a, b) <= 6.0 + }); + match (endpoint, line) { + (Some((i, end)), _) => app.gesture = Some(Gesture::GuideEndpoint { i, end, from: q }), + (None, Some(i)) => app.ui.selected_guide = if selected == Some(i) { None } else { Some((id.0, i)) }, + // drawing a new guide leaves nothing selected + (None, None) => { + app.ui.selected_guide = None; + app.gesture = Some(Gesture::Guide { a: map.norm(q) }); + } + } + } + let q = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())); + if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, q) { + draw(map.screen(*a), q, false); + } + // the endpoint being dragged: the guide follows the pointer, bright + if let (Some(Gesture::GuideEndpoint { i, end, .. }), Some(q)) = (&app.gesture, q) + && let Some(guide) = d.geometry.guides.get(*i) + { + let fixed = if *end == 0 { guide.1 } else { guide.0 }; + draw(map.screen(frame.corrected_to_transformed(fixed)), q, true); + } + if ui.input(|i| i.pointer.any_released()) { + match app.gesture.take() { + // the endpoint lands: the guide stays (one undo step) and is selected. A press + // without a real drag only selects — no no-op undo entry for a click. + Some(Gesture::GuideEndpoint { i, end, from }) => { + app.ui.selected_guide = Some((id.0, i)); + if let (Some(q), Some(guide)) = (q, d.geometry.guides.get(i)) { + let fixed_t = if end == 0 { guide.1 } else { guide.0 }; + let new_t = frame.transformed_to_corrected(map.norm(q)); + let (a, b) = if end == 0 { (new_t, fixed_t) } else { (fixed_t, new_t) }; + let (na, nb) = (frame.corrected_to_transformed(a), frame.corrected_to_transformed(b)); + // a press that only selected (it landed within the endpoint's grab radius + // and never moved) writes no guide: compare against where it went down + let moved = from.distance(q) > 2.0; + if moved && na.dist(nb) > 0.02 { + let all: Vec<[f64; 4]> = d + .geometry + .guides + .iter() + .enumerate() + .map(|(j, g)| { + let (x, y) = if j == i { (a, b) } else { *g }; + [x.x, x.y, y.x, y.y] + }) + .collect(); + let _ = app.run("geometry.guides", json!({"guides": all})); + } + } + } + Some(Gesture::Guide { a }) => { + if let Some(q) = q + && map.norm(q).dist(a) > 0.02 + { + let b = map.norm(q); + let (ca, cb) = (frame.transformed_to_corrected(a), frame.transformed_to_corrected(b)); + let _ = app.run("geometry.guides", json!({"guides": [[ca.x, ca.y, cb.x, cb.y]], "add": true})); + } + } + other => app.gesture = other, + } + } +} + +/// Normalized oriented coords → the straightened (rotated) frame the crop rect lives in. +fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point { + let (w, h) = (frame.ow, frame.oh); + let px = Point::new(n.x * w, n.y * h); + let r = Affine::rotate_about(angle.to_radians(), Point::new(w / 2.0, h / 2.0)).apply(px); + Point::new(r.x / w, r.y / h) +} + +/// How far `q` is from the segment `a`–`b` (screen pixels): the click that selects a guide. +fn point_segment_dist(q: Pos2, a: Pos2, b: Pos2) -> f32 { + let ab = b - a; + let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + (a + ab * t).distance(q) +} + +/// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo) +/// in the order they appear on screen when the photo is shown flipped. +pub(super) fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] { + let at = |i: usize| pts.get(i).copied().unwrap_or(Pos2::ZERO); + let order = match (flip_h, flip_v) { + (false, false) => [0, 1, 2, 3], + (true, false) => [1, 0, 3, 2], + (false, true) => [3, 2, 1, 0], + (true, true) => [2, 3, 0, 1], + }; + order.map(at) +} + +/// The dimming outside the crop box: the photo's rectangle minus the crop quad, as one mesh of +/// non-overlapping convex pieces. The quad is a rotated rectangle, so its opposite sides are +/// parallel and the plane outside it splits into four disjoint half-strips: outside edge `i` +/// and inside edge `i + 1`. Each is clipped to the photo and fanned into triangles. +/// +/// Issue #731: joining photo corner `i` to crop corner `i` with a quad per side only works while +/// the two rectangles are aligned. Rotated, those quads cross themselves and each other, which +/// showed as dimmed wedges inside the crop and doubly dark ones outside, growing with the angle. +/// A degenerate quad (a point, a line, a non-finite corner) dims nothing. +pub(super) fn outside_crop_mesh(img: Rect, quad: &[Pos2; 4], color: Color32) -> egui::epaint::Mesh { + let mut mesh = egui::epaint::Mesh::default(); + if !quad.iter().all(|q| q.x.is_finite() && q.y.is_finite()) || !img.is_finite() { + return mesh; + } + let centre = pos2((quad[0].x + quad[1].x + quad[2].x + quad[3].x) / 4.0, (quad[0].y + quad[1].y + quad[2].y + quad[3].y) / 4.0); + // signed distance from edge i's line, positive towards the crop's centre + let side = |i: usize, q: Pos2| -> Option { + let (a, b) = (quad[i % 4], quad[(i + 1) % 4]); + let n = vec2(a.y - b.y, b.x - a.x); + let orient = n.dot(centre - a); + // an edge the centre lies on: the box is flat + (orient.abs() > 1e-6 * n.length().max(1.0)).then(|| n.dot(q - a) * orient.signum()) + }; + // Sutherland–Hodgman: the part of a convex polygon where `keep` is at or above zero + let clip = |poly: &[Pos2], keep: &dyn Fn(Pos2) -> Option| -> Option> { + let mut out = Vec::with_capacity(poly.len() + 2); + for (k, &a) in poly.iter().enumerate() { + let b = *poly.get((k + 1) % poly.len())?; + let (da, db) = (keep(a)?, keep(b)?); + if da >= 0.0 { + out.push(a); + } + if (da < 0.0) != (db < 0.0) && (da - db).abs() > f32::EPSILON { + let t = da / (da - db); + out.push(a + (b - a) * t); + } + } + Some(out) + }; + let photo = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()]; + for i in 0..4 { + let outside = |q: Pos2| side(i, q).map(|d| -d); + let inside_next = |q: Pos2| side(i + 1, q); + let Some(strip) = clip(&photo, &outside).and_then(|poly| clip(&poly, &inside_next)) else { return egui::epaint::Mesh::default() }; + if strip.len() < 3 { + continue; + } + let base = mesh.vertices.len() as u32; + mesh.vertices.extend(strip.iter().map(|&pos| egui::epaint::Vertex { pos, uv: Pos2::ZERO, color })); + for k in 1..(strip.len() as u32).saturating_sub(1) { + mesh.indices.extend([base, base + k, base + k + 1]); + } + } + mesh +} + +fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] { + let f = Frame { crop: d.crop.geometry, ..frame.clone() }; + f.crop_quad_norm() +} + +/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto. +/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle. +/// `held`: drawn with ⌘ held in the crop tool, which stays active afterwards. +fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, held: bool) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if !held && resp.double_clicked() { + app.auto_straighten(ui.ctx()); + app.ui.tool.clear(); + app.gesture = None; + return; + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + app.gesture = Some(Gesture::StraightenLine { a: q }); + } + let Some(Gesture::StraightenLine { a }) = app.gesture.clone() else { return }; + let Some(b) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + let p = ui.painter(); + p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(140))); + p.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); + if resp.drag_stopped() { + app.gesture = None; + let v = b - a; + if v.length() > 8.0 { + let mut deg = v.y.atan2(v.x).to_degrees() as f64; + // fold to the nearest axis: a line closer to vertical straightens plumb lines + while deg > 45.0 { + deg -= 90.0; + } + while deg < -45.0 { + deg += 90.0; + } + let _ = app.run("crop.straighten", json!({"angle": (-deg * 100.0).round() / 100.0})); + } + if !held { + app.ui.tool.clear(); + } + } +} diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs new file mode 100644 index 000000000..c39fc4442 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -0,0 +1,227 @@ +use super::*; + +/// Size of the Navigator mini map (points). +const NAV_W: f32 = 180.0; + +/// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the +/// visible region outlined; click or drag on it to pan. Returns the area it covers. +pub(super) fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) -> Option { + let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; + if !app.ui.navigator || !zoomed || app.ui.fullscreen { + return None; + } + let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)); + let aspect = img.width() / img.height().max(1.0); + let (w, h) = if aspect >= 1.0 { (NAV_W, NAV_W / aspect) } else { (NAV_W * aspect, NAV_W) }; + let frame = Rect::from_min_size(pos2(canvas.right() - w - 16.0, canvas.bottom() - h - 16.0), vec2(w, h)); + let p = ui.painter_at(canvas); + p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190)); + match tex { + Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE), + None => p.rect_filled(frame, 0.0, Tokens::get(ui.ctx()).skeleton), + }; + // the visible part of the photo, in normalized image coordinates + let vis = canvas.intersect(img); + let n = |q: Pos2| pos2((q.x - img.left()) / img.width(), (q.y - img.top()) / img.height()); + let (a, b) = (n(vis.min), n(vis.max)); + let to_nav = |q: Pos2| pos2(frame.left() + q.x * frame.width(), frame.top() + q.y * frame.height()); + let view = Rect::from_min_max(to_nav(a), to_nav(b)); + p.rect_stroke(view, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + register(ui, "canvas:navigator", frame); + let resp = ui.interact(frame.expand(5.0), egui::Id::unique("navigator"), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Navigator")); + if (resp.clicked() || resp.dragged()) + && let Some(q) = resp.interact_pointer_pos() + { + // centre the view on the point under the pointer + let u = ((q.x - frame.left()) / frame.width()).clamp(0.0, 1.0); + let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0); + app.ui.pan = (u, v); + } + Some(frame.expand(5.0)) +} + +// ------------------------------------------------------------------------ filmstrip + +/// A filmstrip cell's file-name label: names longer than 14 characters are cut to 13 and an +/// ellipsis. Counts characters, not bytes, so a CJK name is never cut inside a character (#266). +pub(super) fn film_label(name: &str) -> String { + if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::()) } else { name.to_string() } +} + +/// Draw the horizontal filmstrip in `r`: selectable thumbnail cells with names and format when +/// film names are on, badges when film badges are on, and stack counts, following the active +/// photo and scrolling sideways with the wheel. Clicks select: Command toggles and Shift extends +/// the range; in Compare they adjust the pair's selection, and a plain click replaces the active +/// side. +pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(r, 0.0, t.canvas); + ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20)); + let ids = app.session.visible_cloned(); + let active = app.session.selection.active; + let cell_w = 120.0; + let ppp = ui.ctx().pixels_per_point(); + if ids.is_empty() { + let why = if app.session.filter != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" }; + ui.painter().text(r.center(), Align2::CENTER_CENTER, why, t.font(12.5), t.text_dim); + return; + } + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0)))); + // a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work) + child.style_mut().always_scroll_the_only_direction = true; + // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` + let follow = crate::panels::grid::follow_active(child.ctx(), egui::Id::unique("film-follow-active"), active, app.session.source); + // a wheel notch scrolls a third of its width; see `grid::wheel_step` + register(ui, "filmstrip", child.max_rect()); + let wheel = crate::panels::grid::wheel_step(&child, egui::Id::unique("film-wheel"), child.max_rect(), child.max_rect().width(), true); + egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| { + if wheel != egui::Vec2::ZERO { + ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none()); + } + let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover()); + app.film_scroll = Some(vp.left()); + for (i, id) in ids.iter().enumerate() { + let cr = Rect::from_min_size(pos2(area.left() + i as f32 * cell_w, area.top()), vec2(cell_w, area.height())); + let local = Rect::from_min_size(pos2(i as f32 * cell_w, 0.0), cr.size()); + // centred when it is (partly) off screen; a click on a visible thumbnail leaves the strip alone + if follow && Some(*id) == active && !(local.left() >= vp.left() && local.right() <= vp.right()) { + ui.scroll_to_rect(cr, Some(egui::Align::Center)); + } + if !local.intersects(vp.expand2(vec2(cell_w * 4.0, 0.0))) { + continue; + } + let resp = ui.interact(cr, egui::Id::unique(("film", id.0)), Sense::click()); + let failed = app.renderer.thumb(*id).is_none() && app.renderer.failure(Slot::Thumb(*id)).is_some(); + crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed)); + register(ui, format!("film:{}", id.0), cr); + let state = app.session.selection.state_of(*id); + let sel = state == lightcraft_engine::SelectionState::Active; + let p = ui.painter(); + let label = app.session.catalog.photo(*id).and_then(|ph| ph.label); + let selected = state != lightcraft_engine::SelectionState::NotSelected; + let base = if selected { + t.cell_selected + } else if resp.hovered() { + t.cell_selected.gamma_multiply(0.6) + } else { + t.canvas + }; + p.rect_filled(cr, 0.0, crate::theme::label_background(base, label, selected)); + let names = app.ui.settings.film_names; + if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) { + let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name); + p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, film_label(name), t.font(10.0), t.text_dim); + p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim); + } + let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0)); + crate::panels::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8); + let thumb = app.renderer.thumb(*id); + if thumb.is_none() { + p.rect_filled(img_area, 0.0, t.skeleton); + if failed { + p.text(img_area.center(), Align2::CENTER_CENTER, "!", t.semibold(14.0), t.text); + } + } + if let Some(tex) = thumb { + let [tw, th] = tex.size; + let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32); + let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s)); + p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + if sel { + p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); + } else if state == lightcraft_engine::SelectionState::Selected { + p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::from_gray(170)), StrokeKind::Outside); + } + if app.ui.settings.film_badges + && let Some(ph) = app.session.catalog.photo(*id) + { + film_badges(p, &t, fr, ph); + } + if let Some(st) = app.session.catalog.stack_of(*id) { + let collapsed = st.collapsed; + let text = + if collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; + let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE); + let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0)); + // like the grid's badge: a click expands / collapses the stack + let badge = ui.interact(br, egui::Id::unique(("film-stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed { + "Stack — click to expand" + } else { + "Stack — click to collapse" + }); + crate::widgets::announce(&badge, crate::i18n::tr(if collapsed { "Expand Stack" } else { "Collapse Stack" })); + register(ui, format!("filmstack:{}", id.0), br); + let p = ui.painter(); + let fill = if badge.hovered() { Color32::from_black_alpha(230) } else { Color32::from_black_alpha(170) }; + p.rect_filled(br, 7.5, fill); + crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE); + p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + if badge.clicked() { + crate::panels::grid::toggle_stack(app, ui.ctx(), *id); + } + } + } + if resp.clicked() { + let m = ui.input(|i| i.modifiers); + let compare = app.ui.view == crate::state::ViewMode::Compare; + let r = if compare && !m.command && !m.shift { + // Compare: the clicked photo replaces the active side (or swaps with the other one) + app.run("compare.set", json!({"id": id.0})) + } else if compare { + // ⌘/Ctrl- and Shift-clicks change the selection; the pair on screen stays (#187) + crate::panels::compare::select_with_modifier(app, *id, !m.command) + } else { + let mode = if m.command { + "toggle" + } else if m.shift { + "range" + } else { + "replace" + }; + app.run("library.select", json!({"ids": [id.0], "mode": mode})) + }; + if compare && let Err(e) = r { + app.toast(ui.ctx(), e); + } + } + // the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted) + crate::widgets::context_menu(&resp, |ui| crate::panels::grid::context_menu(app, ui, *id)); + } + }); +} + +/// What a screen reader says for a filmstrip thumbnail: the file, and why there is no picture +/// when the thumbnail failed (the painted `!` has no name of its own). The name is empty when the +/// catalog no longer has the photo (or its file name is blank); the reason then stands alone. +pub(crate) fn film_spoken(file_name: &str, failed: bool) -> String { + match (file_name.is_empty(), failed) { + (_, false) => file_name.to_string(), + (true, true) => crate::i18n::tr("This photo can't be opened").to_string(), + (false, true) => format!("{file_name}, {}", crate::i18n::tr("This photo can't be opened")), + } +} + +/// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface). +fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) { + use crate::icons::{Icon, paint}; + use lightcraft_catalog::Flag; + let edited = ph.is_edited(); + if ph.rating == 0 && ph.flag == Flag::None && !edited { + return; + } + let bar = Rect::from_min_max(pos2(fr.left(), fr.bottom() - 15.0), fr.right_bottom()); + p.rect_filled(bar, 0.0, Color32::from_black_alpha(130)); + let y = bar.center().y; + let mut x = bar.left() + 3.0; + x += crate::rating::paint_rating(p, pos2(x, y), ph.rating, crate::rating::RatingSize::Small, t.star) + 2.0; + match ph.flag { + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), + Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), + Flag::None => {} + } + if edited { + paint(p, Rect::from_min_size(pos2(bar.right() - 13.0, y - 5.0), vec2(10.0, 10.0)), Icon::Sliders, t.text_label); + } +} diff --git a/crates/ui-egui/src/panels/detail/geometry.rs b/crates/ui-egui/src/panels/detail/geometry.rs new file mode 100644 index 000000000..6424dabd8 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/geometry.rs @@ -0,0 +1,385 @@ +#[cfg(test)] +use super::info::label_badge_rect; +use super::*; + +/// Screen ↔ normalized-image mapping for the displayed frame. +#[derive(Clone, Copy)] +pub struct CanvasMap { + pub(super) rect: Rect, + to_norm: Affine, + from_norm: Affine, + k: f32, +} + +impl CanvasMap { + pub(super) fn new(frame: &Frame, rect: Rect) -> CanvasMap { + let k = 8.0f32; + let (w, h) = ((rect.width() * k).round().max(1.0) as usize, (rect.height() * k).round().max(1.0) as usize); + let to_norm = frame.out_to_norm(w, h); + CanvasMap { rect, to_norm, from_norm: to_norm.inverse().unwrap_or(Affine::IDENTITY), k } + } + pub fn norm(&self, p: Pos2) -> Point { + self.to_norm.apply(Point::new(((p.x - self.rect.left()) * self.k) as f64, ((p.y - self.rect.top()) * self.k) as f64)) + } + pub fn screen(&self, n: Point) -> Pos2 { + let q = self.from_norm.apply(n); + pos2(self.rect.left() + q.x as f32 / self.k, self.rect.top() + q.y as f32 / self.k) + } +} + +pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { + let zoom = match zoom { + Zoom::Percent(p) if !p.is_finite() || p <= 0.0 => Zoom::Fit, + _ => zoom, + }; + let (aw, ah) = (area.width(), area.height()); + let (w, h) = match zoom { + Zoom::Fit => { + if aw / ah > aspect { + (ah * aspect, ah) + } else { + (aw, aw / aspect) + } + } + Zoom::Fill => { + if aw / ah > aspect { + (aw, aw / aspect) + } else { + (ah * aspect, ah) + } + } + Zoom::Percent(p) => { + // the photo's own pixels at p% (its width as shown: cropped and rotated) + let w = img_px[0] as f32 * p / 100.0 / ppp; + (w, w / aspect) + } + }; + let pan = bounded_pan(area, vec2(w, h), pan); + let c = pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h); + let rect = Rect::from_center_size(c, vec2(w, h)); + // At 100 % the texture maps texel for texel to screen pixels, so its corner must sit on a whole + // physical pixel: half a pixel off and the GPU's bilinear filter blurs the view (an odd-width + // canvas on a 2x display does this). Other zooms resample anyway, and a pinch must not jump. + let one_to_one = ppp.is_finite() && ppp > 0.0 && (w * ppp - img_px[0] as f32).abs() < 0.5; + if matches!(zoom, Zoom::Percent(_)) && one_to_one { + let snap = |v: f32| (v * ppp).round() / ppp; + return Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), rect.size()); + } + rect +} + +/// Fit a decoded image inside the frame calculated from catalog metadata. Unsupported RAWs can +/// only show their embedded camera JPEG, whose crop and aspect ratio can differ from that metadata. +pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect { + let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); + let scale = (area.width() / w).min(area.height() / h); + Rect::from_center_size(area.center(), vec2(w * scale, h * scale)) +} + +/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is +/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred +/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its +/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to +/// the sampler. +pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let (w, h) = (size[0] as f32, size[1] as f32); + if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 { + return rect; + } + let snap = |v: f32| (v * ppp).round() / ppp; + Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp)) +} + +/// Keep the viewport inside the photo; axes smaller than the viewport stay centred. +fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) { + let bound = |visible: f32, image: f32, centre: f32| { + let centre = if centre.is_finite() { centre } else { 0.5 }; + if image > visible.max(0.0) { + let half = visible.max(0.0) / image / 2.0; + centre.clamp(half, 1.0 - half) + } else { + 0.5 + } + }; + (bound(area.width(), size.x, pan.0), bound(area.height(), size.y, pan.1)) +} + +pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: egui::Vec2) { + let centre = area.center() - img.min - delta; + let pan = bounded_pan(area, img.size(), (centre.x / img.width().max(1.0), centre.y / img.height().max(1.0))); + let _ = app.run("view.navigate", json!({"pan": pan})); +} + +/// A drag with the middle button (the mouse wheel pressed) pans the zoomed photo whatever tool is +/// active, so the healing brush, mask brushes and crop never paint or move while you look around; +/// the hand cursor shows while it does. +pub(super) fn middle_pan(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, area: Rect, img: Rect) -> bool { + if !resp.dragged_by(egui::PointerButton::Middle) { + return false; + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if img.width() > area.width() + 1.0 || img.height() > area.height() + 1.0 { + pan_image(app, area, img, resp.drag_delta()); + } + true +} + +/// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad. +fn wheel_notches(events: &[egui::Event]) -> f32 { + events + .iter() + .filter_map(|e| match e { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page, delta, modifiers, .. } + if *modifiers == egui::Modifiers::NONE && delta.y.is_finite() => + { + Some(delta.y) + } + _ => None, + }) + .sum() +} + +/// Native pinch (also modifier-wheel zoom) and two-finger scroll, scoped to this image view. +pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, area: Rect, img: Rect, native: [usize; 2]) -> bool { + if !resp.hovered() { + return false; + } + let (mut factor, mut delta, anchor) = + ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos()))); + // A notched mouse wheel (line/page units, no modifier) zooms around the pointer, through egui's smoothed wheel + // delta like Ctrl+wheel (so it glides instead of jumping); trackpads report points and still pan. + let wheel_id = egui::Id::unique("loupe-wheel-zoom"); + if ui.input(|i| wheel_notches(&i.events)) != 0.0 { + ui.data_mut(|d| d.insert_temp(wheel_id, true)); + } + if ui.data(|d| d.get_temp::(wheel_id)).unwrap_or(false) { + let smooth = ui.input(|i| i.smooth_scroll_delta.y); + let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed); + if smooth == 0.0 { + ui.data_mut(|d| d.remove_temp::(wheel_id)); + } else { + factor *= (speed * smooth).exp(); + delta = egui::Vec2::ZERO; + } + } + if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) { + return false; + } + let Some(anchor) = anchor.filter(|p| area.contains(*p)) else { return false }; + let ppp = ui.ctx().pixels_per_point(); + let aspect = img.width() / img.height().max(1.0); + let fit = fit_rect(area, aspect, Zoom::Fit, native, ppp, (0.5, 0.5)); + let fit_pct = fit.width() * ppp * 100.0 / native[0].max(1) as f32; + let mut zoom = app.ui.zoom; + let size = if factor != 1.0 { + // Start at the displayed scale (including Fit/Fill and an interrupted click animation). + let pct = (img.width() * ppp * 100.0 / native[0].max(1) as f32 * factor).clamp(fit_pct.min(800.0), 800.0); + zoom = if pct <= fit_pct { Zoom::Fit } else { Zoom::Percent(pct) }; + fit_rect(area, aspect, zoom, native, ppp, (0.5, 0.5)).size() + } else { + img.size() + }; + // The image point under the pointer remains there as its size changes, then follows the pan. + let point = (anchor - img.min) / img.size(); + let offset = area.center() - anchor - delta; + let pan = bounded_pan(area, size, (point.x + offset.x / size.x.max(1.0), point.y + offset.y / size.y.max(1.0))); + let _ = app.run("view.navigate", json!({"zoom": zoom, "pan": pan})); + ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + ui.ctx().request_repaint(); + true +} + +/// The largest texture the GPU behind `ctx` takes (egui reports it; 2048 until it does, the +/// smallest limit WebGL devices have). +/// +/// The value egui keeps, not the frame's raw input: a native host (egui-winit) reports the limit +/// in the first frame's input only, so the raw value is `None` from the second frame on +/// (issue #652: every window render was cut to 2048 px, with blurry strips beside it). +pub(crate) fn texture_side(ctx: &egui::Context) -> usize { + ctx.input(|i| i.max_texture_side) +} + +/// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what +/// 100 % zoom and the render size limit are measured against), at least 1 × 1. +pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { + let (w, h) = frame.native_size(); + let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 1e9) as usize } else { 1 }; + [px(w), px(h)] +} + +/// The camera's focus point of `photo` in the displayed frame (its crop, rotation and flips), as the +/// normalized box over the drawn image: `None` when the photo has none or it is cropped away. +pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option { + let f = photo.meta.focus.as_ref()?; + let d = &photo.develop; + let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref()); + let [w, h] = output_px(&frame); + let to_out = frame.out_to_norm(w, h).inverse()?; + // stored on the upright photo: the user's Rotate / Flip on top, then the crop + let r = d.orientation.map_norm_rect(f.rect); + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| { + let q = to_out.apply(c); + (q.x / w as f64, q.y / h as f64) + }); + let xs = corners.map(|c| c.0); + let ys = corners.map(|c| c.1); + let out = lightcraft_geom::Rect { + x0: xs.iter().copied().fold(f64::INFINITY, f64::min), + y0: ys.iter().copied().fold(f64::INFINITY, f64::min), + x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + if ![out.x0, out.y0, out.x1, out.y1].iter().all(|v| v.is_finite()) { + return None; + } + // its centre must be in the frame; the box may reach past the edge (it is clipped when drawn) + let (cx, cy) = ((out.x0 + out.x1) / 2.0, (out.y0 + out.y1) / 2.0); + ((0.0..=1.0).contains(&cx) && (0.0..=1.0).contains(&cy)).then_some(out) +} + +/// Where Toggle Zoom centres the view on the photo's focus point (`app.ui.pan`), if it has one in frame. +pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> { + let r = focus_box(app.session.catalog.photo(id)?)?; + Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) +} + +/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. +pub(super) fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { + let t = if *anim { 0.22 } else { 0.0 }; + let id = egui::Id::unique("loupe_anim"); + let v = |k: &str, x: f32| ctx.animate_value_with_time(id.with(k), x, t); + let (c, s) = (target.center(), target.size()); + let r = Rect::from_center_size(pos2(v("cx", c.x), v("cy", c.y)), vec2(v("w", s.x), v("h", s.y))); + if *anim && (r.center() - c).abs().max_elem() < 0.5 && (r.size() - s).abs().max_elem() < 0.5 { + *anim = false; + } + r +} + +#[cfg(test)] +mod preview_geometry_tests { + use super::{fit_rect, fit_texture_rect, label_badge_rect}; + use crate::state::Zoom; + use egui::{Rect, pos2, vec2}; + + /// The "Before" label sits on a dark pill that wraps the text with padding on every side. + #[test] + fn before_label_badge_wraps_the_text() { + let text = vec2(40.0, 14.0); + let pane = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0)); + let bg = label_badge_rect(photo, pane, text, 0.0); + assert_eq!(bg.min, pos2(28.0, 38.0)); + assert_eq!(bg.size(), vec2(56.0, 22.0)); + assert!(photo.contains_rect(bg)); + } + + /// Zoomed in, the photo's corner is off the canvas: the pill moves to the canvas' corner + /// instead of leaving with it (issue #2267), on whichever axes the photo reaches past. + #[test] + fn before_label_badge_stays_in_the_pane_when_the_photo_reaches_past_it() { + let text = vec2(40.0, 14.0); + let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); + let both = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, 0.0); + assert_eq!(both.min, pos2(108.0, 58.0)); + assert!(pane.contains_rect(both)); + let left_only = label_badge_rect(Rect::from_min_size(pos2(-900.0, 120.0), vec2(3000.0, 400.0)), pane, text, 0.0); + assert_eq!(left_only.min, pos2(108.0, 128.0)); + assert!(pane.contains_rect(left_only)); + } + + /// With a filter active, the pill in the canvas' corner has that corner: the label sits below + /// it, whether the photo is past the pane or just under the pill. A photo lower down is left + /// alone. A wide label near the pane's right edge stays inside the pane. + #[test] + fn before_label_badge_keeps_clear_of_the_filter_pill_and_the_right_edge() { + let text = vec2(40.0, 14.0); + let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); + let inset = super::FILTER_PILL_INSET + super::FILTER_PILL_HEIGHT; + let past = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, inset); + assert_eq!(past.min, pos2(108.0, 50.0 + inset + 8.0)); + let under = label_badge_rect(Rect::from_min_size(pos2(200.0, 60.0), vec2(400.0, 300.0)), pane, text, inset); + assert_eq!(under.min.y, 50.0 + inset + 8.0, "a photo whose corner is under the pill moves down too"); + let lower = label_badge_rect(Rect::from_min_size(pos2(200.0, 200.0), vec2(400.0, 300.0)), pane, text, inset); + assert_eq!(lower.min, pos2(208.0, 208.0)); + let wide = label_badge_rect(Rect::from_min_size(pos2(860.0, 200.0), vec2(400.0, 300.0)), pane, text, 0.0); + assert!(pane.contains_rect(wide), "{wide:?}"); + assert_eq!(wide.max.x, pane.right() - 8.0, "8 points from the pane's right edge, like the left inset"); + } + + #[test] + fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() { + let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); + let image = fit_texture_rect(frame, [100, 200]); + + assert!((image.width() / image.height() - 0.5).abs() < 1e-6); + assert_eq!(image.center(), frame.center()); + assert!(frame.contains_rect(image)); + } + + /// A 100 % view must put the photo's corner on a whole physical pixel, or the GPU's bilinear + /// filter softens it (a 1279.5 pt canvas on a 2x display centred the photo half a pixel off). + #[test] + fn percent_zoom_lands_on_whole_physical_pixels() { + let area = Rect::from_min_size(pos2(0.0, 42.0), vec2(1279.5, 726.0)); + for ppp in [1.0, 1.25, 2.0, 3.0] { + for pan in [(0.5, 0.5), (0.3, 0.7), (0.4999, 0.5001)] { + let image = fit_rect(area, 5152.0 / 7728.0, Zoom::Percent(100.0), [5152, 7728], ppp, pan); + for v in [image.min.x, image.min.y] { + assert!((v * ppp - (v * ppp).round()).abs() < 1e-3, "{v} pt is not a whole pixel at {ppp}x, pan {pan:?}"); + } + assert!((image.width() * ppp - 5152.0).abs() < 1e-3, "size stays the photo's own pixels"); + } + } + } + + #[test] + fn portrait_preview_aspect_fits_detail_area_without_stretching() { + let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); + let image = fit_rect(area, 0.5, Zoom::Fit, [4000, 6000], 1.0, (0.5, 0.5)); + + assert!((image.width() / image.height() - 0.5).abs() < 1e-6); + assert_eq!(image.size(), vec2(100.0, 200.0)); + assert_eq!(image.center(), area.center()); + } +} + +#[cfg(test)] +mod wheel_zoom_tests { + use super::*; + + fn wheel(unit: egui::MouseWheelUnit, y: f32, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers } + } + + #[test] + fn only_notched_unmodified_wheels_count() { + let m = egui::Modifiers::NONE; + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, m), wheel(egui::MouseWheelUnit::Line, 2.0, m)]), 3.0); + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Point, 40.0, m)]), 0.0); + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL)]), 0.0); + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, f32::NAN, m)]), 0.0); + } +} + +#[cfg(test)] +mod snap_tests { + use super::*; + + /// A texture drawn at its own size lands on whole physical pixels at exactly that size (no + /// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given. + #[test] + fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() { + let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2)); + let s = snap_fit_rect(r, [1088, 720], 1.0); + assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0)); + // at 1.5 points per pixel the snap is to physical pixels and the size stays in points + let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5); + assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}"); + let other = snap_fit_rect(r, [2560, 1694], 1.0); + assert_eq!(other, r); + let bad = snap_fit_rect(r, [1088, 720], f32::NAN); + assert_eq!((bad.width(), bad.height()), (1088.0, 720.0)); + } +} diff --git a/crates/ui-egui/src/panels/detail/info.rs b/crates/ui-egui/src/panels/detail/info.rs new file mode 100644 index 000000000..520ad6213 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/info.rs @@ -0,0 +1,547 @@ +use super::geometry::focus_box; +use super::*; + +/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Focus Point): green corner +/// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. +/// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:`). +pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) { + let Some(r) = focus_box(photo) else { return }; + let at = |x: f64, y: f64| pos2(img.left() + x as f32 * img.width(), img.top() + y as f32 * img.height()); + let b = Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1)); + let b = Rect::from_center_size(b.center(), b.size().max(vec2(14.0, 14.0))); + let arm = (b.width().min(b.height()) * 0.3).max(5.0); + let green = Color32::from_rgb(90, 230, 110); + for (c, dx, dy) in [(b.left_top(), 1.0, 1.0), (b.right_top(), -1.0, 1.0), (b.left_bottom(), 1.0, -1.0), (b.right_bottom(), -1.0, -1.0)] { + let pts = vec![c + vec2(dx * arm, 0.0), c, c + vec2(0.0, dy * arm)]; + p.add(egui::Shape::line(pts.clone(), Stroke::new(4.0, Color32::from_black_alpha(170)))); + p.add(egui::Shape::line(pts, Stroke::new(2.0, green))); + } + let c = b.center(); + p.circle_filled(c, 2.5, Color32::from_black_alpha(170)); + p.circle_filled(c, 1.5, green); + register_geometry(p.ctx(), key, b); +} + +/// View Options' Show Briefly When Photo Changes: note when the loupe changes to photo `id`, and +/// whether the brief info overlay shows this frame (until [`BRIEF_INFO_SECS`] have passed; a +/// repaint is asked for then, so it hides without input). +/// +/// [`BRIEF_INFO_SECS`]: crate::state::BRIEF_INFO_SECS +pub(super) fn brief_info(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { + let now = ctx.input(|i| i.time); + let brief = &mut app.ui.brief_info; + if brief.photo != Some(id) { + brief.photo = Some(id); + brief.since = now; + } + let left = crate::state::BRIEF_INFO_SECS - (now - brief.since); + brief.shown = app.ui.settings.loupe_info.briefly != crate::state::InfoOverlay::Off && left > 0.0; + if brief.shown { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); + } +} + +/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the +/// photo's top left, inset 8 points, with the `text` size plus padding. Zoomed in, the photo +/// reaches past its `pane` (the canvas): the pill then sits at the pane's top left, so the label +/// stays in view (issue #2267: at 100 % it left the screen with the photo's corner), below the +/// `top_inset` the filter pill takes in that corner, and never past the pane's right edge. +pub(super) fn label_badge_rect(photo: Rect, pane: Rect, text: egui::Vec2, top_inset: f32) -> Rect { + let size = text + vec2(16.0, 8.0); + let left = photo.left().max(pane.left()).min(pane.right() - size.x - 16.0).max(pane.left()); + let corner = pos2(left, photo.top().max(pane.top() + top_inset)); + Rect::from_min_size(corner + vec2(8.0, 8.0), size) +} + +/// The info overlay at the canvas' top left (`view.infoOverlay`): the lines View Options chose for +/// Info 1 or Info 2 (`view.viewOptions`). +pub(super) fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { + let Some(lines) = info_overlay_lines(app, photo) else { return }; + let t = Tokens::get(p.ctx()); + let mut y = canvas.top() + info_overlay_top(app, &t); + for (i, l) in lines.iter().enumerate() { + // laid out uncoloured: `galley` only fills placeholder text with its colour, so a white + // layout made the shadow a second white copy (#1690) + let g = p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER); + let at = pos2(canvas.left() + 14.0, y); + // a soft shadow keeps the text readable on bright photos + p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); + p.galley(at, g.clone(), Color32::WHITE); + y += g.size().y + INFO_OVERLAY_GAP; + } + y = overlay_badges(p, &t, pos2(canvas.left() + 14.0, y), photo); + register_geometry(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); +} + +/// Where the info overlay's text starts below the canvas' top (without window controls over it), +/// and the gap after each line. +const INFO_OVERLAY_TOP: f32 = 12.0; + +const INFO_OVERLAY_GAP: f32 = 3.0; + +/// The info overlay's font for line `i` (the file name is the first, larger line). +fn info_overlay_font(t: &Tokens, i: usize) -> egui::FontId { + if i == 0 { t.semibold(15.0) } else { t.font(12.5) } +} + +/// How far below the canvas' top the info overlay's text and badges reach (0 when it is off), so a +/// label drawn in that corner can sit below it. +pub(super) fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraft_catalog::Photo) -> f32 { + let Some(lines) = info_overlay_lines(app, photo) else { return 0.0 }; + let t = Tokens::get(p.ctx()); + let text = lines.iter().enumerate().fold(info_overlay_top(app, &t), |y, (i, l)| { + y + p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER).size().y + INFO_OVERLAY_GAP + }); + text + if has_overlay_badges(photo) { OVERLAY_BADGES_H } else { 0.0 } +} + +/// Where the info overlay's text starts below the canvas' top: the full-screen preview has no top +/// bar, so on macOS it starts below the window controls that sit over the photo there (#2460). +fn info_overlay_top(app: &LightcraftApp, t: &Tokens) -> f32 { + let controls = app.ui.fullscreen && app.integrated_titlebar && !app.window_is_fullscreen; + if controls { t.top_bar_h } else { INFO_OVERLAY_TOP } +} + +/// The info overlay's lines for `photo`, or `None` when it is off. +fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Option> { + use crate::state::InfoOverlay; + // with the overlay off, Show Briefly When Photo Changes shows its Info for a moment + let brief = app.ui.info_overlay == InfoOverlay::Off && app.ui.brief_info.shown; + let mode = if brief { app.ui.settings.loupe_info.briefly } else { app.ui.info_overlay }; + let fields = match mode { + InfoOverlay::Off => return None, + InfoOverlay::Basic => &app.ui.settings.loupe_info.info1, + InfoOverlay::Exposure => &app.ui.settings.loupe_info.info2, + }; + let lines: Vec = fields.iter().filter_map(|f| info_line(*f, photo)).collect(); + if lines.is_empty() { + return None; + } + Some(lines) +} + +/// The height of the info overlay's rating and flag row. +const OVERLAY_BADGES_H: f32 = 17.0; + +fn has_overlay_badges(photo: &lightcraft_catalog::Photo) -> bool { + photo.rating > 0 || photo.flag != lightcraft_catalog::Flag::None +} + +/// The star rating and flag under the info overlay's lines, when the photo has either; returns +/// the y below them. +fn overlay_badges(p: &egui::Painter, t: &Tokens, at: Pos2, photo: &lightcraft_catalog::Photo) -> f32 { + use crate::icons::{Icon, paint}; + use lightcraft_catalog::Flag; + let flag = match photo.flag { + Flag::Pick => Some((Icon::FlagPickFilled, Color32::WHITE)), + Flag::Reject => Some((Icon::FlagReject, t.reject)), + Flag::None => None, + }; + if !has_overlay_badges(photo) { + return at.y; + } + let stars = photo.rating.min(5); + let mut icons: Vec<(Icon, Color32)> = vec![(Icon::StarFilled, Color32::WHITE); stars as usize]; + icons.extend(flag); + let side = 12.0; + let mut x = at.x; + for (i, (icon, color)) in icons.into_iter().enumerate() { + // the flag stands a little apart from the stars + if i > 0 && i == stars as usize { + x += 5.0; + } + let r = Rect::from_min_size(pos2(x, at.y + 1.0), vec2(side, side)); + paint(p, r.translate(vec2(1.0, 1.0)), icon, Color32::from_black_alpha(200)); + paint(p, r, icon, color); + x += side + 2.0; + } + register_geometry(p.ctx(), "canvas:infoOverlayBadges", Rect::from_min_max(at, pos2(x, at.y + side + 2.0))); + at.y + OVERLAY_BADGES_H +} + +/// The filter pill's inset from the canvas' top-left corner and its height (the same inset from +/// the left as from the top, so the pill sits in the corner, #1645). +pub(super) const FILTER_PILL_INSET: f32 = 3.0; + +pub(super) const FILTER_PILL_HEIGHT: f32 = 20.0; + +/// Whether [`filter_pill`] has something to show (a filter is active). +pub(super) fn filter_pill_shown(app: &LightcraftApp) -> bool { + !lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).is_empty() +} + +/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous +/// follow them, so a filter must never be invisible here. A click clears them all. It sits in the +/// canvas margin, so the photo does not move. +pub(super) fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { + let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); + if chips.is_empty() { + return; + } + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let label = + chips.iter().map(|c| crate::panels::chips::display_label(c, &app.session.filter, &app.session.catalog)).collect::>().join(" · "); + let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225)); + let inset = FILTER_PILL_INSET; + let w = (g.size().x + 40.0).min((canvas.width() - 2.0 * inset).max(60.0)); + let r = Rect::from_min_size(canvas.min + vec2(inset, inset), vec2(w, FILTER_PILL_HEIGHT)); + let resp = ui.interact(r, egui::Id::unique("loupe-filter-pill"), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Filters")); + register(ui, "loupe:filters", r); + p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 })); + let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y)); + p.with_clip_rect(text_clip).galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); + let (c, m) = (pos2(r.right() - 13.0, r.center().y), 3.0); + let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) }); + p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); + p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); + if resp.on_hover_text(crate::i18n::tr("Click to clear these filters")).clicked() { + let _ = app.run("library.clearFilter", json!({})); + } +} + +/// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so; +/// hovering it explains (registered as `notice:previewOnly:loupe`). +pub(super) fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let text = crate::i18n::tr_format!("Preview only — editing the camera's embedded JPEG ({})", crate::widgets::preview_only_variant(reason)); + let g = p.layout_no_wrap(text, t.font(12.0), Color32::WHITE); + let size = vec2(g.size().x + 40.0, 26.0); + let r = Rect::from_min_size(pos2(canvas.center().x - size.x / 2.0, canvas.top() + 12.0), size); + p.rect_filled(r, 13.0, Color32::from_black_alpha(185)); + crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(15.0, 15.0)), crate::icons::Icon::Info, t.caution); + p.galley(pos2(r.left() + 29.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); + register(ui, "notice:previewOnly:loupe", r); + ui.interact(r, egui::Id::unique("preview-only-pill"), Sense::hover()).on_hover_text(crate::widgets::preview_only_explanation(reason)); +} + +/// One line of the info overlay for `photo`; `None` when the field is None or the photo has +/// nothing to show for it. +fn info_line(field: crate::state::LoupeInfoField, photo: &lightcraft_catalog::Photo) -> Option { + use crate::state::LoupeInfoField as F; + let m = &photo.meta; + let text = match field { + F::None => return None, + F::FileName => photo.file_name.clone(), + F::CaptureDate => pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)), + F::Dimensions => format!("{} × {}", photo.width, photo.height), + F::Exposure => { + let mut exp = Vec::new(); + if !m.shutter.is_empty() { + exp.push(format!("{} s", m.shutter)); + } + if let Some(a) = m.aperture { + exp.push(format!("f/{a:.1}")); + } + if let Some(i) = m.iso { + exp.push(format!("ISO {i}")); + } + if let Some(f) = m.focal_mm { + exp.push(format!("{f:.0} mm")); + } + if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") } + } + F::Camera => m.camera.clone(), + F::Lens => m.lens.clone(), + F::CameraAndLens => [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" · "), + }; + (!text.is_empty()).then_some(text) +} + +/// `2026-09-30T12:00:00` → `2026-09-30 12:00`. +fn pretty_date(iso: &str) -> String { + let d = iso.replacen('T', " ", 1); + d.get(..16).map(str::to_string).unwrap_or(d) +} + +/// How long a render may be on its way, with nothing new on screen, before the loupe says +/// "Rendering…" (s): as long as the activity stack waits before it shows a task. +pub(super) const RENDERING_AFTER_SECS: f64 = 0.5; + +/// What [`rendering_overdue`] keeps between frames while a render is coming: the texture keys on +/// screen, since when, and whether the pill is up. +pub(crate) type RenderingWait = Option<([Option; 4], f64, bool)>; + +/// Whether the loupe says "Rendering…": a render has been `coming` for [`RENDERING_AFTER_SECS`] with +/// no new texture `keys` on screen. The wait starts when the render starts coming (an idle app draws +/// no frames, so the last one may be long ago), and once up the pill stays until nothing is coming: +/// drafts slower than the wait don't make it blink. +pub(crate) fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: bool, now: f64) -> bool { + if !coming { + *wait = None; + return false; + } + let (seen, since, up) = wait.get_or_insert((keys, now, false)); + if !*up && *seen != keys { + (*seen, *since) = (keys, now); + } + *up |= now - *since > RENDERING_AFTER_SECS; + *up +} + +/// A pill over the photo, 12 pt above the bottom of `over` or just above the Navigator when that +/// is in the way, registered as `id`; `caution` adds the preview-only pill's caution icon. The +/// loupe's "Rendering…" says the pixels on screen aren't final, so it isn't an activity row (see +/// docs/background-tasks.md); "This photo can't be opened" says the stand-in on screen is all +/// there will be. +pub(crate) fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option, id: &str, text: &str, caution: bool) -> Rect { + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let g = p.layout_no_wrap(text.to_string(), t.font(12.0), Color32::from_gray(225)); + let inset = if caution { 29.0 } else { 10.0 }; + let size = vec2(g.size().x + inset + 10.0, 20.0); + let mut r = Rect::from_center_size(pos2(over.center().x, over.bottom() - 12.0 - size.y / 2.0), size); + if let Some(nav) = navigator.filter(|n| n.expand(8.0).intersects(r)) { + r = Rect::from_min_size(pos2(nav.right() - size.x, nav.top() - 8.0 - size.y), size); + } + p.rect_filled(r, 10.0, Color32::from_black_alpha(185)); + if caution { + crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(13.0, 13.0)), crate::icons::Icon::Info, t.caution); + } + p.galley(pos2(r.left() + inset, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); + register(ui, id, r); + r +} + +/// The caution pill saying a render failed behind the stand-in on screen (see [`loupe_pill`]): +/// "Original offline · no smart preview" when that is why, else "This photo can't be opened", with +/// the error on hover and for screen readers. +#[allow(clippy::too_many_arguments)] +pub(crate) fn failed_pill( + ui: &egui::Ui, + app: &LightcraftApp, + photo: &lightcraft_catalog::Photo, + canvas: Rect, + over: Rect, + navigator: Option, + id: &str, + error: &str, +) { + let offline = matches!(&photo.source, lightcraft_catalog::Source::File { path } if app.session.media.availability.is_offline(path)); + let text = crate::i18n::tr(if offline { "Original offline · no smart preview" } else { "This photo can't be opened" }); + let r = loupe_pill(ui, canvas, over, navigator, id, text, true); + let resp = ui.interact(r, egui::Id::unique(("render-failed", id)), Sense::hover()).on_hover_text(error); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, format!("{text}: {error}"))); +} + +pub(super) fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { + use lightcraft_engine::media::QuickSource; + match q { + QuickSource::Cached => "cached", + QuickSource::Embedded => "embedded", + QuickSource::Small => "small", + } +} + +#[cfg(test)] +mod filter_pill_tests { + use super::*; + + /// #1645: the filter pill sits in the loupe's top-left corner as far from the left edge as + /// from the top one. + #[test] + fn the_filter_pill_is_inset_equally_from_both_edges() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.session.filter.text = "717".into(); + let ctx = egui::Context::default(); + let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| filter_pill(&mut app, ui, canvas)); + out.textures_delta.clear(); + let widgets = crate::widgets::take_registry(&ctx); + let pill = widgets.iter().find(|(w, _)| w == "loupe:filters").map(|(_, r)| *r).expect("the pill shows with a search"); + let (left, top) = (pill.left() - canvas.left(), pill.top() - canvas.top()); + assert!(top > 0.0, "inside the canvas: {pill:?}"); + assert_eq!(left, top, "left and top insets: {pill:?} in {canvas:?}"); + } +} + +#[cfg(test)] +mod info_overlay_tests { + use super::*; + + /// #1690: the info overlay's shadow is a dark copy under the text. Drawn as a second white + /// copy one point off, each line read doubled. + #[test] + fn the_info_overlay_shadow_is_dark() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.ui.info_overlay = crate::state::InfoOverlay::Basic; + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(&app, ui.painter(), canvas, &photo)); + out.textures_delta.clear(); + // the colour a text shape paints its glyphs in, as epaint resolves it + let ink = |s: &egui::epaint::TextShape| { + let c = s.galley.job.sections.first().map_or(Color32::PLACEHOLDER, |sec| sec.format.color); + s.override_text_color.unwrap_or(if c == Color32::PLACEHOLDER { s.fallback_color } else { c }) + }; + fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { + match shape { + egui::epaint::Shape::Text(t) => out.push(t.clone()), + egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = Vec::new(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); + let copies: Vec = all.iter().filter(|t| t.galley.job.text == photo.file_name).map(ink).collect(); + assert_eq!(copies.len(), 2, "the file name and its shadow: {copies:?}"); + assert_eq!(copies[1], Color32::WHITE, "the text on top"); + assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]); + } + + /// View Options' Show Briefly When Photo Changes: with the overlay off, the chosen Info shows + /// for `BRIEF_INFO_SECS` after the loupe changes photo, then hides; an overlay that is on stays. + #[test] + fn show_briefly_shows_the_info_after_the_photo_changes() { + use crate::state::{BRIEF_INFO_SECS, InfoOverlay}; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + let photos: Vec = app.session.catalog.photos().take(2).map(|p| (**p).clone()).collect(); + assert_eq!(photos.len(), 2, "the demo catalog has two photos"); + let ctx = egui::Context::default(); + // the loupe shows photo `i` at `time`: the lines the overlay paints then + let at = |app: &mut LightcraftApp, i: usize, time: f64| { + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, time, vec![]); + let mut out = ctx.run_ui(raw, |ui| brief_info(app, ui.ctx(), photos[i].id)); + out.textures_delta.clear(); + painted_lines(app, &photos[i]) + }; + let name = |i: usize| photos[i].file_name.clone(); + assert!(at(&mut app, 0, 1.0).is_empty(), "off by default"); + app.ui.settings.loupe_info.briefly = InfoOverlay::Exposure; + let info2 = at(&mut app, 1, 2.0); + assert_eq!(info2.first(), Some(&name(1)), "Info 2 shows when the photo changes: {info2:?}"); + assert!(!at(&mut app, 1, 2.0 + BRIEF_INFO_SECS - 0.5).is_empty(), "still up"); + assert!(at(&mut app, 1, 2.0 + BRIEF_INFO_SECS + 0.1).is_empty(), "then hidden"); + assert_eq!(at(&mut app, 0, 10.0).first(), Some(&name(0)), "and shown again for the next photo"); + app.ui.settings.loupe_info.briefly = InfoOverlay::Basic; + let info1 = at(&mut app, 1, 20.0); + assert_eq!(info1.len(), 3, "Info 1's three lines: {info1:?}"); + assert_ne!(info1, info2, "Info 1, not Info 2"); + app.ui.info_overlay = InfoOverlay::Exposure; + assert_eq!(at(&mut app, 1, 30.0), info2, "an overlay that is on shows its own Info, and stays"); + } + + /// The lines the overlay paints, top to bottom (each once: the shadow copies left out). + fn painted_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Vec { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); + out.textures_delta.clear(); + let mut lines = Vec::new(); + for c in &out.shapes { + if let egui::epaint::Shape::Text(t) = &c.shape + && t.override_text_color.unwrap_or(t.fallback_color) == Color32::WHITE + { + lines.push(t.galley.job.text.clone()); + } + } + lines + } + + /// View ▸ View Options: Info 1 and Info 2 each show the three lines chosen for them, in that + /// order, without the lines set to None. + #[test] + fn the_info_overlay_shows_the_lines_chosen_in_view_options() { + use crate::state::{InfoOverlay, LoupeInfoField as F}; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + let size = format!("{} × {}", photo.width, photo.height); + let date = pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)); + app.ui.settings.loupe_info.info1 = [F::Dimensions, F::None, F::FileName]; + app.ui.settings.loupe_info.info2 = [F::CaptureDate, F::FileName, F::None]; + app.ui.info_overlay = InfoOverlay::Basic; + assert_eq!(painted_lines(&app, &photo), vec![size.clone(), photo.file_name.clone()], "Info 1"); + app.ui.info_overlay = InfoOverlay::Exposure; + assert_eq!(painted_lines(&app, &photo), vec![date, photo.file_name.clone()], "Info 2"); + app.ui.settings.loupe_info.info1 = [F::None; 3]; + app.ui.info_overlay = InfoOverlay::Basic; + assert_eq!(painted_lines(&app, &photo), Vec::::new(), "nothing chosen, nothing shown"); + } + + fn demo_app() -> (LightcraftApp, lightcraft_catalog::Photo) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.ui.info_overlay = crate::state::InfoOverlay::Basic; + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + (app, photo) + } + + /// The overlay's text shapes and the rects it registered, painted on a canvas at the window's corner. + fn paint_overlay(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> (Vec, Vec<(String, Rect)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); + out.textures_delta.clear(); + fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { + match shape { + egui::epaint::Shape::Text(t) => out.push(t.clone()), + egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = Vec::new(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); + (all, crate::widgets::take_registry(&ctx)) + } + + fn name_top(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> f32 { + let (texts, _) = paint_overlay(app, photo); + texts.iter().filter(|t| t.galley.job.text == photo.file_name).map(|t| t.pos.y).fold(f32::INFINITY, f32::min) + } + + /// #2460: in the full-screen preview on macOS the window controls stay at the top-left, so the + /// file name starts below them; in macOS full screen they are gone and it keeps the corner. + #[test] + fn the_file_name_clears_the_macos_window_controls_in_the_full_screen_preview() { + let (mut app, photo) = demo_app(); + app.ui.fullscreen = true; + assert_eq!(name_top(&app, &photo), 12.0, "no window controls over the photo"); + app.integrated_titlebar = true; + let bar = Tokens::get(&egui::Context::default()).top_bar_h; + assert!(name_top(&app, &photo) >= bar, "below the window controls"); + app.window_is_fullscreen = true; + assert_eq!(name_top(&app, &photo), 12.0, "macOS full screen hides the window controls"); + app.window_is_fullscreen = false; + app.ui.fullscreen = false; + assert_eq!(name_top(&app, &photo), 12.0, "the loupe's canvas starts below the top bar"); + } + + /// #2460: a rated or flagged photo shows its stars and flag under the date and size. + #[test] + fn the_info_overlay_shows_the_rating_and_flag_under_the_date() { + let (_, mut photo) = demo_app(); + let (app, _) = demo_app(); + photo.rating = 0; + photo.flag = lightcraft_catalog::Flag::None; + let badges = |photo: &lightcraft_catalog::Photo| { + paint_overlay(&app, photo).1.into_iter().find(|(id, _)| id == "canvas:infoOverlayBadges").map(|(_, r)| r) + }; + assert_eq!(badges(&photo), None, "nothing to show"); + photo.rating = 3; + let stars = badges(&photo).expect("three stars"); + photo.flag = lightcraft_catalog::Flag::Pick; + let flagged = badges(&photo).expect("three stars and a flag"); + assert!(flagged.width() > stars.width(), "the flag follows the stars: {stars:?} {flagged:?}"); + let (texts, _) = paint_overlay(&app, &photo); + let date_bottom = texts.iter().map(|t| t.pos.y + t.galley.size().y).fold(0.0, f32::max); + assert!(flagged.top() >= date_bottom, "under the date and size: {flagged:?}, text ends at {date_bottom}"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut extent = 0.0; + ctx.run_ui(raw, |ui| extent = info_overlay_extent(&app, ui.painter(), &photo)).textures_delta.clear(); + assert!(extent >= flagged.bottom(), "a label below the overlay clears the badges: {extent} < {flagged:?}"); + photo.rating = 0; + photo.flag = lightcraft_catalog::Flag::Reject; + assert!(badges(&photo).is_some(), "a rejected photo shows its flag alone"); + } +} diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs new file mode 100644 index 000000000..073de8aa2 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -0,0 +1,657 @@ +use super::geometry::CanvasMap; +use super::*; + +/// What the user did to a face box this frame. +pub(super) enum RegionEdit { + /// The × was clicked. + Remove(usize), + /// A handle drag ended: the region's new box (normalized, upright frame). + Resize(usize, lightcraft_geom::Rect), + /// A name was typed or picked for the face. + Name(usize, String), + /// The name box's offer to set up face recognition was pressed. + SetUp, +} + +/// Handles of a box: (x, y) as fractions of its width and height, and the cursor they show. +const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ + (0.0, 0.0, egui::CursorIcon::ResizeNwSe), + (0.5, 0.0, egui::CursorIcon::ResizeVertical), + (1.0, 0.0, egui::CursorIcon::ResizeNeSw), + (1.0, 0.5, egui::CursorIcon::ResizeHorizontal), + (1.0, 1.0, egui::CursorIcon::ResizeNwSe), + (0.5, 1.0, egui::CursorIcon::ResizeVertical), + (0.0, 1.0, egui::CursorIcon::ResizeNeSw), + (0.0, 0.5, egui::CursorIcon::ResizeHorizontal), +]; + +/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows +/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once, +/// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: +/// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo; +/// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes. +pub(super) fn region_overlay( + ui: &egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + photo: &lightcraft_catalog::Photo, + orient: lightcraft_geom::Orientation, + hints: Option<&crate::panels::faces::Hints>, + people: &[String], + editing: &mut Option, + setup: crate::panels::faces::Setup, +) -> Option { + let t = Tokens::get(p.ctx()); + let clip = p.clip_rect(); + let pointer = ui.input(|i| i.pointer.hover_pos()); + // the box being dragged: (region index, its box so far) + let drag_key = egui::Id::unique("region-drag"); + let live: Option<(usize, lightcraft_geom::Rect)> = ui.data(|d| d.get_temp(drag_key)); + let mut edit = None; + for (index, r) in photo.meta.regions.iter().enumerate() { + let dragging = live.filter(|(i, _)| *i == index); + let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n)); + let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1))); + // white with a black keyline just outside it, so the box shows on any background + p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside); + p.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Outside); + if dragging.is_some() || (live.is_none() && pointer.is_some_and(|h| rect.expand(8.0).contains(h))) { + for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() { + let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height()); + let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::unique(("region-handle", index, h)), Sense::drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Resize region")); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(*cursor); + } + let sq = Rect::from_center_size(c, vec2(7.0, 7.0)); + p.rect_filled(sq, 0.0, if resp.hovered() || resp.dragged() { Color32::from_rgb(120, 190, 255) } else { Color32::WHITE }); + p.rect_stroke(sq, 0.0, Stroke::new(1.0, Color32::from_black_alpha(220)), StrokeKind::Outside); + if resp.dragged() + && let Some(pp) = resp.interact_pointer_pos() + { + // move the dragged edge(s) to the pointer, never closer than 12 px to the opposite one + let mut n = rect; + if *fx == 0.0 { + n.min.x = pp.x.min(n.max.x - 12.0); + } else if *fx == 1.0 { + n.max.x = pp.x.max(n.min.x + 12.0); + } + if *fy == 0.0 { + n.min.y = pp.y.min(n.max.y - 12.0); + } else if *fy == 1.0 { + n.max.y = pp.y.max(n.min.y + 12.0); + } + let (a, b) = (map.norm(n.min), map.norm(n.max)); + let shown = lightcraft_geom::Rect { + x0: a.x.min(b.x).clamp(0.0, 1.0), + y0: a.y.min(b.y).clamp(0.0, 1.0), + x1: a.x.max(b.x).clamp(0.0, 1.0), + y1: a.y.max(b.y).clamp(0.0, 1.0), + }; + // back to the upright frame the region is stored in + let new = orient.inverse().map_norm_rect(shown); + ui.data_mut(|d| d.insert_temp(drag_key, (index, new))); + ui.ctx().request_repaint(); + } + if resp.drag_stopped() { + if let Some((i, new)) = ui.data(|d| d.get_temp::<(usize, lightcraft_geom::Rect)>(drag_key)) { + edit = Some(RegionEdit::Resize(i, new)); + } + ui.data_mut(|d| d.remove_temp::<(usize, lightcraft_geom::Rect)>(drag_key)); + } + } + } + if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { + let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0)); + let resp = ui.interact(xr, egui::Id::unique(("region-x", index)), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Remove region")); + register(ui, format!("regionRemove:{index}"), xr); + let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 }); + p.rect_filled(xr, 2.0, fill); + p.rect_stroke(xr, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside); + let (c, m) = (xr.center(), 3.5); + let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) }); + p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); + p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); + if resp.on_hover_text(crate::i18n::tr("Remove this face box (undo with Edit ▸ Undo)")).clicked() { + edit = Some(RegionEdit::Remove(index)); + } + } + // what the label says: the name, a guess to confirm ("Jane Doe?"), or on hover an invitation to name the face + let is_face = r.kind == lightcraft_meta::RegionKind::Face; + let hint = hints.and_then(|h| h.by_index.get(&index)); + let hovered = pointer.is_some_and(|h| rect.expand(3.0).contains(h)); + let editing_this = editing.as_ref().is_some_and(|e| e.photo == photo.id.0 && e.index == index); + let (text, text_color, fill_alpha, invitation) = match (&r.name, hint.and_then(|h| h.suggestion.as_ref())) { + (Some(n), _) => (n.clone(), Color32::from_gray(225), 235, false), + (None, Some((n, _))) if is_face => (format!("{n}?"), Color32::from_gray(185), 200, false), + (None, None) if is_face && live.is_none() => ("Add name".to_string(), Color32::from_gray(150), 190, true), + _ => continue, + }; + // the name in a dark label with a caret, centred above the box (below it when there is no room) + let name = &text; + let g = p.layout_no_wrap(name.clone(), t.font(13.0), text_color); + let (pad, caret) = (vec2(14.0, 7.0), 5.0); + let size = g.size() + pad * 2.0; + let above = rect.top() - caret - size.y >= clip.top(); + let (top, tip, base) = if above { + (rect.top() - caret - size.y, rect.top() - 1.0, rect.top() - caret - 1.0) + } else { + (rect.bottom() + caret, rect.bottom() + 1.0, rect.bottom() + caret + 1.0) + }; + let left = (rect.center().x - size.x / 2.0).clamp(clip.left(), (clip.right() - size.x).max(clip.left())); + let label = Rect::from_min_size(pos2(left, top), size); + // the invitation shows while the pointer is on the box *or on the label* (and the gap between), so it can be reached + if invitation && !(hovered || editing_this || pointer.is_some_and(|h| rect.union(label).expand(3.0).contains(h))) { + continue; + } + let fill = Color32::from_rgba_unmultiplied(56, 56, 56, fill_alpha); + p.rect_filled(label, 3.0, fill); + p.rect_stroke(label, 3.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Inside); + let cx = rect.center().x.clamp(label.left() + caret + 4.0, label.right() - caret - 4.0); + p.add(egui::Shape::convex_polygon(vec![pos2(cx - caret, base), pos2(cx + caret, base), pos2(cx, tip)], fill, Stroke::NONE)); + p.galley(label.min + pad, g, text_color); + if is_face && live.is_none() { + // a click on the label opens the name box, filled with the name or the guess + let resp = ui.interact(label, egui::Id::unique(("region-label", index)), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Click to name this face")); + register(ui, format!("regionLabel:{index}"), label); + if resp.on_hover_text(crate::i18n::tr("Click to name this face")).clicked() { + let start = r.name.clone().or_else(|| hint.and_then(|h| h.suggestion.as_ref().map(|(n, _)| n.clone()))).unwrap_or_default(); + *editing = Some(crate::state::NameEdit { photo: photo.id.0, index, text: start, fresh: true }); + } + } + if editing_this && let Some(e) = editing.as_mut() { + let candidates = hint.map(|h| h.candidates.as_slice()).unwrap_or(&[]); + match crate::panels::faces::name_editor(ui.ctx(), pos2(label.left(), rect.bottom() + 8.0), e, candidates, people, setup) { + crate::panels::faces::Editor::Submit(name) => edit = Some(RegionEdit::Name(index, name)), + crate::panels::faces::Editor::Setup => edit = Some(RegionEdit::SetUp), + crate::panels::faces::Editor::Cancel => *editing = None, + crate::panels::faces::Editor::Open => {} + } + } + } + edit +} + +// ------------------------------------------------------------------------ masks + +/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo. +pub(crate) fn component_pin(shape: &MaskShape) -> Option { + match shape { + MaskShape::Radial { center, .. } => Some(*center), + MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)), + MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(), + _ => None, + } +} + +/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. +fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape { + let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); + match shape.clone() { + MaskShape::Radial { center, rx, ry, angle, feather, invert } => match handle { + 1 | 3 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let new_rx = (dx * co + dy * s).abs().max(0.01); + let mut new_ry = ry; + if alt { + new_ry = ry * (new_rx / rx.max(0.01)); + } + if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { + new_ry = new_rx; + } + MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } + } + 2 | 4 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let new_ry = (dx * (-s) + dy * co).abs().max(0.01); + let mut new_rx = rx; + if alt { + new_rx = rx * (new_ry / ry.max(0.01)); + } + if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { + new_rx = new_ry; + } + MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } + } + 5 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let new_angle = dy.atan2(dx).to_degrees(); + MaskShape::Radial { center, rx, ry, angle: new_angle, feather, invert } + } + _ => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, + }, + MaskShape::Linear { start, end } => match handle { + 1 => MaskShape::Linear { start: at, end }, + 2 => MaskShape::Linear { start, end: at }, + _ => MaskShape::Linear { start: mv(start), end: mv(end) }, + }, + MaskShape::Brush { mut strokes } => { + for st in &mut strokes { + st.points.iter_mut().for_each(|p| *p = mv(*p)); + } + MaskShape::Brush { strokes } + } + s => s, + } +} + +/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units, +/// so undo the same scaling and rotation used to draw its outline before testing containment. +fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool { + let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false }; + if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 { + return false; + } + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let x = (dx * co + dy * s) / rx; + let y = (-dx * s + dy * co) / ry; + x * x + y * y <= 1.0 +} + +/// Colour Range eyedropper: a click samples the colour under it for the selected mask's +/// colour range, at normalized frame coordinates (crop, rotation and zoom aware); +/// ⇧-click adds a sample (up to 5). +pub(super) fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, map: &CanvasMap) { + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + let add = ui.input(|i| i.modifiers.shift); + if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) { + app.toast(ui.ctx(), e); + } + } +} + +/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask +/// component (click selects its mask, drag moves the component), and brush painting. The mask +/// itself shows as a rendered overlay ([`view_overlay`]). +pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { + let long = (map.rect.width().max(map.rect.height())) as f64; + let active = app.session.active_mask; + let clip = app.canvas_rect.unwrap_or(map.rect); + let p = &ui.painter_at(clip); + // (mask, component, handle, screen position) of everything that can be grabbed + let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new(); + // pins follow Show Pins and the Tool Overlay mode (Shift+H); Auto hides them while brushing + let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); + let mut pins = p.clone(); + if mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Brush { .. })) { + pins.set_invisible(); + } + for m in &d.masks { + let sel = Some(m.id) == active; + // Ctrl+Shift+O (`mask_overlay_all`) shows every visible mask's region; outline each one + // too, not just the selected mask. Handles and the rotation grip stay on the selected + // mask so nothing else becomes draggable. The component check keeps this in step with the + // painted region (`view_overlay` at detail.rs:1354 only covers masks with components). + let show_outline = sel || (app.ui.mask_overlay_all && m.visible && !m.components.is_empty()); + let stroke_a: u8 = if sel { 230 } else { 170 }; + for (ci, c) in m.components.iter().enumerate() { + match &c.shape { + MaskShape::Radial { center, rx, ry, angle, .. } if show_outline => { + let pts: Vec = (0..64) + .map(|i| { + let a = i as f64 / 64.0 * std::f64::consts::TAU; + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a.cos(), ry * a.sin()); + let l = frame_long_norm(map); + map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) + }) + .collect(); + p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); + + if sel { + let l = frame_long_norm(map); + for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { + let a_local = a_deg.to_radians(); + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); + let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); + p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((m.id, ci, i as u8 + 1, q)); + } + + let (s, co) = angle.to_radians().sin_cos(); + let rot_r = rx + 0.08; + let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); + let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); + p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); + p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); + grips.push((m.id, ci, 5, rot_q)); + } + } + MaskShape::Linear { start, end } if show_outline => { + let (a, b) = (map.screen(*start), map.screen(*end)); + let dir = (b - a).normalized(); + let perp = vec2(-dir.y, dir.x) * 3000.0; + let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; + for (q, alpha) in [(a, a1), (b, a2)] { + p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); + } + if sel { + for (i, q) in [a, b].iter().enumerate() { + p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); + grips.push((m.id, ci, i as u8 + 1, *q)); + } + } + } + _ => {} + } + if app.ui.mask_pins + && mode.shows(sel, live) + && let Some(at) = component_pin(&c.shape) + { + let q = map.screen(at); + pin(&pins, q, sel); + register(ui, format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((m.id, ci, 0, q)); + } + } + } + mask_keys(app, ui, map, d, &grips); + // Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude + // (clicks still on their way to the model included) + let pending = app.session.segmenter.pending_clicks().cloned(); + for m in d.masks.iter().filter(|m| Some(m.id) == active) { + for (k, c) in m.components.iter().enumerate() { + if let MaskShape::Object { hint, exclude, .. } = &c.shape { + let (hint, exclude) = match pending.as_ref().filter(|q| (q.mask, q.comp) == (m.id, k)) { + Some(q) => (&q.hint, &q.exclude), + None => (hint, exclude), + }; + for (pts, col) in [(hint, Color32::from_rgb(40, 200, 90)), (exclude, Color32::from_rgb(230, 60, 60))] { + for q in pts { + let q = map.screen(*q); + p.circle_filled(q, 5.0, col); + p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); + } + } + } + } + } + // Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction + // never runs while it is open), so the eyedropper is handled here too. + if app.ui.tool == "colorRange" { + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + color_range_pick(app, ui, resp, map); + return; + } + // Object tool: a click includes what's under it, ⌥-click leaves it out + if app.ui.tool == "object" { + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + let exclude = ui.input(|i| i.modifiers.alt); + match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { + Ok(_) => { + if let Some(mid) = app.session.active_mask { + app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); + } + } + Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), + } + } + return; + } + // brush tool + if app.ui.tool == "brush" { + let r = (app.ui.brush_size as f64 * long) as f32; + if let Some(h) = resp.hover_pos() { + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); + } + if let Some(q) = resp.interact_pointer_pos() + && (resp.drag_started() || resp.dragged() || resp.clicked()) + { + let n = map.norm(q); + let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; + match &mut app.gesture { + // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) + Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} + Some(Gesture::Brush { points, pressure }) => { + record_pressure(pressure, points.len(), force); + points.push(n); + } + _ => { + let mut pressure = Vec::new(); + record_pressure(&mut pressure, 0, force); + app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + } + } + } + // the stroke being painted (the render shows it once it's committed), with the committed + // stroke's dab spacing, feather and flow (issue #517) + if let Some(Gesture::Brush { points, pressure }) = &app.gesture { + let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); + let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; + let params = crate::brush_live::LiveParams { + clip, + scale: ui.ctx().pixels_per_point(), + r, + hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), + flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), + density: 1.0, + color: app.ui.mask_overlay_color, + opacity: opacity.clamp(0.0, 1.0), + }; + crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); + } else { + app.brush_live = None; + } + if (resp.drag_stopped() || resp.clicked()) + && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() + { + app.brush_live = None; + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing + let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); + let erase = app.ui.brush_erase != alt; + // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom + let connect = shift && resp.clicked() && pts.len() == 1; + let _ = app.run( + "mask.brushStroke", + json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, + "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), + ); + } + return; + } + // the grip under the pointer: the selected mask's first, then the nearest + let hit = |q: Pos2| { + grips + .iter() + .filter(|g| g.3.distance(q) < 10.0) + .min_by(|a, b| { + (Some(a.0) != active, a.3.distance(q)).partial_cmp(&(Some(b.0) != active, b.3.distance(q))).unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + }; + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + && let Some((mid, ..)) = hit(q) + && Some(mid) != active + { + let _ = app.run("mask.select", json!({"id": mid})); + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| { + let m = d.masks.iter().find(|m| Some(m.id) == active)?; + // Handles take precedence; otherwise grab the selected radial component under + // the press, including components after the first in compound masks. + let at = map.norm(q); + if let Some(ci) = m.components.iter().position(|c| radial_body_contains(&c.shape, at, map)) { + return Some((m.id, ci, 0)); + } + // anywhere on the photo drags the selected linear gradient + matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) + }); + if let Some((mask, comp, handle)) = grip + && let Some(original_shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone()) + { + if Some(mask) != active { + let _ = app.run("mask.select", json!({"id": mask})); + } + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); + app.gesture = Some(Gesture::MaskHandle { mask, comp, handle, original_shape }); + } + } + if resp.dragged() + && let (Some(Gesture::MaskHandle { mask, comp, handle, original_shape }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) + { + let n = map.norm(q); + let press_origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(q); + let n0 = map.norm(press_origin); + let dn = Point::new(n.x - n0.x, n.y - n0.y); + let alt = ui.input(|i| i.modifiers.alt); + let new_shape = moved_shape(&original_shape, handle, dn, n, map, alt); + let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape})); + } + if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } +} + +/// A mask handle's name for screen readers: `handle` 0 is the component's pin, 1..5 a radial +/// gradient's right / bottom / left / top edge and rotation, 1..2 a linear gradient's start and end. +fn mask_handle_name(mask: &str, shape: &MaskShape, handle: u8) -> String { + let part = match (shape, handle) { + (_, 0) => "pin", + (MaskShape::Radial { .. }, 1) => "right edge", + (MaskShape::Radial { .. }, 2) => "bottom edge", + (MaskShape::Radial { .. }, 3) => "left edge", + (MaskShape::Radial { .. }, 4) => "top edge", + (MaskShape::Radial { .. }, _) => "rotation", + (MaskShape::Linear { .. }, 1) => "start", + (MaskShape::Linear { .. }, _) => "end", + _ => "handle", + }; + format!("{mask} {}", crate::i18n::tr(part)) +} + +/// Keyboard stand-ins for the mask handles and pins: an arrow press moves the focused one by a +/// step (one undo step), as dragging it would; Return on a pin selects its mask. +fn mask_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(u32, usize, u8, Pos2)]) { + for &(mid, ci, handle, q) in grips { + let Some(m) = d.masks.iter().find(|m| m.id == mid) else { continue }; + let Some(shape) = m.components.get(ci).map(|c| &c.shape) else { continue }; + let at = map.norm(q); + let mask_name = if m.name.trim().is_empty() { format!("{} {mid}", crate::i18n::tr("Mask")) } else { m.name.clone() }; + let name = mask_handle_name(&mask_name, shape, handle); + let keys = crate::canvas_keys::handle(ui, &format!("mask:{mid}:{ci}:{handle}"), &name, crate::canvas_keys::position_text(at.x, at.y), q); + if keys.activated && app.session.active_mask != Some(mid) { + let _ = app.run("mask.select", json!({"id": mid})); + } + let Some((dx, dy)) = keys.delta else { continue }; + if app.session.active_mask != Some(mid) { + let _ = app.run("mask.select", json!({"id": mid})); + } + let to = Point::new(at.x + dx, at.y + dy); + let new_shape = moved_shape(shape, handle, Point::new(dx, dy), to, map, false); + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); + let _ = app.run("mask.update", json!({"id": mid, "component": ci, "shape": new_shape})); + let _ = app.run("develop.endInteraction", json!({})); + } +} + +/// Long-edge units → normalized units for x and y. +fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { + // derive from the mapping: 1 normalized unit in x and y in screen points + let o = map.screen(Point::new(0.0, 0.0)); + let x = map.screen(Point::new(1.0, 0.0)).distance(o) as f64; + let y = map.screen(Point::new(0.0, 1.0)).distance(o) as f64; + let l = x.max(y); + (l / x.max(1e-9), l / y.max(1e-9)) +} + +/// Auto's condition for the Tool Overlay (Shift+H): the pointer is over the photo, or a pin is +/// being dragged. +pub(super) fn pins_live(app: &LightcraftApp, resp: &egui::Response, map: &CanvasMap) -> bool { + resp.hover_pos().is_some_and(|q| map.rect.contains(q)) + || matches!(app.gesture, Some(Gesture::SpotMove { .. } | Gesture::MaskHandle { .. })) + // a handle moved with the keyboard stays shown while it has the focus + || crate::canvas_keys::has_focus(&resp.ctx) +} + +pub(super) fn pin(p: &egui::Painter, c: Pos2, sel: bool) { + p.circle_filled(c, 6.0, if sel { Color32::from_rgb(1, 101, 221) } else { Color32::from_gray(200) }); + p.circle_stroke(c, 6.0, Stroke::new(1.5, Color32::WHITE)); +} + +/// The pen pressure (0..1) reported this frame: the force of the latest touch or pen event that carries one (winit +/// reports a pen's pressure as a touch force, on the platforms that give it), `None` for a mouse. +pub(crate) fn pen_force(i: &egui::InputState) -> Option { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), + _ => None, + }) +} + +/// Record the pressure of the stroke's point `index`: `force` when the pen reported one this frame, else the last +/// known one. Points before the first reported force take it too; a stroke without any keeps no pressure list. +pub(crate) fn record_pressure(pressure: &mut Vec, index: usize, force: Option) { + match (force, pressure.last().copied()) { + (Some(f), _) if pressure.is_empty() => pressure.extend(std::iter::repeat_n(f, index + 1)), + (Some(f), _) => pressure.push(f), + (None, Some(last)) => pressure.push(last), + (None, None) => {} + } +} + +#[cfg(test)] +mod pen_tests { + use super::*; + + #[test] + fn pen_pressure_is_recorded_per_point_and_absent_for_a_mouse() { + let mut p = Vec::new(); + record_pressure(&mut p, 0, None); + record_pressure(&mut p, 1, None); + assert!(p.is_empty(), "a mouse: no pressure list"); + // the first force covers the points before it, a frame without one repeats the last + record_pressure(&mut p, 2, Some(0.4)); + record_pressure(&mut p, 3, None); + record_pressure(&mut p, 4, Some(0.9)); + assert_eq!(p, vec![0.4, 0.4, 0.4, 0.4, 0.9]); + + let mut i = egui::InputState::default(); + assert_eq!(pen_force(&i), None); + let touch = |force| egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(1), + phase: egui::TouchPhase::Move, + pos: egui::pos2(1.0, 1.0), + force, + }; + i.events = vec![touch(Some(0.3)), touch(Some(f32::NAN)), touch(None)]; + assert_eq!(pen_force(&i), Some(0.3), "the latest usable force"); + i.events = vec![touch(Some(4.0))]; + assert_eq!(pen_force(&i), Some(1.0)); + } +} diff --git a/crates/ui-egui/src/panels/detail/retouch.rs b/crates/ui-egui/src/panels/detail/retouch.rs new file mode 100644 index 000000000..64f4c2281 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/retouch.rs @@ -0,0 +1,249 @@ +use super::geometry::CanvasMap; +use super::masks::{pin, pins_live}; +use super::*; + +// ------------------------------------------------------------------------ remove + +/// The Remove tool on the photo: every spot's outline and pin (the selected one with its source), +/// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one. +/// The Tool Overlay mode (Shift+H) says which spots show; a hidden spot can't be grabbed, so a +/// click there adds a new one. +pub(super) fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { + let long = (map.rect.width().max(map.rect.height())) as f64; + let active = app.session.active_spot.filter(|i| *i < d.spots.len()); + let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); + // Auto also hides them while a new spot is painted + let painting = mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Spot { .. })); + let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect)); + let mut pins = p.clone(); + if painting { + pins.set_invisible(); + } + // (spot, is source, screen centre, radius) of everything that can be grabbed + let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new(); + for (i, s) in d.spots.iter().enumerate() { + let r = (s.size * long) as f32; + let sel = Some(i) == active; + if !mode.shows(sel, live) { + continue; + } + let p = &pins; + let col = Color32::from_white_alpha(if sel { 230 } else { 150 }); + for q in &s.points { + p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col)); + } + let Some(&t) = s.points.first() else { continue }; + let tq = map.screen(t); + if let Some(o) = s.source_offset.filter(|_| sel) { + let sq = map.screen(Point::new(t.x + o.x, t.y + o.y)); + for q in &s.points { + p.circle_stroke(map.screen(Point::new(q.x + o.x, q.y + o.y)), r, Stroke::new(1.0, Color32::from_white_alpha(170))); + } + let dir = (tq - sq).normalized(); + p.arrow(sq + dir * r, (tq - sq) - dir * (2.0 * r).min((tq - sq).length()), Stroke::new(1.0, Color32::from_white_alpha(200))); + register(ui, format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0))); + grips.push((i, true, sq, r)); + } + pin(p, tq, sel); + register(ui, format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); + grips.push((i, false, tq, r)); + } + if spot_keys(app, ui, map, d, &grips) { + return; + } + let r = (app.ui.remove_size as f64 * long) as f32; + // a target or source under `q`: the selected spot's first, then the nearest pin + let hit = |q: Pos2| { + grips + .iter() + .filter(|g| g.2.distance(q) < g.3.max(8.0)) + .min_by(|a, b| { + (Some(a.0) != active, a.2.distance(q)).partial_cmp(&(Some(b.0) != active, b.2.distance(q))).unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + }; + let hover = resp.hover_pos().and_then(hit); + if let Some(h) = resp.hover_pos() { + if hover.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } else { + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + p.circle_stroke(h, r * (1.0 - app.ui.remove_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(110))); + } + } + let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); + if resp.drag_started() + && let Some((spot, source, ..)) = press.and_then(hit) + { + if Some(spot) != active { + let _ = app.run("spot.select", json!({"index": spot})); + } + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); + app.gesture = Some(Gesture::SpotMove { spot, source }); + } + if let Some(Gesture::SpotMove { spot, source }) = app.gesture.clone() { + if resp.dragged() + && let Some(q) = resp.interact_pointer_pos() + { + let (n, n0) = (map.norm(q), map.norm(q - resp.drag_delta())); + let dn = [n.x - n0.x, n.y - n0.y]; + if dn != [0.0, 0.0] { + let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: dn})); + } + } + if resp.drag_stopped() { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } + return; + } + if resp.clicked() + && let Some((spot, ..)) = press.and_then(hit) + { + let _ = app.run("spot.select", json!({"index": spot})); + return; + } + if let Some(q) = resp.interact_pointer_pos() + && (resp.drag_started() || resp.dragged() || resp.clicked()) + { + let n = map.norm(q); + match &mut app.gesture { + Some(Gesture::Spot { points }) => points.push(n), + _ => app.gesture = Some(Gesture::Spot { points: vec![n] }), + } + } + if let Some(Gesture::Spot { points }) = &app.gesture { + for q in points { + p.circle_filled(map.screen(*q), r, Color32::from_white_alpha(60)); + } + } + if (resp.drag_stopped() || resp.clicked()) + && let Some(Gesture::Spot { points }) = app.gesture.take() + { + let mode = match app.ui.tool.as_str() { + "heal" => "heal", + "clone" => "clone", + "fill" => "fill", + _ => "remove", + }; + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + let _ = app.run( + "spot.add", + json!({"mode": mode, "points": pts, "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), + ); + } +} + +/// Keyboard stand-ins for the Remove tool: each spot's target and source (an arrow press moves +/// it a step, one undo step; Return selects the spot), and a placement point that the arrows move +/// and Return turns into a new spot of the brush's size there. True when a spot was added. +fn spot_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(usize, bool, Pos2, f32)]) -> bool { + for &(spot, source, q, _) in grips { + let at = map.norm(q); + let name = + format!("{} {}{}", crate::i18n::tr("Spot"), spot + 1, if source { format!(" {}", crate::i18n::tr("source")) } else { String::new() }); + let size = d.spots.get(spot).map(|s| s.size).unwrap_or_default(); + let value = format!("{}, {} {:.0}%", crate::canvas_keys::position_text(at.x, at.y), crate::i18n::tr("size"), size * 100.0); + let keys = crate::canvas_keys::handle(ui, &format!("spot:{spot}:{}", if source { "source" } else { "target" }), &name, value, q); + if (keys.activated || keys.delta.is_some()) && app.session.active_spot != Some(spot) { + let _ = app.run("spot.select", json!({"index": spot})); + } + if let Some((dx, dy)) = keys.delta { + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); + let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: [dx, dy]})); + let _ = app.run("develop.endInteraction", json!({})); + } + } + // where a new spot goes: the photo's centre at first, then wherever the arrows took it + let key = egui::Id::unique("lc-spot-placement"); + let place: (f64, f64) = ui.data(|m| m.get_temp(key)).unwrap_or((0.5, 0.5)); + let q = map.screen(Point::new(place.0, place.1)); + let keys = crate::canvas_keys::handle( + ui, + "spot:new", + crate::i18n::tr("New spot (Return adds it here)"), + crate::canvas_keys::position_text(place.0, place.1), + q, + ); + if let Some((dx, dy)) = keys.delta { + let moved = ((place.0 + dx).clamp(0.0, 1.0), (place.1 + dy).clamp(0.0, 1.0)); + ui.data_mut(|m| m.insert_temp(key, moved)); + } + if crate::canvas_keys::has_focus(ui.ctx()) && ui.memory(|m| m.has_focus(crate::canvas_keys::id("spot:new"))) { + let r = (app.ui.remove_size as f64 * (map.rect.width().max(map.rect.height())) as f64) as f32; + ui.painter().circle_stroke(q, r, Stroke::new(1.0, Color32::WHITE)); + } + if keys.activated { + let mode = match app.ui.tool.as_str() { + "heal" => "heal", + "clone" => "clone", + "fill" => "fill", + _ => "remove", + }; + let _ = app.run( + "spot.add", + json!({"mode": mode, "points": [[place.0, place.1]], "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), + ); + return true; + } + false +} + +// ------------------------------------------------------------------------ red eye + +/// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it. +pub(super) fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { + // screen points per long-edge unit + let o = map.screen(Point::new(0.0, 0.0)); + let (sx, sy) = (map.screen(Point::new(1.0, 0.0)).distance(o), map.screen(Point::new(0.0, 1.0)).distance(o)); + let per_long = sx.max(sy); + let p = ui.painter(); + for (i, e) in d.red_eye.iter().enumerate() { + let c = map.screen(e.center); + let r = vec2(e.rx as f32 * per_long, e.ry as f32 * per_long); + let sel = i == app.ui.eye; + let col = if sel { Color32::WHITE } else { Color32::from_white_alpha(150) }; + p.add(egui::Shape::ellipse_stroke(c, r, Stroke::new(if sel { 1.5 } else { 1.0 }, col))); + p.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.0, col)); + p.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.0, col)); + } + if resp.hover_pos().is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + // from where the button went down (a drag is recognized only after some movement) + app.gesture = Some(Gesture::Eye { a: map.norm(q) }); + } + if let Some(Gesture::Eye { a }) = app.gesture + && let Some(q) = resp.interact_pointer_pos() + { + let (a, b) = (map.screen(a), q); + p.add(egui::Shape::ellipse_stroke(a.lerp(b, 0.5), ((b - a) / 2.0).abs(), Stroke::new(1.0, Color32::WHITE))); + } + if resp.drag_stopped() + && let Some(Gesture::Eye { a }) = app.gesture.take() + && let Some(q) = resp.interact_pointer_pos() + { + let (sa, sb) = (map.screen(a), q); + let half = ((sb - sa) / 2.0).abs() / per_long; + if half.x > 0.002 && half.y > 0.002 { + let c = map.norm(sa.lerp(sb, 0.5)); + let r = app.run("redeye.add", json!({"center": [c.x, c.y], "rx": half.x, "ry": half.y, "pet": app.ui.eye_pet})); + if let Ok(v) = r { + app.ui.eye = v["index"].as_u64().unwrap_or(0) as usize; + } + } + } else if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + && let Some(i) = d.red_eye.iter().position(|e| { + let c = map.screen(e.center); + let (dx, dy) = ((q.x - c.x) / (e.rx as f32 * per_long).max(4.0), (q.y - c.y) / (e.ry as f32 * per_long).max(4.0)); + dx * dx + dy * dy <= 1.0 + }) + { + app.ui.eye = i; + } +} diff --git a/crates/ui-egui/src/panels/detail/window.rs b/crates/ui-egui/src/panels/detail/window.rs new file mode 100644 index 000000000..0b441913b --- /dev/null +++ b/crates/ui-egui/src/panels/detail/window.rs @@ -0,0 +1,119 @@ +use super::*; + +/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all) +/// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize +/// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high +/// pass reads about 3σ = 1.8 % of the long edge, inside the window's margin +/// ([`crate::region::margin_for`]: 4.5 %). Each is tested equal to the whole-frame render's pixels. +pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { + use lightcraft_pipeline::Overlay as O; + match overlay { + O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true, + } +} + +/// What a window render of the loupe needs to know about the view it belongs to. +pub(super) struct WindowCtx { + pub(super) id: PhotoId, + pub(super) overlay: lightcraft_pipeline::Overlay, + /// Long edge of the frame the windows are cut from (`None`: no windows). + pub(super) frame_edge: Option, + pub(super) drawn_long: f32, + pub(super) aspect: f32, + pub(super) ppp: f32, + pub(super) texture_side: usize, + pub(super) crop_tool: bool, + pub(super) interacting: bool, + /// Hash of the photo's develop settings. + pub(super) look: u64, + /// A pinch or two-finger scroll is running: keep the windows there are. + pub(super) holding: bool, +} + +/// One side of the loupe that gets a window render. +#[derive(Clone, Copy)] +pub(super) struct WindowView { + pub(super) slot: Slot, + /// The Before side (the photo without its edits). + pub(super) before: bool, + /// The rect the photo is drawn in. + pub(super) img: Rect, + /// The rect it is requested for (the drawn rect without a click-zoom animation). + pub(super) target: Rect, + /// The area whose pixels are on screen. + pub(super) visible: Rect, +} + +/// Ask for the window of `v` that holds what is on screen; remember it so its texture can be +/// drawn where it belongs. `None`: no window is wanted (or possible) for this view. +pub(super) fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option { + let frame_edge = c.frame_edge?; + // while a pinch or scroll runs the windows there are keep being drawn, magnified: no new one + // per frame (zooming out would ask for ever wider ones) + if c.holding { + return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); + } + let (fw, fh) = + if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) }; + let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); + // what is on screen, in pixels of the window's frame (the drawn frame, scaled) + let to_px = c.ppp * frame_edge as f32 / c.drawn_long.max(1.0); + let visible = ( + to_px * (v.visible.left() - v.target.left()), + to_px * (v.visible.top() - v.target.top()), + to_px * (v.visible.right() - v.target.left()), + to_px * (v.visible.bottom() - v.target.top()), + ); + let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; + // a window waits for the original another window job is decoding (see `defer_window`) + let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh))); + let window_pending = app.renderer.is_pending(Slot::Region) || app.renderer.is_pending(Slot::RegionBefore); + if crate::region::defer_window(window_pending, original_held) { + return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); + } + let job = if v.before { + app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool) + } else { + app.session.region_job(c.id, fw, fh, win, !c.crop_tool) + }; + // refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one + // render, so the whole frame is rendered at the drawn size instead (see `region::plan`) + let Some(job) = job else { + app.window_refused = Some((c.id, c.look, frame_edge)); + return None; + }; + // a drag renders drafts of the window, as it does of the whole frame. The Before tile stays + // plain; the After tile carries the same mask overlay as the whole-frame render. + let job = if c.interacting { job.draft() } else { job }; + let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay }; + let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) }; + // the After window is proofed like the whole-frame render under it; the Before side is + // never proofed (its whole-frame render isn't either) + let proof = app.ui.soft_proof.then_some(app.ui.proof); + let job = job.with_overlay(overlay).with_proof(if v.before { None } else { proof }); + // keyed as the renderer keys the texture (a display profile changes the key) + let job = app.renderer.job_for(v.slot, job); + // The overlay and the proof are part of the tile's identity too, so changing mask view/colour + // or the proof never leaves a stale region tile on screen while its replacement renders. + let proof_key = if v.before { 0 } else { proof.map_or(0, |p| p.key()) }; + let look = job.settings.hash64() ^ overlay.key().rotate_left(17) ^ proof_key.rotate_left(41); + let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; + // windows of other photos, looks and zooms of this side can't be drawn any more + app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); + app.region_tiles.insert((v.before, job.key), view); + app.renderer.request(v.slot, job, 99); + Some(view) +} + +/// Draw `v`'s window texture over the whole-frame render, if it is the one asked for (or, in a +/// drag, an earlier draft of it). +pub(super) fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &WindowView, wanted: &crate::region::RegionView) { + let Some(tex) = app.renderer.textures.get(&v.slot).filter(|t| t.photo == c.id) else { return }; + let Some(tile) = app.region_tiles.get(&(v.before, tex.key)).filter(|t| t.is_current(c.id, wanted.full, wanted.settings, c.interacting)) else { + return; + }; + let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32); + let at = v.img.min + vec2(tile.window.x as f32 / fw * v.img.width(), tile.window.y as f32 / fh * v.img.height()); + let dst = Rect::from_min_size(at, vec2(tile.window.w as f32 / fw * v.img.width(), tile.window.h as f32 / fh * v.img.height())); + p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); +} From 15ece5cbd0076ef06e292dcd8c1c77b1311c7a1a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:11:14 +0000 Subject: [PATCH 120/191] refactor(lightcraft-ui-egui): split menus.rs into menus/ --- crates/ui-egui/src/menus.rs | 2537 +---------------------- crates/ui-egui/src/menus/files.rs | 262 +++ crates/ui-egui/src/menus/run.rs | 1992 ++++++++++++++++++ crates/ui-egui/src/menus/ui_commands.rs | 280 +++ 4 files changed, 2542 insertions(+), 2529 deletions(-) create mode 100644 crates/ui-egui/src/menus/files.rs create mode 100644 crates/ui-egui/src/menus/run.rs create mode 100644 crates/ui-egui/src/menus/ui_commands.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 63b7b3f63..b6b6c66c5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -8,2280 +8,20 @@ use crate::LightcraftApp; use crate::pick::{PickRequest, Picked}; use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; +mod files; +mod run; +mod ui_commands; + +pub use files::{confirm_delete, delete_in_album, reveal_label, show_in_folders_enabled}; +pub use run::{parse_rgb, run_ui_command}; +pub use ui_commands::{LANGUAGE_COMMANDS, UI_COMMANDS, language_from_command, ui_commands}; + pub mod menu_level; pub mod menubar; /// (id, label, shortcut, menu path) pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str); -/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language -/// has a matching menu command (also listed through the control channel). Tests check coverage. -pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ - ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), - // The picker order (`Locale::for_picker`): alphabetical by the language's own name after - // English; Cyrillic, Georgian, then CJK names follow the Latin ones. A test keeps them equal. - ("app.language.indonesian", crate::i18n::Locale::Id.name(), None, "Edit>Language"), - ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), - ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), - ("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"), - ("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"), - ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), - ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), - ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), - ("app.language.finnish", crate::i18n::Locale::Fi.name(), None, "Edit>Language"), - ("app.language.swedish", crate::i18n::Locale::Sv.name(), None, "Edit>Language"), - ("app.language.vietnamese", crate::i18n::Locale::Vi.name(), None, "Edit>Language"), - ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), - ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), - ("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"), - ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), - ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), - ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), -]; - -/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from -/// this file, so an id listed in `docs/parity-checklist.md` is checked wherever it is declared. -pub fn ui_commands() -> impl Iterator { - LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS) -} - -/// The language a Language-menu command selects, if the id is one. The engine and the UI both go -/// through here, so the menu, the settings row and the control channel agree on the mapping. -pub fn language_from_command(id: &str) -> Option { - match id { - "app.language.english" => Some(crate::i18n::Locale::En), - "app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans), - "app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant), - "app.language.japanese" => Some(crate::i18n::Locale::Ja), - "app.language.portuguese" => Some(crate::i18n::Locale::PtBr), - "app.language.french" => Some(crate::i18n::Locale::Fr), - "app.language.spanish" => Some(crate::i18n::Locale::Es), - "app.language.german" => Some(crate::i18n::Locale::De), - "app.language.russian" => Some(crate::i18n::Locale::Ru), - "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), - "app.language.polish" => Some(crate::i18n::Locale::Pl), - "app.language.czech" => Some(crate::i18n::Locale::Cs), - "app.language.italian" => Some(crate::i18n::Locale::It), - "app.language.georgian" => Some(crate::i18n::Locale::Ka), - "app.language.swedish" => Some(crate::i18n::Locale::Sv), - "app.language.finnish" => Some(crate::i18n::Locale::Fi), - "app.language.indonesian" => Some(crate::i18n::Locale::Id), - "app.language.vietnamese" => Some(crate::i18n::Locale::Vi), - _ => None, - } -} - -pub const UI_COMMANDS: &[UiCommand] = &[ - ("ui.dock", "Arrange Panel", None, ""), - ("ui.dock.reset", "Reset Panel Layout", None, "Window"), - ("modelSetup.cancel", "Cancel Pending Photo Action", None, ""), - ("view.photoGrid", "Photo Grid", None, "View"), - ("view.squareGrid", "Square Grid", None, "View"), - // G: Photo Grid ↔ Square Grid (from other views: the photo grid) - ("view.gridToggle", "Grid", Some("G"), ""), - ("library.selectOnlyActive", "Select Only Active Photo", Some("Cmd+Shift+D"), "Edit"), - ("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"), - ("view.detail", "Detail View", Some("D"), "View"), - ("view.compare", "Compare", Some("Shift+C"), "View"), - ("view.survey", "Survey", Some("N"), "View"), - ("view.people", "People", None, "View"), - ("view.person", "Show Person", None, ""), - ("view.faceBoxes", "Face Boxes", None, "View"), - ("view.focusPoint", "Focus Point", Some("Alt+F"), "View"), - ("view.zoomToFocus", "Zoom to Focus Point", None, "View"), - ("view.reference", "Reference View", Some("Shift+R"), "View"), - ("photo.setReference", "Set as Reference Photo", None, ""), - ("compare.swap", "Swap Compare Photos", None, "View"), - ("compare.set", "Show in Compare", None, ""), - ("compare.makeSelect", "Make Candidate the Select", None, "View"), - ("view.autoAdvance", "Auto Advance", None, "Photo"), - ("view.filmstrip", "Filmstrip", Some("/"), "View"), - ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), - ("view.rightPanel", "Right Panel", None, "View"), - ("view.beforeAfter", "Before/After Side by Side", Some("Y"), "View"), - ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), - ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), - ("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"), - ("view.showOriginal", "Show Original", Some("\\"), "View"), - ("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"), - ("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"), - ("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"), - // the ratio a click (and Z / Space) zooms to - ("view.clickZoom", "Click Zoom Ratio", None, ""), - ("view.navigate", "Set Image Zoom and Pan", None, ""), - ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), - ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), - // Library grids only (Photo Grid, Square Grid, People): the bottom-bar slider's step by key - ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), - ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), - ("view.clipping", "Clipping", Some("J"), "View"), - ("view.visualizeHdr", "Visualize HDR", None, "View"), - // in grids S expands/collapses stacks (the engine command it shadows) - ("view.softProof", "Soft Proofing", Some("S"), "View"), - ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), - ("view.maskOverlay", "Mask Overlay", Some("O"), "View"), - // Ctrl+Shift+O / Cmd+Shift+O: every mask's region at once, not just the selected one - ("view.maskOverlayAll", "All Mask Overlays", Some("Cmd+Shift+O"), "View"), - // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) - ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), - ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), - ("view.maskPins", "Mask Pins", None, "View"), - // {mode?: auto | always | selected | never}: when spot and mask pins show (steps to the next - // mode when omitted; Shift+H, like Classic's H for pins, since H opens Remove). View ▸ Tool - // Overlay and the Remove panel list the modes. - ("view.toolOverlay", "Cycle Tool Overlay", Some("Shift+H"), ""), - ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), - // A while the Crop panel is open: the aspect lock as a first-class row. Visualize Spots keeps - // the key outside the panel; the two are a contextual pair in shortcuts::handle - ("crop.lockAspect", "Lock Aspect Ratio", Some("A"), ""), - ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), - ("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"), - ("view.back", "Back to Grid", Some("Escape"), ""), - ("tool.done", "Done", Some("Enter"), ""), - ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), - ("local.addRoot", "Add Folder to Local", None, ""), - ("local.hide", "Remove from Local", None, ""), - ("local.restoreHidden", "Show Hidden Local Locations", None, ""), - // {on?}: the Info panel's Target Photo / Selected Photos choice (on = Selected Photos) - ("view.infoSelectedPhotos", "Show Metadata for Selected Photos", None, ""), - ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), - ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), - ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), - ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), - ("view.viewOptions", "View Options…", Some("Cmd+J"), "View"), - ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), - ("view.layoutImage", "Layout Image", Some("Cmd+Alt+O"), "View"), - ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), - ("view.layoutImageMatte", "Layout Image Matte", None, ""), - ("view.navigator", "Navigator", None, "View"), - ("panel.edit", "Edit Panel", Some("E"), "Window"), - ("panel.profiles", "Profile Browser", None, "Window"), - ("panel.crop", "Crop & Rotate", Some("C"), "Window"), - ("panel.remove", "Remove", Some("H"), "Window"), - ("panel.masking", "Masking", Some("M"), "Window"), - ("panel.redeye", "Red Eye", None, "Window"), - ("panel.presets", "Presets", Some("Shift+P"), "Window"), - ("panel.info", "Info", Some("I"), "Window"), - ("panel.keywords", "Keywords", Some("K"), "Window"), - // the Keywords panel with the cursor in its Add keyword box - ("panel.addKeywords", "Add Keywords", Some("Cmd+K"), ""), - ("panel.versions", "Versions", Some("Shift+V"), "Window"), - ("panel.activity", "History", None, "Window"), - ("panel.close", "Close Panel", None, ""), - // {solo?}: true opens just that section (⌥-click on its header); otherwise opens or closes it - ("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"), - ("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"), - ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), - ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), - ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), - ("section.calibration", "Calibration", None, ""), - // {on?}; no params toggles. Opening one Edit section closes the others. - ("view.soloSections", "Solo Mode", None, "Window>Edit Sections"), - ("tool.brush", "Brush", Some("B"), "Window>Tools"), - ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), - ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), - ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), - ("tool.none", "No Tool", None, ""), - // brush size / feather of the active brush (Masking brush, Remove tool and its selected spot) - ("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"), - ("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"), - ("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"), - ("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"), - ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), - ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), - ("dialog.smartAlbum", "New Smart Album…", None, "File"), - ("view.photoCounts", "Photo Counts", None, "View"), - ("view.sidebarSection", "Show Sidebar Section", None, ""), - ("view.sidebarMoveSection", "Move Sidebar Section", None, ""), - ("view.sidebarReset", "Reset Sidebar Sections", None, ""), - ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), - ("view.slideshowOptions", "Slideshow Options…", None, "View"), - ("dialog.slideshowExport", "Export Slideshow…", None, "View"), - ("app.slideshowExport", "Export Slideshow", None, ""), - // the slideshow's playback controls (Space, ←/→ and Esc while it plays) - ("slideshow.togglePause", "Pause / Resume Slideshow", None, ""), - ("slideshow.next", "Next Slide", None, ""), - ("slideshow.previous", "Previous Slide", None, ""), - ("slideshow.end", "End Slideshow", None, ""), - ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), - ("tool.keywordPainter", "Keyword Painter", None, ""), - ("view.gridInfo", "Grid Info", None, ""), - // {mode?: auto | light | dark}: Appearance Mode (cycles Auto, Light, Dark when omitted; the - // top bar's appearance button). View ▸ Appearance lists the modes. - ("view.appearance", "Next Appearance Mode", None, ""), - // {theme: charcoal | midnight | silver | paper}: pick a theme and fix the mode to its family. - ("view.theme", "Theme", None, ""), - ("dialog.allMetadata", "All Metadata…", None, "Photo"), - ("dialog.faceModel", "Add Face Model…", None, ""), - ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), - ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), - ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), - ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"), - ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), - ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), - ("dialog.export", "Export…", None, "File"), - ("dialog.print", "Print…", Some("Cmd+P"), "File"), - ("app.print", "Print to PDF", None, ""), - ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), - ("app.contactSheet", "Export Contact Sheet PDF", None, ""), - ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), - ("photo.editInExternal", "Edit in Other Editor", Some("Cmd+Alt+E"), "Photo>Edit In"), - // {ids?, app?}: the original files themselves (a raw stays a raw: no render, no copy), opened - // in the other editor, e.g. a raw developer - ("photo.openOriginalInExternal", "Open Original in Other Editor (RAW, no copy)", None, "Photo>Edit In"), - ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), - ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), - ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), - ("merge.hdrLast", "HDR with Last Settings", Some("Ctrl+Shift+H"), "Photo>Photo Merge"), - ("merge.panoramaLast", "Panorama with Last Settings", Some("Ctrl+Shift+M"), "Photo>Photo Merge"), - ("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"), - ("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"), - ("file.addFolder", "Import from Folder…", None, "File"), - // {path?, into?: open | new, name?, location?}: asks where the catalog goes unless `into` (or - // Settings) says (#2672, `lightroom_destination`) - ("file.importLightroom", "Import Lightroom Catalog…", None, "File"), - ("file.addFromDevice", "Import from Device", None, ""), - ("file.findMissing", "Find Missing Photos…", None, "File"), - ("file.backupLibrary", "Back Up Library…", None, "File"), - ("file.restoreLibrary", "Restore Library from Backup…", None, "File"), - ("photo.locate", "Locate Missing File…", None, ""), - ("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""), - ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), - ("file.importPresets", "Import Profiles & Presets…", None, "File"), - ("file.exportPresets", "Export Presets…", None, "File"), - // Masking panel ▸ Effect menu - ("dialog.saveMaskEffect", "Save Current Settings as New Effect Preset…", None, ""), - ("dialog.manageMaskEffects", "Manage Effect Presets…", None, ""), - ("file.exportMaskEffects", "Export Effect Presets…", None, ""), - ("file.importKeywords", "Import Keywords…", None, "File"), - ("file.exportKeywords", "Export Keywords…", None, "File"), - // Edit panel ▸ Curve ▸ Point Curve dropdown - ("file.importCurvePresets", "Import Point Curve Presets…", None, ""), - ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), - ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), - ("app.newLibrary", "New Library…", None, "File"), - ("app.openLibrary", "Open Library…", Some("Cmd+O"), "File"), - ("app.recentLibraries", "Recent Libraries", None, ""), - ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), - // Settings ▸ Display - ("app.displayProfile", "Display Profile", None, ""), - ("app.about", "About LightCraft", None, "Help"), - ("app.checkForUpdates", "Check for Updates…", None, "Help"), - ("app.systemInfo", "System Info…", None, "Help"), - ("app.openLogFolder", "Open Log Folder", None, "Help"), - // the frame-rate display (`frame_rate`): a debugging aid, never saved - ("view.frameRate", "Frame Rate", None, "Help>Debug"), - ("app.whatsNew", "What's New", None, "Help"), - ("dialog.cull", "Assisted Culling…", None, "Photo"), - ("app.help", "LightCraft Help", Some("F1"), "Help"), - ("app.discord", "Join the ArtCraft Discord…", None, "Help"), - ("app.feedback", "Send Feedback…", None, "Help"), - ("app.website", "LightCraft Website", None, "Help"), - ("app.github", "LightCraft on GitHub", None, "Help"), - ("app.artcraft", "ArtCraft Website", None, "Help"), - ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), - ("app.setShortcut", "Set Keyboard Shortcut", None, ""), - ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), - ("app.export", "Export Now", None, ""), - ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), - ("app.showInFolders", "Show in Folders", None, "Photo"), - ("app.openSamModelFolder", "Show in File Manager", None, ""), - ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), - ("dialog.labelNames", "Edit Color Label Names…", None, ""), - ("dialog.captureTime", "Edit Capture Time…", None, "Photo"), - ("photo.tagFromTracklog", "Auto-Tag from Tracklog…", None, "Photo"), - ("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"), -]; - -fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { - if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) { - app.ui.right = RightPanel::None; - app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name))); - } else { - app.ui.right = p; - app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name))); - if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { - app.ui.view = ViewMode::Detail; - } - } - if p != RightPanel::Masking && app.ui.tool != "wbPicker" { - app.ui.tool.clear(); - } - let _ = app.session.end_interaction(); -} - -/// `[` / `]` (size ×`k`) and ⇧`[` / ⇧`]` (feather +`df`) for the brush in use: the Remove tool's -/// (and its selected spot's) or the Masking brush's. -fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { - if app.ui.right == RightPanel::Remove { - app.ui.remove_size = (app.ui.remove_size * k).clamp(0.001, 0.25); - app.ui.remove_feather = (app.ui.remove_feather + df).clamp(0.0, 100.0); - if app.session.active_spot.is_some() { - let mut p = json!({}); - if k != 1.0 { - p["size"] = json!(app.ui.remove_size); - } - if df != 0.0 { - p["feather"] = json!(app.ui.remove_feather); - } - let _ = app.run("spot.update", p); - } - json!({"size": app.ui.remove_size, "feather": app.ui.remove_feather}) - } else { - app.ui.brush_size = (app.ui.brush_size * k).clamp(0.002, 0.5); - app.ui.brush_feather = (app.ui.brush_feather + df).clamp(0.0, 100.0); - json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather}) - } -} - -/// The `parent` folder id of a `dialog.new*` command (none: the top level). -fn parent_param(p: &Value) -> Option { - p.get("parent").and_then(Value::as_u64) -} - -/// `file.addPhotos` / `file.addFolder` / `file.addFromDevice` `{album?}`: the review of the scan -/// just started opens with that album chosen (an album's context menu ▸ Import ▸ …). An error -/// means another scan is running. -fn import_into_album(app: &mut LightcraftApp, p: &Value, r: Result) -> Result { - if r.is_ok() - && let Some(t) = &mut app.scan - { - t.album = p.get("album").and_then(Value::as_u64); - } - r -} - -/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). -pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { - if let Some(s) = v.as_str() { - let h = s.strip_prefix('#').unwrap_or(s); - if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { - return None; - } - let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); - return Some([c(0)?, c(2)?, c(4)?]); - } - let a = v.as_array()?; - let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8); - Some([c(0)?, c(1)?, c(2)?]) -} - -/// The left-sidebar section a `view.sidebar*` command names (`section`), which this build must have. -fn sidebar_section_param(command: &str, p: &Value) -> Result { - use crate::sidebar::SidebarSection; - let Some(name) = p.get("section").and_then(Value::as_str) else { - return Err(format!("{command}: `section` is needed ({})", SidebarSection::ids())); - }; - let section = SidebarSection::from_id(name).ok_or_else(|| format!("{command}: unknown section `{name}` ({})", SidebarSection::ids()))?; - if !section.available() { - return Err(format!("{command}: there is no `{name}` section here")); - } - Ok(section) -} - -/// An optional true / false parameter: absent or null is `None`, anything else is refused. -fn bool_param(command: &str, p: &Value, key: &str) -> Result, String> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::Bool(b)) => Ok(Some(*b)), - Some(other) => Err(format!("{command}: `{key}` must be true or false, not {other}")), - } -} - -/// What Shift+H says it switched to (the pins themselves may be off screen). -fn tool_overlay_toast(mode: crate::state::ToolOverlay) -> String { - use crate::state::ToolOverlay; - crate::i18n::tr(match mode { - ToolOverlay::Auto => "Tool Overlay: Auto", - ToolOverlay::Always => "Tool Overlay: Always", - ToolOverlay::Selected => "Tool Overlay: Selected", - ToolOverlay::Never => "Tool Overlay: Never", - }) - .to_string() -} - -fn appearance_json(app: &LightcraftApp) -> Value { - let s = &app.ui.settings; - json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) -} - -/// `view.appearance {mode?}`: set Appearance Mode, or step to the next one (Auto, Light, Dark). -/// The theme follows on the next frame ([`LightcraftApp::sync_theme`]). -fn appearance_command(app: &mut LightcraftApp, p: &Value) -> Result { - use crate::state::AppearanceMode; - let mode = match p.get("mode") { - None | Some(Value::Null) => app.ui.settings.appearance_mode.next(), - Some(v) => match v.as_str() { - Some("auto") => AppearanceMode::Auto, - Some("light") => AppearanceMode::Light, - Some("dark") => AppearanceMode::Dark, - _ => return Err(format!("view.appearance: unknown mode {v} (auto|light|dark)")), - }, - }; - app.ui.settings.appearance_mode = mode; - Ok(appearance_json(app)) -} - -/// `view.theme {theme}`: pick a theme; the mode becomes its family's (Dark or Light). -fn theme_command(app: &mut LightcraftApp, p: &Value) -> Result { - let ids = crate::theme::ThemeKind::ALL.map(|k| k.id()).join("|"); - let Some(id) = p.get("theme").and_then(Value::as_str) else { - return Err(format!("view.theme: `theme` is required ({ids})")); - }; - let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| k.id() == id) else { - return Err(format!("view.theme: unknown theme `{id}` ({ids})")); - }; - app.ui.settings.select_theme(kind); - Ok(appearance_json(app)) -} - -/// Handle UI commands; `None` means "not a UI command — send it to the engine". -pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { - if matches!(id, "ui.dock" | "ui.dock.reset") { - return Some(crate::docking::command(app, id == "ui.dock.reset", p)); - } - if matches!(id, "library.inspectLightroom" | "library.importLightroom") { - // the app's own context: a fresh one's repaints reach no window and its clock starts at zero - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - return Some(crate::lightroom_import::command(app, id, p, &ctx)); - } - if id == "photo.reload" { - // the files are read in the background (a raw's decode takes a second or more) - return crate::reload::command(app, p); - } - if let Some(language) = language_from_command(id) { - app.ui.language = language; - // Immediately, not on the next frame: the reply and anything else run this frame - // (menus rebuilt from it, toasts) are already in the new language. - crate::i18n::set_language(app.ui.language); - return Some(Ok(json!(app.ui.language))); - } - // the app's own context: a toast's time comes from its clock (a fresh context's clock is at 0, - // and a toast set by it was long over by the app's clock: it never showed) - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - let r: Result = match id { - "view.photoGrid" => { - app.ui.view = ViewMode::PhotoGrid; - Ok(Value::Null) - } - "view.squareGrid" => { - app.ui.view = ViewMode::SquareGrid; - Ok(Value::Null) - } - "view.gridInfo" => { - // {info?: filename | exposure | date} (cycles when omitted) - let next = match p.get("info").and_then(Value::as_str) { - Some(i @ ("filename" | "exposure" | "date")) => i.to_string(), - Some(other) => return Some(Err(format!("view.gridInfo: unknown `{other}` (filename|exposure|date)"))), - None => match app.ui.grid_info.as_str() { - "filename" => "exposure".into(), - "exposure" => "date".into(), - _ => "filename".into(), - }, - }; - app.ui.grid_info = next; - app.ui.show_filenames = true; - // the caption is drawn on Square Grid cells only: from Photo Grid, show it there - if app.ui.view == ViewMode::PhotoGrid { - app.ui.view = ViewMode::SquareGrid; - } - Ok(json!({"info": app.ui.grid_info})) - } - "tool.keywordPainter" => { - // {keyword?}: paint that keyword onto photos in the grid by clicking them; no keyword stops - app.ui.keyword_painter = p.get("keyword").and_then(Value::as_str).map(str::trim).filter(|k| !k.is_empty()).map(str::to_string); - if app.ui.keyword_painter.is_some() && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { - app.ui.view = ViewMode::PhotoGrid; - } - if let Some(k) = app.ui.keyword_painter.clone() { - app.toast(&ctx, crate::i18n::tr_format!("Painting “{k}”: click photos to add or remove it · Esc stops", k = k)); - } - Ok(json!({"keyword": app.ui.keyword_painter})) - } - "view.secondWindow" => { - app.ui.second_window = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.second_window); - Ok(json!({"show": app.ui.second_window})) - } - "view.photoCounts" => { - app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts); - Ok(json!({"show": app.ui.show_counts})) - } - "view.sidebarSection" => { - // {section, show?, collapsed?}: show or hide a section of the left sidebar, fold or - // unfold it; with neither, a shown section is hidden and a hidden one shown - let args = sidebar_section_param(id, p).and_then(|s| Ok((s, bool_param(id, p, "show")?, bool_param(id, p, "collapsed")?))); - let (section, show, collapsed) = match args { - Ok(args) => args, - Err(e) => return Some(Err(e)), - }; - let layout = &mut app.ui.sidebar; - match (show, collapsed) { - (None, None) => layout.set_hidden(section, !layout.is_hidden(section)), - _ => { - if let Some(show) = show { - layout.set_hidden(section, !show); - } - if let Some(collapsed) = collapsed { - layout.set_collapsed(section, collapsed); - } - } - } - Ok(json!({"section": section.id(), "show": !layout.is_hidden(section), "collapsed": layout.is_collapsed(section)})) - } - "view.sidebarMoveSection" => { - // {section, direction: up | down} one place among the shown sections, or - // {section, before:
| null} just above another section (null: last) - let section = match sidebar_section_param(id, p) { - Ok(s) => s, - Err(e) => return Some(Err(e)), - }; - let layout = &mut app.ui.sidebar; - let moved = match (p.get("direction"), p.get("before")) { - (Some(d), None) => match d.as_str() { - Some("up") => layout.move_by(section, true), - Some("down") => layout.move_by(section, false), - _ => return Some(Err(format!("{id}: `direction` must be \"up\" or \"down\", not {d}"))), - }, - (None, Some(Value::Null)) => layout.move_before(section, None), - (None, Some(b)) => { - let before = b.as_str().and_then(crate::sidebar::SidebarSection::from_id); - let Some(before) = before else { - return Some(Err(format!("{id}: `before` must be a section ({}) or null, not {b}", crate::sidebar::SidebarSection::ids()))); - }; - layout.move_before(section, Some(before)) - } - _ => return Some(Err(format!("{id}: give either `direction` (up|down) or `before` (a section, or null for last)"))), - }; - let order: Vec<&str> = layout.sections().iter().map(|s| s.id.id()).collect(); - Ok(json!({"section": section.id(), "moved": moved, "order": order})) - } - "view.sidebarReset" => { - // every section shown and open, in the usual order - app.ui.sidebar = Default::default(); - Ok(json!({"sidebar": app.ui.sidebar})) - } - "view.appearance" => appearance_command(app, p), - "view.theme" => theme_command(app, p), - "library.selectOnlyActive" => { - let Some(active) = app.session.active() else { return Some(Err("no active photo".into())) }; - app.run("library.select", json!({"ids": [active.0], "active": active.0})) - } - "view.gridToggle" => { - app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; - Ok(Value::Null) - } - "view.detail" => { - app.ui.view = ViewMode::Detail; - Ok(Value::Null) - } - "view.compare" => crate::panels::compare::enter_compare(app), - "view.focusPoint" => { - // {on?}; no params toggles. The camera's focus point (Sony, from the file) over the photo. - app.ui.settings.focus_point = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.focus_point); - Ok(json!({"on": app.ui.settings.focus_point})) - } - "view.zoomToFocus" => { - // {on?}; no params toggles. Toggle Zoom (Z) centres on the focus point when there is one. - app.ui.settings.zoom_to_focus = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.zoom_to_focus); - Ok(json!({"on": app.ui.settings.zoom_to_focus})) - } - "view.faceBoxes" => { - app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); - Ok(json!({"show": app.ui.face_boxes})) - } - "view.people" => { - // Everyone; the page just left stays one click away (the chip next to the title) - if let Some(name) = app.ui.person_page.take() { - app.ui.last_person = Some(name); - } - app.ui.person_from = None; - app.ui.view = ViewMode::People; - Ok(json!({"people": app.session.catalog.people().len()})) - } - "view.person" => { - // {name}: one person's page in the People view: their faces, and the faces that look like them - let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { - return Some(Err("view.person: missing `name`".into())); - }; - app.ui.view = ViewMode::People; - app.ui.person_from = None; - app.ui.person_page = Some(name.to_string()); - Ok(json!({"person": name})) - } - "view.survey" => { - app.ui.view = ViewMode::Survey; - Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) - } - "view.reference" => { - // the reference: the one set before, else the active photo (the next one becomes active) - let vis = app.session.visible_cloned(); - let live = |id: u64| crate::panels::compare::live(app, lightcraft_catalog::PhotoId(id)); - let active = app.session.active().map(|a| a.0); - let reference = app.ui.reference.filter(|r| live(*r)).or_else(|| active.filter(|a| live(*a))); - let Some(r) = reference else { - return Some(Err(match active { - Some(a) => format!("photo {a} is in Recently Deleted: restore it to use it as the reference"), - None => "select a photo to use as the reference".into(), - })); - }; - app.ui.reference = Some(r); - if app.session.active().is_none_or(|a| a.0 == r) - && let Some(i) = vis.iter().position(|x| x.0 == r) - && let Some(next) = vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))) - { - let _ = app.session.execute("library.select", &json!({"ids": [next.0]})); - } - app.ui.view = ViewMode::Reference; - Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)})) - } - "photo.setReference" => { - // only an absent (or null) `id` means the active photo; any other non-id is refused - let id = match p.get("id").filter(|v| !v.is_null()) { - Some(v) => match v.as_u64() { - Some(id) => Some(id), - None => return Some(Err(format!("photo.setReference: `id` must be a photo id, not {v}"))), - }, - None => app.session.active().map(|a| a.0), - }; - let Some(id) = id else { return Some(Err("select a photo to use as the reference".into())) }; - match app.session.catalog.photo(lightcraft_catalog::PhotoId(id)) { - None => return Some(Err(format!("no photo {id}"))), - Some(p) if p.deleted => return Some(Err(format!("photo {id} is in Recently Deleted: restore it to use it as the reference"))), - Some(_) => {} - } - app.ui.reference = Some(id); - Ok(json!({"reference": id})) - } - "compare.swap" => crate::panels::compare::swap(app), - "compare.set" => { - let Some(id) = p.get("id").and_then(Value::as_u64) else { return Some(Err("compare.set needs `id`".into())) }; - crate::panels::compare::set_photo(app, lightcraft_catalog::PhotoId(id), p.get("side").and_then(Value::as_str)) - } - "compare.makeSelect" => crate::panels::compare::make_select(app), - "view.autoAdvance" => { - app.ui.auto_advance = !app.ui.auto_advance; - app.toast(&ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); - Ok(json!({"autoAdvance": app.ui.auto_advance})) - } - "view.back" => { - if app.ui.dialog.is_some() { - app.ui.dialog = None; - } else if app.ui.lights_out != crate::state::LightsOut::On { - app.ui.lights_out = crate::state::LightsOut::On; - } else if app.ui.keyword_painter.is_some() { - app.ui.keyword_painter = None; - } else if app.ui.fullscreen { - // Through `slideshow::end`: it also releases the held slide textures. Clearing the - // two fields directly left every rendered slide in the renderer. - crate::panels::slideshow::end(app); - } else if !app.ui.tool.is_empty() { - app.ui.tool.clear(); - } else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail { - // Esc leaves the crop tool (the crop stays), as C does; the next Esc goes back - panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); - } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { - app.ui.person_page = None; - } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey | ViewMode::Reference) { - app.ui.view = ViewMode::Detail; - } else if app.ui.view == ViewMode::Detail { - // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid - match app.ui.person_from.take() { - Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { - app.ui.view = ViewMode::People; - app.ui.person_page = name; - } - _ => app.ui.view = ViewMode::PhotoGrid, - } - } - Ok(Value::Null) - } - "view.slideshow" => { - // the photos in view, full screen, with the playback settings (see panels::slideshow) - let now = app.tick_now(); - let r = crate::panels::slideshow::start(app, p, now); - if r.is_ok() { - app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); - } - r - } - "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => { - let now = app.tick_now(); - crate::panels::slideshow::command(app, id, now) - } - "view.slideshowOptions" => { - app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() }); - Ok(Value::Null) - } - "view.fullScreenPreview" => { - if app.ui.fullscreen { - // Through `slideshow::end`: it also releases the held slide textures. Clearing the - // two fields directly left every rendered slide in the renderer. - crate::panels::slideshow::end(app); - } else { - app.ui.fullscreen = true; - app.ui.zoom = Zoom::Fit; - app.ui.tool.clear(); - let _ = app.session.end_interaction(); - } - Ok(json!({"fullscreen": app.ui.fullscreen})) - } - "view.lightsOut" => { - // {mode?: on | dim | off} (cycles when omitted) - match p.get("mode").and_then(Value::as_str) { - Some(m) => serde_json::from_value(json!(m)).map_err(|_| format!("unknown lights-out mode: {m} (on|dim|off)")), - None => Ok(app.ui.lights_out.next()), - } - .map(|mode| { - app.ui.lights_out = mode; - json!({"lightsOut": mode}) - }) - } - "view.enterFullScreen" => { - // applied by the host's frame logic (it knows the window's current state) - let on = p.get("on").and_then(Value::as_bool); - app.ui.window_fullscreen = Some(on.unwrap_or(!app.window_is_fullscreen)); - Ok(json!({"windowFullscreen": app.ui.window_fullscreen})) - } - "view.infoOverlay" => { - app.ui.info_overlay = match p.get("mode").and_then(Value::as_str) { - Some(m) => match serde_json::from_value(json!(m)) { - Ok(v) => v, - Err(_) => return Some(Err(format!("unknown info overlay `{m}` (off|basic|exposure)"))), - }, - None => app.ui.info_overlay.next(), - }; - let label = match app.ui.info_overlay { - crate::state::InfoOverlay::Off => "Info Overlay Off", - crate::state::InfoOverlay::Basic => "Info Overlay: File", - crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", - }; - app.toast(&ctx, crate::i18n::tr(label)); - Ok(json!({"infoOverlay": app.ui.info_overlay})) - } - "view.viewOptions" => { - // `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes - // shows and `loupeMessages` Show message when loading or rendering photos, for an agent; - // without any of them the window opens - let mut info = app.ui.settings.loupe_info; - let mut set = false; - // `loupeMessages` sets Show message when loading or rendering photos - let messages = match p.get("loupeMessages") { - None => app.ui.settings.loupe_messages, - Some(v) => match v.as_bool() { - Some(on) => { - set = true; - on - } - None => return Some(Err("`loupeMessages`: true|false".into())), - }, - }; - for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { - let Some(v) = p.get(key) else { continue }; - match serde_json::from_value(v.clone()) { - Ok(v) => *lines = v, - Err(_) => { - return Some(Err(format!("`{key}`: three of none|fileName|captureDate|dimensions|exposure|camera|lens|cameraAndLens"))); - } - } - set = true; - } - if let Some(v) = p.get("briefly") { - match serde_json::from_value(v.clone()) { - Ok(v) => info.briefly = v, - Err(_) => return Some(Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into())), - } - set = true; - } - if set { - app.ui.settings.loupe_info = info; - app.ui.settings.loupe_messages = messages; - } else { - app.ui.dialog = Some(Dialog::ViewOptions); - } - Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages})) - } - "view.chooseLayoutImage" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(path) => path.trim().to_string(), - None => { - let req = PickRequest::file(crate::i18n::tr("Choose Layout Image…"), "PNG", &["png"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { - Picked::Now(paths) => match paths.first() { - Some(path) => path.trim().to_string(), - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform; supply path".into())), - } - } - }; - if path.is_empty() { - return Some(Err("view.chooseLayoutImage: path is empty".into())); - } - let li = &mut app.ui.layout_image; - li.path = path; - li.show = true; - li.revision = li.revision.wrapping_add(1); - app.toast(&ctx, "Layout Image On"); - Ok(json!({"layoutImage": app.ui.layout_image})) - } - "view.layoutImage" => { - let show = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.layout_image.show); - // nothing chosen yet: showing it chooses one first - if show && app.ui.layout_image.path.trim().is_empty() { - return run_ui_command(app, "view.chooseLayoutImage", &json!({})); - } - app.ui.layout_image.show = show; - app.toast(&ctx, if show { "Layout Image On" } else { "Layout Image Off" }); - Ok(json!({"layoutImage": app.ui.layout_image})) - } - "view.layoutImageOpacity" | "view.layoutImageMatte" => { - let key = if id == "view.layoutImageOpacity" { "opacity" } else { "matte" }; - let Some(v) = p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) else { - return Some(Err(format!("{id}: {key} (0..1) is required"))); - }; - let v = v.clamp(0.0, 1.0) as f32; - if key == "opacity" { - app.ui.layout_image.opacity = v; - } else { - app.ui.layout_image.matte = v; - } - Ok(json!({"layoutImage": app.ui.layout_image})) - } - "view.navigator" => { - app.ui.navigator = !app.ui.navigator; - Ok(json!({"navigator": app.ui.navigator})) - } - "app.settings" => { - let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general"); - if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) { - let tabs: Vec<&str> = crate::panels::settings::TABS.iter().map(|(id, _)| *id).collect(); - return Some(Err(format!("unknown settings tab `{tab}` ({})", tabs.join("|")))); - } - app.ui.dialog = Some(Dialog::Settings { tab: tab.into() }); - Ok(Value::Null) - } - "app.openLibrary" => crate::panels::settings::open_library(app, p), - "app.newLibrary" => crate::panels::settings::new_library(app, p), - "app.recentLibraries" => crate::panels::settings::recent_libraries(app, p), - "app.clearRecentLibraries" => crate::panels::settings::recent_libraries(app, &json!({"clear": true})), - "app.displayProfile" => crate::panels::settings::display_profile(app, p), - "file.backupLibrary" | "file.restoreLibrary" => { - let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; - match action { - Some(f) => f(&mut app.session), - None => Err("not available here: on the desktop the library is a folder; back it up with your other files".into()), - } - } - "view.filmstrip" => { - app.ui.filmstrip = !app.ui.filmstrip; - Ok(Value::Null) - } - "view.frameRate" => { - // {show?: bool} (toggles when omitted) - app.ui.frame_rate = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.frame_rate); - Ok(json!({"show": app.ui.frame_rate})) - } - "view.leftPanel" => { - app.ui.left_panel = !app.ui.left_panel; - Ok(Value::Null) - } - "view.rightPanel" => { - // Classic's F8: hide the right panel, and bring back the same one next time (Edit when - // none was hidden). Hiding a tool ends it, as closing its panel does. - if app.ui.right != RightPanel::None && crate::docking::exposed(app, crate::docking::Panel::Tools) { - app.ui.right_hidden = app.ui.right; - app.ui.right = RightPanel::None; - if app.ui.tool != "wbPicker" { - app.ui.tool.clear(); - } - let _ = app.session.end_interaction(); - } else if app.ui.right == RightPanel::None { - let p = match std::mem::take(&mut app.ui.right_hidden) { - RightPanel::None => RightPanel::Edit, - p => p, - }; - app.ui.right = p; - if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { - app.ui.view = ViewMode::Detail; - } - if p == RightPanel::Remove && app.ui.tool.is_empty() { - app.ui.tool = "remove".into(); - } - } - // A right panel that is open but not showing (minimized, or behind another tab) - // keeps its mode; reveal_command brings it to the front. - Ok(json!({"open": app.ui.right != RightPanel::None})) - } - "view.beforeAfter" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::SideBySide { BeforeAfter::Off } else { BeforeAfter::SideBySide }; - Ok(Value::Null) - } - "view.beforeAfterSplit" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::Split { BeforeAfter::Off } else { BeforeAfter::Split }; - Ok(Value::Null) - } - "view.beforeAfterTopBottom" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::TopBottom { BeforeAfter::Off } else { BeforeAfter::TopBottom }; - Ok(Value::Null) - } - "view.beforeAfterSplitTopBottom" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::SplitTopBottom { BeforeAfter::Off } else { BeforeAfter::SplitTopBottom }; - Ok(Value::Null) - } - "view.showOriginal" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::Original { BeforeAfter::Off } else { BeforeAfter::Original }; - Ok(Value::Null) - } - "view.zoomFit" => { - app.ui.zoom = Zoom::Fit; - Ok(Value::Null) - } - "view.zoom100" => { - app.ui.zoom = Zoom::Percent(100.0); - Ok(Value::Null) - } - "view.zoomToggle" => { - // the same ratio a click on the photo zooms to; zooming in centres on the camera's focus - // point when the photo has one (View ▸ Zoom to Focus Point) - let zoom_in = app.ui.zoom == Zoom::Fit; - app.ui.zoom = if zoom_in { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; - app.ui.zoom_anim = true; - if zoom_in - && app.ui.settings.zoom_to_focus - && let Some(pan) = app.session.active().and_then(|id| crate::panels::detail::focus_pan(app, id)) - { - app.ui.pan = pan; - return Some(Ok(json!({"focus": [pan.0, pan.1]}))); - } - Ok(Value::Null) - } - "view.clickZoom" => { - // {ratio?: 1|2|3|4|8} → {ratio} - if let Some(r) = p.get("ratio").and_then(Value::as_f64) { - let pct = (r * 100.0).round() as u32; - if !crate::state::CLICK_ZOOMS.contains(&pct) { - return Some(Err(format!("view.clickZoom: ratio {r} (1, 2, 3, 4 or 8)"))); - } - app.ui.click_zoom = pct; - } - Ok(json!({"ratio": app.ui.click_zoom / 100})) - } - "view.zoomIn" | "view.zoomOut" => { - let steps = [25.0, 50.0, 100.0, 200.0, 400.0, 800.0]; - let cur = match app.ui.zoom { - Zoom::Percent(p) => p, - _ => 25.0, - }; - let next = if id == "view.zoomIn" { - steps.iter().find(|s| **s > cur).copied().unwrap_or(800.0) - } else { - steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0.0) - }; - app.ui.zoom = if next == 0.0 { Zoom::Fit } else { Zoom::Percent(next) }; - Ok(Value::Null) - } - "view.thumbSizeUp" | "view.thumbSizeDown" => { - // the bottom-bar slider's range, one `THUMB_SIZE_STEP` per call; {} → {size} - if !thumb_size_view(app) { - return Some(Err(format!("{id}: only in Photo Grid, Square Grid or People"))); - } - let step = if id == "view.thumbSizeUp" { crate::state::THUMB_SIZE_STEP } else { -crate::state::THUMB_SIZE_STEP }; - app.ui.thumb_size = (app.ui.thumb_size + step).clamp(crate::state::THUMB_SIZE_MIN, crate::state::THUMB_SIZE_MAX); - Ok(json!({"size": app.ui.thumb_size})) - } - "view.navigate" => { - // {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre). - // Validate the complete request before changing either part of the viewport. - let zoom = match p.get("zoom") { - Some(v) => match serde_json::from_value::(v.clone()) { - Ok(Zoom::Percent(p)) if !p.is_finite() || p <= 0.0 || p > 800.0 => { - return Some(Err("view.navigate: zoom percent must be greater than 0 and at most 800".into())); - } - Ok(z) => z, - Err(e) => return Some(Err(format!("view.navigate: {e}"))), - }, - None => app.ui.zoom, - }; - let pan = match p.get("pan") { - Some(v) => match serde_json::from_value::<(f32, f32)>(v.clone()) { - Ok((x, y)) if x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y) => (x, y), - _ => return Some(Err("view.navigate: pan must be [x, y] with finite coordinates from 0 to 1".into())), - }, - None => app.ui.pan, - }; - app.ui.zoom = zoom; - app.ui.pan = pan; - app.ui.zoom_anim = false; - Ok(json!({"zoom": zoom, "pan": pan})) - } - "view.clipping" => { - app.ui.show_clipping = !app.ui.show_clipping; - Ok(Value::Null) - } - "view.visualizeHdr" => { - // {on?}; no params toggles. Shown on photos edited in HDR (see `develop.hdr`). - app.ui.hdr_visualize = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.hdr_visualize); - Ok(json!({"on": app.ui.hdr_visualize})) - } - "view.softProof" => { - // {on?, space?, profile?: ICC path | null, intent?: perceptual|relative, paperInk?, - // destWarning?, displayWarning?}; no params toggles - let has = |k: &str| p.get(k).is_some(); - if let Some(s) = p.get("space").and_then(Value::as_str) { - match lightcraft_engine::pipeline::OutputSpace::parse(s) { - Some(sp) => { - app.ui.proof.space = sp; - // a standard space replaces a device profile (unless one is given too) - app.ui.proof_profile = None; - } - None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))), - } - } - if let Some(i) = p.get("intent") { - match i.as_str().and_then(lightcraft_engine::proof::ProofIntent::parse) { - Some(i) => app.ui.proof_intent = i, - None => return Some(Err("view.softProof: intent must be perceptual or relative".into())), - } - } - if let Some(b) = p.get("paperInk").and_then(Value::as_bool) { - app.ui.proof_paper_ink = b; - } - let path = match p.get("profile") { - Some(Value::String(s)) => Some(s.clone()), - Some(Value::Null) => { - app.ui.proof_profile = None; - None - } - Some(_) => return Some(Err("view.softProof: profile must be an ICC file path or null".into())), - None => app.ui.proof_profile.as_ref().map(|(f, _)| f.clone()), - }; - match path { - Some(path) => match lightcraft_engine::proof::load(&path, app.ui.proof_intent, app.ui.proof_paper_ink) { - Ok(pp) => { - app.ui.proof.profile = pp.id; - app.ui.proof_profile = Some((path, pp.name)); - } - Err(e) => return Some(Err(format!("view.softProof: {e}"))), - }, - None => app.ui.proof.profile = 0, - } - if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { - app.ui.proof.dest_warning = b; - } - if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { - app.ui.proof.display_warning = b; - } - app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { - Some(b) => b, - None if ["space", "destWarning", "displayWarning", "profile", "intent", "paperInk"].iter().any(|k| has(k)) => app.ui.soft_proof, - None => !app.ui.soft_proof, - }; - if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { - app.ui.view = ViewMode::Detail; - } - let pr = app.ui.proof; - let profile = app.ui.proof_profile.as_ref().map(|(f, n)| json!({"path": f, "name": n})); - Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "profile": profile, "intent": app.ui.proof_intent.name(), - "paperInk": app.ui.proof_paper_ink, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) - } - "view.histogram" => { - app.ui.histogram = !app.ui.histogram; - Ok(Value::Null) - } - "view.maskOverlay" => { - app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay); - Ok(json!({"maskOverlay": app.ui.mask_overlay})) - } - "view.maskOverlayAll" => { - app.ui.mask_overlay_all = !app.ui.mask_overlay_all; - Ok(json!({"maskOverlayAll": app.ui.mask_overlay_all})) - } - "view.maskOverlayMode" => { - use lightcraft_engine::pipeline::MaskView; - let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); - let next = match p.get("mode").and_then(Value::as_str) { - Some(m) => match MaskView::parse(m) { - Some(v) => v, - None => { - let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect(); - return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|")))); - } - }, - None => cur.next(), - }; - app.ui.mask_overlay_mode = next.name().into(); - app.ui.mask_overlay = true; - app.toast(&ctx, next.label()); - Ok(json!({"mode": next.name()})) - } - "view.maskOverlayColor" => { - // no params: the next of the panel's swatch colours - if p.get("color").is_none() && p.get("opacity").is_none() { - let all = crate::panels::masking::OVERLAY_COLORS; - let i = all.iter().position(|c| *c == app.ui.mask_overlay_color).map_or(0, |i| (i + 1) % all.len()); - app.ui.mask_overlay_color = all[i]; - } - if let Some(c) = p.get("color") { - match parse_rgb(c) { - Some(rgb) => app.ui.mask_overlay_color = rgb, - None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())), - } - } - if let Some(o) = p.get("opacity").and_then(Value::as_f64) { - app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32; - } - let [r, g, b] = app.ui.mask_overlay_color; - Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity})) - } - "brush.smaller" | "brush.larger" | "brush.featherLess" | "brush.featherMore" => { - let (k, df) = match id { - "brush.smaller" => (1.0 / 1.2, 0.0), - "brush.larger" => (1.2, 0.0), - "brush.featherLess" => (1.0, -10.0), - _ => (1.0, 10.0), - }; - Ok(adjust_brush(app, k, df)) - } - "view.maskPins" => { - app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); - Ok(json!({"maskPins": app.ui.mask_pins})) - } - "view.infoSelectedPhotos" => { - // {on?}; no params toggles. On: the Info panel shows what the selected photos share. - let on = match p.get("on") { - None | Some(Value::Null) => !app.ui.settings.info_selected_photos, - Some(Value::Bool(on)) => *on, - Some(v) => return Some(Err(format!("view.infoSelectedPhotos: `on` must be true or false, not {v}"))), - }; - app.ui.settings.info_selected_photos = on; - Ok(json!({"on": on})) - } - "view.toolOverlay" => { - use crate::state::ToolOverlay; - let mode = match p.get("mode") { - None | Some(Value::Null) => app.ui.tool_overlay.next(), - Some(v) => match v.as_str().and_then(ToolOverlay::parse) { - Some(m) => m, - None => return Some(Err(format!("view.toolOverlay: unknown mode {v} (auto|always|selected|never)"))), - }, - }; - app.ui.tool_overlay = mode; - app.toast(&ctx, tool_overlay_toast(mode)); - Ok(json!({"mode": mode.name()})) - } - "view.visualizeSpots" => { - // like Lightroom's A: opens the Remove tool with the view on, or toggles it there - if app.ui.right == RightPanel::Remove { - app.ui.visualize_spots = !app.ui.visualize_spots; - } else { - app.ui.right = RightPanel::Remove; - app.ui.visualize_spots = true; - } - Ok(Value::Null) - } - // A under the Crop panel: the aspect lock (the panel's button runs the same toggle) - "crop.lockAspect" => app.run("crop.aspect", json!({"aspect": "toggle"})), - "view.cropOverlay" => { - use crate::state::CropOverlay::*; - app.ui.crop_overlay = match app.ui.crop_overlay { - Thirds => Grid, - Grid => Golden, - Golden => Diagonal, - Diagonal => Triangle, - Triangle => Spiral, - Spiral => None, - None => Thirds, - }; - Ok(json!({"overlay": app.ui.crop_overlay})) - } - "view.cropOverlayOrientation" => { - app.ui.crop_overlay_orient = (app.ui.crop_overlay_orient + 1) % 4; - Ok(json!({"orientation": app.ui.crop_overlay_orient})) - } - "local.addRoot" => { - // a folder kept in Local's sidebar (saved with the UI state); nothing on disk changes - let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(Err("local.addRoot needs a path".into())) }; - let abs = std::path::absolute(path).map(|a| a.to_string_lossy().trim_end_matches(['/', '\\']).to_string()).unwrap_or(path.into()); - let abs = if abs.is_empty() { path.to_string() } else { abs }; - if !std::path::Path::new(&abs).is_dir() { - return Some(Err(format!("{path}: not a folder"))); - } - use crate::panels::left::same_folder; - if !app.ui.local_roots.iter().any(|r| same_folder(r, &abs)) { - app.ui.local_roots.push(abs.clone()); - } - app.ui.hidden_locations.retain(|h| !same_folder(h, &abs)); - Ok(json!({"roots": app.ui.local_roots})) - } - "local.hide" => match p.get("path").and_then(Value::as_str) { - Some(path) => { - if !app.ui.hidden_locations.iter().any(|h| crate::panels::left::same_folder(h, path)) { - app.ui.hidden_locations.push(path.to_string()); - } - Ok(json!({"hidden": app.ui.hidden_locations})) - } - None => Err("local.hide needs a path".into()), - }, - "local.restoreHidden" => { - // one path, or (no path) every hidden location - match p.get("path").and_then(Value::as_str) { - Some(path) => app.ui.hidden_locations.retain(|h| !crate::panels::left::same_folder(h, path)), - None => app.ui.hidden_locations.clear(), - } - Ok(json!({"hidden": app.ui.hidden_locations})) - } - "view.soloSections" => bool_param(id, p, "on").map(|on| { - app.ui.set_single_panel(on.unwrap_or(!app.ui.single_panel)); - json!({"soloSections": app.ui.single_panel}) - }), - "view.filterBar" => { - app.ui.filter_bar = !app.ui.filter_bar; - if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { - app.ui.view = ViewMode::PhotoGrid; - } - Ok(json!({"filterBar": app.ui.filter_bar})) - } - "panel.edit" => { - panel(app, &ctx, RightPanel::Edit, "Edit"); - Ok(Value::Null) - } - "panel.profiles" => { - // toggles between the profile browser and the Edit panel it belongs to - app.ui.right = if app.ui.right == RightPanel::Profiles && crate::docking::exposed(app, crate::docking::Panel::Tools) { - RightPanel::Edit - } else { - RightPanel::Profiles - }; - if !matches!(app.ui.view, ViewMode::Detail) { - app.ui.view = ViewMode::Detail; - } - Ok(json!({"open": app.ui.right == RightPanel::Profiles})) - } - "panel.crop" => { - panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); - Ok(Value::Null) - } - "panel.remove" => { - panel(app, &ctx, RightPanel::Remove, "Remove"); - if app.ui.right == RightPanel::Remove && app.ui.tool.is_empty() { - app.ui.tool = "remove".into(); - } - Ok(Value::Null) - } - "panel.masking" => { - panel(app, &ctx, RightPanel::Masking, "Masking"); - Ok(Value::Null) - } - "panel.redeye" => { - panel(app, &ctx, RightPanel::RedEye, "Red Eye"); - Ok(Value::Null) - } - "panel.info" => { - panel(app, &ctx, RightPanel::Info, "Info"); - Ok(Value::Null) - } - "panel.keywords" => { - panel(app, &ctx, RightPanel::Keywords, "Keywords"); - Ok(Value::Null) - } - "panel.addKeywords" => { - // opens Keywording when it is folded shut; the panel stays open when pressed again - if app.ui.right != RightPanel::Keywords { - panel(app, &ctx, RightPanel::Keywords, "Keywords"); - } - app.ui.folded_sections.retain(|s| s != "keywording"); - app.ui.focus_keywords = Some(ctx.cumulative_pass_nr()); - Ok(Value::Null) - } - "panel.versions" => { - panel(app, &ctx, RightPanel::Versions, "Versions"); - Ok(Value::Null) - } - "panel.activity" => { - panel(app, &ctx, RightPanel::Activity, "History"); - Ok(Value::Null) - } - "panel.presets" => { - app.ui.presets = !app.ui.presets || !crate::docking::exposed(app, crate::docking::Panel::Presets); - if app.ui.presets && app.ui.view != ViewMode::Detail { - app.ui.view = ViewMode::Detail; - } - Ok(Value::Null) - } - "tool.done" => { - // Return commits a tool panel (crop, remove, red eye, masking): back to Edit - use RightPanel::*; - if app.ui.dialog.is_none() && matches!(app.ui.right, Crop | Remove | RedEye | Masking) { - let _ = app.session.end_interaction(); - app.ui.tool.clear(); - app.ui.right = Edit; - } - Ok(Value::Null) - } - "panel.close" => { - app.ui.right = RightPanel::None; - app.ui.presets = false; - Ok(Value::Null) - } - s if s.starts_with("section.") => bool_param(s, p, "solo").and_then(|solo| { - // only the Edit sections listed above: an unknown one would close the real ones - if !UI_COMMANDS.iter().any(|c| c.0 == s) { - return Err(format!("{s}: no such Edit section")); - } - let sec = &s["section.".len()..]; - if app.ui.right != RightPanel::Edit { - app.ui.right = RightPanel::Edit; - } - if solo == Some(true) { - app.ui.solo_section(sec); - } else { - app.ui.toggle_section(sec); - } - Ok(Value::Null) - }), - s if s.starts_with("tool.") => { - let tool = &s["tool.".len()..]; - match tool { - "none" => app.ui.tool.clear(), - "guidedUpright" => { - // Crop & Geometry with Guided Upright on, ready to draw guides - app.ui.right = RightPanel::Crop; - app.ui.view = ViewMode::Detail; - let guided = app - .session - .active() - .and_then(|id| app.session.develop_of(id)) - .is_some_and(|d| d.geometry.upright == lightcraft_develop::Upright::Guided); - if !guided && let Err(e) = app.run("geometry.upright", json!({"mode": "guided"})) { - return Some(Err(e)); - } - app.ui.tool = "guidedUpright".into(); - } - "brush" => { - // A new mask and Add/Subtract are selection operations, not the brush's - // Erase mode. Paint positively into a component with the requested op. - let create = if p.get("new").and_then(Value::as_bool) == Some(true) { - Some(app.run("mask.add", json!({"kind": "brush"}))) - } else { - p.get("op").map(|op| app.run("mask.addComponent", json!({"kind": "brush", "op": op}))) - }; - if let Some(result) = create { - if let Err(e) = result { - return Some(Err(e)); - } - app.ui.brush_erase = false; - app.ui.mask_overlay = true; - } - app.ui.right = RightPanel::Masking; - app.ui.view = ViewMode::Detail; - app.ui.tool = "brush".into(); - } - "linear" | "radial" => { - app.ui.right = RightPanel::Masking; - app.ui.view = ViewMode::Detail; - app.ui.tool = tool.into(); - return Some(app.session.execute("mask.add", &json!({"kind": tool})).map_err(|e| e.to_string())); - } - "wbPicker" => { - app.ui.right = RightPanel::Edit; - app.ui.view = ViewMode::Detail; - app.ui.tool = "wbPicker".into(); - } - other => return Some(Err(format!("unknown tool `{other}`"))), - } - Ok(Value::Null) - } - "dialog.newFolder" => { - app.ui.dialog = Some(Dialog::NewAlbum { - name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), - folder: true, - parent: parent_param(p), - include_selected: false, - }); - Ok(Value::Null) - } - "dialog.newAlbum" => { - // {name?, parent?, includeSelected? (true)} - app.ui.dialog = Some(Dialog::NewAlbum { - name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), - folder: false, - parent: parent_param(p), - include_selected: p.get("includeSelected").and_then(Value::as_bool).unwrap_or(true), - }); - Ok(Value::Null) - } - "dialog.autoStack" => { - app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 }); - Ok(Value::Null) - } - "dialog.allMetadata" => { - let r = match app.session.execute("photo.allMetadata", p) { - Ok(r) => r, - Err(e) => return Some(Err(e.to_string())), - }; - let title = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.file_name.clone()).unwrap_or_default(); - app.ui.dialog = Some(Dialog::AllMetadata { title, rows: r, search: String::new() }); - Ok(Value::Null) - } - "dialog.smartAlbum" => { - // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 - let album = p - .get("id") - .and_then(Value::as_u64) - .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); - app.ui.dialog = Some(match album { - Some(a) => Dialog::SmartRules { - id: Some(a.id.0), - name: a.name.clone(), - // older rules read as they always matched (RuleSet::upgrade) - rules: a - .smart - .as_ref() - .and_then(|f| f.rule_set.clone()) - .map(|mut r| { - r.upgrade(); - r - }) - .unwrap_or_default(), - parent: None, - }, - None => Dialog::SmartRules { - id: None, - name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - parent: parent_param(p), - }, - }); - Ok(Value::Null) - } - "dialog.newSmartAlbum" => { - app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), parent: parent_param(p) }); - Ok(Value::Null) - } - "dialog.captureTime" => { - // a time that doesn't fully read isn't offered as the starting point (`10:0` would read as midnight) - let time = app - .session - .active() - .and_then(|id| app.session.catalog.photo(id)) - .map(|p| p.date()) - .filter(|d| lightcraft_catalog::dates::shift_iso(d, 0).is_some()) - .map(|d| d.replace('T', " ")) - .unwrap_or_default(); - let mode = p.get("mode").and_then(Value::as_str).unwrap_or("set").to_string(); - app.ui.dialog = - Some(Dialog::CaptureTime { mode, time: time.get(..19).unwrap_or(&time).to_string(), days: 0, hours: 0, minutes: 0, zone: 0.0 }); - Ok(Value::Null) - } - "dialog.labelNames" => { - let names = - lightcraft_catalog::ColorLabel::ALL.iter().map(|l| app.session.catalog.custom_label_name(*l).unwrap_or("").to_string()).collect(); - app.ui.dialog = Some(Dialog::LabelNames { names, save_as: String::new() }); - Ok(Value::Null) - } - "dialog.rename" => { - let template = p.get("template").and_then(Value::as_str).unwrap_or("{name}").to_string(); - app.ui.dialog = Some(Dialog::Rename { template, start: p.get("start").and_then(Value::as_u64).unwrap_or(1) as u32 }); - Ok(Value::Null) - } - "dialog.createPreset" => { - app.ui.dialog = Some(Dialog::create_preset()); - Ok(Value::Null) - } - "dialog.pasteSettings" => { - let groups = - app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); - app.ui.dialog = Some(Dialog::PasteSettings { groups }); - Ok(Value::Null) - } - "view.focusSearch" => { - // the search field lives in the top bar of the library and detail views alike - app.ui.focus_search = true; - Ok(Value::Null) - } - "dialog.copySettings" => { - let groups = - app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); - app.ui.dialog = Some(Dialog::CopySettings { groups }); - Ok(Value::Null) - } - "dialog.slideshowExport" => { - let format = match p.get("format").and_then(Value::as_str) { - Some("jpeg") => lightcraft_engine::slideshow_export::Format::Jpeg, - Some("video") => lightcraft_engine::slideshow_export::Format::Video, - _ => lightcraft_engine::slideshow_export::Format::Pdf, - }; - app.ui.dialog = Some(Dialog::SlideshowExport { settings: lightcraft_engine::slideshow_export::ExportSettings::for_format(format) }); - Ok(Value::Null) - } - "app.slideshowExport" => crate::slideshow_export::start(app, id, p), - "dialog.contactSheet" => { - app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() }); - Ok(Value::Null) - } - "dialog.print" => { - app.ui.print = Default::default(); - app.ui.dialog = Some(Dialog::Print); - Ok(Value::Null) - } - "app.print" => { - if app.export.is_some() { - return Some(Err("an export is already running".into())); - } - let mut params = p.clone(); - if p.get("path").is_none() { - let req = PickRequest::save("Print to PDF", "PDF", &["pdf"], "Print.pdf"); - let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), - }; - let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; - params["path"] = json!(path); - } - crate::export_task::start_print(app, ¶ms) - } - "app.contactSheet" => { - if app.export.is_some() { - return Some(Err("an export is already running".into())); - } - let mut params = p.clone(); - if p.get("path").is_none() { - let req = PickRequest::save("Contact Sheet PDF", "PDF", &["pdf"], "Contact Sheet.pdf"); - let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), - }; - let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; - params["path"] = json!(path); - } - crate::export_task::start_contact_sheet(app, ¶ms) - } - "dialog.export" => { - let prev = app.session.last_export.clone().unwrap_or_default(); - let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d); - let dir = prev.get("dir").and_then(Value::as_str).map(str::to_string).unwrap_or_else(crate::control::default_export_dir); - // stored settings: a depth the format lacks (saved before #1952) opens at the format's own - let opts = lightcraft_engine::export::ExportOptions::from_json(&lightcraft_engine::export::ExportOptions::known_keys_only(&prev)); - // no previous export: 2048 px long edge; a previous full-size export: full size - let full_size = opts.resize.is_none() && lightcraft_engine::export::ExportOptions::has_size_param(&prev); - let resize = opts.resize.unwrap_or_default(); - app.ui.dialog = Some(Dialog::Export { opts, full_size, resize, preset_name: String::new(), limit_kb: u("limitKb", 0) as u32, dir }); - Ok(Value::Null) - } - "merge.hdrLast" => crate::merge::start_last(app, "merge.hdr"), - "merge.hdrBatch" => crate::merge::start_batch(app, p), - "merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"), - "merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"), - "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), - "dialog.faceModel" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => app.services.pick_model_file.as_mut().and_then(|f| f().into_iter().next()), - }; - match path { - Some(path) => crate::panels::faces::open_dialog(app, &path), - None => Ok(Value::Null), - } - } - "dialog.mergePanorama" => crate::merge::open(app, "merge.panorama"), - "dialog.mergeHdrPanorama" => crate::merge::open(app, "merge.hdrPanorama"), - "app.openSamModelFolder" => crate::panels::dialogs::show_model_folder(app), - "app.about" => { - app.ui.dialog = Some(Dialog::About); - Ok(Value::Null) - } - "app.checkForUpdates" => crate::updates_ui::check(app), - "photo.editInPhotoCraft" => edit_in(app, &ctx, p, true), - "photo.editInExternal" => edit_in(app, &ctx, p, false), - "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), - "dialog.cull" => { - let (burst_gap, similarity) = app.ui.cull_burst.unwrap_or((10.0, 0.93)); - app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true, burst_gap, similarity }); - Ok(Value::Null) - } - "app.whatsNew" => { - app.ui.dialog = Some(Dialog::WhatsNew); - Ok(json!({"text": crate::panels::dialogs::WHATS_NEW})) - } - "app.systemInfo" => { - let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); - let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); - let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); - let mut rows = vec![ - ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), - ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), - ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), - ( - "GPU".to_string(), - gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { - Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), - None => "none (CPU rendering)".into(), - }), - ), - ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), - ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), - ( - "Preview size".to_string(), - match app.ui.settings.preview_limit { - 0 => "automatic".to_string(), - n => format!("{n} px"), - }, - ), - ("Photos".to_string(), info["photos"].to_string()), - ("Albums".to_string(), info["albums"].to_string()), - ]; - if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { - rows.push(("Library".into(), dir.to_string())); - } - rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); - rows.push(( - "Frame update".into(), - format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), - )); - rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); - if let Some(f) = lightcraft_engine::gpu::last_fallback() { - rows.push(("Last GPU fallback".into(), f)); - } - let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::>()); - if p.get("open").and_then(Value::as_bool).unwrap_or(true) { - app.ui.dialog = Some(Dialog::SystemInfo { rows }); - } - Ok(r) - } - "app.shortcuts" => { - app.ui.dialog = Some(Dialog::Shortcuts); - Ok(Value::Null) - } - "app.setShortcut" => crate::shortcuts::set_shortcut(app, p), - "app.resetShortcuts" => { - app.ui.settings.keymap.clear(); - Ok(Value::Null) - } - "library.browse" if !cfg!(target_arch = "wasm32") => { - if crate::lightroom_import::is_running(app) { - return Some(Err("finish Lightroom catalog import before browsing folders".into())); - } - // listed and read in the background (see `import::browse`) - let path = p.get("path").and_then(Value::as_str)?; - crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool)) - } - "file.addPhotos" => { - if crate::lightroom_import::is_running(app) { - return Some(Err("finish Lightroom catalog import before adding photos".into())); - } - let paths = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files(None, crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later | Picked::Unavailable => return Some(Ok(Value::Null)), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - // review first: the import dialog lists what was found - let r = crate::import::open(app, paths); - import_into_album(app, p, r) - } - "file.importLightroom" => { - // where the catalog goes (#2672): checked before a file dialog opens - let req = match crate::lightroom_destination::Request::parse(p) { - Ok(req) => req, - Err(e) => return Some(Err(e)), - }; - let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { - Some(path) => Some(path), - None => { - let req = - PickRequest::file(crate::i18n::tr("Import Lightroom Catalog"), crate::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_lightroom_catalog.as_mut().and_then(|f| f()).map(|x| vec![x])) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - match path { - Some(path) => crate::lightroom_destination::import(app, &path, &req), - None => Ok(Value::Null), - } - } - "app.quit" => { - app.ui.quit = true; - Ok(Value::Null) - } - "dialog.saveMetadataPreset" => { - // from the active photo's copyright, creator and place - crate::panels::dialogs::prompt(app, "Save Metadata Preset", "Preset name", "", "metadata.savePreset", json!({}), "name"); - Ok(Value::Null) - } - "file.findMissing" => { - let within = match p.get("within") { - None | Some(Value::Null) => None, - Some(Value::String(path)) if !path.is_empty() => Some(path.clone()), - Some(Value::String(_)) => return Some(Err("file.findMissing: `within` must not be empty".into())), - Some(value) => return Some(Err(format!("file.findMissing: `within` must be a folder path, not {value}"))), - }; - let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); - let timeout = match p.get("timeout") { - None | Some(Value::Null) => std::time::Duration::from_secs(600), - Some(Value::Number(n)) => { - if let Some(s) = n.as_f64() { - if s.is_finite() && s >= 0.0 { - match std::time::Duration::try_from_secs_f64(s) { - Ok(d) => d, - Err(_) => return Some(Err("file.findMissing: `timeout` is too large".into())), - } - } else { - return Some(Err("file.findMissing: `timeout` must be a non-negative number of seconds".into())); - } - } else { - return Some(Err("file.findMissing: `timeout` must be a number of seconds".into())); - } - } - Some(other) => return Some(Err(format!("file.findMissing: `timeout` must be a number of seconds, not {other}"))), - }; - let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; - let folder = match p.get("folder").and_then(Value::as_str) { - Some(f) => Some(f.to_string()), - None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { - s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - }, - }; - let Some(folder) = folder else { return Some(Ok(Value::Null)) }; - // the search (checking every photo's file, walking the folder) runs on a worker thread; - // the relinking happens back here, as one undo step - const LABEL: &str = "Find Missing Photos"; - if app.tasks.is_running(LABEL) { - return Some(Err("Find Missing Photos is already searching".into())); - } - let mut candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog); - if let Some(within) = within { - candidates.retain(|candidate| lightcraft_catalog::query::folder_within(&candidate.path, &within)); - } - #[cfg(test)] - let test_delay = app.ui.find_missing_test_delay; - let work = move || { - #[cfg(test)] - if let Some(d) = test_delay { - std::thread::sleep(d); - } - lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder) - }; - let done = |app: &mut LightcraftApp, ctx: &egui::Context, plan: Result| { - // relinked under the session as it is now: photos relinked meanwhile and files - // now in use are skipped - let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json())); - match r { - Ok(v) => { - let n = v["found"].as_array().map_or(0, Vec::len); - let left = v["missing"].as_u64().unwrap_or(0); - let unsure = v["ambiguous"].as_array().map_or(0, Vec::len); - let unsure = if unsure > 0 { - crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure) - } else { - String::new() - }; - app.toast( - ctx, - crate::i18n::tr_format!( - "Found {n} missing photo{}; {left} still missing{unsure}", - if n == 1 { "" } else { "s" }, - left = left, - n = n, - unsure = unsure - ), - ); - app.ui.last_find_missing = Some(v.clone()); - app.ui.last_find_missing_result = Some(Ok(v)); - } - Err(e) => { - app.toast(ctx, e.clone()); - app.ui.last_find_missing_result = Some(Err(e)); - } - } - }; - app.ui.last_find_missing = None; - app.ui.last_find_missing_result = None; - if let Err(e) = crate::tasks::spawn(app, LABEL, Some("findMissing"), work, done) { - return Some(Err(e)); - } - if wait { - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - let completed = crate::tasks::wait_for(app, &ctx, LABEL, timeout); - if !completed { - return Some(Err("Find Missing Photos: search timed out (search continues in the background)".into())); - } - return match app.ui.last_find_missing_result.take() { - Some(Ok(v)) => Some(Ok(v)), - Some(Err(e)) => Some(Err(e)), - None => Some(Err("Find Missing Photos: worker terminated unexpectedly".into())), - }; - } - Ok(json!({"background": true})) - } - "photo.tagFromTracklog" => { - // a GPX file → GPS for the selected photos by capture time (one undo step) - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = p.as_object().cloned().unwrap_or_default(); - params.insert("path".into(), json!(path)); - let ask_zone = !params.contains_key("offset"); - let params = Value::Object(params); - if ask_zone { - // GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here - crate::panels::dialogs::prompt( - app, - "Auto-Tag from Tracklog", - "Camera time zone, e.g. -07:00 (empty: UTC)", - "", - "photo.tagFromTracklog", - params, - "offset", - ); - return Some(Ok(Value::Null)); - } - let r = app.run("photo.autoTagTracklog", params); - if let Ok(v) = &r { - let n = v["tagged"].as_u64().unwrap_or(0); - let sk = &v["skipped"]; - let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n); - let outside = sk["outside"].as_u64().unwrap_or(0); - if outside > 0 { - msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside); - } - let kept = sk["hasGps"].as_u64().unwrap_or(0); - if kept > 0 { - msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept); - } - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - app.toast(&ctx, msg); - } - r - } - "photo.locate" => { - let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) }; - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = - PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); - match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - match path { - Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), - None => Ok(Value::Null), - } - } - "file.addFromDevice" => { - // a camera / card: review its DCIM folder, copying into the library by default - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => match lightcraft_engine::devices::devices_now().into_iter().next() { - Some(d) => d.path, - None => return Some(Err("no camera or memory card found".into())), - }, - }; - let r = crate::import::open(app, vec![path]); - // only the scan just started (an error means another scan is running) - if r.is_ok() - && let Some(t) = &mut app.scan - { - t.copy = true; - } - import_into_album(app, p, r) - } - "file.addFolder" => { - // a folder (searched recursively) into the import review - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => match crate::pick::ask(app, id, p, "path", PickRequest::folder(crate::i18n::tr("Import from Folder…")), |s| { - s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - }, - }; - match path { - Some(path) => { - let r = crate::import::open(app, vec![path]); - import_into_album(app, p, r) - } - None => Ok(Value::Null), - } - } - "file.importPresets" => { - let paths = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Presets").into()), - crate::i18n::tr("Presets & Profiles"), - crate::pick::PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - // .cube LUTs (and those inside zips / folders) become profiles - let lut_paths: Vec<&String> = paths - .iter() - .filter(|p| { - let l = p.to_ascii_lowercase(); - l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() - }) - .collect(); - // one toast for both halves (a second toast would replace the first) - let mut parts: Vec = Vec::new(); - // `name: reason` for each file that couldn't be imported - let mut problems: Vec = Vec::new(); - let mut profiles_result = Value::Null; - if !lut_paths.is_empty() { - match app.session.execute("profile.import", &json!({"paths": lut_paths})) { - Ok(v) => { - let profiles = v["imported"].as_array().map_or(0, Vec::len); - if profiles > 0 { - parts.push(crate::i18n::tr_format!( - "Imported {profiles} profile{} (Profile browser ▸ their groups)", - if profiles == 1 { "" } else { "s" }, - profiles = profiles - )); - } - problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| { - let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); - let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); - format!("{name}: {reason}") - })); - profiles_result = v; - } - Err(e) => { - problems.push(e.to_string()); - profiles_result = json!({"error": e.to_string()}); - } - } - } - let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); - let r = if preset_paths.is_empty() { - Ok(json!({"imported": [], "failed": [], "skipped": 0})) - } else { - app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) - }; - if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast - let effects = v["effects"].as_array().map_or(0, Vec::len); - if effects > 0 { - parts.push(crate::panels::mask_effects::import_toast(v)); - } - let profile_news = !parts.is_empty() || !problems.is_empty(); - let msg = match (n, failed) { - // a zip of LUTs (or only local presets): the other half already says what happened - (0, 0) if profile_news => None, - (0, 0) => Some("No new presets".to_string()), - (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), - (n, f) => Some(crate::i18n::tr_format!( - "Imported {n} preset{}, {f} file{} not readable", - if n == 1 { "" } else { "s" }, - if f == 1 { "" } else { "s" }, - f = f, - n = n - )), - }; - if let Some(mut msg) = msg { - // settings with no counterpart here (the other editor's profiles, masks…) - let mut skipped: Vec<&str> = v["imported"] - .as_array() - .into_iter() - .flatten() - .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) - .filter_map(Value::as_str) - .collect(); - skipped.sort_unstable(); - skipped.dedup(); - if !skipped.is_empty() { - let names: Vec<&str> = skipped.iter().take(3).copied().collect(); - msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); - } - parts.push(msg); - } - if n > 0 { - app.ui.presets = true; - } - } - let mut msg = parts.join(" · "); - if !problems.is_empty() { - let detail = - format!("{}{}", problems.iter().take(2).cloned().collect::>().join("; "), if problems.len() > 2 { "…" } else { "" }); - if msg.is_empty() { - msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); - } else { - msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail); - } - app.toast_error(&ctx, msg); - } else if !msg.is_empty() { - app.toast(&ctx, msg); - } - let r = r.map(|mut v| { - if !lut_paths.is_empty() - && let Some(o) = v.as_object_mut() - { - o.insert("profiles".into(), profiles_result); - } - v - }); - return Some(r); - } - "file.importKeywords" => { - // a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8) - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) { - Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string()); - match &r { - Ok(v) => { - let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0)); - let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added); - if updated > 0 { - msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated)); - } - app.toast(&ctx, msg); - } - Err(e) => app.toast(&ctx, e.clone()), - } - return Some(r); - } - "file.exportKeywords" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let name = "Keywords.txt"; - let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) { - Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string()); - if let Err(e) = &r { - app.toast(&ctx, e.clone()); - } - if let Ok(v) = &r { - let n = v["keywords"].as_u64().unwrap_or(0); - let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n); - // Capture One's importer refuses ; , < > in a list: say which to rename first - let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - if !refuses.is_empty() { - msg.push_str(&crate::i18n::tr_format!( - " — Capture One won't import a list with ; , < or > in a keyword: {names}", - names = refuses.iter().take(5).copied().collect::>().join(" · ") - )); - } - // what the format can't hold (a name in brackets, a line break…) isn't in the file - let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - if !left.is_empty() { - msg.push_str(&crate::i18n::tr_format!( - " — left out, as a keyword list can't hold them: {names}", - // escaped: a line break in a name would break the toast - names = left.iter().take(5).map(|n| n.escape_debug().to_string()).collect::>().join(" · ") - )); - } - app.toast(&ctx, msg); - } - return Some(r); - } - "dialog.saveMaskEffect" => { - if app.session.active_mask.is_none() { - return Some(Err("select a mask first".into())); - } - crate::panels::mask_effects::open_save(app); - Ok(Value::Null) - } - "dialog.manageMaskEffects" => { - crate::panels::mask_effects::open_manage(app); - Ok(Value::Null) - } - "file.exportMaskEffects" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); - let req = PickRequest::save( - crate::i18n::tr("Export Effect Presets"), - crate::i18n::tr("LightCraft Effect Presets"), - &["lceffect"], - name.clone(), - ); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - for k in ["names", "group"] { - if let Some(v) = p.get(k) { - params[k] = v.clone(); - } - } - let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); - match &r { - Ok(v) => app.toast(&ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), - Err(e) => app.toast_error(&ctx, e.clone()), - } - return Some(r); - } - "file.exportPresets" => { - let group = p.get("group").and_then(Value::as_str).map(str::to_string); - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets")); - let req = PickRequest::save(crate::i18n::tr("Export Presets"), crate::i18n::tr("LightCraft Preset"), &["lcpreset"], name.clone()); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - if let Some(g) = group { - params["group"] = json!(g); - } - if let Some(ids) = p.get("ids") { - params["ids"] = ids.clone(); - } - let r = app.session.execute("preset.export", ¶ms).map_err(|e| e.to_string()); - if let Ok(v) = &r { - app.toast(&ctx, crate::i18n::tr_format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); - } - return Some(r); - } - "file.importCurvePresets" => { - let paths: Vec = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Point Curve Presets").into()), - crate::i18n::tr("Point Curve Presets"), - crate::pick::CURVE_PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); - if let Ok(v) = &r { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); - if failed > 0 { - msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); - } - app.toast(&ctx, msg); - } - return Some(r); - } - "file.exportCurvePresets" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = PickRequest::save( - crate::i18n::tr("Export Point Curve Presets"), - crate::i18n::tr("Point Curve Presets"), - &["lccurve"], - "Point Curves.lccurve", - ); - match crate::pick::ask(app, id, p, "path", req, |s| { - s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - if let Some(n) = p.get("names") { - params["names"] = n.clone(); - } - let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); - if let Ok(v) = &r { - app.toast(&ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); - } - return Some(r); - } - "app.export" => crate::control::export_active(app, p), - "app.showInFinder" => show_in_finder(app), - "app.showInFolders" => show_in_folders(app, p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId)), - "app.openLogFolder" => open_log_folder(app), - "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => { - let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE); - crate::links::open(app, url) - } - "app.exportPrevious" => match app.session.last_export.clone() { - Some(prev) => crate::control::export_active(app, &lightcraft_engine::export::ExportOptions::known_keys_only(&prev)), - None => Err("nothing exported yet — use Export…".into()), - }, - _ => return None, - }; - Some(r) -} - /// Views whose bottom bar has the thumbnail-size slider and that draw tiles of that size. fn thumb_size_view(app: &LightcraftApp) -> bool { matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid | ViewMode::People) @@ -2379,264 +119,3 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { } v } - -/// The Delete key in an album: a normal album loses the selected photos (`album.removePhotos`), -/// which stay in the library and their folder; a smart album keeps them (its rules decide) and -/// says so. True when the key was handled here, false outside albums (then it deletes the photos -/// from the library). Photo ▸ Delete Photo still deletes from the library everywhere. -pub fn delete_in_album(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { - let lightcraft_engine::LibrarySource::Album(aid) = app.session.source else { return false }; - let Some(album) = app.session.catalog.album(aid) else { return false }; - if album.is_smart() { - app.toast(ctx, crate::i18n::tr("Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo")); - return true; - } - if app.session.selected().is_empty() { - return true; - } - match app.run("album.removePhotos", json!({"id": aid.0})) { - Ok(r) => { - let n = r["removed"].as_u64().unwrap_or(0); - let name = r["album"].as_str().unwrap_or_default().to_string(); - let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); - app.toast(ctx, crate::i18n::tr_format!("Removed {what} from {name}", what = what, name = name)); - } - Err(e) => app.toast(ctx, e), - } - true -} - -/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead -/// of deleting; true when it did (the dialog's OK runs `photo.delete`). -pub fn confirm_delete(app: &mut LightcraftApp) -> bool { - if !app.ui.settings.confirm_delete { - return false; - } - let count = app.session.selected().len(); - if count == 0 { - return false; - } - app.ui.dialog = Some(Dialog::ConfirmDelete { count }); - true -} - -/// Platform-appropriate label for revealing a file in the system file manager. -pub fn reveal_label() -> &'static str { - if cfg!(target_os = "macos") { - "Show in Finder" - } else if cfg!(target_os = "windows") { - "Show in Explorer" - } else { - "Show in File Manager" - } -} - -/// Help ▸ Open Log Folder: reveal the host's log file in the system file manager, so it can be -/// attached to a report without hunting for the settings folder (#260). -fn open_log_folder(app: &mut LightcraftApp) -> Result { - let path = app.services.log_file.clone().ok_or("this session keeps no log file")?; - let reveal = app.services.reveal.as_mut().ok_or("not available here")?; - reveal(&path)?; - Ok(json!({"path": path})) -} - -/// Reveal the active photo's original in the system file manager. -fn show_in_finder(app: &mut LightcraftApp) -> Result { - let id = app.session.active().ok_or("no photo selected")?; - let path = match app.session.catalog.photo(id).map(|p| p.source.clone()) { - Some(lightcraft_engine::catalog::Source::File { path }) => path, - _ => return Err("this photo has no file (demo scene)".into()), - }; - let reveal = app.services.reveal.as_mut().ok_or("not available here")?; - reveal(&path)?; - Ok(json!({"path": path})) -} - -/// The folder the active photo was imported from, as the sidebar's Folders section counts it (a -/// photo straight in a disk's root is in the root). `None` for a demo scene, a photo only browsed -/// in Local or in Recently Deleted, and one on the startup disk's root, which has no row to show. -fn photo_folder(app: &LightcraftApp, id: Option) -> Option { - let photo = id.or_else(|| app.session.active()).and_then(|id| app.session.catalog.photo(id)).filter(|p| p.in_library())?; - let lightcraft_engine::catalog::Source::File { path } = &photo.source else { return None }; - let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?; - (!lightcraft_catalog::folders::is_startup_disk(dir)).then(|| dir.to_string()) -} - -/// Whether Show in Folders has a row to show for photo `id` (the active one when `None`): its -/// folder must be a row of the tree, which stops `MAX_DEPTH` levels down. With no fresh tree to -/// look in, the folder alone decides, and the command still checks. -pub fn show_in_folders_enabled(app: &LightcraftApp, id: Option) -> bool { - let Some(dir) = photo_folder(app, id) else { return false }; - app.caches.folder_tree_if_fresh(&app.session.catalog).is_none_or(|tree| crate::panels::left::folder_row_of(&tree, &dir).is_some()) -} - -/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library): show the folder of photo `id` -/// (the active one when `None`; the grid's menu passes the photo right-clicked) as a click on its -/// row in the sidebar's Folders section does, and bring the row into view: the sidebar, the -/// section and the folders above it open, and it scrolls there (`reveal_folder`). -fn show_in_folders(app: &mut LightcraftApp, id: Option) -> Result { - let dir = photo_folder(app, id).ok_or("this photo is not in a folder of the library")?; - let tree = app.caches.folder_tree(&app.session.catalog); - // the row's own spelling (forward slashes): what its click hands `library.source`, and its id - let path = crate::panels::left::folder_row_of(&tree, &dir).map(|n| n.path.clone()).ok_or("this photo's folder is not in Folders")?; - app.run("library.source", json!({"kind": "libraryFolder", "path": path}))?; - app.ui.left_panel = true; - app.ui.reveal_folder = Some(path.clone()); - Ok(json!({"path": path})) -} - -/// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original) -/// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the -/// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first, -/// so a missing editor leaves no copy behind. Once the copies exist, an editor that won't open them -/// is reported in the result's `failed` (and a toast), not as an error. -fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { - // every way in (menu, shortcut, context menu, the control channel) says why nothing opened - edit_in_inner(app, ctx, p, photocraft).inspect_err(|e| app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e))) -} - -fn edit_in_inner(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { - let settings = &app.ui.settings; - let (file, configured) = if photocraft { - let path = settings.photocraft_path.trim(); - (settings.photocraft_file, if path.is_empty() { crate::state::PHOTOCRAFT.to_string() } else { path.to_string() }) - } else { - (settings.external_file, settings.external_editor.clone()) - }; - let editor = p.get("app").and_then(Value::as_str).map_or(configured, str::to_string); - if let Some(check) = app.services.check_editor.as_ref() { - check(&editor)?; - } - let mut params = file.params(); - if let (Some(o), Some(given)) = (params.as_object_mut(), p.as_object()) { - for (k, v) in given.iter().filter(|(k, _)| k.as_str() != "app") { - o.insert(k.clone(), v.clone()); - } - } - let r = app.session.execute("photo.editExternal", ¶ms).map_err(|e| e.to_string())?; - // every selected photo got a copy (#920); each opens in the editor - let copies: Vec<(String, Option)> = r["copies"] - .as_array() - .map(|c| c.iter().map(|c| (c["path"].as_str().unwrap_or_default().to_string(), c["id"].as_u64())).collect()) - .unwrap_or_else(|| vec![(r["path"].as_str().unwrap_or_default().to_string(), r["id"].as_u64())]); - let mut failed = None; - for (path, id) in &copies { - if let Some(id) = id { - app.ui.external_edits.push(*id); - } - // the copy is LightCraft's own render, but it goes through the same gate as any other - // path handed to the system opener (#2135) - match lightcraft_engine::open_path::openable(path, "") { - Err(e) => failed = Some(e), - Ok(absolute) => { - if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) { - failed = Some(e); - } - } - } - } - let mut r = r; - if let Some(o) = r.as_object_mut() { - o.insert("failed".into(), json!(failed)); - } - if let Some(e) = failed { - // the copies are in the library already (one undo step): an error would invite a retry - // that makes another set, so the result says what failed and the toast says why - app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); - return Ok(r); - } - match copies.as_slice() { - [(path, _)] => { - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); - } - _ => app.toast(ctx, crate::i18n::tr_format!("{n} photos opened for editing; each is stacked with its original", n = copies.len())), - } - Ok(r) -} - -/// Most originals one Open Original hands the editor (each starts it once). -const MAX_ORIGINALS: usize = 50; - -/// Photo ▸ Edit In ▸ Open Original in Other Editor: hand each selected photo's original file -/// itself (a raw stays a raw: nothing is rendered, copied or added to the library) to the editor -/// set in Settings ▸ External Editing, e.g. a raw developer. `p` may give `ids` and the editor -/// (`app`). Demo photos and missing files are skipped and listed; when no photo has a file to -/// open, it fails saying why. -fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> Result { - let r = open_originals(app, p); - if let Err(e) = &r { - app.toast(ctx, e.clone()); - } - let r = r?; - let opened = r["opened"].as_array().map_or(0, Vec::len); - match (r["failed"].as_str(), r["opened"][0]["path"].as_str()) { - (Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), - (None, Some(path)) if opened == 1 => { - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - app.toast(ctx, crate::i18n::tr_format!("{name} opened in the other editor", name = name)); - } - _ => app.toast(ctx, crate::i18n::tr_format!("{n} originals opened in the other editor", n = opened)), - } - Ok(r) -} - -fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result { - let mut ids = app.session.targets(p).map_err(|e| e.to_string())?; - ids.dedup(); - if ids.is_empty() { - return Err("no photo selected".into()); - } - if ids.len() > MAX_ORIGINALS { - return Err(format!("{} photos selected: open at most {MAX_ORIGINALS} originals at a time", ids.len())); - } - let mut files = Vec::new(); - let mut skipped = Vec::new(); - for id in ids { - // the path may come from an imported catalog: only a photo file LightCraft reads is - // handed to the opener, never a program (#2135) - match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) { - Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) { - Ok(absolute) => files.push((id, absolute)), - Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})), - }, - Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})), - None => skipped.push(json!({"id": id.0, "reason": "no such photo"})), - } - } - if files.is_empty() { - let why = skipped.first().and_then(|s| s["reason"].as_str()).unwrap_or("no original file"); - return Err(if skipped.len() == 1 { - format!("nothing to open: {why}") - } else { - format!("nothing to open: none of these photos has its original file ({why})") - }); - } - let editor = p.get("app").and_then(Value::as_str).map_or_else(|| app.ui.settings.external_editor.clone(), str::to_string); - if let Some(check) = app.services.check_editor.as_ref() { - check(&editor)?; - } - // all at once where the host can: an editor that keeps one window (DxO PhotoLab) loses the - // files that arrive while it is still starting - if let Some(open_all) = app.services.open_all_with.as_mut() { - let paths: Vec = files.iter().map(|(_, path)| path.clone()).collect(); - open_all(&paths, &editor).map_err(|e| format!("Couldn't open the editor: {e}"))?; - let opened: Vec = files.iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect(); - return Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": null})); - } - let open = app.services.open_with.as_mut().ok_or("opening another application isn't available here")?; - let mut opened = Vec::new(); - let mut failed = None; - for (id, path) in files { - match open(&path, &editor) { - Ok(()) => opened.push(json!({"id": id.0, "path": path})), - Err(e) => failed = Some(e), - } - } - if opened.is_empty() - && let Some(e) = failed - { - return Err(format!("Couldn't open the editor: {e}")); - } - Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": failed})) -} diff --git a/crates/ui-egui/src/menus/files.rs b/crates/ui-egui/src/menus/files.rs new file mode 100644 index 000000000..52389d930 --- /dev/null +++ b/crates/ui-egui/src/menus/files.rs @@ -0,0 +1,262 @@ +use super::*; + +/// The Delete key in an album: a normal album loses the selected photos (`album.removePhotos`), +/// which stay in the library and their folder; a smart album keeps them (its rules decide) and +/// says so. True when the key was handled here, false outside albums (then it deletes the photos +/// from the library). Photo ▸ Delete Photo still deletes from the library everywhere. +pub fn delete_in_album(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + let lightcraft_engine::LibrarySource::Album(aid) = app.session.source else { return false }; + let Some(album) = app.session.catalog.album(aid) else { return false }; + if album.is_smart() { + app.toast(ctx, crate::i18n::tr("Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo")); + return true; + } + if app.session.selected().is_empty() { + return true; + } + match app.run("album.removePhotos", json!({"id": aid.0})) { + Ok(r) => { + let n = r["removed"].as_u64().unwrap_or(0); + let name = r["album"].as_str().unwrap_or_default().to_string(); + let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); + app.toast(ctx, crate::i18n::tr_format!("Removed {what} from {name}", what = what, name = name)); + } + Err(e) => app.toast(ctx, e), + } + true +} + +/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead +/// of deleting; true when it did (the dialog's OK runs `photo.delete`). +pub fn confirm_delete(app: &mut LightcraftApp) -> bool { + if !app.ui.settings.confirm_delete { + return false; + } + let count = app.session.selected().len(); + if count == 0 { + return false; + } + app.ui.dialog = Some(Dialog::ConfirmDelete { count }); + true +} + +/// Platform-appropriate label for revealing a file in the system file manager. +pub fn reveal_label() -> &'static str { + if cfg!(target_os = "macos") { + "Show in Finder" + } else if cfg!(target_os = "windows") { + "Show in Explorer" + } else { + "Show in File Manager" + } +} + +/// Help ▸ Open Log Folder: reveal the host's log file in the system file manager, so it can be +/// attached to a report without hunting for the settings folder (#260). +pub(super) fn open_log_folder(app: &mut LightcraftApp) -> Result { + let path = app.services.log_file.clone().ok_or("this session keeps no log file")?; + let reveal = app.services.reveal.as_mut().ok_or("not available here")?; + reveal(&path)?; + Ok(json!({"path": path})) +} + +/// Reveal the active photo's original in the system file manager. +pub(super) fn show_in_finder(app: &mut LightcraftApp) -> Result { + let id = app.session.active().ok_or("no photo selected")?; + let path = match app.session.catalog.photo(id).map(|p| p.source.clone()) { + Some(lightcraft_engine::catalog::Source::File { path }) => path, + _ => return Err("this photo has no file (demo scene)".into()), + }; + let reveal = app.services.reveal.as_mut().ok_or("not available here")?; + reveal(&path)?; + Ok(json!({"path": path})) +} + +/// The folder the active photo was imported from, as the sidebar's Folders section counts it (a +/// photo straight in a disk's root is in the root). `None` for a demo scene, a photo only browsed +/// in Local or in Recently Deleted, and one on the startup disk's root, which has no row to show. +fn photo_folder(app: &LightcraftApp, id: Option) -> Option { + let photo = id.or_else(|| app.session.active()).and_then(|id| app.session.catalog.photo(id)).filter(|p| p.in_library())?; + let lightcraft_engine::catalog::Source::File { path } = &photo.source else { return None }; + let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?; + (!lightcraft_catalog::folders::is_startup_disk(dir)).then(|| dir.to_string()) +} + +/// Whether Show in Folders has a row to show for photo `id` (the active one when `None`): its +/// folder must be a row of the tree, which stops `MAX_DEPTH` levels down. With no fresh tree to +/// look in, the folder alone decides, and the command still checks. +pub fn show_in_folders_enabled(app: &LightcraftApp, id: Option) -> bool { + let Some(dir) = photo_folder(app, id) else { return false }; + app.caches.folder_tree_if_fresh(&app.session.catalog).is_none_or(|tree| crate::panels::left::folder_row_of(&tree, &dir).is_some()) +} + +/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library): show the folder of photo `id` +/// (the active one when `None`; the grid's menu passes the photo right-clicked) as a click on its +/// row in the sidebar's Folders section does, and bring the row into view: the sidebar, the +/// section and the folders above it open, and it scrolls there (`reveal_folder`). +pub(super) fn show_in_folders(app: &mut LightcraftApp, id: Option) -> Result { + let dir = photo_folder(app, id).ok_or("this photo is not in a folder of the library")?; + let tree = app.caches.folder_tree(&app.session.catalog); + // the row's own spelling (forward slashes): what its click hands `library.source`, and its id + let path = crate::panels::left::folder_row_of(&tree, &dir).map(|n| n.path.clone()).ok_or("this photo's folder is not in Folders")?; + app.run("library.source", json!({"kind": "libraryFolder", "path": path}))?; + app.ui.left_panel = true; + app.ui.reveal_folder = Some(path.clone()); + Ok(json!({"path": path})) +} + +/// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original) +/// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the +/// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first, +/// so a missing editor leaves no copy behind. Once the copies exist, an editor that won't open them +/// is reported in the result's `failed` (and a toast), not as an error. +pub(super) fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { + // every way in (menu, shortcut, context menu, the control channel) says why nothing opened + edit_in_inner(app, ctx, p, photocraft).inspect_err(|e| app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e))) +} + +fn edit_in_inner(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { + let settings = &app.ui.settings; + let (file, configured) = if photocraft { + let path = settings.photocraft_path.trim(); + (settings.photocraft_file, if path.is_empty() { crate::state::PHOTOCRAFT.to_string() } else { path.to_string() }) + } else { + (settings.external_file, settings.external_editor.clone()) + }; + let editor = p.get("app").and_then(Value::as_str).map_or(configured, str::to_string); + if let Some(check) = app.services.check_editor.as_ref() { + check(&editor)?; + } + let mut params = file.params(); + if let (Some(o), Some(given)) = (params.as_object_mut(), p.as_object()) { + for (k, v) in given.iter().filter(|(k, _)| k.as_str() != "app") { + o.insert(k.clone(), v.clone()); + } + } + let r = app.session.execute("photo.editExternal", ¶ms).map_err(|e| e.to_string())?; + // every selected photo got a copy (#920); each opens in the editor + let copies: Vec<(String, Option)> = r["copies"] + .as_array() + .map(|c| c.iter().map(|c| (c["path"].as_str().unwrap_or_default().to_string(), c["id"].as_u64())).collect()) + .unwrap_or_else(|| vec![(r["path"].as_str().unwrap_or_default().to_string(), r["id"].as_u64())]); + let mut failed = None; + for (path, id) in &copies { + if let Some(id) = id { + app.ui.external_edits.push(*id); + } + // the copy is LightCraft's own render, but it goes through the same gate as any other + // path handed to the system opener (#2135) + match lightcraft_engine::open_path::openable(path, "") { + Err(e) => failed = Some(e), + Ok(absolute) => { + if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) { + failed = Some(e); + } + } + } + } + let mut r = r; + if let Some(o) = r.as_object_mut() { + o.insert("failed".into(), json!(failed)); + } + if let Some(e) = failed { + // the copies are in the library already (one undo step): an error would invite a retry + // that makes another set, so the result says what failed and the toast says why + app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); + return Ok(r); + } + match copies.as_slice() { + [(path, _)] => { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); + } + _ => app.toast(ctx, crate::i18n::tr_format!("{n} photos opened for editing; each is stacked with its original", n = copies.len())), + } + Ok(r) +} + +/// Most originals one Open Original hands the editor (each starts it once). +const MAX_ORIGINALS: usize = 50; + +/// Photo ▸ Edit In ▸ Open Original in Other Editor: hand each selected photo's original file +/// itself (a raw stays a raw: nothing is rendered, copied or added to the library) to the editor +/// set in Settings ▸ External Editing, e.g. a raw developer. `p` may give `ids` and the editor +/// (`app`). Demo photos and missing files are skipped and listed; when no photo has a file to +/// open, it fails saying why. +pub(super) fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> Result { + let r = open_originals(app, p); + if let Err(e) = &r { + app.toast(ctx, e.clone()); + } + let r = r?; + let opened = r["opened"].as_array().map_or(0, Vec::len); + match (r["failed"].as_str(), r["opened"][0]["path"].as_str()) { + (Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), + (None, Some(path)) if opened == 1 => { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(ctx, crate::i18n::tr_format!("{name} opened in the other editor", name = name)); + } + _ => app.toast(ctx, crate::i18n::tr_format!("{n} originals opened in the other editor", n = opened)), + } + Ok(r) +} + +fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result { + let mut ids = app.session.targets(p).map_err(|e| e.to_string())?; + ids.dedup(); + if ids.is_empty() { + return Err("no photo selected".into()); + } + if ids.len() > MAX_ORIGINALS { + return Err(format!("{} photos selected: open at most {MAX_ORIGINALS} originals at a time", ids.len())); + } + let mut files = Vec::new(); + let mut skipped = Vec::new(); + for id in ids { + // the path may come from an imported catalog: only a photo file LightCraft reads is + // handed to the opener, never a program (#2135) + match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) { + Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) { + Ok(absolute) => files.push((id, absolute)), + Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})), + }, + Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})), + None => skipped.push(json!({"id": id.0, "reason": "no such photo"})), + } + } + if files.is_empty() { + let why = skipped.first().and_then(|s| s["reason"].as_str()).unwrap_or("no original file"); + return Err(if skipped.len() == 1 { + format!("nothing to open: {why}") + } else { + format!("nothing to open: none of these photos has its original file ({why})") + }); + } + let editor = p.get("app").and_then(Value::as_str).map_or_else(|| app.ui.settings.external_editor.clone(), str::to_string); + if let Some(check) = app.services.check_editor.as_ref() { + check(&editor)?; + } + // all at once where the host can: an editor that keeps one window (DxO PhotoLab) loses the + // files that arrive while it is still starting + if let Some(open_all) = app.services.open_all_with.as_mut() { + let paths: Vec = files.iter().map(|(_, path)| path.clone()).collect(); + open_all(&paths, &editor).map_err(|e| format!("Couldn't open the editor: {e}"))?; + let opened: Vec = files.iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect(); + return Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": null})); + } + let open = app.services.open_with.as_mut().ok_or("opening another application isn't available here")?; + let mut opened = Vec::new(); + let mut failed = None; + for (id, path) in files { + match open(&path, &editor) { + Ok(()) => opened.push(json!({"id": id.0, "path": path})), + Err(e) => failed = Some(e), + } + } + if opened.is_empty() + && let Some(e) = failed + { + return Err(format!("Couldn't open the editor: {e}")); + } + Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": failed})) +} diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs new file mode 100644 index 000000000..c550ff0d4 --- /dev/null +++ b/crates/ui-egui/src/menus/run.rs @@ -0,0 +1,1992 @@ +use super::files::{edit_in, open_log_folder, open_original_in, show_in_finder, show_in_folders}; +use super::ui_commands::{UI_COMMANDS, language_from_command}; +use super::*; + +fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { + if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) { + app.ui.right = RightPanel::None; + app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name))); + } else { + app.ui.right = p; + app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name))); + if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + } + if p != RightPanel::Masking && app.ui.tool != "wbPicker" { + app.ui.tool.clear(); + } + let _ = app.session.end_interaction(); +} + +/// `[` / `]` (size ×`k`) and ⇧`[` / ⇧`]` (feather +`df`) for the brush in use: the Remove tool's +/// (and its selected spot's) or the Masking brush's. +fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { + if app.ui.right == RightPanel::Remove { + app.ui.remove_size = (app.ui.remove_size * k).clamp(0.001, 0.25); + app.ui.remove_feather = (app.ui.remove_feather + df).clamp(0.0, 100.0); + if app.session.active_spot.is_some() { + let mut p = json!({}); + if k != 1.0 { + p["size"] = json!(app.ui.remove_size); + } + if df != 0.0 { + p["feather"] = json!(app.ui.remove_feather); + } + let _ = app.run("spot.update", p); + } + json!({"size": app.ui.remove_size, "feather": app.ui.remove_feather}) + } else { + app.ui.brush_size = (app.ui.brush_size * k).clamp(0.002, 0.5); + app.ui.brush_feather = (app.ui.brush_feather + df).clamp(0.0, 100.0); + json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather}) + } +} + +/// The `parent` folder id of a `dialog.new*` command (none: the top level). +fn parent_param(p: &Value) -> Option { + p.get("parent").and_then(Value::as_u64) +} + +/// `file.addPhotos` / `file.addFolder` / `file.addFromDevice` `{album?}`: the review of the scan +/// just started opens with that album chosen (an album's context menu ▸ Import ▸ …). An error +/// means another scan is running. +fn import_into_album(app: &mut LightcraftApp, p: &Value, r: Result) -> Result { + if r.is_ok() + && let Some(t) = &mut app.scan + { + t.album = p.get("album").and_then(Value::as_u64); + } + r +} + +/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). +pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { + if let Some(s) = v.as_str() { + let h = s.strip_prefix('#').unwrap_or(s); + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); + return Some([c(0)?, c(2)?, c(4)?]); + } + let a = v.as_array()?; + let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8); + Some([c(0)?, c(1)?, c(2)?]) +} + +/// The left-sidebar section a `view.sidebar*` command names (`section`), which this build must have. +fn sidebar_section_param(command: &str, p: &Value) -> Result { + use crate::sidebar::SidebarSection; + let Some(name) = p.get("section").and_then(Value::as_str) else { + return Err(format!("{command}: `section` is needed ({})", SidebarSection::ids())); + }; + let section = SidebarSection::from_id(name).ok_or_else(|| format!("{command}: unknown section `{name}` ({})", SidebarSection::ids()))?; + if !section.available() { + return Err(format!("{command}: there is no `{name}` section here")); + } + Ok(section) +} + +/// An optional true / false parameter: absent or null is `None`, anything else is refused. +fn bool_param(command: &str, p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(other) => Err(format!("{command}: `{key}` must be true or false, not {other}")), + } +} + +/// What Shift+H says it switched to (the pins themselves may be off screen). +fn tool_overlay_toast(mode: crate::state::ToolOverlay) -> String { + use crate::state::ToolOverlay; + crate::i18n::tr(match mode { + ToolOverlay::Auto => "Tool Overlay: Auto", + ToolOverlay::Always => "Tool Overlay: Always", + ToolOverlay::Selected => "Tool Overlay: Selected", + ToolOverlay::Never => "Tool Overlay: Never", + }) + .to_string() +} + +fn appearance_json(app: &LightcraftApp) -> Value { + let s = &app.ui.settings; + json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) +} + +/// `view.appearance {mode?}`: set Appearance Mode, or step to the next one (Auto, Light, Dark). +/// The theme follows on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance_command(app: &mut LightcraftApp, p: &Value) -> Result { + use crate::state::AppearanceMode; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.settings.appearance_mode.next(), + Some(v) => match v.as_str() { + Some("auto") => AppearanceMode::Auto, + Some("light") => AppearanceMode::Light, + Some("dark") => AppearanceMode::Dark, + _ => return Err(format!("view.appearance: unknown mode {v} (auto|light|dark)")), + }, + }; + app.ui.settings.appearance_mode = mode; + Ok(appearance_json(app)) +} + +/// `view.theme {theme}`: pick a theme; the mode becomes its family's (Dark or Light). +fn theme_command(app: &mut LightcraftApp, p: &Value) -> Result { + let ids = crate::theme::ThemeKind::ALL.map(|k| k.id()).join("|"); + let Some(id) = p.get("theme").and_then(Value::as_str) else { + return Err(format!("view.theme: `theme` is required ({ids})")); + }; + let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| k.id() == id) else { + return Err(format!("view.theme: unknown theme `{id}` ({ids})")); + }; + app.ui.settings.select_theme(kind); + Ok(appearance_json(app)) +} + +/// Handle UI commands; `None` means "not a UI command — send it to the engine". +pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { + if matches!(id, "ui.dock" | "ui.dock.reset") { + return Some(crate::docking::command(app, id == "ui.dock.reset", p)); + } + if matches!(id, "library.inspectLightroom" | "library.importLightroom") { + // the app's own context: a fresh one's repaints reach no window and its clock starts at zero + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + return Some(crate::lightroom_import::command(app, id, p, &ctx)); + } + if id == "photo.reload" { + // the files are read in the background (a raw's decode takes a second or more) + return crate::reload::command(app, p); + } + if let Some(language) = language_from_command(id) { + app.ui.language = language; + // Immediately, not on the next frame: the reply and anything else run this frame + // (menus rebuilt from it, toasts) are already in the new language. + crate::i18n::set_language(app.ui.language); + return Some(Ok(json!(app.ui.language))); + } + // the app's own context: a toast's time comes from its clock (a fresh context's clock is at 0, + // and a toast set by it was long over by the app's clock: it never showed) + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + let r: Result = match id { + "view.photoGrid" => { + app.ui.view = ViewMode::PhotoGrid; + Ok(Value::Null) + } + "view.squareGrid" => { + app.ui.view = ViewMode::SquareGrid; + Ok(Value::Null) + } + "view.gridInfo" => { + // {info?: filename | exposure | date} (cycles when omitted) + let next = match p.get("info").and_then(Value::as_str) { + Some(i @ ("filename" | "exposure" | "date")) => i.to_string(), + Some(other) => return Some(Err(format!("view.gridInfo: unknown `{other}` (filename|exposure|date)"))), + None => match app.ui.grid_info.as_str() { + "filename" => "exposure".into(), + "exposure" => "date".into(), + _ => "filename".into(), + }, + }; + app.ui.grid_info = next; + app.ui.show_filenames = true; + // the caption is drawn on Square Grid cells only: from Photo Grid, show it there + if app.ui.view == ViewMode::PhotoGrid { + app.ui.view = ViewMode::SquareGrid; + } + Ok(json!({"info": app.ui.grid_info})) + } + "tool.keywordPainter" => { + // {keyword?}: paint that keyword onto photos in the grid by clicking them; no keyword stops + app.ui.keyword_painter = p.get("keyword").and_then(Value::as_str).map(str::trim).filter(|k| !k.is_empty()).map(str::to_string); + if app.ui.keyword_painter.is_some() && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { + app.ui.view = ViewMode::PhotoGrid; + } + if let Some(k) = app.ui.keyword_painter.clone() { + app.toast(&ctx, crate::i18n::tr_format!("Painting “{k}”: click photos to add or remove it · Esc stops", k = k)); + } + Ok(json!({"keyword": app.ui.keyword_painter})) + } + "view.secondWindow" => { + app.ui.second_window = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.second_window); + Ok(json!({"show": app.ui.second_window})) + } + "view.photoCounts" => { + app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts); + Ok(json!({"show": app.ui.show_counts})) + } + "view.sidebarSection" => { + // {section, show?, collapsed?}: show or hide a section of the left sidebar, fold or + // unfold it; with neither, a shown section is hidden and a hidden one shown + let args = sidebar_section_param(id, p).and_then(|s| Ok((s, bool_param(id, p, "show")?, bool_param(id, p, "collapsed")?))); + let (section, show, collapsed) = match args { + Ok(args) => args, + Err(e) => return Some(Err(e)), + }; + let layout = &mut app.ui.sidebar; + match (show, collapsed) { + (None, None) => layout.set_hidden(section, !layout.is_hidden(section)), + _ => { + if let Some(show) = show { + layout.set_hidden(section, !show); + } + if let Some(collapsed) = collapsed { + layout.set_collapsed(section, collapsed); + } + } + } + Ok(json!({"section": section.id(), "show": !layout.is_hidden(section), "collapsed": layout.is_collapsed(section)})) + } + "view.sidebarMoveSection" => { + // {section, direction: up | down} one place among the shown sections, or + // {section, before:
| null} just above another section (null: last) + let section = match sidebar_section_param(id, p) { + Ok(s) => s, + Err(e) => return Some(Err(e)), + }; + let layout = &mut app.ui.sidebar; + let moved = match (p.get("direction"), p.get("before")) { + (Some(d), None) => match d.as_str() { + Some("up") => layout.move_by(section, true), + Some("down") => layout.move_by(section, false), + _ => return Some(Err(format!("{id}: `direction` must be \"up\" or \"down\", not {d}"))), + }, + (None, Some(Value::Null)) => layout.move_before(section, None), + (None, Some(b)) => { + let before = b.as_str().and_then(crate::sidebar::SidebarSection::from_id); + let Some(before) = before else { + return Some(Err(format!("{id}: `before` must be a section ({}) or null, not {b}", crate::sidebar::SidebarSection::ids()))); + }; + layout.move_before(section, Some(before)) + } + _ => return Some(Err(format!("{id}: give either `direction` (up|down) or `before` (a section, or null for last)"))), + }; + let order: Vec<&str> = layout.sections().iter().map(|s| s.id.id()).collect(); + Ok(json!({"section": section.id(), "moved": moved, "order": order})) + } + "view.sidebarReset" => { + // every section shown and open, in the usual order + app.ui.sidebar = Default::default(); + Ok(json!({"sidebar": app.ui.sidebar})) + } + "view.appearance" => appearance_command(app, p), + "view.theme" => theme_command(app, p), + "library.selectOnlyActive" => { + let Some(active) = app.session.active() else { return Some(Err("no active photo".into())) }; + app.run("library.select", json!({"ids": [active.0], "active": active.0})) + } + "view.gridToggle" => { + app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; + Ok(Value::Null) + } + "view.detail" => { + app.ui.view = ViewMode::Detail; + Ok(Value::Null) + } + "view.compare" => crate::panels::compare::enter_compare(app), + "view.focusPoint" => { + // {on?}; no params toggles. The camera's focus point (Sony, from the file) over the photo. + app.ui.settings.focus_point = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.focus_point); + Ok(json!({"on": app.ui.settings.focus_point})) + } + "view.zoomToFocus" => { + // {on?}; no params toggles. Toggle Zoom (Z) centres on the focus point when there is one. + app.ui.settings.zoom_to_focus = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.zoom_to_focus); + Ok(json!({"on": app.ui.settings.zoom_to_focus})) + } + "view.faceBoxes" => { + app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); + Ok(json!({"show": app.ui.face_boxes})) + } + "view.people" => { + // Everyone; the page just left stays one click away (the chip next to the title) + if let Some(name) = app.ui.person_page.take() { + app.ui.last_person = Some(name); + } + app.ui.person_from = None; + app.ui.view = ViewMode::People; + Ok(json!({"people": app.session.catalog.people().len()})) + } + "view.person" => { + // {name}: one person's page in the People view: their faces, and the faces that look like them + let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { + return Some(Err("view.person: missing `name`".into())); + }; + app.ui.view = ViewMode::People; + app.ui.person_from = None; + app.ui.person_page = Some(name.to_string()); + Ok(json!({"person": name})) + } + "view.survey" => { + app.ui.view = ViewMode::Survey; + Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) + } + "view.reference" => { + // the reference: the one set before, else the active photo (the next one becomes active) + let vis = app.session.visible_cloned(); + let live = |id: u64| crate::panels::compare::live(app, lightcraft_catalog::PhotoId(id)); + let active = app.session.active().map(|a| a.0); + let reference = app.ui.reference.filter(|r| live(*r)).or_else(|| active.filter(|a| live(*a))); + let Some(r) = reference else { + return Some(Err(match active { + Some(a) => format!("photo {a} is in Recently Deleted: restore it to use it as the reference"), + None => "select a photo to use as the reference".into(), + })); + }; + app.ui.reference = Some(r); + if app.session.active().is_none_or(|a| a.0 == r) + && let Some(i) = vis.iter().position(|x| x.0 == r) + && let Some(next) = vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))) + { + let _ = app.session.execute("library.select", &json!({"ids": [next.0]})); + } + app.ui.view = ViewMode::Reference; + Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)})) + } + "photo.setReference" => { + // only an absent (or null) `id` means the active photo; any other non-id is refused + let id = match p.get("id").filter(|v| !v.is_null()) { + Some(v) => match v.as_u64() { + Some(id) => Some(id), + None => return Some(Err(format!("photo.setReference: `id` must be a photo id, not {v}"))), + }, + None => app.session.active().map(|a| a.0), + }; + let Some(id) = id else { return Some(Err("select a photo to use as the reference".into())) }; + match app.session.catalog.photo(lightcraft_catalog::PhotoId(id)) { + None => return Some(Err(format!("no photo {id}"))), + Some(p) if p.deleted => return Some(Err(format!("photo {id} is in Recently Deleted: restore it to use it as the reference"))), + Some(_) => {} + } + app.ui.reference = Some(id); + Ok(json!({"reference": id})) + } + "compare.swap" => crate::panels::compare::swap(app), + "compare.set" => { + let Some(id) = p.get("id").and_then(Value::as_u64) else { return Some(Err("compare.set needs `id`".into())) }; + crate::panels::compare::set_photo(app, lightcraft_catalog::PhotoId(id), p.get("side").and_then(Value::as_str)) + } + "compare.makeSelect" => crate::panels::compare::make_select(app), + "view.autoAdvance" => { + app.ui.auto_advance = !app.ui.auto_advance; + app.toast(&ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); + Ok(json!({"autoAdvance": app.ui.auto_advance})) + } + "view.back" => { + if app.ui.dialog.is_some() { + app.ui.dialog = None; + } else if app.ui.lights_out != crate::state::LightsOut::On { + app.ui.lights_out = crate::state::LightsOut::On; + } else if app.ui.keyword_painter.is_some() { + app.ui.keyword_painter = None; + } else if app.ui.fullscreen { + // Through `slideshow::end`: it also releases the held slide textures. Clearing the + // two fields directly left every rendered slide in the renderer. + crate::panels::slideshow::end(app); + } else if !app.ui.tool.is_empty() { + app.ui.tool.clear(); + } else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail { + // Esc leaves the crop tool (the crop stays), as C does; the next Esc goes back + panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); + } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { + app.ui.person_page = None; + } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey | ViewMode::Reference) { + app.ui.view = ViewMode::Detail; + } else if app.ui.view == ViewMode::Detail { + // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid + match app.ui.person_from.take() { + Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { + app.ui.view = ViewMode::People; + app.ui.person_page = name; + } + _ => app.ui.view = ViewMode::PhotoGrid, + } + } + Ok(Value::Null) + } + "view.slideshow" => { + // the photos in view, full screen, with the playback settings (see panels::slideshow) + let now = app.tick_now(); + let r = crate::panels::slideshow::start(app, p, now); + if r.is_ok() { + app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); + } + r + } + "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => { + let now = app.tick_now(); + crate::panels::slideshow::command(app, id, now) + } + "view.slideshowOptions" => { + app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() }); + Ok(Value::Null) + } + "view.fullScreenPreview" => { + if app.ui.fullscreen { + // Through `slideshow::end`: it also releases the held slide textures. Clearing the + // two fields directly left every rendered slide in the renderer. + crate::panels::slideshow::end(app); + } else { + app.ui.fullscreen = true; + app.ui.zoom = Zoom::Fit; + app.ui.tool.clear(); + let _ = app.session.end_interaction(); + } + Ok(json!({"fullscreen": app.ui.fullscreen})) + } + "view.lightsOut" => { + // {mode?: on | dim | off} (cycles when omitted) + match p.get("mode").and_then(Value::as_str) { + Some(m) => serde_json::from_value(json!(m)).map_err(|_| format!("unknown lights-out mode: {m} (on|dim|off)")), + None => Ok(app.ui.lights_out.next()), + } + .map(|mode| { + app.ui.lights_out = mode; + json!({"lightsOut": mode}) + }) + } + "view.enterFullScreen" => { + // applied by the host's frame logic (it knows the window's current state) + let on = p.get("on").and_then(Value::as_bool); + app.ui.window_fullscreen = Some(on.unwrap_or(!app.window_is_fullscreen)); + Ok(json!({"windowFullscreen": app.ui.window_fullscreen})) + } + "view.infoOverlay" => { + app.ui.info_overlay = match p.get("mode").and_then(Value::as_str) { + Some(m) => match serde_json::from_value(json!(m)) { + Ok(v) => v, + Err(_) => return Some(Err(format!("unknown info overlay `{m}` (off|basic|exposure)"))), + }, + None => app.ui.info_overlay.next(), + }; + let label = match app.ui.info_overlay { + crate::state::InfoOverlay::Off => "Info Overlay Off", + crate::state::InfoOverlay::Basic => "Info Overlay: File", + crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", + }; + app.toast(&ctx, crate::i18n::tr(label)); + Ok(json!({"infoOverlay": app.ui.info_overlay})) + } + "view.viewOptions" => { + // `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes + // shows and `loupeMessages` Show message when loading or rendering photos, for an agent; + // without any of them the window opens + let mut info = app.ui.settings.loupe_info; + let mut set = false; + // `loupeMessages` sets Show message when loading or rendering photos + let messages = match p.get("loupeMessages") { + None => app.ui.settings.loupe_messages, + Some(v) => match v.as_bool() { + Some(on) => { + set = true; + on + } + None => return Some(Err("`loupeMessages`: true|false".into())), + }, + }; + for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { + let Some(v) = p.get(key) else { continue }; + match serde_json::from_value(v.clone()) { + Ok(v) => *lines = v, + Err(_) => { + return Some(Err(format!("`{key}`: three of none|fileName|captureDate|dimensions|exposure|camera|lens|cameraAndLens"))); + } + } + set = true; + } + if let Some(v) = p.get("briefly") { + match serde_json::from_value(v.clone()) { + Ok(v) => info.briefly = v, + Err(_) => return Some(Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into())), + } + set = true; + } + if set { + app.ui.settings.loupe_info = info; + app.ui.settings.loupe_messages = messages; + } else { + app.ui.dialog = Some(Dialog::ViewOptions); + } + Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages})) + } + "view.chooseLayoutImage" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(path) => path.trim().to_string(), + None => { + let req = PickRequest::file(crate::i18n::tr("Choose Layout Image…"), "PNG", &["png"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(paths) => match paths.first() { + Some(path) => path.trim().to_string(), + None => return Some(Ok(Value::Null)), + }, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform; supply path".into())), + } + } + }; + if path.is_empty() { + return Some(Err("view.chooseLayoutImage: path is empty".into())); + } + let li = &mut app.ui.layout_image; + li.path = path; + li.show = true; + li.revision = li.revision.wrapping_add(1); + app.toast(&ctx, "Layout Image On"); + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.layoutImage" => { + let show = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.layout_image.show); + // nothing chosen yet: showing it chooses one first + if show && app.ui.layout_image.path.trim().is_empty() { + return run_ui_command(app, "view.chooseLayoutImage", &json!({})); + } + app.ui.layout_image.show = show; + app.toast(&ctx, if show { "Layout Image On" } else { "Layout Image Off" }); + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.layoutImageOpacity" | "view.layoutImageMatte" => { + let key = if id == "view.layoutImageOpacity" { "opacity" } else { "matte" }; + let Some(v) = p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) else { + return Some(Err(format!("{id}: {key} (0..1) is required"))); + }; + let v = v.clamp(0.0, 1.0) as f32; + if key == "opacity" { + app.ui.layout_image.opacity = v; + } else { + app.ui.layout_image.matte = v; + } + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.navigator" => { + app.ui.navigator = !app.ui.navigator; + Ok(json!({"navigator": app.ui.navigator})) + } + "app.settings" => { + let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general"); + if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) { + let tabs: Vec<&str> = crate::panels::settings::TABS.iter().map(|(id, _)| *id).collect(); + return Some(Err(format!("unknown settings tab `{tab}` ({})", tabs.join("|")))); + } + app.ui.dialog = Some(Dialog::Settings { tab: tab.into() }); + Ok(Value::Null) + } + "app.openLibrary" => crate::panels::settings::open_library(app, p), + "app.newLibrary" => crate::panels::settings::new_library(app, p), + "app.recentLibraries" => crate::panels::settings::recent_libraries(app, p), + "app.clearRecentLibraries" => crate::panels::settings::recent_libraries(app, &json!({"clear": true})), + "app.displayProfile" => crate::panels::settings::display_profile(app, p), + "file.backupLibrary" | "file.restoreLibrary" => { + let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; + match action { + Some(f) => f(&mut app.session), + None => Err("not available here: on the desktop the library is a folder; back it up with your other files".into()), + } + } + "view.filmstrip" => { + app.ui.filmstrip = !app.ui.filmstrip; + Ok(Value::Null) + } + "view.frameRate" => { + // {show?: bool} (toggles when omitted) + app.ui.frame_rate = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.frame_rate); + Ok(json!({"show": app.ui.frame_rate})) + } + "view.leftPanel" => { + app.ui.left_panel = !app.ui.left_panel; + Ok(Value::Null) + } + "view.rightPanel" => { + // Classic's F8: hide the right panel, and bring back the same one next time (Edit when + // none was hidden). Hiding a tool ends it, as closing its panel does. + if app.ui.right != RightPanel::None && crate::docking::exposed(app, crate::docking::Panel::Tools) { + app.ui.right_hidden = app.ui.right; + app.ui.right = RightPanel::None; + if app.ui.tool != "wbPicker" { + app.ui.tool.clear(); + } + let _ = app.session.end_interaction(); + } else if app.ui.right == RightPanel::None { + let p = match std::mem::take(&mut app.ui.right_hidden) { + RightPanel::None => RightPanel::Edit, + p => p, + }; + app.ui.right = p; + if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + if p == RightPanel::Remove && app.ui.tool.is_empty() { + app.ui.tool = "remove".into(); + } + } + // A right panel that is open but not showing (minimized, or behind another tab) + // keeps its mode; reveal_command brings it to the front. + Ok(json!({"open": app.ui.right != RightPanel::None})) + } + "view.beforeAfter" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::SideBySide { BeforeAfter::Off } else { BeforeAfter::SideBySide }; + Ok(Value::Null) + } + "view.beforeAfterSplit" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::Split { BeforeAfter::Off } else { BeforeAfter::Split }; + Ok(Value::Null) + } + "view.beforeAfterTopBottom" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::TopBottom { BeforeAfter::Off } else { BeforeAfter::TopBottom }; + Ok(Value::Null) + } + "view.beforeAfterSplitTopBottom" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::SplitTopBottom { BeforeAfter::Off } else { BeforeAfter::SplitTopBottom }; + Ok(Value::Null) + } + "view.showOriginal" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::Original { BeforeAfter::Off } else { BeforeAfter::Original }; + Ok(Value::Null) + } + "view.zoomFit" => { + app.ui.zoom = Zoom::Fit; + Ok(Value::Null) + } + "view.zoom100" => { + app.ui.zoom = Zoom::Percent(100.0); + Ok(Value::Null) + } + "view.zoomToggle" => { + // the same ratio a click on the photo zooms to; zooming in centres on the camera's focus + // point when the photo has one (View ▸ Zoom to Focus Point) + let zoom_in = app.ui.zoom == Zoom::Fit; + app.ui.zoom = if zoom_in { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; + app.ui.zoom_anim = true; + if zoom_in + && app.ui.settings.zoom_to_focus + && let Some(pan) = app.session.active().and_then(|id| crate::panels::detail::focus_pan(app, id)) + { + app.ui.pan = pan; + return Some(Ok(json!({"focus": [pan.0, pan.1]}))); + } + Ok(Value::Null) + } + "view.clickZoom" => { + // {ratio?: 1|2|3|4|8} → {ratio} + if let Some(r) = p.get("ratio").and_then(Value::as_f64) { + let pct = (r * 100.0).round() as u32; + if !crate::state::CLICK_ZOOMS.contains(&pct) { + return Some(Err(format!("view.clickZoom: ratio {r} (1, 2, 3, 4 or 8)"))); + } + app.ui.click_zoom = pct; + } + Ok(json!({"ratio": app.ui.click_zoom / 100})) + } + "view.zoomIn" | "view.zoomOut" => { + let steps = [25.0, 50.0, 100.0, 200.0, 400.0, 800.0]; + let cur = match app.ui.zoom { + Zoom::Percent(p) => p, + _ => 25.0, + }; + let next = if id == "view.zoomIn" { + steps.iter().find(|s| **s > cur).copied().unwrap_or(800.0) + } else { + steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0.0) + }; + app.ui.zoom = if next == 0.0 { Zoom::Fit } else { Zoom::Percent(next) }; + Ok(Value::Null) + } + "view.thumbSizeUp" | "view.thumbSizeDown" => { + // the bottom-bar slider's range, one `THUMB_SIZE_STEP` per call; {} → {size} + if !thumb_size_view(app) { + return Some(Err(format!("{id}: only in Photo Grid, Square Grid or People"))); + } + let step = if id == "view.thumbSizeUp" { crate::state::THUMB_SIZE_STEP } else { -crate::state::THUMB_SIZE_STEP }; + app.ui.thumb_size = (app.ui.thumb_size + step).clamp(crate::state::THUMB_SIZE_MIN, crate::state::THUMB_SIZE_MAX); + Ok(json!({"size": app.ui.thumb_size})) + } + "view.navigate" => { + // {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre). + // Validate the complete request before changing either part of the viewport. + let zoom = match p.get("zoom") { + Some(v) => match serde_json::from_value::(v.clone()) { + Ok(Zoom::Percent(p)) if !p.is_finite() || p <= 0.0 || p > 800.0 => { + return Some(Err("view.navigate: zoom percent must be greater than 0 and at most 800".into())); + } + Ok(z) => z, + Err(e) => return Some(Err(format!("view.navigate: {e}"))), + }, + None => app.ui.zoom, + }; + let pan = match p.get("pan") { + Some(v) => match serde_json::from_value::<(f32, f32)>(v.clone()) { + Ok((x, y)) if x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y) => (x, y), + _ => return Some(Err("view.navigate: pan must be [x, y] with finite coordinates from 0 to 1".into())), + }, + None => app.ui.pan, + }; + app.ui.zoom = zoom; + app.ui.pan = pan; + app.ui.zoom_anim = false; + Ok(json!({"zoom": zoom, "pan": pan})) + } + "view.clipping" => { + app.ui.show_clipping = !app.ui.show_clipping; + Ok(Value::Null) + } + "view.visualizeHdr" => { + // {on?}; no params toggles. Shown on photos edited in HDR (see `develop.hdr`). + app.ui.hdr_visualize = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.hdr_visualize); + Ok(json!({"on": app.ui.hdr_visualize})) + } + "view.softProof" => { + // {on?, space?, profile?: ICC path | null, intent?: perceptual|relative, paperInk?, + // destWarning?, displayWarning?}; no params toggles + let has = |k: &str| p.get(k).is_some(); + if let Some(s) = p.get("space").and_then(Value::as_str) { + match lightcraft_engine::pipeline::OutputSpace::parse(s) { + Some(sp) => { + app.ui.proof.space = sp; + // a standard space replaces a device profile (unless one is given too) + app.ui.proof_profile = None; + } + None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))), + } + } + if let Some(i) = p.get("intent") { + match i.as_str().and_then(lightcraft_engine::proof::ProofIntent::parse) { + Some(i) => app.ui.proof_intent = i, + None => return Some(Err("view.softProof: intent must be perceptual or relative".into())), + } + } + if let Some(b) = p.get("paperInk").and_then(Value::as_bool) { + app.ui.proof_paper_ink = b; + } + let path = match p.get("profile") { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Null) => { + app.ui.proof_profile = None; + None + } + Some(_) => return Some(Err("view.softProof: profile must be an ICC file path or null".into())), + None => app.ui.proof_profile.as_ref().map(|(f, _)| f.clone()), + }; + match path { + Some(path) => match lightcraft_engine::proof::load(&path, app.ui.proof_intent, app.ui.proof_paper_ink) { + Ok(pp) => { + app.ui.proof.profile = pp.id; + app.ui.proof_profile = Some((path, pp.name)); + } + Err(e) => return Some(Err(format!("view.softProof: {e}"))), + }, + None => app.ui.proof.profile = 0, + } + if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { + app.ui.proof.dest_warning = b; + } + if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { + app.ui.proof.display_warning = b; + } + app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { + Some(b) => b, + None if ["space", "destWarning", "displayWarning", "profile", "intent", "paperInk"].iter().any(|k| has(k)) => app.ui.soft_proof, + None => !app.ui.soft_proof, + }; + if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { + app.ui.view = ViewMode::Detail; + } + let pr = app.ui.proof; + let profile = app.ui.proof_profile.as_ref().map(|(f, n)| json!({"path": f, "name": n})); + Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "profile": profile, "intent": app.ui.proof_intent.name(), + "paperInk": app.ui.proof_paper_ink, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) + } + "view.histogram" => { + app.ui.histogram = !app.ui.histogram; + Ok(Value::Null) + } + "view.maskOverlay" => { + app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay); + Ok(json!({"maskOverlay": app.ui.mask_overlay})) + } + "view.maskOverlayAll" => { + app.ui.mask_overlay_all = !app.ui.mask_overlay_all; + Ok(json!({"maskOverlayAll": app.ui.mask_overlay_all})) + } + "view.maskOverlayMode" => { + use lightcraft_engine::pipeline::MaskView; + let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); + let next = match p.get("mode").and_then(Value::as_str) { + Some(m) => match MaskView::parse(m) { + Some(v) => v, + None => { + let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect(); + return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|")))); + } + }, + None => cur.next(), + }; + app.ui.mask_overlay_mode = next.name().into(); + app.ui.mask_overlay = true; + app.toast(&ctx, next.label()); + Ok(json!({"mode": next.name()})) + } + "view.maskOverlayColor" => { + // no params: the next of the panel's swatch colours + if p.get("color").is_none() && p.get("opacity").is_none() { + let all = crate::panels::masking::OVERLAY_COLORS; + let i = all.iter().position(|c| *c == app.ui.mask_overlay_color).map_or(0, |i| (i + 1) % all.len()); + app.ui.mask_overlay_color = all[i]; + } + if let Some(c) = p.get("color") { + match parse_rgb(c) { + Some(rgb) => app.ui.mask_overlay_color = rgb, + None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())), + } + } + if let Some(o) = p.get("opacity").and_then(Value::as_f64) { + app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32; + } + let [r, g, b] = app.ui.mask_overlay_color; + Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity})) + } + "brush.smaller" | "brush.larger" | "brush.featherLess" | "brush.featherMore" => { + let (k, df) = match id { + "brush.smaller" => (1.0 / 1.2, 0.0), + "brush.larger" => (1.2, 0.0), + "brush.featherLess" => (1.0, -10.0), + _ => (1.0, 10.0), + }; + Ok(adjust_brush(app, k, df)) + } + "view.maskPins" => { + app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); + Ok(json!({"maskPins": app.ui.mask_pins})) + } + "view.infoSelectedPhotos" => { + // {on?}; no params toggles. On: the Info panel shows what the selected photos share. + let on = match p.get("on") { + None | Some(Value::Null) => !app.ui.settings.info_selected_photos, + Some(Value::Bool(on)) => *on, + Some(v) => return Some(Err(format!("view.infoSelectedPhotos: `on` must be true or false, not {v}"))), + }; + app.ui.settings.info_selected_photos = on; + Ok(json!({"on": on})) + } + "view.toolOverlay" => { + use crate::state::ToolOverlay; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.tool_overlay.next(), + Some(v) => match v.as_str().and_then(ToolOverlay::parse) { + Some(m) => m, + None => return Some(Err(format!("view.toolOverlay: unknown mode {v} (auto|always|selected|never)"))), + }, + }; + app.ui.tool_overlay = mode; + app.toast(&ctx, tool_overlay_toast(mode)); + Ok(json!({"mode": mode.name()})) + } + "view.visualizeSpots" => { + // like Lightroom's A: opens the Remove tool with the view on, or toggles it there + if app.ui.right == RightPanel::Remove { + app.ui.visualize_spots = !app.ui.visualize_spots; + } else { + app.ui.right = RightPanel::Remove; + app.ui.visualize_spots = true; + } + Ok(Value::Null) + } + // A under the Crop panel: the aspect lock (the panel's button runs the same toggle) + "crop.lockAspect" => app.run("crop.aspect", json!({"aspect": "toggle"})), + "view.cropOverlay" => { + use crate::state::CropOverlay::*; + app.ui.crop_overlay = match app.ui.crop_overlay { + Thirds => Grid, + Grid => Golden, + Golden => Diagonal, + Diagonal => Triangle, + Triangle => Spiral, + Spiral => None, + None => Thirds, + }; + Ok(json!({"overlay": app.ui.crop_overlay})) + } + "view.cropOverlayOrientation" => { + app.ui.crop_overlay_orient = (app.ui.crop_overlay_orient + 1) % 4; + Ok(json!({"orientation": app.ui.crop_overlay_orient})) + } + "local.addRoot" => { + // a folder kept in Local's sidebar (saved with the UI state); nothing on disk changes + let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(Err("local.addRoot needs a path".into())) }; + let abs = std::path::absolute(path).map(|a| a.to_string_lossy().trim_end_matches(['/', '\\']).to_string()).unwrap_or(path.into()); + let abs = if abs.is_empty() { path.to_string() } else { abs }; + if !std::path::Path::new(&abs).is_dir() { + return Some(Err(format!("{path}: not a folder"))); + } + use crate::panels::left::same_folder; + if !app.ui.local_roots.iter().any(|r| same_folder(r, &abs)) { + app.ui.local_roots.push(abs.clone()); + } + app.ui.hidden_locations.retain(|h| !same_folder(h, &abs)); + Ok(json!({"roots": app.ui.local_roots})) + } + "local.hide" => match p.get("path").and_then(Value::as_str) { + Some(path) => { + if !app.ui.hidden_locations.iter().any(|h| crate::panels::left::same_folder(h, path)) { + app.ui.hidden_locations.push(path.to_string()); + } + Ok(json!({"hidden": app.ui.hidden_locations})) + } + None => Err("local.hide needs a path".into()), + }, + "local.restoreHidden" => { + // one path, or (no path) every hidden location + match p.get("path").and_then(Value::as_str) { + Some(path) => app.ui.hidden_locations.retain(|h| !crate::panels::left::same_folder(h, path)), + None => app.ui.hidden_locations.clear(), + } + Ok(json!({"hidden": app.ui.hidden_locations})) + } + "view.soloSections" => bool_param(id, p, "on").map(|on| { + app.ui.set_single_panel(on.unwrap_or(!app.ui.single_panel)); + json!({"soloSections": app.ui.single_panel}) + }), + "view.filterBar" => { + app.ui.filter_bar = !app.ui.filter_bar; + if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { + app.ui.view = ViewMode::PhotoGrid; + } + Ok(json!({"filterBar": app.ui.filter_bar})) + } + "panel.edit" => { + panel(app, &ctx, RightPanel::Edit, "Edit"); + Ok(Value::Null) + } + "panel.profiles" => { + // toggles between the profile browser and the Edit panel it belongs to + app.ui.right = if app.ui.right == RightPanel::Profiles && crate::docking::exposed(app, crate::docking::Panel::Tools) { + RightPanel::Edit + } else { + RightPanel::Profiles + }; + if !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + Ok(json!({"open": app.ui.right == RightPanel::Profiles})) + } + "panel.crop" => { + panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); + Ok(Value::Null) + } + "panel.remove" => { + panel(app, &ctx, RightPanel::Remove, "Remove"); + if app.ui.right == RightPanel::Remove && app.ui.tool.is_empty() { + app.ui.tool = "remove".into(); + } + Ok(Value::Null) + } + "panel.masking" => { + panel(app, &ctx, RightPanel::Masking, "Masking"); + Ok(Value::Null) + } + "panel.redeye" => { + panel(app, &ctx, RightPanel::RedEye, "Red Eye"); + Ok(Value::Null) + } + "panel.info" => { + panel(app, &ctx, RightPanel::Info, "Info"); + Ok(Value::Null) + } + "panel.keywords" => { + panel(app, &ctx, RightPanel::Keywords, "Keywords"); + Ok(Value::Null) + } + "panel.addKeywords" => { + // opens Keywording when it is folded shut; the panel stays open when pressed again + if app.ui.right != RightPanel::Keywords { + panel(app, &ctx, RightPanel::Keywords, "Keywords"); + } + app.ui.folded_sections.retain(|s| s != "keywording"); + app.ui.focus_keywords = Some(ctx.cumulative_pass_nr()); + Ok(Value::Null) + } + "panel.versions" => { + panel(app, &ctx, RightPanel::Versions, "Versions"); + Ok(Value::Null) + } + "panel.activity" => { + panel(app, &ctx, RightPanel::Activity, "History"); + Ok(Value::Null) + } + "panel.presets" => { + app.ui.presets = !app.ui.presets || !crate::docking::exposed(app, crate::docking::Panel::Presets); + if app.ui.presets && app.ui.view != ViewMode::Detail { + app.ui.view = ViewMode::Detail; + } + Ok(Value::Null) + } + "tool.done" => { + // Return commits a tool panel (crop, remove, red eye, masking): back to Edit + use RightPanel::*; + if app.ui.dialog.is_none() && matches!(app.ui.right, Crop | Remove | RedEye | Masking) { + let _ = app.session.end_interaction(); + app.ui.tool.clear(); + app.ui.right = Edit; + } + Ok(Value::Null) + } + "panel.close" => { + app.ui.right = RightPanel::None; + app.ui.presets = false; + Ok(Value::Null) + } + s if s.starts_with("section.") => bool_param(s, p, "solo").and_then(|solo| { + // only the Edit sections listed above: an unknown one would close the real ones + if !UI_COMMANDS.iter().any(|c| c.0 == s) { + return Err(format!("{s}: no such Edit section")); + } + let sec = &s["section.".len()..]; + if app.ui.right != RightPanel::Edit { + app.ui.right = RightPanel::Edit; + } + if solo == Some(true) { + app.ui.solo_section(sec); + } else { + app.ui.toggle_section(sec); + } + Ok(Value::Null) + }), + s if s.starts_with("tool.") => { + let tool = &s["tool.".len()..]; + match tool { + "none" => app.ui.tool.clear(), + "guidedUpright" => { + // Crop & Geometry with Guided Upright on, ready to draw guides + app.ui.right = RightPanel::Crop; + app.ui.view = ViewMode::Detail; + let guided = app + .session + .active() + .and_then(|id| app.session.develop_of(id)) + .is_some_and(|d| d.geometry.upright == lightcraft_develop::Upright::Guided); + if !guided && let Err(e) = app.run("geometry.upright", json!({"mode": "guided"})) { + return Some(Err(e)); + } + app.ui.tool = "guidedUpright".into(); + } + "brush" => { + // A new mask and Add/Subtract are selection operations, not the brush's + // Erase mode. Paint positively into a component with the requested op. + let create = if p.get("new").and_then(Value::as_bool) == Some(true) { + Some(app.run("mask.add", json!({"kind": "brush"}))) + } else { + p.get("op").map(|op| app.run("mask.addComponent", json!({"kind": "brush", "op": op}))) + }; + if let Some(result) = create { + if let Err(e) = result { + return Some(Err(e)); + } + app.ui.brush_erase = false; + app.ui.mask_overlay = true; + } + app.ui.right = RightPanel::Masking; + app.ui.view = ViewMode::Detail; + app.ui.tool = "brush".into(); + } + "linear" | "radial" => { + app.ui.right = RightPanel::Masking; + app.ui.view = ViewMode::Detail; + app.ui.tool = tool.into(); + return Some(app.session.execute("mask.add", &json!({"kind": tool})).map_err(|e| e.to_string())); + } + "wbPicker" => { + app.ui.right = RightPanel::Edit; + app.ui.view = ViewMode::Detail; + app.ui.tool = "wbPicker".into(); + } + other => return Some(Err(format!("unknown tool `{other}`"))), + } + Ok(Value::Null) + } + "dialog.newFolder" => { + app.ui.dialog = Some(Dialog::NewAlbum { + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + folder: true, + parent: parent_param(p), + include_selected: false, + }); + Ok(Value::Null) + } + "dialog.newAlbum" => { + // {name?, parent?, includeSelected? (true)} + app.ui.dialog = Some(Dialog::NewAlbum { + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + folder: false, + parent: parent_param(p), + include_selected: p.get("includeSelected").and_then(Value::as_bool).unwrap_or(true), + }); + Ok(Value::Null) + } + "dialog.autoStack" => { + app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 }); + Ok(Value::Null) + } + "dialog.allMetadata" => { + let r = match app.session.execute("photo.allMetadata", p) { + Ok(r) => r, + Err(e) => return Some(Err(e.to_string())), + }; + let title = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.file_name.clone()).unwrap_or_default(); + app.ui.dialog = Some(Dialog::AllMetadata { title, rows: r, search: String::new() }); + Ok(Value::Null) + } + "dialog.smartAlbum" => { + // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 + let album = p + .get("id") + .and_then(Value::as_u64) + .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); + app.ui.dialog = Some(match album { + Some(a) => Dialog::SmartRules { + id: Some(a.id.0), + name: a.name.clone(), + // older rules read as they always matched (RuleSet::upgrade) + rules: a + .smart + .as_ref() + .and_then(|f| f.rule_set.clone()) + .map(|mut r| { + r.upgrade(); + r + }) + .unwrap_or_default(), + parent: None, + }, + None => Dialog::SmartRules { + id: None, + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + parent: parent_param(p), + }, + }); + Ok(Value::Null) + } + "dialog.newSmartAlbum" => { + app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), parent: parent_param(p) }); + Ok(Value::Null) + } + "dialog.captureTime" => { + // a time that doesn't fully read isn't offered as the starting point (`10:0` would read as midnight) + let time = app + .session + .active() + .and_then(|id| app.session.catalog.photo(id)) + .map(|p| p.date()) + .filter(|d| lightcraft_catalog::dates::shift_iso(d, 0).is_some()) + .map(|d| d.replace('T', " ")) + .unwrap_or_default(); + let mode = p.get("mode").and_then(Value::as_str).unwrap_or("set").to_string(); + app.ui.dialog = + Some(Dialog::CaptureTime { mode, time: time.get(..19).unwrap_or(&time).to_string(), days: 0, hours: 0, minutes: 0, zone: 0.0 }); + Ok(Value::Null) + } + "dialog.labelNames" => { + let names = + lightcraft_catalog::ColorLabel::ALL.iter().map(|l| app.session.catalog.custom_label_name(*l).unwrap_or("").to_string()).collect(); + app.ui.dialog = Some(Dialog::LabelNames { names, save_as: String::new() }); + Ok(Value::Null) + } + "dialog.rename" => { + let template = p.get("template").and_then(Value::as_str).unwrap_or("{name}").to_string(); + app.ui.dialog = Some(Dialog::Rename { template, start: p.get("start").and_then(Value::as_u64).unwrap_or(1) as u32 }); + Ok(Value::Null) + } + "dialog.createPreset" => { + app.ui.dialog = Some(Dialog::create_preset()); + Ok(Value::Null) + } + "dialog.pasteSettings" => { + let groups = + app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); + app.ui.dialog = Some(Dialog::PasteSettings { groups }); + Ok(Value::Null) + } + "view.focusSearch" => { + // the search field lives in the top bar of the library and detail views alike + app.ui.focus_search = true; + Ok(Value::Null) + } + "dialog.copySettings" => { + let groups = + app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); + app.ui.dialog = Some(Dialog::CopySettings { groups }); + Ok(Value::Null) + } + "dialog.slideshowExport" => { + let format = match p.get("format").and_then(Value::as_str) { + Some("jpeg") => lightcraft_engine::slideshow_export::Format::Jpeg, + Some("video") => lightcraft_engine::slideshow_export::Format::Video, + _ => lightcraft_engine::slideshow_export::Format::Pdf, + }; + app.ui.dialog = Some(Dialog::SlideshowExport { settings: lightcraft_engine::slideshow_export::ExportSettings::for_format(format) }); + Ok(Value::Null) + } + "app.slideshowExport" => crate::slideshow_export::start(app, id, p), + "dialog.contactSheet" => { + app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() }); + Ok(Value::Null) + } + "dialog.print" => { + app.ui.print = Default::default(); + app.ui.dialog = Some(Dialog::Print); + Ok(Value::Null) + } + "app.print" => { + if app.export.is_some() { + return Some(Err("an export is already running".into())); + } + let mut params = p.clone(); + if p.get("path").is_none() { + let req = PickRequest::save("Print to PDF", "PDF", &["pdf"], "Print.pdf"); + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), + }; + let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; + params["path"] = json!(path); + } + crate::export_task::start_print(app, ¶ms) + } + "app.contactSheet" => { + if app.export.is_some() { + return Some(Err("an export is already running".into())); + } + let mut params = p.clone(); + if p.get("path").is_none() { + let req = PickRequest::save("Contact Sheet PDF", "PDF", &["pdf"], "Contact Sheet.pdf"); + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), + }; + let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; + params["path"] = json!(path); + } + crate::export_task::start_contact_sheet(app, ¶ms) + } + "dialog.export" => { + let prev = app.session.last_export.clone().unwrap_or_default(); + let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d); + let dir = prev.get("dir").and_then(Value::as_str).map(str::to_string).unwrap_or_else(crate::control::default_export_dir); + // stored settings: a depth the format lacks (saved before #1952) opens at the format's own + let opts = lightcraft_engine::export::ExportOptions::from_json(&lightcraft_engine::export::ExportOptions::known_keys_only(&prev)); + // no previous export: 2048 px long edge; a previous full-size export: full size + let full_size = opts.resize.is_none() && lightcraft_engine::export::ExportOptions::has_size_param(&prev); + let resize = opts.resize.unwrap_or_default(); + app.ui.dialog = Some(Dialog::Export { opts, full_size, resize, preset_name: String::new(), limit_kb: u("limitKb", 0) as u32, dir }); + Ok(Value::Null) + } + "merge.hdrLast" => crate::merge::start_last(app, "merge.hdr"), + "merge.hdrBatch" => crate::merge::start_batch(app, p), + "merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"), + "merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"), + "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), + "dialog.faceModel" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => app.services.pick_model_file.as_mut().and_then(|f| f().into_iter().next()), + }; + match path { + Some(path) => crate::panels::faces::open_dialog(app, &path), + None => Ok(Value::Null), + } + } + "dialog.mergePanorama" => crate::merge::open(app, "merge.panorama"), + "dialog.mergeHdrPanorama" => crate::merge::open(app, "merge.hdrPanorama"), + "app.openSamModelFolder" => crate::panels::dialogs::show_model_folder(app), + "app.about" => { + app.ui.dialog = Some(Dialog::About); + Ok(Value::Null) + } + "app.checkForUpdates" => crate::updates_ui::check(app), + "photo.editInPhotoCraft" => edit_in(app, &ctx, p, true), + "photo.editInExternal" => edit_in(app, &ctx, p, false), + "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), + "dialog.cull" => { + let (burst_gap, similarity) = app.ui.cull_burst.unwrap_or((10.0, 0.93)); + app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true, burst_gap, similarity }); + Ok(Value::Null) + } + "app.whatsNew" => { + app.ui.dialog = Some(Dialog::WhatsNew); + Ok(json!({"text": crate::panels::dialogs::WHATS_NEW})) + } + "app.systemInfo" => { + let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); + let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); + let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); + let mut rows = vec![ + ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), + ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), + ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), + ( + "GPU".to_string(), + gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { + Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), + None => "none (CPU rendering)".into(), + }), + ), + ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), + ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), + ( + "Preview size".to_string(), + match app.ui.settings.preview_limit { + 0 => "automatic".to_string(), + n => format!("{n} px"), + }, + ), + ("Photos".to_string(), info["photos"].to_string()), + ("Albums".to_string(), info["albums"].to_string()), + ]; + if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { + rows.push(("Library".into(), dir.to_string())); + } + rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); + rows.push(( + "Frame update".into(), + format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), + )); + rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); + if let Some(f) = lightcraft_engine::gpu::last_fallback() { + rows.push(("Last GPU fallback".into(), f)); + } + let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::>()); + if p.get("open").and_then(Value::as_bool).unwrap_or(true) { + app.ui.dialog = Some(Dialog::SystemInfo { rows }); + } + Ok(r) + } + "app.shortcuts" => { + app.ui.dialog = Some(Dialog::Shortcuts); + Ok(Value::Null) + } + "app.setShortcut" => crate::shortcuts::set_shortcut(app, p), + "app.resetShortcuts" => { + app.ui.settings.keymap.clear(); + Ok(Value::Null) + } + "library.browse" if !cfg!(target_arch = "wasm32") => { + if crate::lightroom_import::is_running(app) { + return Some(Err("finish Lightroom catalog import before browsing folders".into())); + } + // listed and read in the background (see `import::browse`) + let path = p.get("path").and_then(Value::as_str)?; + crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool)) + } + "file.addPhotos" => { + if crate::lightroom_import::is_running(app) { + return Some(Err("finish Lightroom catalog import before adding photos".into())); + } + let paths = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => { + let req = PickRequest::files(None, crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later | Picked::Unavailable => return Some(Ok(Value::Null)), + } + } + }; + if paths.is_empty() { + return Some(Ok(Value::Null)); + } + // review first: the import dialog lists what was found + let r = crate::import::open(app, paths); + import_into_album(app, p, r) + } + "file.importLightroom" => { + // where the catalog goes (#2672): checked before a file dialog opens + let req = match crate::lightroom_destination::Request::parse(p) { + Ok(req) => req, + Err(e) => return Some(Err(e)), + }; + let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { + Some(path) => Some(path), + None => { + let req = + PickRequest::file(crate::i18n::tr("Import Lightroom Catalog"), crate::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_lightroom_catalog.as_mut().and_then(|f| f()).map(|x| vec![x])) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + match path { + Some(path) => crate::lightroom_destination::import(app, &path, &req), + None => Ok(Value::Null), + } + } + "app.quit" => { + app.ui.quit = true; + Ok(Value::Null) + } + "dialog.saveMetadataPreset" => { + // from the active photo's copyright, creator and place + crate::panels::dialogs::prompt(app, "Save Metadata Preset", "Preset name", "", "metadata.savePreset", json!({}), "name"); + Ok(Value::Null) + } + "file.findMissing" => { + let within = match p.get("within") { + None | Some(Value::Null) => None, + Some(Value::String(path)) if !path.is_empty() => Some(path.clone()), + Some(Value::String(_)) => return Some(Err("file.findMissing: `within` must not be empty".into())), + Some(value) => return Some(Err(format!("file.findMissing: `within` must be a folder path, not {value}"))), + }; + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + let timeout = match p.get("timeout") { + None | Some(Value::Null) => std::time::Duration::from_secs(600), + Some(Value::Number(n)) => { + if let Some(s) = n.as_f64() { + if s.is_finite() && s >= 0.0 { + match std::time::Duration::try_from_secs_f64(s) { + Ok(d) => d, + Err(_) => return Some(Err("file.findMissing: `timeout` is too large".into())), + } + } else { + return Some(Err("file.findMissing: `timeout` must be a non-negative number of seconds".into())); + } + } else { + return Some(Err("file.findMissing: `timeout` must be a number of seconds".into())); + } + } + Some(other) => return Some(Err(format!("file.findMissing: `timeout` must be a number of seconds, not {other}"))), + }; + let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; + let folder = match p.get("folder").and_then(Value::as_str) { + Some(f) => Some(f.to_string()), + None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, + }; + let Some(folder) = folder else { return Some(Ok(Value::Null)) }; + // the search (checking every photo's file, walking the folder) runs on a worker thread; + // the relinking happens back here, as one undo step + const LABEL: &str = "Find Missing Photos"; + if app.tasks.is_running(LABEL) { + return Some(Err("Find Missing Photos is already searching".into())); + } + let mut candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog); + if let Some(within) = within { + candidates.retain(|candidate| lightcraft_catalog::query::folder_within(&candidate.path, &within)); + } + #[cfg(test)] + let test_delay = app.ui.find_missing_test_delay; + let work = move || { + #[cfg(test)] + if let Some(d) = test_delay { + std::thread::sleep(d); + } + lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder) + }; + let done = |app: &mut LightcraftApp, ctx: &egui::Context, plan: Result| { + // relinked under the session as it is now: photos relinked meanwhile and files + // now in use are skipped + let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json())); + match r { + Ok(v) => { + let n = v["found"].as_array().map_or(0, Vec::len); + let left = v["missing"].as_u64().unwrap_or(0); + let unsure = v["ambiguous"].as_array().map_or(0, Vec::len); + let unsure = if unsure > 0 { + crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure) + } else { + String::new() + }; + app.toast( + ctx, + crate::i18n::tr_format!( + "Found {n} missing photo{}; {left} still missing{unsure}", + if n == 1 { "" } else { "s" }, + left = left, + n = n, + unsure = unsure + ), + ); + app.ui.last_find_missing = Some(v.clone()); + app.ui.last_find_missing_result = Some(Ok(v)); + } + Err(e) => { + app.toast(ctx, e.clone()); + app.ui.last_find_missing_result = Some(Err(e)); + } + } + }; + app.ui.last_find_missing = None; + app.ui.last_find_missing_result = None; + if let Err(e) = crate::tasks::spawn(app, LABEL, Some("findMissing"), work, done) { + return Some(Err(e)); + } + if wait { + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + let completed = crate::tasks::wait_for(app, &ctx, LABEL, timeout); + if !completed { + return Some(Err("Find Missing Photos: search timed out (search continues in the background)".into())); + } + return match app.ui.last_find_missing_result.take() { + Some(Ok(v)) => Some(Ok(v)), + Some(Err(e)) => Some(Err(e)), + None => Some(Err("Find Missing Photos: worker terminated unexpectedly".into())), + }; + } + Ok(json!({"background": true})) + } + "photo.tagFromTracklog" => { + // a GPX file → GPS for the selected photos by capture time (one undo step) + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = p.as_object().cloned().unwrap_or_default(); + params.insert("path".into(), json!(path)); + let ask_zone = !params.contains_key("offset"); + let params = Value::Object(params); + if ask_zone { + // GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here + crate::panels::dialogs::prompt( + app, + "Auto-Tag from Tracklog", + "Camera time zone, e.g. -07:00 (empty: UTC)", + "", + "photo.tagFromTracklog", + params, + "offset", + ); + return Some(Ok(Value::Null)); + } + let r = app.run("photo.autoTagTracklog", params); + if let Ok(v) = &r { + let n = v["tagged"].as_u64().unwrap_or(0); + let sk = &v["skipped"]; + let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n); + let outside = sk["outside"].as_u64().unwrap_or(0); + if outside > 0 { + msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside); + } + let kept = sk["hasGps"].as_u64().unwrap_or(0); + if kept > 0 { + msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept); + } + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + app.toast(&ctx, msg); + } + r + } + "photo.locate" => { + let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) }; + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = + PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + match path { + Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), + None => Ok(Value::Null), + } + } + "file.addFromDevice" => { + // a camera / card: review its DCIM folder, copying into the library by default + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => match lightcraft_engine::devices::devices_now().into_iter().next() { + Some(d) => d.path, + None => return Some(Err("no camera or memory card found".into())), + }, + }; + let r = crate::import::open(app, vec![path]); + // only the scan just started (an error means another scan is running) + if r.is_ok() + && let Some(t) = &mut app.scan + { + t.copy = true; + } + import_into_album(app, p, r) + } + "file.addFolder" => { + // a folder (searched recursively) into the import review + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => match crate::pick::ask(app, id, p, "path", PickRequest::folder(crate::i18n::tr("Import from Folder…")), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, + }; + match path { + Some(path) => { + let r = crate::import::open(app, vec![path]); + import_into_album(app, p, r) + } + None => Ok(Value::Null), + } + } + "file.importPresets" => { + let paths = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Presets").into()), + crate::i18n::tr("Presets & Profiles"), + crate::pick::PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + if paths.is_empty() { + return Some(Ok(Value::Null)); + } + // .cube LUTs (and those inside zips / folders) become profiles + let lut_paths: Vec<&String> = paths + .iter() + .filter(|p| { + let l = p.to_ascii_lowercase(); + l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() + }) + .collect(); + // one toast for both halves (a second toast would replace the first) + let mut parts: Vec = Vec::new(); + // `name: reason` for each file that couldn't be imported + let mut problems: Vec = Vec::new(); + let mut profiles_result = Value::Null; + if !lut_paths.is_empty() { + match app.session.execute("profile.import", &json!({"paths": lut_paths})) { + Ok(v) => { + let profiles = v["imported"].as_array().map_or(0, Vec::len); + if profiles > 0 { + parts.push(crate::i18n::tr_format!( + "Imported {profiles} profile{} (Profile browser ▸ their groups)", + if profiles == 1 { "" } else { "s" }, + profiles = profiles + )); + } + problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| { + let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); + let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); + format!("{name}: {reason}") + })); + profiles_result = v; + } + Err(e) => { + problems.push(e.to_string()); + profiles_result = json!({"error": e.to_string()}); + } + } + } + let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); + let r = if preset_paths.is_empty() { + Ok(json!({"imported": [], "failed": [], "skipped": 0})) + } else { + app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) + }; + if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast + let effects = v["effects"].as_array().map_or(0, Vec::len); + if effects > 0 { + parts.push(crate::panels::mask_effects::import_toast(v)); + } + let profile_news = !parts.is_empty() || !problems.is_empty(); + let msg = match (n, failed) { + // a zip of LUTs (or only local presets): the other half already says what happened + (0, 0) if profile_news => None, + (0, 0) => Some("No new presets".to_string()), + (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), + (n, f) => Some(crate::i18n::tr_format!( + "Imported {n} preset{}, {f} file{} not readable", + if n == 1 { "" } else { "s" }, + if f == 1 { "" } else { "s" }, + f = f, + n = n + )), + }; + if let Some(mut msg) = msg { + // settings with no counterpart here (the other editor's profiles, masks…) + let mut skipped: Vec<&str> = v["imported"] + .as_array() + .into_iter() + .flatten() + .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) + .filter_map(Value::as_str) + .collect(); + skipped.sort_unstable(); + skipped.dedup(); + if !skipped.is_empty() { + let names: Vec<&str> = skipped.iter().take(3).copied().collect(); + msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); + } + parts.push(msg); + } + if n > 0 { + app.ui.presets = true; + } + } + let mut msg = parts.join(" · "); + if !problems.is_empty() { + let detail = + format!("{}{}", problems.iter().take(2).cloned().collect::>().join("; "), if problems.len() > 2 { "…" } else { "" }); + if msg.is_empty() { + msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); + } else { + msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail); + } + app.toast_error(&ctx, msg); + } else if !msg.is_empty() { + app.toast(&ctx, msg); + } + let r = r.map(|mut v| { + if !lut_paths.is_empty() + && let Some(o) = v.as_object_mut() + { + o.insert("profiles".into(), profiles_result); + } + v + }); + return Some(r); + } + "file.importKeywords" => { + // a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8) + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) { + Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Some(Ok(Value::Null)), + }, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string()); + match &r { + Ok(v) => { + let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0)); + let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added); + if updated > 0 { + msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated)); + } + app.toast(&ctx, msg); + } + Err(e) => app.toast(&ctx, e.clone()), + } + return Some(r); + } + "file.exportKeywords" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let name = "Keywords.txt"; + let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) { + Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Some(Ok(Value::Null)), + }, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string()); + if let Err(e) = &r { + app.toast(&ctx, e.clone()); + } + if let Ok(v) = &r { + let n = v["keywords"].as_u64().unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n); + // Capture One's importer refuses ; , < > in a list: say which to rename first + let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + if !refuses.is_empty() { + msg.push_str(&crate::i18n::tr_format!( + " — Capture One won't import a list with ; , < or > in a keyword: {names}", + names = refuses.iter().take(5).copied().collect::>().join(" · ") + )); + } + // what the format can't hold (a name in brackets, a line break…) isn't in the file + let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + if !left.is_empty() { + msg.push_str(&crate::i18n::tr_format!( + " — left out, as a keyword list can't hold them: {names}", + // escaped: a line break in a name would break the toast + names = left.iter().take(5).map(|n| n.escape_debug().to_string()).collect::>().join(" · ") + )); + } + app.toast(&ctx, msg); + } + return Some(r); + } + "dialog.saveMaskEffect" => { + if app.session.active_mask.is_none() { + return Some(Err("select a mask first".into())); + } + crate::panels::mask_effects::open_save(app); + Ok(Value::Null) + } + "dialog.manageMaskEffects" => { + crate::panels::mask_effects::open_manage(app); + Ok(Value::Null) + } + "file.exportMaskEffects" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); + let req = PickRequest::save( + crate::i18n::tr("Export Effect Presets"), + crate::i18n::tr("LightCraft Effect Presets"), + &["lceffect"], + name.clone(), + ); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = json!({"path": path}); + for k in ["names", "group"] { + if let Some(v) = p.get(k) { + params[k] = v.clone(); + } + } + let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); + match &r { + Ok(v) => app.toast(&ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), + Err(e) => app.toast_error(&ctx, e.clone()), + } + return Some(r); + } + "file.exportPresets" => { + let group = p.get("group").and_then(Value::as_str).map(str::to_string); + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets")); + let req = PickRequest::save(crate::i18n::tr("Export Presets"), crate::i18n::tr("LightCraft Preset"), &["lcpreset"], name.clone()); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = json!({"path": path}); + if let Some(g) = group { + params["group"] = json!(g); + } + if let Some(ids) = p.get("ids") { + params["ids"] = ids.clone(); + } + let r = app.session.execute("preset.export", ¶ms).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.toast(&ctx, crate::i18n::tr_format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); + } + return Some(r); + } + "file.importCurvePresets" => { + let paths: Vec = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Point Curve Presets").into()), + crate::i18n::tr("Point Curve Presets"), + crate::pick::CURVE_PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + if paths.is_empty() { + return Some(Ok(Value::Null)); + } + let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); + if failed > 0 { + msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); + } + app.toast(&ctx, msg); + } + return Some(r); + } + "file.exportCurvePresets" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::save( + crate::i18n::tr("Export Point Curve Presets"), + crate::i18n::tr("Point Curve Presets"), + &["lccurve"], + "Point Curves.lccurve", + ); + match crate::pick::ask(app, id, p, "path", req, |s| { + s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = json!({"path": path}); + if let Some(n) = p.get("names") { + params["names"] = n.clone(); + } + let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.toast(&ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); + } + return Some(r); + } + "app.export" => crate::control::export_active(app, p), + "app.showInFinder" => show_in_finder(app), + "app.showInFolders" => show_in_folders(app, p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId)), + "app.openLogFolder" => open_log_folder(app), + "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => { + let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE); + crate::links::open(app, url) + } + "app.exportPrevious" => match app.session.last_export.clone() { + Some(prev) => crate::control::export_active(app, &lightcraft_engine::export::ExportOptions::known_keys_only(&prev)), + None => Err("nothing exported yet — use Export…".into()), + }, + _ => return None, + }; + Some(r) +} diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs new file mode 100644 index 000000000..7236b5528 --- /dev/null +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -0,0 +1,280 @@ +use super::*; + +/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language +/// has a matching menu command (also listed through the control channel). Tests check coverage. +pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ + ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), + // The picker order (`Locale::for_picker`): alphabetical by the language's own name after + // English; Cyrillic, Georgian, then CJK names follow the Latin ones. A test keeps them equal. + ("app.language.indonesian", crate::i18n::Locale::Id.name(), None, "Edit>Language"), + ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), + ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), + ("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"), + ("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"), + ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), + ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), + ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), + ("app.language.finnish", crate::i18n::Locale::Fi.name(), None, "Edit>Language"), + ("app.language.swedish", crate::i18n::Locale::Sv.name(), None, "Edit>Language"), + ("app.language.vietnamese", crate::i18n::Locale::Vi.name(), None, "Edit>Language"), + ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), + ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), + ("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"), + ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), + ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), + ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), +]; + +/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from +/// this file, so an id listed in `docs/parity-checklist.md` is checked wherever it is declared. +pub fn ui_commands() -> impl Iterator { + LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS) +} + +/// The language a Language-menu command selects, if the id is one. The engine and the UI both go +/// through here, so the menu, the settings row and the control channel agree on the mapping. +pub fn language_from_command(id: &str) -> Option { + match id { + "app.language.english" => Some(crate::i18n::Locale::En), + "app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans), + "app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant), + "app.language.japanese" => Some(crate::i18n::Locale::Ja), + "app.language.portuguese" => Some(crate::i18n::Locale::PtBr), + "app.language.french" => Some(crate::i18n::Locale::Fr), + "app.language.spanish" => Some(crate::i18n::Locale::Es), + "app.language.german" => Some(crate::i18n::Locale::De), + "app.language.russian" => Some(crate::i18n::Locale::Ru), + "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), + "app.language.polish" => Some(crate::i18n::Locale::Pl), + "app.language.czech" => Some(crate::i18n::Locale::Cs), + "app.language.italian" => Some(crate::i18n::Locale::It), + "app.language.georgian" => Some(crate::i18n::Locale::Ka), + "app.language.swedish" => Some(crate::i18n::Locale::Sv), + "app.language.finnish" => Some(crate::i18n::Locale::Fi), + "app.language.indonesian" => Some(crate::i18n::Locale::Id), + "app.language.vietnamese" => Some(crate::i18n::Locale::Vi), + _ => None, + } +} + +pub const UI_COMMANDS: &[UiCommand] = &[ + ("ui.dock", "Arrange Panel", None, ""), + ("ui.dock.reset", "Reset Panel Layout", None, "Window"), + ("modelSetup.cancel", "Cancel Pending Photo Action", None, ""), + ("view.photoGrid", "Photo Grid", None, "View"), + ("view.squareGrid", "Square Grid", None, "View"), + // G: Photo Grid ↔ Square Grid (from other views: the photo grid) + ("view.gridToggle", "Grid", Some("G"), ""), + ("library.selectOnlyActive", "Select Only Active Photo", Some("Cmd+Shift+D"), "Edit"), + ("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"), + ("view.detail", "Detail View", Some("D"), "View"), + ("view.compare", "Compare", Some("Shift+C"), "View"), + ("view.survey", "Survey", Some("N"), "View"), + ("view.people", "People", None, "View"), + ("view.person", "Show Person", None, ""), + ("view.faceBoxes", "Face Boxes", None, "View"), + ("view.focusPoint", "Focus Point", Some("Alt+F"), "View"), + ("view.zoomToFocus", "Zoom to Focus Point", None, "View"), + ("view.reference", "Reference View", Some("Shift+R"), "View"), + ("photo.setReference", "Set as Reference Photo", None, ""), + ("compare.swap", "Swap Compare Photos", None, "View"), + ("compare.set", "Show in Compare", None, ""), + ("compare.makeSelect", "Make Candidate the Select", None, "View"), + ("view.autoAdvance", "Auto Advance", None, "Photo"), + ("view.filmstrip", "Filmstrip", Some("/"), "View"), + ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), + ("view.rightPanel", "Right Panel", None, "View"), + ("view.beforeAfter", "Before/After Side by Side", Some("Y"), "View"), + ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), + ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), + ("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"), + ("view.showOriginal", "Show Original", Some("\\"), "View"), + ("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"), + ("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"), + ("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"), + // the ratio a click (and Z / Space) zooms to + ("view.clickZoom", "Click Zoom Ratio", None, ""), + ("view.navigate", "Set Image Zoom and Pan", None, ""), + ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), + ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), + // Library grids only (Photo Grid, Square Grid, People): the bottom-bar slider's step by key + ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), + ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), + ("view.clipping", "Clipping", Some("J"), "View"), + ("view.visualizeHdr", "Visualize HDR", None, "View"), + // in grids S expands/collapses stacks (the engine command it shadows) + ("view.softProof", "Soft Proofing", Some("S"), "View"), + ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), + ("view.maskOverlay", "Mask Overlay", Some("O"), "View"), + // Ctrl+Shift+O / Cmd+Shift+O: every mask's region at once, not just the selected one + ("view.maskOverlayAll", "All Mask Overlays", Some("Cmd+Shift+O"), "View"), + // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) + ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), + ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), + ("view.maskPins", "Mask Pins", None, "View"), + // {mode?: auto | always | selected | never}: when spot and mask pins show (steps to the next + // mode when omitted; Shift+H, like Classic's H for pins, since H opens Remove). View ▸ Tool + // Overlay and the Remove panel list the modes. + ("view.toolOverlay", "Cycle Tool Overlay", Some("Shift+H"), ""), + ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), + // A while the Crop panel is open: the aspect lock as a first-class row. Visualize Spots keeps + // the key outside the panel; the two are a contextual pair in shortcuts::handle + ("crop.lockAspect", "Lock Aspect Ratio", Some("A"), ""), + ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), + ("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"), + ("view.back", "Back to Grid", Some("Escape"), ""), + ("tool.done", "Done", Some("Enter"), ""), + ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), + ("local.addRoot", "Add Folder to Local", None, ""), + ("local.hide", "Remove from Local", None, ""), + ("local.restoreHidden", "Show Hidden Local Locations", None, ""), + // {on?}: the Info panel's Target Photo / Selected Photos choice (on = Selected Photos) + ("view.infoSelectedPhotos", "Show Metadata for Selected Photos", None, ""), + ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), + ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), + ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), + ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), + ("view.viewOptions", "View Options…", Some("Cmd+J"), "View"), + ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), + ("view.layoutImage", "Layout Image", Some("Cmd+Alt+O"), "View"), + ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), + ("view.layoutImageMatte", "Layout Image Matte", None, ""), + ("view.navigator", "Navigator", None, "View"), + ("panel.edit", "Edit Panel", Some("E"), "Window"), + ("panel.profiles", "Profile Browser", None, "Window"), + ("panel.crop", "Crop & Rotate", Some("C"), "Window"), + ("panel.remove", "Remove", Some("H"), "Window"), + ("panel.masking", "Masking", Some("M"), "Window"), + ("panel.redeye", "Red Eye", None, "Window"), + ("panel.presets", "Presets", Some("Shift+P"), "Window"), + ("panel.info", "Info", Some("I"), "Window"), + ("panel.keywords", "Keywords", Some("K"), "Window"), + // the Keywords panel with the cursor in its Add keyword box + ("panel.addKeywords", "Add Keywords", Some("Cmd+K"), ""), + ("panel.versions", "Versions", Some("Shift+V"), "Window"), + ("panel.activity", "History", None, "Window"), + ("panel.close", "Close Panel", None, ""), + // {solo?}: true opens just that section (⌥-click on its header); otherwise opens or closes it + ("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"), + ("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"), + ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), + ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), + ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), + ("section.calibration", "Calibration", None, ""), + // {on?}; no params toggles. Opening one Edit section closes the others. + ("view.soloSections", "Solo Mode", None, "Window>Edit Sections"), + ("tool.brush", "Brush", Some("B"), "Window>Tools"), + ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), + ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), + ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), + ("tool.none", "No Tool", None, ""), + // brush size / feather of the active brush (Masking brush, Remove tool and its selected spot) + ("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"), + ("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"), + ("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"), + ("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"), + ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), + ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), + ("dialog.smartAlbum", "New Smart Album…", None, "File"), + ("view.photoCounts", "Photo Counts", None, "View"), + ("view.sidebarSection", "Show Sidebar Section", None, ""), + ("view.sidebarMoveSection", "Move Sidebar Section", None, ""), + ("view.sidebarReset", "Reset Sidebar Sections", None, ""), + ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), + ("view.slideshowOptions", "Slideshow Options…", None, "View"), + ("dialog.slideshowExport", "Export Slideshow…", None, "View"), + ("app.slideshowExport", "Export Slideshow", None, ""), + // the slideshow's playback controls (Space, ←/→ and Esc while it plays) + ("slideshow.togglePause", "Pause / Resume Slideshow", None, ""), + ("slideshow.next", "Next Slide", None, ""), + ("slideshow.previous", "Previous Slide", None, ""), + ("slideshow.end", "End Slideshow", None, ""), + ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), + ("tool.keywordPainter", "Keyword Painter", None, ""), + ("view.gridInfo", "Grid Info", None, ""), + // {mode?: auto | light | dark}: Appearance Mode (cycles Auto, Light, Dark when omitted; the + // top bar's appearance button). View ▸ Appearance lists the modes. + ("view.appearance", "Next Appearance Mode", None, ""), + // {theme: charcoal | midnight | silver | paper}: pick a theme and fix the mode to its family. + ("view.theme", "Theme", None, ""), + ("dialog.allMetadata", "All Metadata…", None, "Photo"), + ("dialog.faceModel", "Add Face Model…", None, ""), + ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), + ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), + ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), + ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"), + ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), + ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), + ("dialog.export", "Export…", None, "File"), + ("dialog.print", "Print…", Some("Cmd+P"), "File"), + ("app.print", "Print to PDF", None, ""), + ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), + ("app.contactSheet", "Export Contact Sheet PDF", None, ""), + ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), + ("photo.editInExternal", "Edit in Other Editor", Some("Cmd+Alt+E"), "Photo>Edit In"), + // {ids?, app?}: the original files themselves (a raw stays a raw: no render, no copy), opened + // in the other editor, e.g. a raw developer + ("photo.openOriginalInExternal", "Open Original in Other Editor (RAW, no copy)", None, "Photo>Edit In"), + ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), + ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), + ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), + ("merge.hdrLast", "HDR with Last Settings", Some("Ctrl+Shift+H"), "Photo>Photo Merge"), + ("merge.panoramaLast", "Panorama with Last Settings", Some("Ctrl+Shift+M"), "Photo>Photo Merge"), + ("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"), + ("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"), + ("file.addFolder", "Import from Folder…", None, "File"), + // {path?, into?: open | new, name?, location?}: asks where the catalog goes unless `into` (or + // Settings) says (#2672, `lightroom_destination`) + ("file.importLightroom", "Import Lightroom Catalog…", None, "File"), + ("file.addFromDevice", "Import from Device", None, ""), + ("file.findMissing", "Find Missing Photos…", None, "File"), + ("file.backupLibrary", "Back Up Library…", None, "File"), + ("file.restoreLibrary", "Restore Library from Backup…", None, "File"), + ("photo.locate", "Locate Missing File…", None, ""), + ("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""), + ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), + ("file.importPresets", "Import Profiles & Presets…", None, "File"), + ("file.exportPresets", "Export Presets…", None, "File"), + // Masking panel ▸ Effect menu + ("dialog.saveMaskEffect", "Save Current Settings as New Effect Preset…", None, ""), + ("dialog.manageMaskEffects", "Manage Effect Presets…", None, ""), + ("file.exportMaskEffects", "Export Effect Presets…", None, ""), + ("file.importKeywords", "Import Keywords…", None, "File"), + ("file.exportKeywords", "Export Keywords…", None, "File"), + // Edit panel ▸ Curve ▸ Point Curve dropdown + ("file.importCurvePresets", "Import Point Curve Presets…", None, ""), + ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), + ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), + ("app.newLibrary", "New Library…", None, "File"), + ("app.openLibrary", "Open Library…", Some("Cmd+O"), "File"), + ("app.recentLibraries", "Recent Libraries", None, ""), + ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), + // Settings ▸ Display + ("app.displayProfile", "Display Profile", None, ""), + ("app.about", "About LightCraft", None, "Help"), + ("app.checkForUpdates", "Check for Updates…", None, "Help"), + ("app.systemInfo", "System Info…", None, "Help"), + ("app.openLogFolder", "Open Log Folder", None, "Help"), + // the frame-rate display (`frame_rate`): a debugging aid, never saved + ("view.frameRate", "Frame Rate", None, "Help>Debug"), + ("app.whatsNew", "What's New", None, "Help"), + ("dialog.cull", "Assisted Culling…", None, "Photo"), + ("app.help", "LightCraft Help", Some("F1"), "Help"), + ("app.discord", "Join the ArtCraft Discord…", None, "Help"), + ("app.feedback", "Send Feedback…", None, "Help"), + ("app.website", "LightCraft Website", None, "Help"), + ("app.github", "LightCraft on GitHub", None, "Help"), + ("app.artcraft", "ArtCraft Website", None, "Help"), + ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), + ("app.setShortcut", "Set Keyboard Shortcut", None, ""), + ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), + ("app.export", "Export Now", None, ""), + ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), + ("app.showInFolders", "Show in Folders", None, "Photo"), + ("app.openSamModelFolder", "Show in File Manager", None, ""), + ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), + ("dialog.labelNames", "Edit Color Label Names…", None, ""), + ("dialog.captureTime", "Edit Capture Time…", None, "Photo"), + ("photo.tagFromTracklog", "Auto-Tag from Tracklog…", None, "Photo"), + ("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"), +]; From 4e13f61ba027fb37083f41ffab3ad0483a90949a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:11:14 +0000 Subject: [PATCH 121/191] refactor(lightcraft-ui-egui): split panels/left.rs into panels/left/ --- crates/ui-egui/src/panels/left.rs | 2258 +------------------- crates/ui-egui/src/panels/left/albums.rs | 415 ++++ crates/ui-egui/src/panels/left/dates.rs | 46 + crates/ui-egui/src/panels/left/folders.rs | 661 ++++++ crates/ui-egui/src/panels/left/keywords.rs | 55 + crates/ui-egui/src/panels/left/local.rs | 406 ++++ crates/ui-egui/src/panels/left/rows.rs | 255 +++ crates/ui-egui/src/panels/left/sections.rs | 419 ++++ 8 files changed, 2282 insertions(+), 2233 deletions(-) create mode 100644 crates/ui-egui/src/panels/left/albums.rs create mode 100644 crates/ui-egui/src/panels/left/dates.rs create mode 100644 crates/ui-egui/src/panels/left/folders.rs create mode 100644 crates/ui-egui/src/panels/left/keywords.rs create mode 100644 crates/ui-egui/src/panels/left/local.rs create mode 100644 crates/ui-egui/src/panels/left/rows.rs create mode 100644 crates/ui-egui/src/panels/left/sections.rs diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 5a81edfab..522afe084 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -11,633 +11,32 @@ use crate::sidebar::SidebarSection; use crate::theme::Tokens; use crate::widgets::{icon_button, register}; -/// How wide the sidebar's content needs to be, from the widest row of the last frame: rows are -/// drawn at this width (or the panel's, if wider), and the sidebar scrolls sideways when it -/// exceeds the panel. One frame behind, which needs no second layout pass. -pub(crate) fn content_width(ctx: &egui::Context) -> f32 { - ctx.data(|d| d.get_temp::(egui::Id::unique("left-content-width"))).unwrap_or(0.0) -} +mod albums; +mod dates; +mod folders; +mod keywords; +mod local; +mod rows; +mod sections; -/// The most a row asks for its name: longer ones are cut when there is no more room. -const MAX_NAME_NEED: f32 = 140.0; - -/// Height of a sidebar row. -const ROW_HEIGHT: f32 = 29.0; - -/// Temp-data key of the row (its widget id after `source:`, lower-case) the sidebar scrolls to -/// this frame: set before the sections are drawn, cleared after. -const SCROLL_TO_ROW: &str = "left-scroll-to-row"; - -/// Temp-data key of the Folders choice whose rows above were last opened ([`reveal_chosen`]): -/// removed, they open again. -const LIBFOLDER_REVEALED: &str = "libfolder-revealed"; - -/// A row says how wide it needs to be. -fn note_width(ui: &egui::Ui, w: f32) { - ui.data_mut(|d| { - let m = d.get_temp_mut_or_insert_with::(egui::Id::unique("left-content-width-next"), || 0.0); - *m = m.max(w); - }); -} - -/// One level of a sidebar tree when the panel has room: callers pass `indent` in these steps. -pub(crate) const INDENT_STEP: f32 = 16.0; - -/// The narrowest a level is drawn: the chevron still steps visibly right of its parent's. -pub(crate) const MIN_INDENT_STEP: f32 = 8.0; - -/// What a row keeps for its name when the step is chosen: the deepest row's name is not cut to -/// less than this while a narrower step would still keep it. -const MIN_NAME_ROOM: f32 = 80.0; - -/// Room kept for a count at the end of the deepest row (three digits and their gap). -const COUNT_ROOM: f32 = 40.0; - -/// The step a level is drawn with this frame: [`INDENT_STEP`] while the deepest open row fits the -/// panel, narrower when it would not, never below [`MIN_INDENT_STEP`]. From the last frame. -pub(crate) fn indent_step(ctx: &egui::Context) -> f32 { - ctx.data(|d| d.get_temp::(egui::Id::unique("left-indent-step"))).unwrap_or(INDENT_STEP) -} - -/// How far right a row at `indent` (in [`INDENT_STEP`]s) is drawn this frame. -fn indent_x(ctx: &egui::Context, indent: f32) -> f32 { - indent / INDENT_STEP * indent_step(ctx) -} - -/// [`indent_x`] for a row being laid out, which also notes how deep the tree goes, for next -/// frame's step. -fn drawn_indent(ui: &egui::Ui, indent: f32) -> f32 { - ui.data_mut(|d| { - let m = d.get_temp_mut_or_insert_with::(egui::Id::unique("left-deepest-indent-next"), || 0.0); - *m = m.max(indent); - }); - indent_x(ui.ctx(), indent) -} - -/// The widest step (16, 14, 12, 10 or 8 pt) at which a row `deepest` deep (in [`INDENT_STEP`]s) -/// keeps [`MIN_NAME_ROOM`] for its name and its count in a panel `panel_w` wide. In steps, so a -/// row does not slide sideways at every point the panel is dragged; when even the narrowest step -/// does not fit, the sidebar scrolls sideways as before. -pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 { - let levels = deepest / INDENT_STEP; - if levels < 1.0 { - return INDENT_STEP; - } - let room = panel_w - 42.0 - MIN_NAME_ROOM - 18.0 - COUNT_ROOM; - [16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP) -} - -fn row( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - count: Option, - selected: bool, - indent: f32, -) -> egui::Response { - row_named(app, ui, id, icon, label, None, count, selected, indent) -} - -/// [`row`] whose spoken name is `spoken` when the painted `label` is a shortened form of it. -fn row_named( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, - count: Option, - selected: bool, - indent: f32, -) -> egui::Response { - row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click(), None) -} - -/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`) and a colour -/// label's dot (`mark`) before the count. -#[allow(clippy::too_many_arguments)] -fn row_sensed( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, - count: Option, - selected: bool, - indent: f32, - sense: Sense, - mark: Option, -) -> egui::Response { - let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; - let t = Tokens::get(ui.ctx()); - let indent = drawn_indent(ui, indent); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense); - if scroll_asked(ui, id) { - ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none()); - } - register(ui, format!("source:{id}"), r); - let mark_w = if mark.is_some() { MARK_W } else { 0.0 }; - let said = spoken.unwrap_or(label); - let name = match count { - Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = said, n = n), - None => said.to_string(), - }; - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &name)); - // what is visible of the row: when the sidebar is scrolled sideways its bars and counts end at - // the panel's edge, not at the end of the (wider) content - let visible_right: f32 = ui.data(|d| d.get_temp(egui::Id::unique("left-visible-right"))).unwrap_or(f32::MAX); - let edge = r.right().min(visible_right); - let inner = Rect::from_min_max(r.min + vec2(8.0, 0.0), pos2((r.right() - 8.0).min(edge - 8.0).max(r.left() + 8.0), r.bottom())); - if selected { - ui.painter().rect_filled(inner, 4.0, t.canvas); - register(ui, format!("highlight:{id}"), inner); - } else if resp.hovered() { - ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6)); - } - paint( - ui.painter(), - Rect::from_min_size(pos2(r.left() + 18.0 + indent, r.center().y - 8.0), vec2(16.0, 16.0)), - icon, - if selected { t.text } else { t.icon }, - ); - let font = t.font(13.5); - let color = if selected { t.text } else { t.text_label }; - // a count of nothing is there, but fainter: the rows that hold photos stand out - let count_color = |n: usize| if n == 0 { t.text_dim.gamma_multiply(0.55) } else { t.text_dim }; - let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), count_color(n))); - let label_left = r.left() + 42.0 + indent; - let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x); - // a label's dot sits just before the count - if let Some(c) = mark { - let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0)); - ui.painter().circle_filled(dot.center(), 4.0, c); - register(ui, format!("labelMark:{id}"), dot); - } - // the name gives way to the count: cut with an ellipsis, in full on hover - let room = count_left - mark_w - 8.0 - label_left; - let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x; - let full_w = measure(label); - let shown = if full_w <= room { label.to_string() } else { crate::widgets::elide_head(label, room.max(0.0), measure) }; - let label_rect = ui.painter().text(pos2(label_left, r.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); - register(ui, format!("label:{id}"), label_rect); - let resp = if shown != label { resp.on_hover_text(label) } else { resp }; - note_width(ui, row_need(indent, full_w, mark_w, count_galley.as_ref().map(|g| g.size().x))); - if let Some(galley) = count_galley { - let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size()); - register(ui, format!("count:{id}"), rect); - ui.painter().galley(rect.min, galley, t.text_dim); - } - resp -} - -/// Whether the row `id` about to be drawn is out of sight: then it only takes its room, is listed -/// for the control channel and asks for its width (so the content does not widen and narrow as -/// rows scroll by), and the caller skips it. A library can have thousands of keywords or folders, -/// and drawing them all every frame slows the whole app down (issue #1011). A row the sidebar is -/// asked to scroll to is drawn. `label`, `count` and `mark` as for [`row_sensed`]. -fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, count: Option, indent: f32, mark: bool) -> bool { - let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_HEIGHT)); - if ui.is_rect_visible(r) || scroll_asked(ui, id) { - return false; - } - ui.allocate_space(r.size()); - register(ui, format!("source:{id}"), r); - let indent = drawn_indent(ui, indent); - let t = Tokens::get(ui.ctx()); - let painter = ui.painter(); - let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x; - let count_w = count.filter(|_| app.ui.show_counts).map(|n| painter.layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim).size().x); - note_width(ui, row_need(indent, name_w, if mark { MARK_W } else { 0.0 }, count_w)); - true -} - -/// Whether the sidebar is asked to scroll to the row `id` this frame ([`SCROLL_TO_ROW`]). -fn scroll_asked(ui: &egui::Ui, id: &str) -> bool { - ui.data(|d| d.get_temp::(egui::Id::unique(SCROLL_TO_ROW))).is_some_and(|want| want == id.to_lowercase()) -} - -/// How wide a row needs to be, from its indent, name, colour dot and count widths. It asks for -/// room for its name up to a share of a panel, so one very long name does not make everything -/// scroll: depth does that. -fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option) -> f32 { - 42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0) -} - -/// The disclosure chevron at the left of a tree row (`indent` is the row's): right-pointing -/// when closed, down when open, registered as widget `widget` for the control channel. Returns -/// its response: the caller flips the open state on `clicked()`. -fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { - let t = Tokens::get(ui.ctx()); - let c = pos2(row.rect.left() + 10.0 + indent_x(ui.ctx(), indent), row.rect.center().y); - let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); - let tr = ui.interact(tri, id, Sense::click()); - register(ui, widget, tri); - let col = if tr.hovered() { t.text } else { t.text_dim }; - paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); - tr -} - -/// Whether the tree row `key` is open: what the user last chose this session, else `default`. -fn tree_open(ui: &egui::Ui, key: egui::Id, default: bool) -> bool { - ui.data(|d| d.get_temp(key)).unwrap_or(default) -} - -/// Open or close the tree row `key` from now on. -fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) { - ui.data_mut(|d| d.insert_temp(key, open)); -} - -/// The chevron of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, -/// Keywords): a click flips `open` and remembers it under `key`. Returns the chevron's response -/// (it sits on the row and takes its clicks, so a row's context menu is attached to it too). -fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response { - let tri = disclosure_chevron(ui, row, indent, *open, key.with("triangle"), widget); - if tri.clicked() { - *open = !*open; - set_tree_open(ui, key, *open); - } - let label = if *open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") }; - tri.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); - tri -} - -/// Space above a section's header. -const SECTION_GAP: f32 = 10.0; -/// Height of a section's header. -const SECTION_HEADER: f32 = 34.0; - -/// One sidebar section: the gap above it, its header, and (while it is open) the rows `body` -/// draws. Every section goes through here, so they all fold, space and announce themselves alike. -/// `rows`: how many rows the section lists at its top level (a folded header says so). -fn section( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - section: SidebarSection, - rows: usize, - body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui), -) -> PlacedSection { - ui.add_space(SECTION_GAP); - let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover()); - // a hairline in the gap above tells the sections apart - let t = Tokens::get(ui.ctx()); - let edge = visible_right(ui).min(header.right()); - let y = header.top() - SECTION_GAP / 2.0; - ui.painter().hline(header.left() + 18.0..=(edge - 18.0).max(header.left() + 18.0), y, egui::Stroke::new(1.0, t.divider)); - let show = egui::Id::unique("left-show-section"); - if let Some((asked, frame)) = ui.data(|d| d.get_temp::<(SidebarSection, u64)>(show)) - && asked == section - { - ui.data_mut(|d| d.remove::<(SidebarSection, u64)>(show)); - // asked for by the frame before (a request the section was not drawn for is forgotten), - // and up or down only: the sidebar stays where it was scrolled sideways - if ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 2 { - let view_top: f32 = ui.data(|d| d.get_temp(egui::Id::unique("left-view-top"))).unwrap_or(header.top()); - let delta = vec2(0.0, view_top - header.top()); - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); - } - } - let open = section_header(app, ui, section, header, rows, false).open; - if open { - let rows_from = ui.cursor().top(); - body(app, ui); - if ui.cursor().top() <= rows_from { - empty_hint(ui, section); - } - } - PlacedSection { section, top: header.top(), bottom: ui.cursor().top(), open, rows } -} - -/// Where what the panel shows of the sidebar's content ends (screen x): the content is wider -/// than the panel while the sidebar scrolls sideways. -fn visible_right(ui: &egui::Ui) -> f32 { - ui.data(|d| d.get_temp(egui::Id::unique("left-visible-right"))).unwrap_or(f32::MAX) -} - -/// The header of the open section whose own header has scrolled out of sight stays at the top of -/// the sidebar (widget `sidebarSectionPinned:`), so it is always clear which section the rows -/// belong to, and the section can be folded, moved or hidden from anywhere in a long list. The -/// next section's header pushes it out as it arrives. `view_top`: where the visible part starts. -fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection], view_top: f32) { - let pinned = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top); - let rect = pinned.map(|p| { - let top = view_top.min(p.bottom - SECTION_HEADER); - Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER) - }); - // rows ask where it is (next frame: they are laid out before it) - ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-pinned-rect"), rect)); - let (Some(p), Some(r)) = (pinned, rect) else { return }; - let t = Tokens::get(ui.ctx()); - let view = ui.clip_rect(); - // over the rows scrolling beneath it - ui.painter().rect_filled(Rect::from_x_y_ranges(view.x_range(), r.y_range()), 0.0, t.chrome); - ui.painter().hline(view.x_range(), r.bottom(), egui::Stroke::new(1.0, t.divider)); - if section_header(app, ui, p.section, r, p.rows, true).toggled { - // folded from here: its own header comes into view, not whatever was below the section - // (next frame, when the section is laid out folded: see `section`) - let frame = ui.ctx().cumulative_frame_nr(); - ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-show-section"), (p.section, frame))); - ui.ctx().request_repaint(); - } -} - -/// What an open section with nothing to list says in place of its rows (widget -/// `sidebarEmpty:`), so the section stays where it is instead of disappearing. -fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) { - let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::hover()); - register(ui, format!("sidebarEmpty:{}", section.id()), r); - let text = crate::i18n::tr(section.empty_hint()); - let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim); - note_width(ui, 42.0 + galley.size().x + 18.0); - ui.painter().galley(pos2(r.left() + 42.0, r.center().y - galley.size().y / 2.0), galley, t.text_dim); -} - -/// Where a section was laid out this frame (screen y): from its header's top to the end of its rows. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct PlacedSection { - pub section: SidebarSection, - pub top: f32, - pub bottom: f32, - /// Not folded: its rows (or its note that there are none) are drawn. - pub open: bool, - /// How many rows it lists at its top level. - pub rows: usize, -} - -/// What a section's header says happened to it this frame. -struct HeaderOutcome { - open: bool, - /// A click folded or unfolded the section. - toggled: bool, -} - -/// Where a section dragged to `y` would go among the sections drawn: above the first one whose -/// middle is below `y` (`Some`), or after them all (`None`). -pub(crate) fn section_drop_before(placed: &[PlacedSection], y: f32) -> Option { - placed.iter().find(|p| y < (p.top + p.bottom) / 2.0).map(|p| p.section) -} - -/// While a section is dragged by its header: a line shows where it would go, releasing over the -/// sidebar moves it there (`view.sidebarMoveSection`), releasing elsewhere or Esc leaves it. -fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection]) { - let Some(dragged) = app.ui.dragging_section else { return }; - let (released, pos, esc) = ui.input(|i| (i.pointer.primary_released(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); - if esc { - // the drag's Esc, not also a dialog's or a view's - crate::widgets::take_escape(ui.ctx()); - app.ui.dragging_section = None; - return; - } - let view = ui.clip_rect(); - let Some(pos) = pos.filter(|p| view.contains(*p)) else { - if released { - app.ui.dragging_section = None; - } - return; - }; - let before = section_drop_before(placed, pos.y); - if released { - app.ui.dragging_section = None; - let before = before.map_or(serde_json::Value::Null, |b| json!(b.id())); - if let Err(e) = app.run("view.sidebarMoveSection", json!({"section": dragged.id(), "before": before})) { - app.toast(ui.ctx(), e); - } - return; - } - // the line: in the gap above the section it would go before, or below the last one - let y = match before { - Some(b) => placed.iter().find(|p| p.section == b).map(|p| p.top - SECTION_GAP / 2.0), - None => placed.last().map(|p| p.bottom + SECTION_GAP / 2.0), - }; - if let Some(y) = y { - let t = Tokens::get(ui.ctx()); - let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right(ui).min(view.right()) - 8.0, y + 1.0)); - ui.painter().rect_filled(line, 1.0, t.accent); - register(ui, "sidebarSectionDrop", line); - } -} - -/// The dragged section's name follows the pointer; a drag that ended anywhere (also outside the -/// sidebar, or with the sidebar closed) is over. -pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(section) = app.ui.dragging_section else { return }; - let (down, pos) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos())); - if !down || !app.ui.left_panel { - app.ui.dragging_section = None; - return; - } - let Some(pos) = pos else { return }; - let t = Tokens::get(ctx); - ctx.set_cursor_icon(egui::CursorIcon::Grabbing); - egui::Area::new(egui::Id::unique("drag-section")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show( - ctx, - |ui| { - egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - ui.label(egui::RichText::new(crate::i18n::tr(section.title())).color(egui::Color32::WHITE).font(t.semibold(12.0))); - }); - }, - ); -} - -/// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their -/// chevron), then the bold title; a click folds or unfolds the section (kept in the UI state, so -/// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at -/// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo -/// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). -fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect, rows: usize, pinned: bool) -> HeaderOutcome { - let t = Tokens::get(ui.ctx()); - let id = section.id(); - let title = crate::i18n::tr(section.title()); - let (interact, widget) = if pinned { ("sidebar-section-pinned", "sidebarSectionPinned") } else { ("sidebar-section", "sidebarSection") }; - let resp = ui.interact(r, ui.scope_id().with((interact, id)), Sense::click_and_drag()); - register(ui, format!("{widget}:{id}"), r); - let toggled = resp.clicked(); - if toggled { - app.ui.sidebar.toggle_collapsed(section); - } - // dragged by its header, a section moves among the others (see `section_drag`); a press on - // the header's button is the button's, also when it turns into a drag - let on_button = header_button(section).is_some() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| header_button_rect(ui, r).contains(p)); - if resp.drag_started_by(egui::PointerButton::Primary) && !on_button { - // picked up by the pinned header, the drag starts inside the top edge (see `drag_auto_scroll`) - ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-section-drag-from-pinned"), pinned)); - app.ui.dragging_section = Some(section); - } - crate::widgets::context_menu(&resp, |ui| sections_menu(app, ui, Some(section))); - let open = !app.ui.sidebar.is_collapsed(section); - crate::widgets::announce_disclosure(&resp, true, open, title); - // what is visible of the header: its bar and count end at the panel's edge, like a row's - let edge = r.right().min(visible_right(ui)); - let hovered = resp.hovered() && app.ui.dragging_section.is_none(); - if hovered { - let bar = Rect::from_min_max(r.min + vec2(8.0, 2.0), pos2((edge - 8.0).max(r.left() + 8.0), r.bottom() - 2.0)); - ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6)); - } - let c = pos2(r.left() + 10.0, r.center().y); - register(ui, format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(16.0, 16.0))); - let col = if hovered { t.text } else { t.text_dim }; - paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); - ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); - let button = header_button(section); - // (an album being dragged puts "Top Level" in the Albums header) - let room = !(section == SidebarSection::Albums && app.ui.dragging_album.is_some()); - if !open && rows > 0 && app.ui.show_counts && room { - // folded: how much is in there (left of the header's own button) - let right = edge - 18.0 - if button.is_some() { HEADER_BUTTON + 4.0 } else { 0.0 }; - let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim); - register(ui, format!("{widget}Count:{id}"), count); - } - if section == SidebarSection::Albums { - top_level_drop_target(app, ui, r); - } - if let Some(button) = button { - show_header_button(app, ui, r, &button); - } - HeaderOutcome { open, toggled } -} - -/// Side of a header's button. -const HEADER_BUTTON: f32 = 26.0; - -/// A button at the end of a section's header (see [`header_button`]). -struct HeaderButton { - /// Its widget is `icon:`. - id: &'static str, - icon: Icon, - /// What it does, in English: its tooltip and spoken name. - tooltip: &'static str, - action: HeaderAction, -} - -/// What a header's button does when clicked. -enum HeaderAction { - /// Does it at once (usually: opens a dialog). - Run(fn(&mut LightcraftApp, &egui::Context)), - /// Opens a menu with these items. - Menu(fn(&mut LightcraftApp, &mut egui::Ui)), -} - -/// The button a section has at the end of its header, if it has one. This is the one place that -/// says so: the header (its own and the pinned copy) draws the button at the visible edge, keeps -/// its clicks from folding the section and leaves it room beside a folded section's count. -fn header_button(section: SidebarSection) -> Option { - match section { - SidebarSection::Albums => { - Some(HeaderButton { id: "albumNew", icon: Icon::Plus, tooltip: "Create Album", action: HeaderAction::Menu(new_album_menu) }) - } - SidebarSection::Keywords => { - Some(HeaderButton { id: "keywordNew", icon: Icon::Plus, tooltip: "Create Keyword Tag", action: HeaderAction::Run(new_keyword) }) - } - SidebarSection::Local | SidebarSection::ByDate | SidebarSection::Folders => None, - } -} - -/// The end of `header` kept for its button: at the visible edge, also when the sidebar is -/// scrolled sideways. -fn header_button_rect(ui: &egui::Ui, header: Rect) -> Rect { - let right = visible_right(ui).min(header.right()); - Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom())) -} - -/// Draw `button` at the end of `header` and act on it. -fn show_header_button(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect, button: &HeaderButton) { - let mut hdr = - ui.new_child(egui::UiBuilder::new().max_rect(header_button_rect(ui, header)).layout(egui::Layout::right_to_left(egui::Align::Center))); - let resp = icon_button(&mut hdr, button.id, button.icon, vec2(HEADER_BUTTON, HEADER_BUTTON), false, true, button.tooltip); - match button.action { - HeaderAction::Run(run) => { - if resp.clicked() { - run(app, ui.ctx()); - } - } - HeaderAction::Menu(items) => { - egui::Popup::menu(&resp).show(|ui| items(app, ui)); - } - } -} - -/// Keywords' +: Create Keyword Tag, for a keyword at the usual place for new ones (not inside -/// whatever is picked in the Keyword List, which may not even be on screen). -/// Made from here, the keyword is shown here: Keywords opens down to it (`reveal_keyword`). -fn new_keyword(app: &mut LightcraftApp, _ctx: &egui::Context) { - let mut dialog = crate::panels::keyword_list::create_dialog_inside(app, None); - if let crate::state::Dialog::KeywordTag { reveal, .. } = &mut dialog { - *reveal = true; - } - app.ui.dialog = Some(dialog); -} - -/// The menu of a section's header (`on`) and of the My Photos title (`None`): hide this section, -/// show or hide any section, and go back to the usual arrangement. Its items are widgets -/// `sidebarMenu:hide`, `sidebarMenu:section:` and `sidebarMenu:reset`. -fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option) { - let mut run = None; - if let Some(section) = on { - let hide = ui.button(crate::i18n::tr("Hide This Section")); - register(ui, "sidebarMenu:hide", hide.rect); - if hide.clicked() { - run = Some(("view.sidebarSection", json!({"section": section.id(), "show": false}))); - } - for (up, label, widget) in [(true, "Move Up", "sidebarMenu:moveUp"), (false, "Move Down", "sidebarMenu:moveDown")] { - let item = ui.add_enabled(app.ui.sidebar.can_move(section, up), egui::Button::new(crate::i18n::tr(label))); - register(ui, widget, item.rect); - if item.clicked() { - run = Some(("view.sidebarMoveSection", json!({"section": section.id(), "direction": if up { "up" } else { "down" }}))); - } - } - ui.separator(); - } - for s in app.ui.sidebar.sections().iter().filter(|s| s.id.available()) { - let shown = ui.selectable_label(!s.hidden, crate::i18n::tr(s.id.title())); - register(ui, format!("sidebarMenu:section:{}", s.id.id()), shown.rect); - if shown.clicked() { - run = Some(("view.sidebarSection", json!({"section": s.id.id(), "show": s.hidden}))); - } - } - ui.separator(); - let reset = ui.add_enabled(!app.ui.sidebar.is_default(), egui::Button::new(crate::i18n::tr("Reset Sidebar Sections"))); - register(ui, "sidebarMenu:reset", reset.rect); - if reset.clicked() { - run = Some(("view.sidebarReset", json!({}))); - } - if let Some((command, params)) = run { - if let Err(e) = app.run(command, params) { - app.toast(ui.ctx(), e); - } - ui.close(); - } -} - -/// The menu of Albums' +: Create Album, Smart Album, Folder… -fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { - if ui.button(crate::i18n::tr("Create Album…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }); - } - if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::SmartRules { - id: None, - name: String::new(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - parent: None, - }); - } - if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None }); - } - if ui.button(crate::i18n::tr("Create Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None, include_selected: false }); - } - // only once the albums were put in an order by hand - if app.session.catalog.album_children_are_ordered(None) { - ui.separator(); - if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() { - let _ = app.run("album.sort", json!({})); - } - } -} +pub use albums::{album_drag_feedback, auto_scroll_speed}; +use albums::{albums_rows, drag_auto_scroll}; +pub(crate) use dates::browse_all_photos; +use dates::date_rows; +pub(crate) use folders::{folder_row_of, move_photos, moved_text}; +use folders::{folder_rows, reveal_chosen}; +use keywords::keyword_rows; +pub(crate) use local::{fs_cached, fs_cached_running, same_folder}; +use local::{local_now, local_rows}; +#[cfg(test)] +pub(crate) use rows::{INDENT_STEP, MIN_INDENT_STEP}; +use rows::{LIBFOLDER_REVEALED, SCROLL_TO_ROW, row, set_tree_open}; +pub(crate) use rows::{content_width, indent_step, step_for}; +pub(crate) use sections::PlacedSection; +pub use sections::section_drag_feedback; +#[cfg(test)] +pub(crate) use sections::section_drop_before; +use sections::{SECTION_GAP, missing_count, pinned_header, section, section_drag, sections_menu}; /// Draw the My Photos sidebar: title and open-library name, the scrollable sources, albums, /// folders and sections, with the section visibility menu on the title. @@ -787,1610 +186,3 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); }); } - -/// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, -/// whose folders open down to it. -fn albums_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, reveal: Option) { - let kids: AlbumKids = app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect(); - let cat = &app.session.catalog; - let mut open_to = Vec::new(); - let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent); - while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) { - open_to.push(p); - cur = cat.album(p).and_then(|a| a.parent); - } - albums_tree(app, ui, &kids, None, 0.0, &open_to); -} - -/// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. -fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let groups = app.caches.date_groups(&app.session.catalog); - for g in groups.iter() { - if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) { - for (m, n) in &g.months { - let label = crate::i18n::date_group_label(m, true); - if date_row(app, ui, m, &label, *n, INDENT_STEP) { - for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { - let label = crate::i18n::date_group_label(d, true); - date_row(app, ui, d, &label, *n, 2.0 * INDENT_STEP); - } - } - } - } - } -} - -/// How many library photos have no file (Local browse records are not checked; see -/// `cmd::missing::checked_path`). Checking stats every file, which on a network share takes -/// seconds, so it runs on a worker thread: the count shown is the last finished one, refreshed -/// at most every 5 s, and at once (after the running check) when the catalog changed. -fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; - #[derive(Clone, Default)] - struct Job { - n: std::sync::Arc, - running: std::sync::Arc, - at: f64, - rev: u64, - } - let id = egui::Id::unique("missing-count"); - let now = ui.input(|i| i.time); - let rev = app.session.catalog.revision; - let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() }); - let fresh = now - job.at < 5.0 && job.rev == rev; - if !fresh && !job.running.load(Relaxed) { - // the same scope as the Missing Photos view: library photos only, never Local browse records - let paths = lightcraft_engine::cmd::missing::candidates(&app.session.catalog); - job.running.store(true, Relaxed); - job.at = now; - job.rev = rev; - let (n, running, ctx) = (job.n.clone(), job.running.clone(), ui.ctx().clone()); - let work = move || { - let missing = if cfg!(target_arch = "wasm32") { 0 } else { paths.iter().filter(|p| !std::path::Path::new(p).exists()).count() }; - n.store(missing, Relaxed); - running.store(false, Relaxed); - ctx.request_repaint(); - }; - #[cfg(not(target_arch = "wasm32"))] - std::thread::spawn(work); - #[cfg(target_arch = "wasm32")] - work(); - ui.data_mut(|d| d.insert_temp(id, job.clone())); - } - job.n.load(Relaxed) -} - -/// Local's folders as they stand this frame, and the folder being browsed among them. -struct LocalNow { - places: LocalPlaces, - current: Option, - /// The places removed from Local, as (name, path), in the order they were hidden. - hidden: Vec<(String, String)>, -} - -/// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, -/// Downloads, the home folder, the folder being browsed, and the folders kept with Browse -/// Folder…. Worked out every frame the sidebar shows, drawn or not: the row listed for this -/// session only (`local_browse_root`) follows the browsing. `None` where there is no Local (web). -fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option { - if !SidebarSection::Local.available() { - return None; - } - let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default(); - let mut builtin: Vec<(String, String)> = Vec::new(); - if !home.is_empty() { - for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] { - // joined with the platform's separator, like the paths browsing gives back - let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }; - // (checked off the UI thread: a home folder can be on a network share) - if fs_cached(ui, "is-dir", &p, 5.0, |p| std::path::Path::new(p).is_dir()) == Some(true) { - builtin.push((crate::i18n::tr(name).to_string(), p)); - } - } - } - let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); - let current = browsing.as_ref().map(|b| b.path.clone()); - // a hidden built-in place keeps its name (Home, not the user's folder name) - let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone()); - let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect(); - let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); - if current.is_some() { - app.ui.local_browse_root = places.browse_root.clone(); - } - Some(LocalNow { places, current, hidden }) -} - -/// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places. -fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { - let (local, current) = (&now.places, now.current.as_deref()); - for (i, (name, path)) in local.places.iter().enumerate() { - let transient = local.browse_root.as_deref() == Some(path.as_str()); - let reveal = local.owner == Some(i); - folder_tree(app, ui, path, name, path, 0.0, current, reveal, transient); - } - if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked() - { - let picked = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(path) = picked { - match app.run("library.browse", json!({"path": path})) { - // the picked folder stays in Local (and comes back if it was hidden) - Ok(r) => { - let dir = r["path"].as_str().unwrap_or(&path).to_string(); - let _ = app.run("local.addRoot", json!({"path": dir})); - } - Err(e) => app.toast(ui.ctx(), e), - } - } - } - // the way back for hidden places: one comes back from the row itself; several are listed, so - // each comes back on its own, or all at once (issue #2114: the row used to bring every one back) - let hidden = now.hidden.len(); - if hidden > 0 { - let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden); - let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) - .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")); - if let [(_, only)] = now.hidden.as_slice() { - if resp.clicked() { - let _ = app.run("local.restoreHidden", json!({"path": only})); - } - } else { - egui::Popup::menu(&resp).show(|ui| hidden_menu(app, ui, &now.hidden)); - } - } -} - -/// The list behind "Show N hidden locations": each place by name (its path on hover), then -/// Restore All. -fn hidden_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, hidden: &[(String, String)]) { - for (name, path) in hidden { - let r = ui.button(name).on_hover_text(path); - register(ui, format!("local:restoreHidden:{path}"), r.rect); - if r.clicked() { - let _ = app.run("local.restoreHidden", json!({"path": path})); - ui.close(); - } - } - ui.separator(); - let r = ui.button(crate::i18n::tr("Restore All")); - register(ui, "local:restoreHidden:all", r.rect); - if r.clicked() { - let _ = app.run("local.restoreHidden", json!({})); - ui.close(); - } -} - -/// Whether two paths name the same folder, however they are spelled (separators, trailing -/// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`). -pub(crate) fn same_folder(a: &str, b: &str) -> bool { - a == b || lightcraft_catalog::query::folder_key(a) == lightcraft_catalog::query::folder_key(b) -} - -/// Local's top-level folders and how the folder being browsed sits among them. -#[derive(Debug, Default, PartialEq)] -pub(crate) struct LocalPlaces { - /// (label, path) of each top-level folder, in order. - pub places: Vec<(String, String)>, - /// The top-level folder the browsed folder lies in (the innermost one): its tree opens on - /// the way down to it. None when that way passes through a hidden folder. - pub owner: Option, - /// A folder listed only for this session because the browsed folder is in no saved - /// location (browsed from a breadcrumb, the CLI…); it stays while browsing below it. - pub browse_root: Option, -} - -fn folder_label(path: &str) -> String { - std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) -} - -/// Local's top-level folders, in order: the built-in places, the folders kept with Browse -/// Folder… / Keep in Local (`saved`), then — only when the browsed folder lies in none of those — -/// a session root for it: the previous one (`browse_root`) while browsing stays below it, else -/// the browsed folder itself. A folder inside a listed one is shown inside that one's tree (the -/// root stays; its siblings stay reachable), never as a root of its own. Each folder is listed -/// once however its path is spelled (the first spelling wins), and hidden ones are left out — -/// they stay reachable through breadcrumbs and Browse Folder…. -pub(crate) fn local_places( - builtin: Vec<(String, String)>, - saved: &[String], - browsing: Option<&str>, - browse_root: Option<&str>, - hidden: &[String], -) -> LocalPlaces { - use lightcraft_catalog::query::{folder_key, folder_within}; - let is_hidden = |p: &str| hidden.iter().any(|h| same_folder(h, p)); - let mut places = builtin; - for path in saved { - if !places.iter().any(|(_, p)| same_folder(p, path)) { - places.push((folder_label(path), path.clone())); - } - } - places.retain(|(_, p)| !is_hidden(p)); - let mut out = LocalPlaces::default(); - if let Some(c) = browsing - && !places.iter().any(|(_, p)| folder_within(c, p)) - { - let root = browse_root.filter(|r| folder_within(c, r)).unwrap_or(c); - if !is_hidden(root) { - places.push((folder_label(root), root.to_string())); - out.browse_root = Some(root.to_string()); - } - } - if let Some(c) = browsing { - // A tree never opens on the way down through a hidden folder: hiding a kept folder - // beneath Home would otherwise reveal it again inside Home's (possibly huge) tree. - let through_hidden = |p: &str| hidden.iter().any(|h| folder_within(c, h) && folder_within(h, p)); - out.owner = places - .iter() - .enumerate() - .filter(|(_, (_, p))| folder_within(c, p) && !through_hidden(p)) - .max_by_key(|(_, (_, p))| folder_key(p).len()) - .map(|(i, _)| i); - } - out.places = places; - out -} - -/// The subfolders of `path` (not hidden ones), sorted; listed on a worker thread at most every -/// 2 s per folder (empty until the first listing). -fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> { - fs_cached(ui, "subfolders", path, 2.0, list_subfolders).unwrap_or_default() -} - -fn list_subfolders(path: &str) -> Vec<(String, String)> { - let mut v: Vec<(String, String)> = std::fs::read_dir(path) - .map(|rd| { - rd.flatten() - .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) - .filter_map(|e| { - let name = e.file_name().to_string_lossy().to_string(); - (!name.starts_with('.')).then(|| (name, e.path().to_string_lossy().to_string())) - }) - .collect() - }) - .unwrap_or_default(); - v.sort_by_key(|(n, _)| n.to_lowercase()); - v -} - -/// How many [`fs_cached`] answers for `ctx` are being worked out right now. Rows appear (and -/// the sidebar below them moves) when they land, so the headless driver counts them as pending -/// work and waits for them before acting on widget positions. -pub(crate) fn fs_cached_running(ctx: &egui::Context) -> usize { - fs_running_counter(ctx).load(std::sync::atomic::Ordering::Acquire) -} - -fn fs_running_counter(ctx: &egui::Context) -> std::sync::Arc { - ctx.data_mut(|d| d.get_temp_mut_or_default::>(egui::Id::unique("fs-cached-running")).clone()) -} - -/// A file-system answer for `path` (`f(path)`), kept per `kind` and path and refreshed on a worker -/// thread at most every `every` seconds: a folder on a sleeping NAS, a dropped share or a -/// spinning-up drive never blocks a frame. `None` until the first answer arrives. -pub(crate) fn fs_cached(ui: &egui::Ui, kind: &'static str, path: &str, every: f64, f: fn(&str) -> T) -> Option { - struct Entry { - value: Option, - at: Option, - running: bool, - } - type Cell = std::sync::Arc>>; - let id = egui::Id::unique(("fs-cached", kind, path.to_string())); - let now = ui.input(|i| i.time); - let cell: Cell = match ui.data(|d| d.get_temp::>(id)) { - Some(c) => c, - None => { - let c: Cell = std::sync::Arc::new(std::sync::Mutex::new(Entry { value: None, at: None, running: false })); - ui.data_mut(|d| d.insert_temp(id, c.clone())); - c - } - }; - let lock = |c: &Cell| c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).value.clone(); - let start = { - let mut e = cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let due = !e.running && e.at.is_none_or(|at| now - at >= every || now < at); - if due { - e.running = true; - e.at = Some(now); - } - due - }; - if start { - use std::sync::atomic::Ordering; - let running = fs_running_counter(ui.ctx()); - running.fetch_add(1, Ordering::AcqRel); - let (out, path, repaint, done) = (cell.clone(), path.to_string(), ui.ctx().clone(), running.clone()); - let work = move || { - let v = f(&path); - let mut e = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - e.value = Some(v); - e.running = false; - drop(e); - // after the answer is stored: a frame that sees the count drop also sees the answer - done.fetch_sub(1, Ordering::AcqRel); - repaint.request_repaint(); - }; - #[cfg(not(target_arch = "wasm32"))] - if std::thread::Builder::new().name("lc-fs-list".into()).spawn(work).is_err() { - cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner).running = false; - running.fetch_sub(1, Ordering::AcqRel); - } - #[cfg(target_arch = "wasm32")] - work(); - } - lock(&cell) -} - -/// A folder on disk with a disclosure triangle: click browses it, the triangle lists its -/// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the -/// way down to it open whenever the browsed folder changes, so it shows highlighted in place. -/// `transient`: a top-level row listed for this session only (it offers Keep in Local). -/// `place`: the top-level folder this tree hangs from. A folder can be listed in more than one -/// tree (Pictures on its own and inside Home): each row opens and closes by itself, and their -/// triangles have ids of their own, else the copy drawn last takes the other's clicks (#1093). -#[allow(clippy::too_many_arguments)] -fn folder_tree( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - place: &str, - name: &str, - path: &str, - indent: f32, - current: Option<&str>, - reveal: bool, - transient: bool, -) { - let open_id = egui::Id::unique(("folder-open", place.to_string(), path.to_string())); - let sel = current.is_some_and(|c| same_folder(c, path)); - let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)); - let mut open = tree_open(ui, open_id, false); - if reveal && on_the_way { - // opened once per browsed folder: collapsing it again afterwards sticks - let revealed_id = egui::Id::unique(("folder-revealed", place.to_string(), path.to_string())); - let target = current.map(str::to_string); - if ui.data(|d| d.get_temp::>(revealed_id)) != Some(target.clone()) { - open = true; - set_tree_open(ui, open_id, true); - ui.data_mut(|d| d.insert_temp(revealed_id, target)); - } - } - // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the - // icon lines up with the rows above - let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); - let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); - if !tr.clicked() - && resp.clicked() - && let Err(e) = app.run("library.browse", json!({"path": path})) - { - app.toast(ui.ctx(), e); - } - crate::widgets::context_menu(&resp, |ui| { - // the library actions of the Folders tree, when this folder (or one inside it) holds library photos - let library = app.caches.folder_tree(&app.session.catalog); - let held = library_node_for(&library, path); - if (transient || indent > 0.0) - && ui - .button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" })) - .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) - .clicked() - { - let _ = app.run("local.addRoot", json!({"path": path})); - ui.close(); - } - if indent == 0.0 - && ui - .button(crate::i18n::tr("Remove from Local")) - .on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are")) - .clicked() - { - let _ = app.run("local.hide", json!({"path": path})); - ui.close(); - } - if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: crate::i18n::tr_format!("Rename “{name}”", name = name), - hint: "Folder name (renamed on disk; its photos follow)".into(), - value: name.to_string(), - command: "folder.rename".into(), - params: json!({"path": path}), - key: "name".into(), - }); - } - if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { - let into = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(into) = into { - match app.run("folder.move", json!({"path": path, "into": into})) { - Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), - Err(e) => app.toast(ui.ctx(), e), - } - } - } - if app.services.reveal.is_some() - && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() - && let Some(f) = app.services.reveal.as_mut() - { - let _ = f(path); - } - if let Some(held) = &held { - synchronize_item(app, ui, &held.0, &held.1); - } - }); - if open && indent < INDENT_STEP * 8.0 { - for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, place, &n, &p, indent + INDENT_STEP, current, reveal, false); - } - } -} - -/// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open. -fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool { - let open_id = egui::Id::unique(("date-open", key.to_string())); - let mut open = tree_open(ui, open_id, false); - let sel = app.session.filter.date.as_deref() == Some(key); - let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent); - if key.len() < 10 { - tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}")); - } - if resp.clicked() { - let v = if sel { serde_json::Value::Null } else { json!(key) }; - browse_all_photos(app, !sel); - let _ = app.run("library.filter", json!({"date": v})); - } - open -} - -/// By Date and Keywords count every photo in the library, so choosing a row shows those photos -/// from All Photos, not from whatever album or folder happened to be open, where they could be -/// missing (issue #341). Only when choosing (`on`), not when clearing the row again. -pub(crate) fn browse_all_photos(app: &mut LightcraftApp, on: bool) { - if on && app.session.source != LibrarySource::All { - let _ = app.run("library.source", json!({"kind": "all"})); - } -} - -/// The albums inside each folder (`None`: the top level), in the order they are listed. -type AlbumKids = std::collections::HashMap, Vec>; - -/// `open_to`: folders forced open this frame (the way down to an album that was just made). -fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, parent: Option, indent: f32, open_to: &[AlbumId]) { - // 64 folders deep at most (16 points a level): a catalog that loops must not overflow the stack - if indent > 64.0 * INDENT_STEP { - return; - } - let Some(kids) = all.get(&parent) else { return }; - for a in kids { - if a.folder { - let open_id = egui::Id::unique(("folder-open", a.id.0)); - let mut open = tree_open(ui, open_id, true); - if open_to.contains(&a.id) && !open { - open = true; - set_tree_open(ui, open_id, true); - } - let resp = - row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None); - // a folder is no source, so its row folds it too; the triangle is the same click, aimed - let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty()); - if resp.drag_started() { - app.ui.dragging_album = Some(a.id.0); - } - if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open), indent) { - set_tree_open(ui, open_id, true); - } - // what this frame draws; a click shows from the next one - let draw_open = open; - let mut toggled = false; - if has_children { - let tri = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("albumToggle:{}", a.id.0)); - toggled = tri.clicked(); - // the triangle sits on the row and takes its clicks: the menu opens from it too - folder_menu(app, &tri, a); - } - // one click, one toggle: the triangle's is not the row's as well - if resp.clicked() && !toggled { - set_tree_open(ui, open_id, !open); - } - folder_menu(app, &resp, a); - if draw_open { - albums_tree(app, ui, all, Some(a.id), indent + INDENT_STEP, open_to); - } - } else { - let sel = app.session.source == LibrarySource::Album(a.id); - let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album }; - // cached: a smart album's count scans the catalog - let now = (app.session.clock)(); - let n = app.caches.album_counts(&app.session.catalog, &now).get(&a.id).copied().unwrap_or(0); - // the album B adds to is marked "+" - let target = - app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); - let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() }; - // a smart album whose rules no longer check (an album they test was deleted) says so - let problems = app.caches.smart_album_problems(&app.session.catalog).get(&a.id).cloned().unwrap_or_default(); - let label = if problems.is_empty() { label } else { format!("⚠ {label}") }; - let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag(), None); - if !problems.is_empty() { - crate::widgets::register(ui, format!("albumProblem:{}", a.id.0), resp.rect); - } - if resp.drag_started() { - app.ui.dragging_album = Some(a.id.0); - } - album_drag_over(app, ui, &resp, a, None, indent); - if !a.is_smart() { - drop_target(app, ui, &resp, a); - } - if let Some(rules) = &a.smart { - // one tooltip: the rules, then what needs fixing in them - let mut tip = crate::i18n::tr_format!("Smart album: {}", crate::i18n::filter_label(rules, &app.session.catalog)); - if !problems.is_empty() { - tip.push_str("\n\n"); - tip.push_str(crate::i18n::tr("Some rules need fixing (Edit Smart Album…):")); - for p in &problems { - tip.push('\n'); - tip.push_str(&crate::i18n::problem_line(p)); - } - } - resp = resp.on_hover_text(tip); - } - if resp.clicked() { - let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); - } - folder_menu(app, &resp, a); - } - } -} - -/// Whether the pointer is over `rect` and over what the panel shows of it (a row scrolled out of -/// sight is not a drop target). -fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool { - crate::docking::drop_target(ui, rect, ui.input(|i| i.pointer.latest_pos())) -} - -/// [`pointer_over`] for a row: a row scrolled beneath the pinned header is covered by it, so the -/// pointer there is over the header, not over the row. -fn pointer_over_row(ui: &egui::Ui, rect: Rect) -> bool { - pointer_over(ui, rect) && !pointer_on_pinned_header(ui) -} - -/// Where the pinned header was drawn last frame, if one was (rows are laid out before it). -fn pinned_rect(ui: &egui::Ui) -> Option { - ui.data(|d| d.get_temp::>(egui::Id::unique("left-pinned-rect"))).flatten() -} - -fn pointer_on_pinned_header(ui: &egui::Ui) -> bool { - let pos = ui.input(|i| i.pointer.latest_pos()); - pinned_rect(ui).zip(pos).is_some_and(|(r, p)| r.contains(p)) -} - -/// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it. -const SCROLL_EDGE: f32 = 36.0; -/// The fastest the sidebar scrolls for a drag (points per second). -const SCROLL_MAX_SPEED: f32 = 700.0; - -/// How fast the sidebar scrolls (points per second, positive = down) for a dragged item with the -/// pointer at `y`, the visible part being `top..bottom`: none in the middle, faster the nearer to -/// the edge, the top speed at and past it. -pub fn auto_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 { - let depth = |dist: f32| ((SCROLL_EDGE - dist) / SCROLL_EDGE).clamp(0.0, 1.0); - SCROLL_MAX_SPEED * (depth(bottom - y) - depth(y - top)) -} - -/// While an album, or photos from the grid, are dragged over the sidebar: scrolls it when the -/// pointer is near its top or bottom edge, so rows beyond the visible part can be reached. Call -/// inside the scroll area. -fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { - if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() && app.ui.dragging_section.is_none() { - return; - } - let view = ui.clip_rect(); - let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return }; - // a section picked up by its pinned header starts inside the top edge: it scrolls once it has - // left the header. Any other drag scrolls there as at any edge, to reach what is above - let from_pinned = egui::Id::unique("left-section-drag-from-pinned"); - if app.ui.dragging_section.is_some() && ui.data(|d| d.get_temp::(from_pinned)) == Some(true) { - if pointer_on_pinned_header(ui) { - return; - } - ui.data_mut(|d| d.insert_temp(from_pinned, false)); - } - let speed = auto_scroll_speed(p.y, view.top(), view.bottom()); - if speed != 0.0 { - // a positive delta moves the content down: the view goes up - ui.scroll_with_delta_animation(vec2(0.0, -speed * ui.input(|i| i.stable_dt).min(0.1)), egui::style::ScrollAnimation::none()); - ui.ctx().request_repaint(); - } -} - -/// Room a colour label's dot takes before a row's count. -const MARK_W: f32 = 12.0; - -/// How long a dragged album must rest on a closed folder before it opens. -const HOVER_OPEN_SECS: f64 = 0.6; - -/// Whether the album `dragged` may be dropped into the folder `target` (`None`: the top level): -/// a folder other than where it already is, and not itself or something inside it. -fn can_drop_album(app: &LightcraftApp, dragged: AlbumId, target: Option) -> bool { - let Some(d) = app.session.catalog.album(dragged) else { return false }; - match target { - None => d.parent.is_some(), - Some(t) => app.session.catalog.album(t).is_some_and(|f| f.folder) && d.parent != Some(t) && !is_within(app, t, dragged), - } -} - -/// Ends a drag that ran `result`: the folders down to the album open so it shows in its new place. -fn finish_drop(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, result: Result) { - forget_hover(app, ui.ctx()); - match result { - Ok(_) => { - app.ui.reveal_album = Some(dragged.0); - ui.ctx().request_repaint(); - } - Err(e) => app.toast(ui.ctx(), e), - } - app.ui.dragging_album = None; -} - -/// Forgets how long the pointer rested on each folder (a new drag starts counting again). -fn forget_hover(app: &LightcraftApp, ctx: &egui::Context) { - let ids: Vec = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect(); - ctx.data_mut(|d| { - for id in ids { - d.remove_temp::(egui::Id::unique(("album-hover", id))); - } - }); -} - -/// Moves the dragged album into a folder (`album.move`, one undo step; `None`: the top level). -fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option) { - let r = app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})); - finish_drop(app, ui, dragged, r); -} - -/// Places the dragged album in `parent` before the sibling `before` (`None`: last of its kind), by -/// hand (`album.reorder`, one undo step). -fn place_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, parent: Option, before: Option) { - let r = app.run("album.reorder", json!({"id": dragged.0, "parent": parent.map(|p| p.0), "before": before.map(|b| b.0)})); - finish_drop(app, ui, dragged, r); -} - -/// What dropping the dragged album on a row would do. -#[derive(Clone, Copy, Debug, PartialEq)] -enum AlbumDrop { - /// Into the folder. - Into(AlbumId), - /// Beside the row: in `parent`, before `before` (`None`: last of its kind); the line is drawn - /// above the row (`above`) or below it. - Place { parent: Option, before: Option, above: bool }, -} - -/// What dropping `dragged` at `frac` (0 = top edge, 1 = bottom edge) of the row of `target` does, -/// if anything. An album over a folder goes inside it; a folder over a folder goes inside by its -/// middle and beside it by its edges; an album over an album goes before it by its top half and -/// after it by its bottom half; a folder over an album does nothing. Placing where it already -/// is, or inside itself, is nothing too. `expanded`: `target` is a folder shown open. -fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f32, expanded: bool) -> Option { - let cat = &app.session.catalog; - let d = cat.album(dragged)?; - let into = || can_drop_album(app, dragged, Some(target.id)).then_some(AlbumDrop::Into(target.id)); - if d.folder != target.folder { - return if target.folder { into() } else { None }; - } - if target.id == dragged { - return None; - } - let after = frac >= if target.folder { 0.7 } else { 0.5 }; - // the middle of a folder, and the bottom of an open one (its first child is next), mean inside - if target.folder && frac >= 0.3 && (!after || expanded) { - return into(); - } - let parent = target.parent; - if parent.is_some_and(|p| is_within(app, p, dragged)) { - return None; - } - let sibs = cat.album_children(parent); - let at = sibs.iter().position(|a| a.id == target.id)?; - let was = sibs.iter().position(|a| a.id == dragged); - if !after { - (was.map(|w| w + 1) != Some(at)).then_some(AlbumDrop::Place { parent, before: Some(target.id), above: true }) - } else { - let next = sibs.iter().skip(at + 1).find(|a| a.id != dragged).filter(|a| a.folder == target.folder).map(|a| a.id); - (was != Some(at + 1)).then_some(AlbumDrop::Place { parent, before: next, above: false }) - } -} - -/// A row while an album is dragged: a folder outlined under the pointer when the drop would go -/// inside it, a line above or below the row when it would go beside it, and the move or placement -/// done on release. A closed folder with something inside (`open`) opens once the pointer rests on -/// it; returns whether it did. -fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool { - let hover_id = egui::Id::unique(("album-hover", row.id.0)); - let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false }; - let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over_row(ui, resp.rect)); - let Some(pos) = pos else { - if ui.data(|d| d.get_temp::(hover_id)).is_some() { - ui.data_mut(|d| d.remove_temp::(hover_id)); - } - return false; - }; - let expanded = open.as_deref().copied().unwrap_or(false); - let frac = ((pos.y - resp.rect.top()) / resp.rect.height().max(1.0)).clamp(0.0, 1.0); - let drop = album_drop_at(app, dragged, row, frac, expanded); - let mut opened = false; - // only a drop that goes inside opens the folder: on its edges the rows would shift under the pointer - let inside = matches!(drop, Some(AlbumDrop::Into(_))); - if !inside && ui.data(|d| d.get_temp::(hover_id)).is_some() { - ui.data_mut(|d| d.remove_temp::(hover_id)); - } - if inside - && let Some(open) = open - && !*open - { - let now = ui.input(|i| i.time); - let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now)); - if now - since >= HOVER_OPEN_SECS { - *open = true; - opened = true; - } else { - ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); - } - } - let t = Tokens::get(ui.ctx()); - let released = ui.input(|i| i.pointer.primary_released()); - match drop { - Some(AlbumDrop::Into(folder)) => { - ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); - if released { - drop_album(app, ui, dragged, Some(folder)); - } - } - Some(AlbumDrop::Place { parent, before, above }) => { - let y = if above { resp.rect.top() + 0.5 } else { resp.rect.bottom() - 0.5 }; - let (x0, x1) = (resp.rect.left() + 18.0 + indent_x(ui.ctx(), indent), ui.clip_rect().right() - 8.0); - ui.painter().circle_stroke(pos2(x0, y), 3.0, egui::Stroke::new(1.5, t.accent)); - ui.painter().line_segment([pos2(x0 + 3.0, y), pos2(x1.max(x0 + 3.0), y)], egui::Stroke::new(2.0, t.accent)); - if released { - place_album(app, ui, dragged, parent, before); - } - } - None => {} - } - opened -} - -/// While an album that sits in a folder is dragged: the "Albums" header takes it back to the top -/// level (outlined, with a hint). The header stays in view however long the tree is, and nothing -/// moves when it appears. -fn top_level_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect) { - let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return }; - if !can_drop_album(app, dragged, None) { - return; - } - let t = Tokens::get(ui.ctx()); - register(ui, "albumDrop:top", header); - let over = pointer_over(ui, header); - let stroke = if over { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)) }; - ui.painter().rect_stroke(header.shrink2(vec2(8.0, 3.0)), 4.0, stroke, egui::StrokeKind::Inside); - // right-aligned, left of the + button - let edge = header.right().min(ui.clip_rect().right()); - ui.painter().text(pos2(edge - 56.0, header.center().y), Align2::RIGHT_CENTER, crate::i18n::tr("Top Level"), t.font(12.5), t.text_dim); - if over && ui.input(|i| i.pointer.primary_released()) { - drop_album(app, ui, dragged, None); - } -} - -/// While an album is dragged in the sidebar: its name at the pointer; the drag ends when the -/// button is up (the drop targets act on the release frame, before this runs). -pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(id) = app.ui.dragging_album else { return }; - let (released, down, pos, esc) = - ctx.input(|i| (i.pointer.primary_released(), i.pointer.primary_down(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); - if released || !down || esc { - app.ui.dragging_album = None; - forget_hover(app, ctx); - return; - } - let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return }; - let t = Tokens::get(ctx); - ctx.set_cursor_icon(egui::CursorIcon::Grabbing); - egui::Area::new(egui::Id::unique("drag-album")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show( - ctx, - |ui| { - egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - ui.label(egui::RichText::new(name).color(egui::Color32::WHITE).font(t.semibold(12.0))); - }); - }, - ); -} - -/// An album row while photos are dragged from the grid: highlighted under the pointer; a -/// release there adds them. -fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) { - let Some(ids) = app.ui.dragging_photos.clone() else { return }; - // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header - let over = pointer_over_row(ui, resp.rect); - if !over { - return; - } - let released = ui.input(|i| i.pointer.any_released()); - // albums hold library photos (issue #684): Local ones can't be dropped in; the row doesn't - // light up, and a release says why - let photos: Vec = ids.iter().map(|id| lightcraft_catalog::PhotoId(*id)).collect(); - if super::grid::local_only(app, &photos) { - ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); - if released { - app.toast(ui.ctx(), super::grid::albums_hint()); - app.ui.dragging_photos = None; - } - return; - } - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); - if released { - match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { - Ok(r) => { - let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0); - let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0); - let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n); - if skipped > 0 { - msg.push_str(" · "); - msg.push_str(&crate::i18n::tr_format!( - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", - if skipped == 1 { "" } else { "s" }, - n = skipped - )); - } - app.toast(ui.ctx(), msg); - } - Err(e) => app.toast(ui.ctx(), e), - } - app.ui.dragging_photos = None; - } -} - -fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { - crate::widgets::context_menu(resp, |ui| { - if a.folder { - // the same commands as File ▸ New…, aimed at this folder - crate::widgets::menu_button(ui, crate::i18n::tr("New"), |ui| { - for (label, command) in [ - ("Create Album…", "dialog.newAlbum"), - ("Create Smart Album…", "dialog.smartAlbum"), - ("Create Smart Album from Filter…", "dialog.newSmartAlbum"), - ("Create Folder…", "dialog.newFolder"), - ] { - if ui.button(crate::i18n::tr(label)).clicked() { - let _ = app.run(command, json!({"parent": a.id.0})); - ui.close(); - } - } - }); - if app.session.catalog.album_children_are_ordered(Some(a.id)) - && ui.button(crate::i18n::tr("Sort Contents A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() - { - let _ = app.run("album.sort", json!({"parent": a.id.0})); - ui.close(); - } - ui.separator(); - } - if !a.folder && !a.is_smart() { - let local_only = super::grid::local_only(app, &app.session.selected()); - let b = ui - .add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos"))) - .on_disabled_hover_text(super::grid::albums_hint()); - if b.clicked() { - match app.run("album.addPhotos", json!({"id": a.id.0})) { - Ok(r) => super::grid::toast_skipped(app, ui.ctx(), &r), - Err(e) => app.toast(ui.ctx(), e), - } - } - // File ▸ Import's ways in; the import review opens with this album chosen - let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Import"), |ui| { - let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| { - let r = ui.button(label); - crate::widgets::register(ui, widget, r.rect); - if r.clicked() { - if let Err(e) = app.run(command, params) { - app.toast(ui.ctx(), e); - } - ui.close(); - } - }; - for (label, command, widget) in [ - ("Import Photos…", "file.addPhotos", "button:albumImportPhotos"), - ("Import from Folder…", "file.addFolder", "button:albumImportFolder"), - ] { - entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0})); - } - // like File ▸ Import from Device: one entry per camera or card found - let device = crate::widgets::menu_button(ui, crate::i18n::tr("Import from Device"), |ui| { - let devices = lightcraft_engine::devices::devices(); - if devices.is_empty() { - ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found"))); - } - for (i, d) in devices.into_iter().enumerate() { - let params = json!({"path": d.path, "album": a.id.0}); - entry(ui, format!("{}…", d.name), format!("button:albumImportDevice:{i}"), "file.addFromDevice", params); - } - }); - crate::widgets::register(ui, "button:albumImportDevice", device.response.rect); - }); - crate::widgets::register(ui, "button:albumImport", menu.response.rect); - } - if !a.folder && !a.is_smart() { - let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); - if !is_target && ui.button(crate::i18n::tr("Set as Target Album (B adds to it)")).clicked() { - let _ = app.run("album.setTarget", json!({"id": if a.quick { serde_json::Value::Null } else { json!(a.id.0) }})); - } - if is_target && !a.quick && ui.button(crate::i18n::tr("Stop Using as Target Album")).clicked() { - let _ = app.run("album.setTarget", json!({"id": null})); - } - } - if a.quick && ui.button(crate::i18n::tr("Clear Quick Collection")).clicked() { - let _ = app.run("album.clearQuick", json!({})); - } - if a.is_smart() && ui.button(crate::i18n::tr("Edit Smart Album…")).clicked() { - // the same editor as `dialog.smartAlbum` (older smart albums keep their filter fields; - // it works on the rule set, upgraded) - let _ = app.run("dialog.smartAlbum", json!({"id": a.id.0})); - } - if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() { - // refused when the view would make the album test itself: say why - if let Err(e) = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})) { - app.toast(ui.ctx(), e); - } - } - if !a.folder { - // export: show the album, select its photos, then the dialog / a preset - let show_all = |app: &mut LightcraftApp| { - let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); - let _ = app.run("library.selectAll", json!({})); - }; - let has_photos = app.session.catalog.album_count(a.id) > 0; - if ui.add_enabled(has_photos, egui::Button::new(crate::i18n::tr("Export Album…"))).clicked() { - show_all(app); - let _ = app.run("dialog.export", json!({})); - } - ui.add_enabled_ui(has_photos, |ui| { - crate::widgets::menu_button(ui, crate::i18n::tr("Export Album with Preset"), |ui| { - for (p, _) in app.session.all_export_presets() { - if ui.button(&p.name).clicked() { - show_all(app); - if let Err(e) = app.run("app.export", json!({"preset": p.name, "background": true})) { - app.toast(ui.ctx(), e); - } - } - } - }); - }); - ui.separator(); - } - // move into another folder (not into itself or one of its own subfolders) - let mut folders: Vec<(u64, String)> = - app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect(); - folders.sort_by_key(|(_, n)| n.to_lowercase()); - crate::widgets::menu_button(ui, crate::i18n::tr("Move to"), |ui| { - if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() { - let _ = app.run("album.move", json!({"id": a.id.0, "parent": null})); - } - for (fid, name) in &folders { - if ui.add_enabled(a.parent.map(|p| p.0) != Some(*fid), egui::Button::new(name)).clicked() { - let _ = app.run("album.move", json!({"id": a.id.0, "parent": fid})); - } - } - }); - if ui.button(crate::i18n::tr("Rename…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() }); - } - let delete = ui.button(crate::i18n::tr("Delete")); - register(ui, format!("albumDelete:{}", a.id.0), delete.rect); - let delete = - delete.on_hover_text(crate::i18n::tr("Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.")); - if delete.clicked() { - if let Err(e) = app.run("album.delete", json!({"id": a.id.0})) { - app.toast(ui.ctx(), e); - } - ui.close(); - } - }); -} - -/// Whether `id` is `ancestor` or lies inside it. -fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lightcraft_catalog::AlbumId) -> bool { - let mut cur = Some(id); - let mut guard = 0; - while let Some(c) = cur { - if c == ancestor { - return true; - } - cur = app.session.catalog.album(c).and_then(|x| x.parent); - guard += 1; - if guard > 64 { - break; - } - } - false -} - -// "Folders" (drawn in `show`): where on disk the library's photos were imported from, with photo counts (see -// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a Local -// folder: its photos and those of the folders inside it fill the grid. The triangle opens a -// level. Only folders holding imported photos are listed; every folder on disk is under Local. - -/// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows -/// above it so it is on screen; folding one by hand afterwards sticks until the choice changes. -fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) { - let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder); - let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty()); - let seen = egui::Id::unique(LIBFOLDER_REVEALED); - let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key); - if ui.data(|d| d.get_temp::>(seen)) == Some(now.clone()) { - return; - } - ui.data_mut(|d| d.insert_temp(seen, now)); - let Some(chosen) = chosen else { return }; - fn open_above(ui: &egui::Ui, nodes: &[FolderNode], chosen: &str) { - for n in nodes { - if lightcraft_catalog::query::folder_within(chosen, &n.path) && !same_folder(chosen, &n.path) { - let key = lightcraft_catalog::query::folder_key(&n.path); - ui.data_mut(|d| d.insert_temp(egui::Id::unique(("libfolder-open", key)), true)); - open_above(ui, &n.children, chosen); - } - } - } - open_above(ui, tree, &chosen); -} - -/// The Folders row that stands for folder `dir`, however it is spelled. `None` when no row is that -/// folder. -pub(crate) fn folder_row_of<'a>(tree: &'a [FolderNode], dir: &str) -> Option<&'a FolderNode> { - // only the rows that hold `dir` are opened (the startup disk's holds the other disks' paths - // too, so more than one may), and the tree is at most `MAX_DEPTH` deep - tree.iter() - .filter(|n| lightcraft_catalog::query::folder_within(dir, &n.path)) - .find_map(|n| if same_folder(&n.path, dir) { Some(n) } else { folder_row_of(&n.children, dir) }) -} - -fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) { - for n in nodes { - let key = lightcraft_catalog::query::folder_key(&n.path); - let open_id = egui::Id::unique(("libfolder-open", key.clone())); - // a disk starts open, and so does a folder that holds nothing itself and leads to one - // folder (`Users` → `me`): the first row where the library branches is what you look for - let leads_on = n.own == 0 && n.children.len() == 1; - let mut open = tree_open(ui, open_id, n.volume || leads_on); - // a row whose path would cover other disks' photos too only opens and closes - let name = if n.selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() }; - let id = format!("libfolder:{}", n.path); - if !out_of_sight(app, ui, &id, &name, Some(n.count), indent, n.label.is_some()) { - folder_row(app, ui, n, &id, &name, indent, open_id, &mut open); - } - if open && !n.children.is_empty() { - folder_rows(app, ui, &n.children, indent + INDENT_STEP); - } - } -} - -/// One drawn row of [`folder_rows`], shown as `name`; `open_id` and `open` are its triangle's. -#[allow(clippy::too_many_arguments)] -fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &str, name: &str, indent: f32, open_id: egui::Id, open: &mut bool) { - let sel = n.selectable - && app.session.source == LibrarySource::LibraryFolder - && app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); - // the label is said with the name, as it is shown beside it - let spoken = match n.label { - Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)), - None => name.to_string(), - }; - let mark = n.label.map(crate::theme::label_color); - let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); - folder_drop_target(app, ui, &resp, n, open_id, open); - let mut toggled = false; - if !n.children.is_empty() { - let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path)); - toggled = tr.clicked(); - // the triangle sits on the row and takes its clicks: the menu opens from it too - row_menu(app, &tr, n); - } - let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) }; - let resp = resp.on_hover_text(tip); - if resp.clicked() && !toggled { - if n.selectable { - // a source like an album or a Local folder: it replaces what the grid showed - let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); - } else { - *open = !*open; - set_tree_open(ui, open_id, *open); - } - } - row_menu(app, &resp, n); -} - -/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it -/// can be reached. -const SPRING_OPEN_SECONDS: f64 = 0.7; - -/// A Folders row while photos are dragged from the grid: a release moves their files there on -/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where -/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row, -/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos, -/// not in the library) and a release says why; photos already there say so. -fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) { - let Some(ids) = app.ui.dragging_photos.clone() else { return }; - // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header - if !pointer_over_row(ui, resp.rect) { - return; - } - spring_open(ui, n, open_id, open); - let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone()); - let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect(); - let refused = if !n.selectable { - Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it")) - } else if photos.iter().any(|p| p.local) { - Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them")) - } else { - None - }; - let here = |p: &lightcraft_catalog::Photo| match &p.source { - lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)), - _ => false, - }; - let all_here = !photos.is_empty() && photos.iter().all(|p| here(p)); - let count = photos.len(); - let released = ui.input(|i| i.pointer.any_released()); - let hint = match (&refused, all_here) { - (Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(), - (None, true) => crate::i18n::tr_format!("Already in “{}”", name), - (None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count), - }; - super::grid::drag_hint(ui.ctx(), hint); - if let Some(why) = refused { - ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); - if released { - app.toast(ui.ctx(), why); - app.ui.dragging_photos = None; - } - return; - } - if all_here { - if released { - app.ui.dragging_photos = None; - } - return; - } - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); - if released { - app.ui.dragging_photos = None; - // a drop moves files on disk: asked first (as Lightroom Classic does), unless the person - // said not to; the menu's Move Selected Photos Here is asked for by name and goes at once - if app.ui.settings.ask_before_move { - let library = app.session.library_generation(); - app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library }); - } else { - move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name); - } - } -} - -/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders -/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after -/// leaving it (or after another drag). -fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) { - if *open || n.children.is_empty() { - return; - } - let key = egui::Id::unique("folder-spring-open"); - let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr()); - // held without a break: over this row last frame too - let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) { - Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since, - _ => now, - }; - ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame))); - if now - since >= SPRING_OPEN_SECONDS { - *open = true; - set_tree_open(ui, open_id, true); - ui.data_mut(|d| d.remove::<(String, f64, u64)>(key)); - } else { - // keep the clock running while the pointer rests - ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); - } -} - -/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`). -/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches -/// photos no longer on screen. How many moved, or why none did. -pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result { - let mut params = json!({"folder": path}); - if let Some(ids) = ids { - params["ids"] = json!(ids); - } - let r = app.run("photo.moveToFolder", params)?; - let visible: std::collections::HashSet = app.session.visible_cloned().into_iter().collect(); - let sel = &mut app.session.selection; - sel.ids.retain(|id| visible.contains(id)); - if sel.active.is_some_and(|a| !visible.contains(&a)) { - sel.active = sel.ids.first().copied(); - } - Ok(r["moved"].as_u64().unwrap_or(0)) -} - -/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them -/// back, or that they were there already. -pub(crate) fn moved_text(n: u64, name: &str) -> String { - if n == 0 { - crate::i18n::tr_format!("Already in “{}”", name) - } else { - crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n) - } -} - -/// [`move_photos`], saying how it went in a toast. -fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) { - match move_photos(app, ids, path) { - Ok(n) => app.toast(ctx, moved_text(n, name)), - Err(e) => app.toast(ctx, e), - } -} - -/// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the -/// startup disk. -fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { - if !n.volume { - folder_menu_for_library(app, resp, n); - } else if n.path != "/" { - crate::widgets::context_menu(resp, |ui| { - folder_label_menu(app, ui, n); - locate_missing_item(app, ui, n); - synchronize_item(app, ui, n, &n.name); - ui.separator(); - if ui - .button(crate::i18n::tr("Remove Disk from Library…")) - .on_hover_text(crate::i18n::tr("Moves every photo imported from this disk to Recently Deleted; no file is touched")) - .clicked() - { - app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: n.path.clone(), name: n.name.clone(), count: n.count, disk: true }); - ui.close(); - } - }); - } -} - -/// Locate Missing Files… of a Folders row: choose where this folder moved, then let the existing -/// background finder relink only this folder's photos (including the folders inside it). -fn locate_missing_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { - let r = ui.button(crate::i18n::tr("Locate Missing Files…")); - register(ui, "folderLocateMissing", r.rect); - if r.clicked() { - if let Err(e) = app.run("file.findMissing", json!({"within": n.path})) { - app.toast(ui.ctx(), e); - } - ui.close(); - } -} - -/// The node Synchronize Folder… of a Local folder `path` works on, with the name its dialog shows: -/// the Folders row for that very folder, else (a folder above rows) the folder itself, as a folder -/// holding other folders. `None` when no library photo lies in `path` or inside it. Reads the -/// cached Folders tree only; the disk is not touched. -fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, String)> { - use lightcraft_catalog::query::folder_within; - fn exact<'a>(tree: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> { - tree.iter().find_map(|n| if same_folder(&n.path, path) { Some(n) } else { exact(&n.children, path) }) - } - fn any_inside(tree: &[FolderNode], path: &str) -> bool { - tree.iter().any(|n| (n.count > 0 && folder_within(&n.path, path)) || any_inside(&n.children, path)) - } - if let Some(n) = exact(tree, path).filter(|n| n.count > 0) { - return Some((n.clone(), lightcraft_catalog::folders::folder_label(&n.path))); - } - if !any_inside(tree, path) { - return None; - } - let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); - let node = FolderNode { - name: name.clone(), - path: path.to_string(), - count: 0, - own: 0, - volume: false, - selectable: false, - label: None, - children: Vec::new(), - }; - Some((node, name)) -} - -/// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which -/// scans the folder in the background. -fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) { - let r = ui - .button(crate::i18n::tr("Synchronize Folder…")) - .on_hover_text(crate::i18n::tr("Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps")); - crate::widgets::register(ui, "folderSynchronize", r.rect); - if r.clicked() { - // a disk, or a folder holding disks, was chosen as such - if let Err(e) = crate::sync::open(app, &n.path, name, n.volume || !n.selectable) { - app.toast(ui.ctx(), e); - } - ui.close(); - } -} - -/// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`, -/// its colours `folderLabel:`). -fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { - let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| { - if let Some(l) = crate::panels::grid::label_items(app, ui, n.label, "folderLabel") { - if let Err(e) = app.run("folder.label", json!({"path": n.path, "label": crate::panels::grid::label_param(l)})) { - app.toast(ui.ctx(), e); - } - ui.close(); - } - }); - register(ui, "folderLabelMenu", menu.response.rect); -} - -/// The context menu of a folder row: the folder's disk actions (the same as Local's, photos -/// follow) and taking its photos out of the library. -fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { - let path = n.path.as_str(); - let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); - // what dialogs call it: the last two names, so same-named folders are told apart - let label = lightcraft_catalog::folders::folder_label(path); - crate::widgets::context_menu(resp, |ui| { - if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: crate::i18n::tr_format!("Rename “{name}”", name = label), - hint: "Folder name (renamed on disk; its photos follow)".into(), - value: name.clone(), - command: "folder.rename".into(), - params: json!({"path": path}), - key: "name".into(), - }); - ui.close(); - } - if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { - let into = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(into) = into { - match app.run("folder.move", json!({"path": path, "into": into})) { - Ok(r) => app.toast(ui.ctx(), format!("Moved; {} photo(s) relinked", r["relinked"])), - Err(e) => app.toast(ui.ctx(), e), - } - } - ui.close(); - } - if ui.button(crate::i18n::tr("New Folder on Disk…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: crate::i18n::tr_format!("New Folder in “{name}”", name = label), - hint: "Folder name (created on disk)".into(), - value: String::new(), - command: "folder.create".into(), - params: json!({"parent": path}), - key: "name".into(), - }); - ui.close(); - } - let selected = app.session.selection.ids.len(); - if selected > 0 - && ui - .button(crate::i18n::tr("Move Selected Photos Here")) - .on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites")) - .clicked() - { - move_photos_here(app, ui.ctx(), None, path, &name); - ui.close(); - } - if app.services.reveal.is_some() - && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() - && let Some(f) = app.services.reveal.as_mut() - { - let _ = f(path); - ui.close(); - } - folder_label_menu(app, ui, n); - locate_missing_item(app, ui, n); - synchronize_item(app, ui, n, &label); - ui.separator(); - if ui - .button(crate::i18n::tr("Remove from Library…")) - .on_hover_text(crate::i18n::tr("Moves the photos imported from this folder to Recently Deleted; no file is touched")) - .clicked() - { - app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: path.to_string(), name: label.clone(), count: n.count, disk: false }); - ui.close(); - } - }); -} - -/// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click -/// filters the grid by the keyword (children included), the triangle opens a level, and the -/// context menu renames, merges or deletes the keyword across the library. -fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { - for n in nodes { - // leaves (most keywords) have no open state to look up - let open_id = (!n.children.is_empty()).then(|| egui::Id::unique(("kw-open", n.path.to_lowercase()))); - let mut open = open_id.is_some_and(|k| tree_open(ui, k, false)); - let id = format!("keyword:{}", n.path); - if !out_of_sight(app, ui, &id, &n.name, Some(n.count), indent, false) { - keyword_row(app, ui, n, &id, indent, open_id, &mut open); - } - if open { - keyword_rows(app, ui, &n.children, indent + INDENT_STEP); - } - } -} - -/// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it. -fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option, open: &mut bool) { - let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); - let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent); - if let Some(k) = open_id { - tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path)); - } - let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); - if resp.clicked() { - let v = if sel { serde_json::Value::Null } else { json!(n.path) }; - browse_all_photos(app, !sel); - let _ = app.run("library.filter", json!({"keyword": v})); - } - crate::widgets::context_menu(&resp, |ui| { - let has_sel = app.session.active().is_some(); - if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() { - let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); - } - if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]})); - } - ui.separator(); - if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() }); - } - if ui.button(crate::i18n::tr("Merge into…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() }); - } - if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { - let _ = app.run("keyword.delete", json!({"keyword": n.path})); - } - }); -} - -#[cfg(test)] -mod tests { - use super::local_places; - use crate::widgets::elide_head; - - /// Width = characters, so a limit of 12 is "12 characters". - fn fit(text: &str, max: usize) -> String { - elide_head(text, max as f32, |s| s.chars().count() as f32) - } - - #[test] - fn a_label_that_fits_is_left_alone() { - assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures"); - } - - #[test] - fn a_long_path_loses_its_leading_folders_not_its_end() { - assert_eq!(fit("/Users/me/Pictures/Lightroom", 20), "…/Pictures/Lightroom"); - assert_eq!(fit("/Users/me/Pictures/Lightroom", 12), "…/Lightroom"); - } - - #[test] - fn a_single_name_too_long_is_cut_at_the_end() { - assert_eq!(fit("2024-summer-holiday", 8), "2024-su…"); - assert_eq!(fit("2024-summer-holiday", 1), "…"); - assert_eq!(fit("2024-summer-holiday", 0), "…"); - } - - #[test] - fn multibyte_names_are_cut_on_character_boundaries() { - assert_eq!(fit("/写真/夏休み旅行の記録", 6), "夏休み旅行…"); - } - - fn names(v: &[(String, String)]) -> Vec<&str> { - v.iter().map(|(n, _)| n.as_str()).collect() - } - - /// A built-in location browsed (or picked) under another spelling of its path is the same - /// row, not a second root — for every built-in, not just named ones. - #[test] - fn equivalent_paths_are_one_local_location() { - for (home, sep) in [("D:\\Example", '\\'), ("D:/Example", '/'), ("/home/example", '/')] { - let builtin: Vec<(String, String)> = - ["Pictures", "Desktop", "Downloads"].iter().map(|s| (s.to_string(), format!("{home}{sep}{s}"))).collect(); - for (_, p) in builtin.clone() { - // the browsed spelling: other separators, a trailing one, a different drive-letter case - let flipped = p.replace(['/', '\\'], if sep == '/' { "\\" } else { "/" }); - for browsing in [flipped.clone(), format!("{p}{sep}"), p.replacen("D:", "d:", 1)] { - let l = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), None, &[]); - assert_eq!(names(&l.places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}"); - assert_eq!(l.places[l.owner.unwrap()].1, p, "the built-in row is the one highlighted"); - } - // hiding under one spelling hides the other - let l = local_places(builtin.clone(), &[], None, None, std::slice::from_ref(&flipped)); - assert_eq!(l.places.len(), 2, "hidden {flipped}"); - } - } - // a different folder is still added - let l = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), None, &[]); - assert_eq!(names(&l.places), ["Pictures", "Pictures2"]); - } - - /// A folder inside a kept root is shown inside that root's tree: the root stays (its other - /// subfolders stay reachable) and no second root appears; other kept roots stay too. - #[test] - fn browsing_below_a_kept_root_keeps_the_root() { - let photos = "/data/Photos".to_string(); - let other = "/data/Scans".to_string(); - let saved = [photos.clone(), other.clone()]; - let builtin = || vec![("Home".to_string(), "/home/example".to_string())]; - for browsing in ["/data/Photos/2026/20260101", "/data/Photos/2026", "/data/Photos", "D:\\x"] { - let l = local_places(builtin(), &saved, Some(browsing), None, &[]); - let roots: Vec<&str> = l.places.iter().map(|(_, p)| p.as_str()).collect(); - if browsing.starts_with("/data") { - assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans"], "{browsing}"); - assert_eq!(l.owner, Some(1), "the Photos tree opens down to {browsing}"); - assert_eq!(l.browse_root, None); - } else { - assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans", "D:\\x"], "a folder outside them gets a row"); - } - } - // the innermost containing root is the one that opens - let l = local_places(builtin(), &["/home/example/Pictures".into()], Some("/home/example/Pictures/Trip"), None, &[]); - assert_eq!(l.owner, Some(1)); - } - - /// A browsed folder outside every kept root gets a session row, which stays while browsing - /// below it and gives way when browsing moves elsewhere. - #[test] - fn session_root_stays_while_browsing_below_it() { - let l = local_places(Vec::new(), &[], Some("/t/base"), None, &[]); - assert_eq!(l.browse_root.as_deref(), Some("/t/base")); - let l = local_places(Vec::new(), &[], Some("/t/base/Trip/Day 1"), l.browse_root.as_deref(), &[]); - assert_eq!((l.places.len(), l.browse_root.as_deref(), l.owner), (1, Some("/t/base"), Some(0)), "{l:?}"); - let l = local_places(Vec::new(), &[], Some("/elsewhere"), l.browse_root.as_deref(), &[]); - assert_eq!(l.browse_root.as_deref(), Some("/elsewhere")); - // hidden: no row at all - let l = local_places(Vec::new(), &[], Some("/t/base"), None, &["/t/base/".into()]); - assert!(l.places.is_empty() && l.browse_root.is_none()); - } - - /// A hidden folder inside a listed one (a kept folder beneath Home) is not revealed in that - /// one's tree while it is browsed, nor are the folders below it; hiding Home itself still - /// lets Pictures open down to a folder browsed inside it. - #[test] - fn hidden_folder_is_not_revealed_in_an_outer_tree() { - let builtin = || vec![("Pictures".to_string(), "/home/example/Pictures".to_string()), ("Home".to_string(), "/home/example".to_string())]; - let kept = ["/home/example/AppData/Temp/lc".to_string()]; - let l = local_places(builtin(), &kept, Some("/home/example/AppData/Temp/lc"), None, &[]); - assert_eq!(l.owner, Some(2), "shown as its own kept row"); - for browsing in ["/home/example/AppData/Temp/lc", "/home/example/AppData/Temp/lc/Day 1"] { - let l = local_places(builtin(), &kept, Some(browsing), None, &["/home/example/AppData/Temp/lc/".into()]); - assert_eq!(names(&l.places), ["Pictures", "Home"], "{browsing}"); - assert_eq!((l.owner, l.browse_root.as_deref()), (None, None), "Home does not open down to {browsing}"); - } - let l = local_places(builtin(), &[], Some("/home/example/Pictures/Trip"), None, &["/home/example".into()]); - assert_eq!((names(&l.places), l.owner), (vec!["Pictures"], Some(0))); - } - - #[test] - fn a_local_folder_lines_up_with_the_rows_above_and_its_triangle_stays_left_of_the_icon() { - use crate::Services; - use crate::headless::HeadlessView; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); - let mut view = HeadlessView::new(); - let path = "/lightcraft-test/Pictures"; - let mut widgets = vec![]; - for frame in 0..3 { - let raw = HeadlessView::raw_input(egui::vec2(300.0, 200.0), 1.0, frame as f64 / 60.0, vec![]); - view.run(raw, |ui| { - super::row(&mut app, ui, "all", super::Icon::Photos, "All Photos", None, false, 0.0); - super::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false); - }); - widgets = crate::widgets::take_registry(&view.ctx); - } - let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1; - let (all, local) = (rect("label:all"), rect(&format!("label:local:{path}"))); - assert_eq!(local.left(), all.left(), "a top-level Local folder's name starts where All Photos' does"); - let triangle = rect(&format!("folderToggle:{path}")); - // the icon is 16 points wide and ends 8 points before the name - assert!(triangle.right() <= local.left() - 24.0, "triangle {triangle:?} overlaps the icon before {local:?}"); - } -} diff --git a/crates/ui-egui/src/panels/left/albums.rs b/crates/ui-egui/src/panels/left/albums.rs new file mode 100644 index 000000000..e0204c826 --- /dev/null +++ b/crates/ui-egui/src/panels/left/albums.rs @@ -0,0 +1,415 @@ +use super::folders::{folder_menu, is_within}; +use super::rows::{INDENT_STEP, indent_x, row_sensed, set_tree_open, tree_open, tree_toggle}; +use super::*; + +/// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, +/// whose folders open down to it. +pub(super) fn albums_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, reveal: Option) { + let kids: AlbumKids = app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect(); + let cat = &app.session.catalog; + let mut open_to = Vec::new(); + let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent); + while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) { + open_to.push(p); + cur = cat.album(p).and_then(|a| a.parent); + } + albums_tree(app, ui, &kids, None, 0.0, &open_to); +} + +/// The albums inside each folder (`None`: the top level), in the order they are listed. +type AlbumKids = std::collections::HashMap, Vec>; + +/// `open_to`: folders forced open this frame (the way down to an album that was just made). +fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, parent: Option, indent: f32, open_to: &[AlbumId]) { + // 64 folders deep at most (16 points a level): a catalog that loops must not overflow the stack + if indent > 64.0 * INDENT_STEP { + return; + } + let Some(kids) = all.get(&parent) else { return }; + for a in kids { + if a.folder { + let open_id = egui::Id::unique(("folder-open", a.id.0)); + let mut open = tree_open(ui, open_id, true); + if open_to.contains(&a.id) && !open { + open = true; + set_tree_open(ui, open_id, true); + } + let resp = + row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None); + // a folder is no source, so its row folds it too; the triangle is the same click, aimed + let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty()); + if resp.drag_started() { + app.ui.dragging_album = Some(a.id.0); + } + if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open), indent) { + set_tree_open(ui, open_id, true); + } + // what this frame draws; a click shows from the next one + let draw_open = open; + let mut toggled = false; + if has_children { + let tri = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("albumToggle:{}", a.id.0)); + toggled = tri.clicked(); + // the triangle sits on the row and takes its clicks: the menu opens from it too + folder_menu(app, &tri, a); + } + // one click, one toggle: the triangle's is not the row's as well + if resp.clicked() && !toggled { + set_tree_open(ui, open_id, !open); + } + folder_menu(app, &resp, a); + if draw_open { + albums_tree(app, ui, all, Some(a.id), indent + INDENT_STEP, open_to); + } + } else { + let sel = app.session.source == LibrarySource::Album(a.id); + let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album }; + // cached: a smart album's count scans the catalog + let now = (app.session.clock)(); + let n = app.caches.album_counts(&app.session.catalog, &now).get(&a.id).copied().unwrap_or(0); + // the album B adds to is marked "+" + let target = + app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); + let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() }; + // a smart album whose rules no longer check (an album they test was deleted) says so + let problems = app.caches.smart_album_problems(&app.session.catalog).get(&a.id).cloned().unwrap_or_default(); + let label = if problems.is_empty() { label } else { format!("⚠ {label}") }; + let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag(), None); + if !problems.is_empty() { + crate::widgets::register(ui, format!("albumProblem:{}", a.id.0), resp.rect); + } + if resp.drag_started() { + app.ui.dragging_album = Some(a.id.0); + } + album_drag_over(app, ui, &resp, a, None, indent); + if !a.is_smart() { + drop_target(app, ui, &resp, a); + } + if let Some(rules) = &a.smart { + // one tooltip: the rules, then what needs fixing in them + let mut tip = crate::i18n::tr_format!("Smart album: {}", crate::i18n::filter_label(rules, &app.session.catalog)); + if !problems.is_empty() { + tip.push_str("\n\n"); + tip.push_str(crate::i18n::tr("Some rules need fixing (Edit Smart Album…):")); + for p in &problems { + tip.push('\n'); + tip.push_str(&crate::i18n::problem_line(p)); + } + } + resp = resp.on_hover_text(tip); + } + if resp.clicked() { + let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); + } + folder_menu(app, &resp, a); + } + } +} + +/// Whether the pointer is over `rect` and over what the panel shows of it (a row scrolled out of +/// sight is not a drop target). +fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool { + crate::docking::drop_target(ui, rect, ui.input(|i| i.pointer.latest_pos())) +} + +/// [`pointer_over`] for a row: a row scrolled beneath the pinned header is covered by it, so the +/// pointer there is over the header, not over the row. +pub(super) fn pointer_over_row(ui: &egui::Ui, rect: Rect) -> bool { + pointer_over(ui, rect) && !pointer_on_pinned_header(ui) +} + +/// Where the pinned header was drawn last frame, if one was (rows are laid out before it). +fn pinned_rect(ui: &egui::Ui) -> Option { + ui.data(|d| d.get_temp::>(egui::Id::unique("left-pinned-rect"))).flatten() +} + +fn pointer_on_pinned_header(ui: &egui::Ui) -> bool { + let pos = ui.input(|i| i.pointer.latest_pos()); + pinned_rect(ui).zip(pos).is_some_and(|(r, p)| r.contains(p)) +} + +/// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it. +const SCROLL_EDGE: f32 = 36.0; + +/// The fastest the sidebar scrolls for a drag (points per second). +const SCROLL_MAX_SPEED: f32 = 700.0; + +/// How fast the sidebar scrolls (points per second, positive = down) for a dragged item with the +/// pointer at `y`, the visible part being `top..bottom`: none in the middle, faster the nearer to +/// the edge, the top speed at and past it. +pub fn auto_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 { + let depth = |dist: f32| ((SCROLL_EDGE - dist) / SCROLL_EDGE).clamp(0.0, 1.0); + SCROLL_MAX_SPEED * (depth(bottom - y) - depth(y - top)) +} + +/// While an album, or photos from the grid, are dragged over the sidebar: scrolls it when the +/// pointer is near its top or bottom edge, so rows beyond the visible part can be reached. Call +/// inside the scroll area. +pub(super) fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { + if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() && app.ui.dragging_section.is_none() { + return; + } + let view = ui.clip_rect(); + let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return }; + // a section picked up by its pinned header starts inside the top edge: it scrolls once it has + // left the header. Any other drag scrolls there as at any edge, to reach what is above + let from_pinned = egui::Id::unique("left-section-drag-from-pinned"); + if app.ui.dragging_section.is_some() && ui.data(|d| d.get_temp::(from_pinned)) == Some(true) { + if pointer_on_pinned_header(ui) { + return; + } + ui.data_mut(|d| d.insert_temp(from_pinned, false)); + } + let speed = auto_scroll_speed(p.y, view.top(), view.bottom()); + if speed != 0.0 { + // a positive delta moves the content down: the view goes up + ui.scroll_with_delta_animation(vec2(0.0, -speed * ui.input(|i| i.stable_dt).min(0.1)), egui::style::ScrollAnimation::none()); + ui.ctx().request_repaint(); + } +} + +/// Room a colour label's dot takes before a row's count. +pub(super) const MARK_W: f32 = 12.0; + +/// How long a dragged album must rest on a closed folder before it opens. +const HOVER_OPEN_SECS: f64 = 0.6; + +/// Whether the album `dragged` may be dropped into the folder `target` (`None`: the top level): +/// a folder other than where it already is, and not itself or something inside it. +fn can_drop_album(app: &LightcraftApp, dragged: AlbumId, target: Option) -> bool { + let Some(d) = app.session.catalog.album(dragged) else { return false }; + match target { + None => d.parent.is_some(), + Some(t) => app.session.catalog.album(t).is_some_and(|f| f.folder) && d.parent != Some(t) && !is_within(app, t, dragged), + } +} + +/// Ends a drag that ran `result`: the folders down to the album open so it shows in its new place. +fn finish_drop(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, result: Result) { + forget_hover(app, ui.ctx()); + match result { + Ok(_) => { + app.ui.reveal_album = Some(dragged.0); + ui.ctx().request_repaint(); + } + Err(e) => app.toast(ui.ctx(), e), + } + app.ui.dragging_album = None; +} + +/// Forgets how long the pointer rested on each folder (a new drag starts counting again). +fn forget_hover(app: &LightcraftApp, ctx: &egui::Context) { + let ids: Vec = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect(); + ctx.data_mut(|d| { + for id in ids { + d.remove_temp::(egui::Id::unique(("album-hover", id))); + } + }); +} + +/// Moves the dragged album into a folder (`album.move`, one undo step; `None`: the top level). +fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option) { + let r = app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})); + finish_drop(app, ui, dragged, r); +} + +/// Places the dragged album in `parent` before the sibling `before` (`None`: last of its kind), by +/// hand (`album.reorder`, one undo step). +fn place_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, parent: Option, before: Option) { + let r = app.run("album.reorder", json!({"id": dragged.0, "parent": parent.map(|p| p.0), "before": before.map(|b| b.0)})); + finish_drop(app, ui, dragged, r); +} + +/// What dropping the dragged album on a row would do. +#[derive(Clone, Copy, Debug, PartialEq)] +enum AlbumDrop { + /// Into the folder. + Into(AlbumId), + /// Beside the row: in `parent`, before `before` (`None`: last of its kind); the line is drawn + /// above the row (`above`) or below it. + Place { parent: Option, before: Option, above: bool }, +} + +/// What dropping `dragged` at `frac` (0 = top edge, 1 = bottom edge) of the row of `target` does, +/// if anything. An album over a folder goes inside it; a folder over a folder goes inside by its +/// middle and beside it by its edges; an album over an album goes before it by its top half and +/// after it by its bottom half; a folder over an album does nothing. Placing where it already +/// is, or inside itself, is nothing too. `expanded`: `target` is a folder shown open. +fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f32, expanded: bool) -> Option { + let cat = &app.session.catalog; + let d = cat.album(dragged)?; + let into = || can_drop_album(app, dragged, Some(target.id)).then_some(AlbumDrop::Into(target.id)); + if d.folder != target.folder { + return if target.folder { into() } else { None }; + } + if target.id == dragged { + return None; + } + let after = frac >= if target.folder { 0.7 } else { 0.5 }; + // the middle of a folder, and the bottom of an open one (its first child is next), mean inside + if target.folder && frac >= 0.3 && (!after || expanded) { + return into(); + } + let parent = target.parent; + if parent.is_some_and(|p| is_within(app, p, dragged)) { + return None; + } + let sibs = cat.album_children(parent); + let at = sibs.iter().position(|a| a.id == target.id)?; + let was = sibs.iter().position(|a| a.id == dragged); + if !after { + (was.map(|w| w + 1) != Some(at)).then_some(AlbumDrop::Place { parent, before: Some(target.id), above: true }) + } else { + let next = sibs.iter().skip(at + 1).find(|a| a.id != dragged).filter(|a| a.folder == target.folder).map(|a| a.id); + (was != Some(at + 1)).then_some(AlbumDrop::Place { parent, before: next, above: false }) + } +} + +/// A row while an album is dragged: a folder outlined under the pointer when the drop would go +/// inside it, a line above or below the row when it would go beside it, and the move or placement +/// done on release. A closed folder with something inside (`open`) opens once the pointer rests on +/// it; returns whether it did. +fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool { + let hover_id = egui::Id::unique(("album-hover", row.id.0)); + let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false }; + let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over_row(ui, resp.rect)); + let Some(pos) = pos else { + if ui.data(|d| d.get_temp::(hover_id)).is_some() { + ui.data_mut(|d| d.remove_temp::(hover_id)); + } + return false; + }; + let expanded = open.as_deref().copied().unwrap_or(false); + let frac = ((pos.y - resp.rect.top()) / resp.rect.height().max(1.0)).clamp(0.0, 1.0); + let drop = album_drop_at(app, dragged, row, frac, expanded); + let mut opened = false; + // only a drop that goes inside opens the folder: on its edges the rows would shift under the pointer + let inside = matches!(drop, Some(AlbumDrop::Into(_))); + if !inside && ui.data(|d| d.get_temp::(hover_id)).is_some() { + ui.data_mut(|d| d.remove_temp::(hover_id)); + } + if inside + && let Some(open) = open + && !*open + { + let now = ui.input(|i| i.time); + let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now)); + if now - since >= HOVER_OPEN_SECS { + *open = true; + opened = true; + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } + } + let t = Tokens::get(ui.ctx()); + let released = ui.input(|i| i.pointer.primary_released()); + match drop { + Some(AlbumDrop::Into(folder)) => { + ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + if released { + drop_album(app, ui, dragged, Some(folder)); + } + } + Some(AlbumDrop::Place { parent, before, above }) => { + let y = if above { resp.rect.top() + 0.5 } else { resp.rect.bottom() - 0.5 }; + let (x0, x1) = (resp.rect.left() + 18.0 + indent_x(ui.ctx(), indent), ui.clip_rect().right() - 8.0); + ui.painter().circle_stroke(pos2(x0, y), 3.0, egui::Stroke::new(1.5, t.accent)); + ui.painter().line_segment([pos2(x0 + 3.0, y), pos2(x1.max(x0 + 3.0), y)], egui::Stroke::new(2.0, t.accent)); + if released { + place_album(app, ui, dragged, parent, before); + } + } + None => {} + } + opened +} + +/// While an album that sits in a folder is dragged: the "Albums" header takes it back to the top +/// level (outlined, with a hint). The header stays in view however long the tree is, and nothing +/// moves when it appears. +pub(super) fn top_level_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect) { + let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return }; + if !can_drop_album(app, dragged, None) { + return; + } + let t = Tokens::get(ui.ctx()); + register(ui, "albumDrop:top", header); + let over = pointer_over(ui, header); + let stroke = if over { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)) }; + ui.painter().rect_stroke(header.shrink2(vec2(8.0, 3.0)), 4.0, stroke, egui::StrokeKind::Inside); + // right-aligned, left of the + button + let edge = header.right().min(ui.clip_rect().right()); + ui.painter().text(pos2(edge - 56.0, header.center().y), Align2::RIGHT_CENTER, crate::i18n::tr("Top Level"), t.font(12.5), t.text_dim); + if over && ui.input(|i| i.pointer.primary_released()) { + drop_album(app, ui, dragged, None); + } +} + +/// While an album is dragged in the sidebar: its name at the pointer; the drag ends when the +/// button is up (the drop targets act on the release frame, before this runs). +pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(id) = app.ui.dragging_album else { return }; + let (released, down, pos, esc) = + ctx.input(|i| (i.pointer.primary_released(), i.pointer.primary_down(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); + if released || !down || esc { + app.ui.dragging_album = None; + forget_hover(app, ctx); + return; + } + let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return }; + let t = Tokens::get(ctx); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + egui::Area::new(egui::Id::unique("drag-album")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show( + ctx, + |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(name).color(egui::Color32::WHITE).font(t.semibold(12.0))); + }); + }, + ); +} + +/// An album row while photos are dragged from the grid: highlighted under the pointer; a +/// release there adds them. +fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) { + let Some(ids) = app.ui.dragging_photos.clone() else { return }; + // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header + let over = pointer_over_row(ui, resp.rect); + if !over { + return; + } + let released = ui.input(|i| i.pointer.any_released()); + // albums hold library photos (issue #684): Local ones can't be dropped in; the row doesn't + // light up, and a release says why + let photos: Vec = ids.iter().map(|id| lightcraft_catalog::PhotoId(*id)).collect(); + if crate::panels::grid::local_only(app, &photos) { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + if released { + app.toast(ui.ctx(), crate::panels::grid::albums_hint()); + app.ui.dragging_photos = None; + } + return; + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + if released { + match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { + Ok(r) => { + let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0); + let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n); + if skipped > 0 { + msg.push_str(" · "); + msg.push_str(&crate::i18n::tr_format!( + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", + if skipped == 1 { "" } else { "s" }, + n = skipped + )); + } + app.toast(ui.ctx(), msg); + } + Err(e) => app.toast(ui.ctx(), e), + } + app.ui.dragging_photos = None; + } +} diff --git a/crates/ui-egui/src/panels/left/dates.rs b/crates/ui-egui/src/panels/left/dates.rs new file mode 100644 index 000000000..49d8cf898 --- /dev/null +++ b/crates/ui-egui/src/panels/left/dates.rs @@ -0,0 +1,46 @@ +use super::rows::{INDENT_STEP, row, tree_open, tree_toggle}; +use super::*; + +/// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. +pub(super) fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let groups = app.caches.date_groups(&app.session.catalog); + for g in groups.iter() { + if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) { + for (m, n) in &g.months { + let label = crate::i18n::date_group_label(m, true); + if date_row(app, ui, m, &label, *n, INDENT_STEP) { + for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { + let label = crate::i18n::date_group_label(d, true); + date_row(app, ui, d, &label, *n, 2.0 * INDENT_STEP); + } + } + } + } + } +} + +/// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open. +fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool { + let open_id = egui::Id::unique(("date-open", key.to_string())); + let mut open = tree_open(ui, open_id, false); + let sel = app.session.filter.date.as_deref() == Some(key); + let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent); + if key.len() < 10 { + tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}")); + } + if resp.clicked() { + let v = if sel { serde_json::Value::Null } else { json!(key) }; + browse_all_photos(app, !sel); + let _ = app.run("library.filter", json!({"date": v})); + } + open +} + +/// By Date and Keywords count every photo in the library, so choosing a row shows those photos +/// from All Photos, not from whatever album or folder happened to be open, where they could be +/// missing (issue #341). Only when choosing (`on`), not when clearing the row again. +pub(crate) fn browse_all_photos(app: &mut LightcraftApp, on: bool) { + if on && app.session.source != LibrarySource::All { + let _ = app.run("library.source", json!({"kind": "all"})); + } +} diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs new file mode 100644 index 000000000..b9aa33037 --- /dev/null +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -0,0 +1,661 @@ +use super::albums::pointer_over_row; +use super::local::{same_folder, subfolders}; +use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, out_of_sight, row, row_sensed, set_tree_open, tree_open, tree_toggle}; +use super::*; + +/// A folder on disk with a disclosure triangle: click browses it, the triangle lists its +/// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the +/// way down to it open whenever the browsed folder changes, so it shows highlighted in place. +/// `transient`: a top-level row listed for this session only (it offers Keep in Local). +/// `place`: the top-level folder this tree hangs from. A folder can be listed in more than one +/// tree (Pictures on its own and inside Home): each row opens and closes by itself, and their +/// triangles have ids of their own, else the copy drawn last takes the other's clicks (#1093). +#[allow(clippy::too_many_arguments)] +pub(super) fn folder_tree( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + place: &str, + name: &str, + path: &str, + indent: f32, + current: Option<&str>, + reveal: bool, + transient: bool, +) { + let open_id = egui::Id::unique(("folder-open", place.to_string(), path.to_string())); + let sel = current.is_some_and(|c| same_folder(c, path)); + let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)); + let mut open = tree_open(ui, open_id, false); + if reveal && on_the_way { + // opened once per browsed folder: collapsing it again afterwards sticks + let revealed_id = egui::Id::unique(("folder-revealed", place.to_string(), path.to_string())); + let target = current.map(str::to_string); + if ui.data(|d| d.get_temp::>(revealed_id)) != Some(target.clone()) { + open = true; + set_tree_open(ui, open_id, true); + ui.data_mut(|d| d.insert_temp(revealed_id, target)); + } + } + // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the + // icon lines up with the rows above + let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); + let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); + if !tr.clicked() + && resp.clicked() + && let Err(e) = app.run("library.browse", json!({"path": path})) + { + app.toast(ui.ctx(), e); + } + crate::widgets::context_menu(&resp, |ui| { + // the library actions of the Folders tree, when this folder (or one inside it) holds library photos + let library = app.caches.folder_tree(&app.session.catalog); + let held = library_node_for(&library, path); + if (transient || indent > 0.0) + && ui + .button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" })) + .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) + .clicked() + { + let _ = app.run("local.addRoot", json!({"path": path})); + ui.close(); + } + if indent == 0.0 + && ui + .button(crate::i18n::tr("Remove from Local")) + .on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are")) + .clicked() + { + let _ = app.run("local.hide", json!({"path": path})); + ui.close(); + } + if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("Rename “{name}”", name = name), + hint: "Folder name (renamed on disk; its photos follow)".into(), + value: name.to_string(), + command: "folder.rename".into(), + params: json!({"path": path}), + key: "name".into(), + }); + } + if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { + let into = app.services.pick_folder.as_mut().and_then(|f| f()); + if let Some(into) = into { + match app.run("folder.move", json!({"path": path, "into": into})) { + Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), + Err(e) => app.toast(ui.ctx(), e), + } + } + } + if app.services.reveal.is_some() + && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() + && let Some(f) = app.services.reveal.as_mut() + { + let _ = f(path); + } + if let Some(held) = &held { + synchronize_item(app, ui, &held.0, &held.1); + } + }); + if open && indent < INDENT_STEP * 8.0 { + for (n, p) in subfolders(ui, path) { + folder_tree(app, ui, place, &n, &p, indent + INDENT_STEP, current, reveal, false); + } + } +} + +pub(super) fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { + crate::widgets::context_menu(resp, |ui| { + if a.folder { + // the same commands as File ▸ New…, aimed at this folder + crate::widgets::menu_button(ui, crate::i18n::tr("New"), |ui| { + for (label, command) in [ + ("Create Album…", "dialog.newAlbum"), + ("Create Smart Album…", "dialog.smartAlbum"), + ("Create Smart Album from Filter…", "dialog.newSmartAlbum"), + ("Create Folder…", "dialog.newFolder"), + ] { + if ui.button(crate::i18n::tr(label)).clicked() { + let _ = app.run(command, json!({"parent": a.id.0})); + ui.close(); + } + } + }); + if app.session.catalog.album_children_are_ordered(Some(a.id)) + && ui.button(crate::i18n::tr("Sort Contents A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() + { + let _ = app.run("album.sort", json!({"parent": a.id.0})); + ui.close(); + } + ui.separator(); + } + if !a.folder && !a.is_smart() { + let local_only = crate::panels::grid::local_only(app, &app.session.selected()); + let b = ui + .add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos"))) + .on_disabled_hover_text(crate::panels::grid::albums_hint()); + if b.clicked() { + match app.run("album.addPhotos", json!({"id": a.id.0})) { + Ok(r) => crate::panels::grid::toast_skipped(app, ui.ctx(), &r), + Err(e) => app.toast(ui.ctx(), e), + } + } + // File ▸ Import's ways in; the import review opens with this album chosen + let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Import"), |ui| { + let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| { + let r = ui.button(label); + crate::widgets::register(ui, widget, r.rect); + if r.clicked() { + if let Err(e) = app.run(command, params) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }; + for (label, command, widget) in [ + ("Import Photos…", "file.addPhotos", "button:albumImportPhotos"), + ("Import from Folder…", "file.addFolder", "button:albumImportFolder"), + ] { + entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0})); + } + // like File ▸ Import from Device: one entry per camera or card found + let device = crate::widgets::menu_button(ui, crate::i18n::tr("Import from Device"), |ui| { + let devices = lightcraft_engine::devices::devices(); + if devices.is_empty() { + ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found"))); + } + for (i, d) in devices.into_iter().enumerate() { + let params = json!({"path": d.path, "album": a.id.0}); + entry(ui, format!("{}…", d.name), format!("button:albumImportDevice:{i}"), "file.addFromDevice", params); + } + }); + crate::widgets::register(ui, "button:albumImportDevice", device.response.rect); + }); + crate::widgets::register(ui, "button:albumImport", menu.response.rect); + } + if !a.folder && !a.is_smart() { + let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); + if !is_target && ui.button(crate::i18n::tr("Set as Target Album (B adds to it)")).clicked() { + let _ = app.run("album.setTarget", json!({"id": if a.quick { serde_json::Value::Null } else { json!(a.id.0) }})); + } + if is_target && !a.quick && ui.button(crate::i18n::tr("Stop Using as Target Album")).clicked() { + let _ = app.run("album.setTarget", json!({"id": null})); + } + } + if a.quick && ui.button(crate::i18n::tr("Clear Quick Collection")).clicked() { + let _ = app.run("album.clearQuick", json!({})); + } + if a.is_smart() && ui.button(crate::i18n::tr("Edit Smart Album…")).clicked() { + // the same editor as `dialog.smartAlbum` (older smart albums keep their filter fields; + // it works on the rule set, upgraded) + let _ = app.run("dialog.smartAlbum", json!({"id": a.id.0})); + } + if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() { + // refused when the view would make the album test itself: say why + if let Err(e) = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})) { + app.toast(ui.ctx(), e); + } + } + if !a.folder { + // export: show the album, select its photos, then the dialog / a preset + let show_all = |app: &mut LightcraftApp| { + let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); + let _ = app.run("library.selectAll", json!({})); + }; + let has_photos = app.session.catalog.album_count(a.id) > 0; + if ui.add_enabled(has_photos, egui::Button::new(crate::i18n::tr("Export Album…"))).clicked() { + show_all(app); + let _ = app.run("dialog.export", json!({})); + } + ui.add_enabled_ui(has_photos, |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Export Album with Preset"), |ui| { + for (p, _) in app.session.all_export_presets() { + if ui.button(&p.name).clicked() { + show_all(app); + if let Err(e) = app.run("app.export", json!({"preset": p.name, "background": true})) { + app.toast(ui.ctx(), e); + } + } + } + }); + }); + ui.separator(); + } + // move into another folder (not into itself or one of its own subfolders) + let mut folders: Vec<(u64, String)> = + app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect(); + folders.sort_by_key(|(_, n)| n.to_lowercase()); + crate::widgets::menu_button(ui, crate::i18n::tr("Move to"), |ui| { + if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() { + let _ = app.run("album.move", json!({"id": a.id.0, "parent": null})); + } + for (fid, name) in &folders { + if ui.add_enabled(a.parent.map(|p| p.0) != Some(*fid), egui::Button::new(name)).clicked() { + let _ = app.run("album.move", json!({"id": a.id.0, "parent": fid})); + } + } + }); + if ui.button(crate::i18n::tr("Rename…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() }); + } + let delete = ui.button(crate::i18n::tr("Delete")); + register(ui, format!("albumDelete:{}", a.id.0), delete.rect); + let delete = + delete.on_hover_text(crate::i18n::tr("Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.")); + if delete.clicked() { + if let Err(e) = app.run("album.delete", json!({"id": a.id.0})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }); +} + +/// Whether `id` is `ancestor` or lies inside it. +pub(super) fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lightcraft_catalog::AlbumId) -> bool { + let mut cur = Some(id); + let mut guard = 0; + while let Some(c) = cur { + if c == ancestor { + return true; + } + cur = app.session.catalog.album(c).and_then(|x| x.parent); + guard += 1; + if guard > 64 { + break; + } + } + false +} + +// "Folders" (drawn in `show`): where on disk the library's photos were imported from, with photo counts (see +// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a Local +// folder: its photos and those of the folders inside it fill the grid. The triangle opens a +// level. Only folders holding imported photos are listed; every folder on disk is under Local. + +/// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows +/// above it so it is on screen; folding one by hand afterwards sticks until the choice changes. +pub(super) fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) { + let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder); + let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty()); + let seen = egui::Id::unique(LIBFOLDER_REVEALED); + let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key); + if ui.data(|d| d.get_temp::>(seen)) == Some(now.clone()) { + return; + } + ui.data_mut(|d| d.insert_temp(seen, now)); + let Some(chosen) = chosen else { return }; + fn open_above(ui: &egui::Ui, nodes: &[FolderNode], chosen: &str) { + for n in nodes { + if lightcraft_catalog::query::folder_within(chosen, &n.path) && !same_folder(chosen, &n.path) { + let key = lightcraft_catalog::query::folder_key(&n.path); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(("libfolder-open", key)), true)); + open_above(ui, &n.children, chosen); + } + } + } + open_above(ui, tree, &chosen); +} + +/// The Folders row that stands for folder `dir`, however it is spelled. `None` when no row is that +/// folder. +pub(crate) fn folder_row_of<'a>(tree: &'a [FolderNode], dir: &str) -> Option<&'a FolderNode> { + // only the rows that hold `dir` are opened (the startup disk's holds the other disks' paths + // too, so more than one may), and the tree is at most `MAX_DEPTH` deep + tree.iter() + .filter(|n| lightcraft_catalog::query::folder_within(dir, &n.path)) + .find_map(|n| if same_folder(&n.path, dir) { Some(n) } else { folder_row_of(&n.children, dir) }) +} + +pub(super) fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) { + for n in nodes { + let key = lightcraft_catalog::query::folder_key(&n.path); + let open_id = egui::Id::unique(("libfolder-open", key.clone())); + // a disk starts open, and so does a folder that holds nothing itself and leads to one + // folder (`Users` → `me`): the first row where the library branches is what you look for + let leads_on = n.own == 0 && n.children.len() == 1; + let mut open = tree_open(ui, open_id, n.volume || leads_on); + // a row whose path would cover other disks' photos too only opens and closes + let name = if n.selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() }; + let id = format!("libfolder:{}", n.path); + if !out_of_sight(app, ui, &id, &name, Some(n.count), indent, n.label.is_some()) { + folder_row(app, ui, n, &id, &name, indent, open_id, &mut open); + } + if open && !n.children.is_empty() { + folder_rows(app, ui, &n.children, indent + INDENT_STEP); + } + } +} + +/// One drawn row of [`folder_rows`], shown as `name`; `open_id` and `open` are its triangle's. +#[allow(clippy::too_many_arguments)] +fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &str, name: &str, indent: f32, open_id: egui::Id, open: &mut bool) { + let sel = n.selectable + && app.session.source == LibrarySource::LibraryFolder + && app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); + // the label is said with the name, as it is shown beside it + let spoken = match n.label { + Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)), + None => name.to_string(), + }; + let mark = n.label.map(crate::theme::label_color); + let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + folder_drop_target(app, ui, &resp, n, open_id, open); + let mut toggled = false; + if !n.children.is_empty() { + let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path)); + toggled = tr.clicked(); + // the triangle sits on the row and takes its clicks: the menu opens from it too + row_menu(app, &tr, n); + } + let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) }; + let resp = resp.on_hover_text(tip); + if resp.clicked() && !toggled { + if n.selectable { + // a source like an album or a Local folder: it replaces what the grid showed + let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); + } else { + *open = !*open; + set_tree_open(ui, open_id, *open); + } + } + row_menu(app, &resp, n); +} + +/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it +/// can be reached. +const SPRING_OPEN_SECONDS: f64 = 0.7; + +/// A Folders row while photos are dragged from the grid: a release moves their files there on +/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where +/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row, +/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos, +/// not in the library) and a release says why; photos already there say so. +fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + let Some(ids) = app.ui.dragging_photos.clone() else { return }; + // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header + if !pointer_over_row(ui, resp.rect) { + return; + } + spring_open(ui, n, open_id, open); + let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone()); + let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect(); + let refused = if !n.selectable { + Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it")) + } else if photos.iter().any(|p| p.local) { + Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them")) + } else { + None + }; + let here = |p: &lightcraft_catalog::Photo| match &p.source { + lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)), + _ => false, + }; + let all_here = !photos.is_empty() && photos.iter().all(|p| here(p)); + let count = photos.len(); + let released = ui.input(|i| i.pointer.any_released()); + let hint = match (&refused, all_here) { + (Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(), + (None, true) => crate::i18n::tr_format!("Already in “{}”", name), + (None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count), + }; + crate::panels::grid::drag_hint(ui.ctx(), hint); + if let Some(why) = refused { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + if released { + app.toast(ui.ctx(), why); + app.ui.dragging_photos = None; + } + return; + } + if all_here { + if released { + app.ui.dragging_photos = None; + } + return; + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + if released { + app.ui.dragging_photos = None; + // a drop moves files on disk: asked first (as Lightroom Classic does), unless the person + // said not to; the menu's Move Selected Photos Here is asked for by name and goes at once + if app.ui.settings.ask_before_move { + let library = app.session.library_generation(); + app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library }); + } else { + move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name); + } + } +} + +/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders +/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after +/// leaving it (or after another drag). +fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + if *open || n.children.is_empty() { + return; + } + let key = egui::Id::unique("folder-spring-open"); + let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr()); + // held without a break: over this row last frame too + let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) { + Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since, + _ => now, + }; + ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame))); + if now - since >= SPRING_OPEN_SECONDS { + *open = true; + set_tree_open(ui, open_id, true); + ui.data_mut(|d| d.remove::<(String, f64, u64)>(key)); + } else { + // keep the clock running while the pointer rests + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`). +/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches +/// photos no longer on screen. How many moved, or why none did. +pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result { + let mut params = json!({"folder": path}); + if let Some(ids) = ids { + params["ids"] = json!(ids); + } + let r = app.run("photo.moveToFolder", params)?; + let visible: std::collections::HashSet = app.session.visible_cloned().into_iter().collect(); + let sel = &mut app.session.selection; + sel.ids.retain(|id| visible.contains(id)); + if sel.active.is_some_and(|a| !visible.contains(&a)) { + sel.active = sel.ids.first().copied(); + } + Ok(r["moved"].as_u64().unwrap_or(0)) +} + +/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them +/// back, or that they were there already. +pub(crate) fn moved_text(n: u64, name: &str) -> String { + if n == 0 { + crate::i18n::tr_format!("Already in “{}”", name) + } else { + crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n) + } +} + +/// [`move_photos`], saying how it went in a toast. +fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) { + match move_photos(app, ids, path) { + Ok(n) => app.toast(ctx, moved_text(n, name)), + Err(e) => app.toast(ctx, e), + } +} + +/// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the +/// startup disk. +fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { + if !n.volume { + folder_menu_for_library(app, resp, n); + } else if n.path != "/" { + crate::widgets::context_menu(resp, |ui| { + folder_label_menu(app, ui, n); + locate_missing_item(app, ui, n); + synchronize_item(app, ui, n, &n.name); + ui.separator(); + if ui + .button(crate::i18n::tr("Remove Disk from Library…")) + .on_hover_text(crate::i18n::tr("Moves every photo imported from this disk to Recently Deleted; no file is touched")) + .clicked() + { + app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: n.path.clone(), name: n.name.clone(), count: n.count, disk: true }); + ui.close(); + } + }); + } +} + +/// Locate Missing Files… of a Folders row: choose where this folder moved, then let the existing +/// background finder relink only this folder's photos (including the folders inside it). +fn locate_missing_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { + let r = ui.button(crate::i18n::tr("Locate Missing Files…")); + register(ui, "folderLocateMissing", r.rect); + if r.clicked() { + if let Err(e) = app.run("file.findMissing", json!({"within": n.path})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + +/// The node Synchronize Folder… of a Local folder `path` works on, with the name its dialog shows: +/// the Folders row for that very folder, else (a folder above rows) the folder itself, as a folder +/// holding other folders. `None` when no library photo lies in `path` or inside it. Reads the +/// cached Folders tree only; the disk is not touched. +fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, String)> { + use lightcraft_catalog::query::folder_within; + fn exact<'a>(tree: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> { + tree.iter().find_map(|n| if same_folder(&n.path, path) { Some(n) } else { exact(&n.children, path) }) + } + fn any_inside(tree: &[FolderNode], path: &str) -> bool { + tree.iter().any(|n| (n.count > 0 && folder_within(&n.path, path)) || any_inside(&n.children, path)) + } + if let Some(n) = exact(tree, path).filter(|n| n.count > 0) { + return Some((n.clone(), lightcraft_catalog::folders::folder_label(&n.path))); + } + if !any_inside(tree, path) { + return None; + } + let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + let node = FolderNode { + name: name.clone(), + path: path.to_string(), + count: 0, + own: 0, + volume: false, + selectable: false, + label: None, + children: Vec::new(), + }; + Some((node, name)) +} + +/// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which +/// scans the folder in the background. +fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) { + let r = ui + .button(crate::i18n::tr("Synchronize Folder…")) + .on_hover_text(crate::i18n::tr("Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps")); + crate::widgets::register(ui, "folderSynchronize", r.rect); + if r.clicked() { + // a disk, or a folder holding disks, was chosen as such + if let Err(e) = crate::sync::open(app, &n.path, name, n.volume || !n.selectable) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + +/// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`, +/// its colours `folderLabel:`). +fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { + let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| { + if let Some(l) = crate::panels::grid::label_items(app, ui, n.label, "folderLabel") { + if let Err(e) = app.run("folder.label", json!({"path": n.path, "label": crate::panels::grid::label_param(l)})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }); + register(ui, "folderLabelMenu", menu.response.rect); +} + +/// The context menu of a folder row: the folder's disk actions (the same as Local's, photos +/// follow) and taking its photos out of the library. +fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { + let path = n.path.as_str(); + let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + // what dialogs call it: the last two names, so same-named folders are told apart + let label = lightcraft_catalog::folders::folder_label(path); + crate::widgets::context_menu(resp, |ui| { + if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("Rename “{name}”", name = label), + hint: "Folder name (renamed on disk; its photos follow)".into(), + value: name.clone(), + command: "folder.rename".into(), + params: json!({"path": path}), + key: "name".into(), + }); + ui.close(); + } + if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { + let into = app.services.pick_folder.as_mut().and_then(|f| f()); + if let Some(into) = into { + match app.run("folder.move", json!({"path": path, "into": into})) { + Ok(r) => app.toast(ui.ctx(), format!("Moved; {} photo(s) relinked", r["relinked"])), + Err(e) => app.toast(ui.ctx(), e), + } + } + ui.close(); + } + if ui.button(crate::i18n::tr("New Folder on Disk…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("New Folder in “{name}”", name = label), + hint: "Folder name (created on disk)".into(), + value: String::new(), + command: "folder.create".into(), + params: json!({"parent": path}), + key: "name".into(), + }); + ui.close(); + } + let selected = app.session.selection.ids.len(); + if selected > 0 + && ui + .button(crate::i18n::tr("Move Selected Photos Here")) + .on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites")) + .clicked() + { + move_photos_here(app, ui.ctx(), None, path, &name); + ui.close(); + } + if app.services.reveal.is_some() + && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() + && let Some(f) = app.services.reveal.as_mut() + { + let _ = f(path); + ui.close(); + } + folder_label_menu(app, ui, n); + locate_missing_item(app, ui, n); + synchronize_item(app, ui, n, &label); + ui.separator(); + if ui + .button(crate::i18n::tr("Remove from Library…")) + .on_hover_text(crate::i18n::tr("Moves the photos imported from this folder to Recently Deleted; no file is touched")) + .clicked() + { + app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: path.to_string(), name: label.clone(), count: n.count, disk: false }); + ui.close(); + } + }); +} diff --git a/crates/ui-egui/src/panels/left/keywords.rs b/crates/ui-egui/src/panels/left/keywords.rs new file mode 100644 index 000000000..81997f2e2 --- /dev/null +++ b/crates/ui-egui/src/panels/left/keywords.rs @@ -0,0 +1,55 @@ +use super::dates::browse_all_photos; +use super::rows::{INDENT_STEP, out_of_sight, row, tree_open, tree_toggle}; +use super::*; + +/// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click +/// filters the grid by the keyword (children included), the triangle opens a level, and the +/// context menu renames, merges or deletes the keyword across the library. +pub(super) fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { + for n in nodes { + // leaves (most keywords) have no open state to look up + let open_id = (!n.children.is_empty()).then(|| egui::Id::unique(("kw-open", n.path.to_lowercase()))); + let mut open = open_id.is_some_and(|k| tree_open(ui, k, false)); + let id = format!("keyword:{}", n.path); + if !out_of_sight(app, ui, &id, &n.name, Some(n.count), indent, false) { + keyword_row(app, ui, n, &id, indent, open_id, &mut open); + } + if open { + keyword_rows(app, ui, &n.children, indent + INDENT_STEP); + } + } +} + +/// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it. +fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option, open: &mut bool) { + let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); + let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent); + if let Some(k) = open_id { + tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path)); + } + let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); + if resp.clicked() { + let v = if sel { serde_json::Value::Null } else { json!(n.path) }; + browse_all_photos(app, !sel); + let _ = app.run("library.filter", json!({"keyword": v})); + } + crate::widgets::context_menu(&resp, |ui| { + let has_sel = app.session.active().is_some(); + if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() { + let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); + } + if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]})); + } + ui.separator(); + if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() }); + } + if ui.button(crate::i18n::tr("Merge into…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() }); + } + if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { + let _ = app.run("keyword.delete", json!({"keyword": n.path})); + } + }); +} diff --git a/crates/ui-egui/src/panels/left/local.rs b/crates/ui-egui/src/panels/left/local.rs new file mode 100644 index 000000000..0fedcb2b5 --- /dev/null +++ b/crates/ui-egui/src/panels/left/local.rs @@ -0,0 +1,406 @@ +use super::folders::folder_tree; +use super::rows::row; +use super::*; + +/// Local's folders as they stand this frame, and the folder being browsed among them. +pub(super) struct LocalNow { + pub(super) places: LocalPlaces, + current: Option, + /// The places removed from Local, as (name, path), in the order they were hidden. + hidden: Vec<(String, String)>, +} + +/// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, +/// Downloads, the home folder, the folder being browsed, and the folders kept with Browse +/// Folder…. Worked out every frame the sidebar shows, drawn or not: the row listed for this +/// session only (`local_browse_root`) follows the browsing. `None` where there is no Local (web). +pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option { + if !SidebarSection::Local.available() { + return None; + } + let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default(); + let mut builtin: Vec<(String, String)> = Vec::new(); + if !home.is_empty() { + for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] { + // joined with the platform's separator, like the paths browsing gives back + let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }; + // (checked off the UI thread: a home folder can be on a network share) + if fs_cached(ui, "is-dir", &p, 5.0, |p| std::path::Path::new(p).is_dir()) == Some(true) { + builtin.push((crate::i18n::tr(name).to_string(), p)); + } + } + } + let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); + let current = browsing.as_ref().map(|b| b.path.clone()); + // a hidden built-in place keeps its name (Home, not the user's folder name) + let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone()); + let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect(); + let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); + if current.is_some() { + app.ui.local_browse_root = places.browse_root.clone(); + } + Some(LocalNow { places, current, hidden }) +} + +/// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places. +pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { + let (local, current) = (&now.places, now.current.as_deref()); + for (i, (name, path)) in local.places.iter().enumerate() { + let transient = local.browse_root.as_deref() == Some(path.as_str()); + let reveal = local.owner == Some(i); + folder_tree(app, ui, path, name, path, 0.0, current, reveal, transient); + } + if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked() + { + let picked = app.services.pick_folder.as_mut().and_then(|f| f()); + if let Some(path) = picked { + match app.run("library.browse", json!({"path": path})) { + // the picked folder stays in Local (and comes back if it was hidden) + Ok(r) => { + let dir = r["path"].as_str().unwrap_or(&path).to_string(); + let _ = app.run("local.addRoot", json!({"path": dir})); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + } + // the way back for hidden places: one comes back from the row itself; several are listed, so + // each comes back on its own, or all at once (issue #2114: the row used to bring every one back) + let hidden = now.hidden.len(); + if hidden > 0 { + let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden); + let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) + .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")); + if let [(_, only)] = now.hidden.as_slice() { + if resp.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": only})); + } + } else { + egui::Popup::menu(&resp).show(|ui| hidden_menu(app, ui, &now.hidden)); + } + } +} + +/// The list behind "Show N hidden locations": each place by name (its path on hover), then +/// Restore All. +fn hidden_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, hidden: &[(String, String)]) { + for (name, path) in hidden { + let r = ui.button(name).on_hover_text(path); + register(ui, format!("local:restoreHidden:{path}"), r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": path})); + ui.close(); + } + } + ui.separator(); + let r = ui.button(crate::i18n::tr("Restore All")); + register(ui, "local:restoreHidden:all", r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({})); + ui.close(); + } +} + +/// Whether two paths name the same folder, however they are spelled (separators, trailing +/// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`). +pub(crate) fn same_folder(a: &str, b: &str) -> bool { + a == b || lightcraft_catalog::query::folder_key(a) == lightcraft_catalog::query::folder_key(b) +} + +/// Local's top-level folders and how the folder being browsed sits among them. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct LocalPlaces { + /// (label, path) of each top-level folder, in order. + pub places: Vec<(String, String)>, + /// The top-level folder the browsed folder lies in (the innermost one): its tree opens on + /// the way down to it. None when that way passes through a hidden folder. + pub owner: Option, + /// A folder listed only for this session because the browsed folder is in no saved + /// location (browsed from a breadcrumb, the CLI…); it stays while browsing below it. + pub browse_root: Option, +} + +fn folder_label(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) +} + +/// Local's top-level folders, in order: the built-in places, the folders kept with Browse +/// Folder… / Keep in Local (`saved`), then — only when the browsed folder lies in none of those — +/// a session root for it: the previous one (`browse_root`) while browsing stays below it, else +/// the browsed folder itself. A folder inside a listed one is shown inside that one's tree (the +/// root stays; its siblings stay reachable), never as a root of its own. Each folder is listed +/// once however its path is spelled (the first spelling wins), and hidden ones are left out — +/// they stay reachable through breadcrumbs and Browse Folder…. +pub(crate) fn local_places( + builtin: Vec<(String, String)>, + saved: &[String], + browsing: Option<&str>, + browse_root: Option<&str>, + hidden: &[String], +) -> LocalPlaces { + use lightcraft_catalog::query::{folder_key, folder_within}; + let is_hidden = |p: &str| hidden.iter().any(|h| same_folder(h, p)); + let mut places = builtin; + for path in saved { + if !places.iter().any(|(_, p)| same_folder(p, path)) { + places.push((folder_label(path), path.clone())); + } + } + places.retain(|(_, p)| !is_hidden(p)); + let mut out = LocalPlaces::default(); + if let Some(c) = browsing + && !places.iter().any(|(_, p)| folder_within(c, p)) + { + let root = browse_root.filter(|r| folder_within(c, r)).unwrap_or(c); + if !is_hidden(root) { + places.push((folder_label(root), root.to_string())); + out.browse_root = Some(root.to_string()); + } + } + if let Some(c) = browsing { + // A tree never opens on the way down through a hidden folder: hiding a kept folder + // beneath Home would otherwise reveal it again inside Home's (possibly huge) tree. + let through_hidden = |p: &str| hidden.iter().any(|h| folder_within(c, h) && folder_within(h, p)); + out.owner = places + .iter() + .enumerate() + .filter(|(_, (_, p))| folder_within(c, p) && !through_hidden(p)) + .max_by_key(|(_, (_, p))| folder_key(p).len()) + .map(|(i, _)| i); + } + out.places = places; + out +} + +/// The subfolders of `path` (not hidden ones), sorted; listed on a worker thread at most every +/// 2 s per folder (empty until the first listing). +pub(super) fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> { + fs_cached(ui, "subfolders", path, 2.0, list_subfolders).unwrap_or_default() +} + +fn list_subfolders(path: &str) -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = std::fs::read_dir(path) + .map(|rd| { + rd.flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + (!name.starts_with('.')).then(|| (name, e.path().to_string_lossy().to_string())) + }) + .collect() + }) + .unwrap_or_default(); + v.sort_by_key(|(n, _)| n.to_lowercase()); + v +} + +/// How many [`fs_cached`] answers for `ctx` are being worked out right now. Rows appear (and +/// the sidebar below them moves) when they land, so the headless driver counts them as pending +/// work and waits for them before acting on widget positions. +pub(crate) fn fs_cached_running(ctx: &egui::Context) -> usize { + fs_running_counter(ctx).load(std::sync::atomic::Ordering::Acquire) +} + +fn fs_running_counter(ctx: &egui::Context) -> std::sync::Arc { + ctx.data_mut(|d| d.get_temp_mut_or_default::>(egui::Id::unique("fs-cached-running")).clone()) +} + +/// A file-system answer for `path` (`f(path)`), kept per `kind` and path and refreshed on a worker +/// thread at most every `every` seconds: a folder on a sleeping NAS, a dropped share or a +/// spinning-up drive never blocks a frame. `None` until the first answer arrives. +pub(crate) fn fs_cached(ui: &egui::Ui, kind: &'static str, path: &str, every: f64, f: fn(&str) -> T) -> Option { + struct Entry { + value: Option, + at: Option, + running: bool, + } + type Cell = std::sync::Arc>>; + let id = egui::Id::unique(("fs-cached", kind, path.to_string())); + let now = ui.input(|i| i.time); + let cell: Cell = match ui.data(|d| d.get_temp::>(id)) { + Some(c) => c, + None => { + let c: Cell = std::sync::Arc::new(std::sync::Mutex::new(Entry { value: None, at: None, running: false })); + ui.data_mut(|d| d.insert_temp(id, c.clone())); + c + } + }; + let lock = |c: &Cell| c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).value.clone(); + let start = { + let mut e = cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let due = !e.running && e.at.is_none_or(|at| now - at >= every || now < at); + if due { + e.running = true; + e.at = Some(now); + } + due + }; + if start { + use std::sync::atomic::Ordering; + let running = fs_running_counter(ui.ctx()); + running.fetch_add(1, Ordering::AcqRel); + let (out, path, repaint, done) = (cell.clone(), path.to_string(), ui.ctx().clone(), running.clone()); + let work = move || { + let v = f(&path); + let mut e = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + e.value = Some(v); + e.running = false; + drop(e); + // after the answer is stored: a frame that sees the count drop also sees the answer + done.fetch_sub(1, Ordering::AcqRel); + repaint.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if std::thread::Builder::new().name("lc-fs-list".into()).spawn(work).is_err() { + cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner).running = false; + running.fetch_sub(1, Ordering::AcqRel); + } + #[cfg(target_arch = "wasm32")] + work(); + } + lock(&cell) +} + +#[cfg(test)] +mod tests { + use super::local_places; + use crate::widgets::elide_head; + + /// Width = characters, so a limit of 12 is "12 characters". + fn fit(text: &str, max: usize) -> String { + elide_head(text, max as f32, |s| s.chars().count() as f32) + } + + #[test] + fn a_label_that_fits_is_left_alone() { + assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures"); + } + + #[test] + fn a_long_path_loses_its_leading_folders_not_its_end() { + assert_eq!(fit("/Users/me/Pictures/Lightroom", 20), "…/Pictures/Lightroom"); + assert_eq!(fit("/Users/me/Pictures/Lightroom", 12), "…/Lightroom"); + } + + #[test] + fn a_single_name_too_long_is_cut_at_the_end() { + assert_eq!(fit("2024-summer-holiday", 8), "2024-su…"); + assert_eq!(fit("2024-summer-holiday", 1), "…"); + assert_eq!(fit("2024-summer-holiday", 0), "…"); + } + + #[test] + fn multibyte_names_are_cut_on_character_boundaries() { + assert_eq!(fit("/写真/夏休み旅行の記録", 6), "夏休み旅行…"); + } + + fn names(v: &[(String, String)]) -> Vec<&str> { + v.iter().map(|(n, _)| n.as_str()).collect() + } + + /// A built-in location browsed (or picked) under another spelling of its path is the same + /// row, not a second root — for every built-in, not just named ones. + #[test] + fn equivalent_paths_are_one_local_location() { + for (home, sep) in [("D:\\Example", '\\'), ("D:/Example", '/'), ("/home/example", '/')] { + let builtin: Vec<(String, String)> = + ["Pictures", "Desktop", "Downloads"].iter().map(|s| (s.to_string(), format!("{home}{sep}{s}"))).collect(); + for (_, p) in builtin.clone() { + // the browsed spelling: other separators, a trailing one, a different drive-letter case + let flipped = p.replace(['/', '\\'], if sep == '/' { "\\" } else { "/" }); + for browsing in [flipped.clone(), format!("{p}{sep}"), p.replacen("D:", "d:", 1)] { + let l = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), None, &[]); + assert_eq!(names(&l.places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}"); + assert_eq!(l.places[l.owner.unwrap()].1, p, "the built-in row is the one highlighted"); + } + // hiding under one spelling hides the other + let l = local_places(builtin.clone(), &[], None, None, std::slice::from_ref(&flipped)); + assert_eq!(l.places.len(), 2, "hidden {flipped}"); + } + } + // a different folder is still added + let l = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), None, &[]); + assert_eq!(names(&l.places), ["Pictures", "Pictures2"]); + } + + /// A folder inside a kept root is shown inside that root's tree: the root stays (its other + /// subfolders stay reachable) and no second root appears; other kept roots stay too. + #[test] + fn browsing_below_a_kept_root_keeps_the_root() { + let photos = "/data/Photos".to_string(); + let other = "/data/Scans".to_string(); + let saved = [photos.clone(), other.clone()]; + let builtin = || vec![("Home".to_string(), "/home/example".to_string())]; + for browsing in ["/data/Photos/2026/20260101", "/data/Photos/2026", "/data/Photos", "D:\\x"] { + let l = local_places(builtin(), &saved, Some(browsing), None, &[]); + let roots: Vec<&str> = l.places.iter().map(|(_, p)| p.as_str()).collect(); + if browsing.starts_with("/data") { + assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans"], "{browsing}"); + assert_eq!(l.owner, Some(1), "the Photos tree opens down to {browsing}"); + assert_eq!(l.browse_root, None); + } else { + assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans", "D:\\x"], "a folder outside them gets a row"); + } + } + // the innermost containing root is the one that opens + let l = local_places(builtin(), &["/home/example/Pictures".into()], Some("/home/example/Pictures/Trip"), None, &[]); + assert_eq!(l.owner, Some(1)); + } + + /// A browsed folder outside every kept root gets a session row, which stays while browsing + /// below it and gives way when browsing moves elsewhere. + #[test] + fn session_root_stays_while_browsing_below_it() { + let l = local_places(Vec::new(), &[], Some("/t/base"), None, &[]); + assert_eq!(l.browse_root.as_deref(), Some("/t/base")); + let l = local_places(Vec::new(), &[], Some("/t/base/Trip/Day 1"), l.browse_root.as_deref(), &[]); + assert_eq!((l.places.len(), l.browse_root.as_deref(), l.owner), (1, Some("/t/base"), Some(0)), "{l:?}"); + let l = local_places(Vec::new(), &[], Some("/elsewhere"), l.browse_root.as_deref(), &[]); + assert_eq!(l.browse_root.as_deref(), Some("/elsewhere")); + // hidden: no row at all + let l = local_places(Vec::new(), &[], Some("/t/base"), None, &["/t/base/".into()]); + assert!(l.places.is_empty() && l.browse_root.is_none()); + } + + /// A hidden folder inside a listed one (a kept folder beneath Home) is not revealed in that + /// one's tree while it is browsed, nor are the folders below it; hiding Home itself still + /// lets Pictures open down to a folder browsed inside it. + #[test] + fn hidden_folder_is_not_revealed_in_an_outer_tree() { + let builtin = || vec![("Pictures".to_string(), "/home/example/Pictures".to_string()), ("Home".to_string(), "/home/example".to_string())]; + let kept = ["/home/example/AppData/Temp/lc".to_string()]; + let l = local_places(builtin(), &kept, Some("/home/example/AppData/Temp/lc"), None, &[]); + assert_eq!(l.owner, Some(2), "shown as its own kept row"); + for browsing in ["/home/example/AppData/Temp/lc", "/home/example/AppData/Temp/lc/Day 1"] { + let l = local_places(builtin(), &kept, Some(browsing), None, &["/home/example/AppData/Temp/lc/".into()]); + assert_eq!(names(&l.places), ["Pictures", "Home"], "{browsing}"); + assert_eq!((l.owner, l.browse_root.as_deref()), (None, None), "Home does not open down to {browsing}"); + } + let l = local_places(builtin(), &[], Some("/home/example/Pictures/Trip"), None, &["/home/example".into()]); + assert_eq!((names(&l.places), l.owner), (vec!["Pictures"], Some(0))); + } + + #[test] + fn a_local_folder_lines_up_with_the_rows_above_and_its_triangle_stays_left_of_the_icon() { + use crate::Services; + use crate::headless::HeadlessView; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut view = HeadlessView::new(); + let path = "/lightcraft-test/Pictures"; + let mut widgets = vec![]; + for frame in 0..3 { + let raw = HeadlessView::raw_input(egui::vec2(300.0, 200.0), 1.0, frame as f64 / 60.0, vec![]); + view.run(raw, |ui| { + super::row(&mut app, ui, "all", super::Icon::Photos, "All Photos", None, false, 0.0); + super::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false); + }); + widgets = crate::widgets::take_registry(&view.ctx); + } + let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1; + let (all, local) = (rect("label:all"), rect(&format!("label:local:{path}"))); + assert_eq!(local.left(), all.left(), "a top-level Local folder's name starts where All Photos' does"); + let triangle = rect(&format!("folderToggle:{path}")); + // the icon is 16 points wide and ends 8 points before the name + assert!(triangle.right() <= local.left() - 24.0, "triangle {triangle:?} overlaps the icon before {local:?}"); + } +} diff --git a/crates/ui-egui/src/panels/left/rows.rs b/crates/ui-egui/src/panels/left/rows.rs new file mode 100644 index 000000000..541d98b06 --- /dev/null +++ b/crates/ui-egui/src/panels/left/rows.rs @@ -0,0 +1,255 @@ +use super::albums::MARK_W; +use super::*; + +/// How wide the sidebar's content needs to be, from the widest row of the last frame: rows are +/// drawn at this width (or the panel's, if wider), and the sidebar scrolls sideways when it +/// exceeds the panel. One frame behind, which needs no second layout pass. +pub(crate) fn content_width(ctx: &egui::Context) -> f32 { + ctx.data(|d| d.get_temp::(egui::Id::unique("left-content-width"))).unwrap_or(0.0) +} + +/// The most a row asks for its name: longer ones are cut when there is no more room. +const MAX_NAME_NEED: f32 = 140.0; + +/// Height of a sidebar row. +pub(super) const ROW_HEIGHT: f32 = 29.0; + +/// Temp-data key of the row (its widget id after `source:`, lower-case) the sidebar scrolls to +/// this frame: set before the sections are drawn, cleared after. +pub(super) const SCROLL_TO_ROW: &str = "left-scroll-to-row"; + +/// Temp-data key of the Folders choice whose rows above were last opened ([`reveal_chosen`]): +/// removed, they open again. +pub(super) const LIBFOLDER_REVEALED: &str = "libfolder-revealed"; + +/// A row says how wide it needs to be. +pub(super) fn note_width(ui: &egui::Ui, w: f32) { + ui.data_mut(|d| { + let m = d.get_temp_mut_or_insert_with::(egui::Id::unique("left-content-width-next"), || 0.0); + *m = m.max(w); + }); +} + +/// One level of a sidebar tree when the panel has room: callers pass `indent` in these steps. +pub(crate) const INDENT_STEP: f32 = 16.0; + +/// The narrowest a level is drawn: the chevron still steps visibly right of its parent's. +pub(crate) const MIN_INDENT_STEP: f32 = 8.0; + +/// What a row keeps for its name when the step is chosen: the deepest row's name is not cut to +/// less than this while a narrower step would still keep it. +const MIN_NAME_ROOM: f32 = 80.0; + +/// Room kept for a count at the end of the deepest row (three digits and their gap). +const COUNT_ROOM: f32 = 40.0; + +/// The step a level is drawn with this frame: [`INDENT_STEP`] while the deepest open row fits the +/// panel, narrower when it would not, never below [`MIN_INDENT_STEP`]. From the last frame. +pub(crate) fn indent_step(ctx: &egui::Context) -> f32 { + ctx.data(|d| d.get_temp::(egui::Id::unique("left-indent-step"))).unwrap_or(INDENT_STEP) +} + +/// How far right a row at `indent` (in [`INDENT_STEP`]s) is drawn this frame. +pub(super) fn indent_x(ctx: &egui::Context, indent: f32) -> f32 { + indent / INDENT_STEP * indent_step(ctx) +} + +/// [`indent_x`] for a row being laid out, which also notes how deep the tree goes, for next +/// frame's step. +fn drawn_indent(ui: &egui::Ui, indent: f32) -> f32 { + ui.data_mut(|d| { + let m = d.get_temp_mut_or_insert_with::(egui::Id::unique("left-deepest-indent-next"), || 0.0); + *m = m.max(indent); + }); + indent_x(ui.ctx(), indent) +} + +/// The widest step (16, 14, 12, 10 or 8 pt) at which a row `deepest` deep (in [`INDENT_STEP`]s) +/// keeps [`MIN_NAME_ROOM`] for its name and its count in a panel `panel_w` wide. In steps, so a +/// row does not slide sideways at every point the panel is dragged; when even the narrowest step +/// does not fit, the sidebar scrolls sideways as before. +pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 { + let levels = deepest / INDENT_STEP; + if levels < 1.0 { + return INDENT_STEP; + } + let room = panel_w - 42.0 - MIN_NAME_ROOM - 18.0 - COUNT_ROOM; + [16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP) +} + +pub(super) fn row( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + id: &str, + icon: Icon, + label: &str, + count: Option, + selected: bool, + indent: f32, +) -> egui::Response { + row_named(app, ui, id, icon, label, None, count, selected, indent) +} + +/// [`row`] whose spoken name is `spoken` when the painted `label` is a shortened form of it. +fn row_named( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + id: &str, + icon: Icon, + label: &str, + spoken: Option<&str>, + count: Option, + selected: bool, + indent: f32, +) -> egui::Response { + row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click(), None) +} + +/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`) and a colour +/// label's dot (`mark`) before the count. +#[allow(clippy::too_many_arguments)] +pub(super) fn row_sensed( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + id: &str, + icon: Icon, + label: &str, + spoken: Option<&str>, + count: Option, + selected: bool, + indent: f32, + sense: Sense, + mark: Option, +) -> egui::Response { + let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; + let t = Tokens::get(ui.ctx()); + let indent = drawn_indent(ui, indent); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense); + if scroll_asked(ui, id) { + ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none()); + } + register(ui, format!("source:{id}"), r); + let mark_w = if mark.is_some() { MARK_W } else { 0.0 }; + let said = spoken.unwrap_or(label); + let name = match count { + Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = said, n = n), + None => said.to_string(), + }; + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &name)); + // what is visible of the row: when the sidebar is scrolled sideways its bars and counts end at + // the panel's edge, not at the end of the (wider) content + let visible_right: f32 = ui.data(|d| d.get_temp(egui::Id::unique("left-visible-right"))).unwrap_or(f32::MAX); + let edge = r.right().min(visible_right); + let inner = Rect::from_min_max(r.min + vec2(8.0, 0.0), pos2((r.right() - 8.0).min(edge - 8.0).max(r.left() + 8.0), r.bottom())); + if selected { + ui.painter().rect_filled(inner, 4.0, t.canvas); + register(ui, format!("highlight:{id}"), inner); + } else if resp.hovered() { + ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6)); + } + paint( + ui.painter(), + Rect::from_min_size(pos2(r.left() + 18.0 + indent, r.center().y - 8.0), vec2(16.0, 16.0)), + icon, + if selected { t.text } else { t.icon }, + ); + let font = t.font(13.5); + let color = if selected { t.text } else { t.text_label }; + // a count of nothing is there, but fainter: the rows that hold photos stand out + let count_color = |n: usize| if n == 0 { t.text_dim.gamma_multiply(0.55) } else { t.text_dim }; + let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), count_color(n))); + let label_left = r.left() + 42.0 + indent; + let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x); + // a label's dot sits just before the count + if let Some(c) = mark { + let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0)); + ui.painter().circle_filled(dot.center(), 4.0, c); + register(ui, format!("labelMark:{id}"), dot); + } + // the name gives way to the count: cut with an ellipsis, in full on hover + let room = count_left - mark_w - 8.0 - label_left; + let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x; + let full_w = measure(label); + let shown = if full_w <= room { label.to_string() } else { crate::widgets::elide_head(label, room.max(0.0), measure) }; + let label_rect = ui.painter().text(pos2(label_left, r.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); + register(ui, format!("label:{id}"), label_rect); + let resp = if shown != label { resp.on_hover_text(label) } else { resp }; + note_width(ui, row_need(indent, full_w, mark_w, count_galley.as_ref().map(|g| g.size().x))); + if let Some(galley) = count_galley { + let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size()); + register(ui, format!("count:{id}"), rect); + ui.painter().galley(rect.min, galley, t.text_dim); + } + resp +} + +/// Whether the row `id` about to be drawn is out of sight: then it only takes its room, is listed +/// for the control channel and asks for its width (so the content does not widen and narrow as +/// rows scroll by), and the caller skips it. A library can have thousands of keywords or folders, +/// and drawing them all every frame slows the whole app down (issue #1011). A row the sidebar is +/// asked to scroll to is drawn. `label`, `count` and `mark` as for [`row_sensed`]. +pub(super) fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, count: Option, indent: f32, mark: bool) -> bool { + let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_HEIGHT)); + if ui.is_rect_visible(r) || scroll_asked(ui, id) { + return false; + } + ui.allocate_space(r.size()); + register(ui, format!("source:{id}"), r); + let indent = drawn_indent(ui, indent); + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x; + let count_w = count.filter(|_| app.ui.show_counts).map(|n| painter.layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim).size().x); + note_width(ui, row_need(indent, name_w, if mark { MARK_W } else { 0.0 }, count_w)); + true +} + +/// Whether the sidebar is asked to scroll to the row `id` this frame ([`SCROLL_TO_ROW`]). +fn scroll_asked(ui: &egui::Ui, id: &str) -> bool { + ui.data(|d| d.get_temp::(egui::Id::unique(SCROLL_TO_ROW))).is_some_and(|want| want == id.to_lowercase()) +} + +/// How wide a row needs to be, from its indent, name, colour dot and count widths. It asks for +/// room for its name up to a share of a panel, so one very long name does not make everything +/// scroll: depth does that. +fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option) -> f32 { + 42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0) +} + +/// The disclosure chevron at the left of a tree row (`indent` is the row's): right-pointing +/// when closed, down when open, registered as widget `widget` for the control channel. Returns +/// its response: the caller flips the open state on `clicked()`. +fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let c = pos2(row.rect.left() + 10.0 + indent_x(ui.ctx(), indent), row.rect.center().y); + let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); + let tr = ui.interact(tri, id, Sense::click()); + register(ui, widget, tri); + let col = if tr.hovered() { t.text } else { t.text_dim }; + paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); + tr +} + +/// Whether the tree row `key` is open: what the user last chose this session, else `default`. +pub(super) fn tree_open(ui: &egui::Ui, key: egui::Id, default: bool) -> bool { + ui.data(|d| d.get_temp(key)).unwrap_or(default) +} + +/// Open or close the tree row `key` from now on. +pub(super) fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) { + ui.data_mut(|d| d.insert_temp(key, open)); +} + +/// The chevron of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, +/// Keywords): a click flips `open` and remembers it under `key`. Returns the chevron's response +/// (it sits on the row and takes its clicks, so a row's context menu is attached to it too). +pub(super) fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response { + let tri = disclosure_chevron(ui, row, indent, *open, key.with("triangle"), widget); + if tri.clicked() { + *open = !*open; + set_tree_open(ui, key, *open); + } + let label = if *open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") }; + tri.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + tri +} diff --git a/crates/ui-egui/src/panels/left/sections.rs b/crates/ui-egui/src/panels/left/sections.rs new file mode 100644 index 000000000..42cc631b5 --- /dev/null +++ b/crates/ui-egui/src/panels/left/sections.rs @@ -0,0 +1,419 @@ +use super::albums::top_level_drop_target; +use super::rows::{ROW_HEIGHT, note_width}; +use super::*; + +/// Space above a section's header. +pub(super) const SECTION_GAP: f32 = 10.0; + +/// Height of a section's header. +const SECTION_HEADER: f32 = 34.0; + +/// One sidebar section: the gap above it, its header, and (while it is open) the rows `body` +/// draws. Every section goes through here, so they all fold, space and announce themselves alike. +/// `rows`: how many rows the section lists at its top level (a folded header says so). +pub(super) fn section( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + section: SidebarSection, + rows: usize, + body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui), +) -> PlacedSection { + ui.add_space(SECTION_GAP); + let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover()); + // a hairline in the gap above tells the sections apart + let t = Tokens::get(ui.ctx()); + let edge = visible_right(ui).min(header.right()); + let y = header.top() - SECTION_GAP / 2.0; + ui.painter().hline(header.left() + 18.0..=(edge - 18.0).max(header.left() + 18.0), y, egui::Stroke::new(1.0, t.divider)); + let show = egui::Id::unique("left-show-section"); + if let Some((asked, frame)) = ui.data(|d| d.get_temp::<(SidebarSection, u64)>(show)) + && asked == section + { + ui.data_mut(|d| d.remove::<(SidebarSection, u64)>(show)); + // asked for by the frame before (a request the section was not drawn for is forgotten), + // and up or down only: the sidebar stays where it was scrolled sideways + if ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 2 { + let view_top: f32 = ui.data(|d| d.get_temp(egui::Id::unique("left-view-top"))).unwrap_or(header.top()); + let delta = vec2(0.0, view_top - header.top()); + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + } + let open = section_header(app, ui, section, header, rows, false).open; + if open { + let rows_from = ui.cursor().top(); + body(app, ui); + if ui.cursor().top() <= rows_from { + empty_hint(ui, section); + } + } + PlacedSection { section, top: header.top(), bottom: ui.cursor().top(), open, rows } +} + +/// Where what the panel shows of the sidebar's content ends (screen x): the content is wider +/// than the panel while the sidebar scrolls sideways. +fn visible_right(ui: &egui::Ui) -> f32 { + ui.data(|d| d.get_temp(egui::Id::unique("left-visible-right"))).unwrap_or(f32::MAX) +} + +/// The header of the open section whose own header has scrolled out of sight stays at the top of +/// the sidebar (widget `sidebarSectionPinned:`), so it is always clear which section the rows +/// belong to, and the section can be folded, moved or hidden from anywhere in a long list. The +/// next section's header pushes it out as it arrives. `view_top`: where the visible part starts. +pub(super) fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection], view_top: f32) { + let pinned = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top); + let rect = pinned.map(|p| { + let top = view_top.min(p.bottom - SECTION_HEADER); + Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER) + }); + // rows ask where it is (next frame: they are laid out before it) + ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-pinned-rect"), rect)); + let (Some(p), Some(r)) = (pinned, rect) else { return }; + let t = Tokens::get(ui.ctx()); + let view = ui.clip_rect(); + // over the rows scrolling beneath it + ui.painter().rect_filled(Rect::from_x_y_ranges(view.x_range(), r.y_range()), 0.0, t.chrome); + ui.painter().hline(view.x_range(), r.bottom(), egui::Stroke::new(1.0, t.divider)); + if section_header(app, ui, p.section, r, p.rows, true).toggled { + // folded from here: its own header comes into view, not whatever was below the section + // (next frame, when the section is laid out folded: see `section`) + let frame = ui.ctx().cumulative_frame_nr(); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-show-section"), (p.section, frame))); + ui.ctx().request_repaint(); + } +} + +/// What an open section with nothing to list says in place of its rows (widget +/// `sidebarEmpty:`), so the section stays where it is instead of disappearing. +fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::hover()); + register(ui, format!("sidebarEmpty:{}", section.id()), r); + let text = crate::i18n::tr(section.empty_hint()); + let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim); + note_width(ui, 42.0 + galley.size().x + 18.0); + ui.painter().galley(pos2(r.left() + 42.0, r.center().y - galley.size().y / 2.0), galley, t.text_dim); +} + +/// Where a section was laid out this frame (screen y): from its header's top to the end of its rows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PlacedSection { + pub section: SidebarSection, + pub top: f32, + pub bottom: f32, + /// Not folded: its rows (or its note that there are none) are drawn. + pub open: bool, + /// How many rows it lists at its top level. + pub rows: usize, +} + +/// What a section's header says happened to it this frame. +struct HeaderOutcome { + open: bool, + /// A click folded or unfolded the section. + toggled: bool, +} + +/// Where a section dragged to `y` would go among the sections drawn: above the first one whose +/// middle is below `y` (`Some`), or after them all (`None`). +pub(crate) fn section_drop_before(placed: &[PlacedSection], y: f32) -> Option { + placed.iter().find(|p| y < (p.top + p.bottom) / 2.0).map(|p| p.section) +} + +/// While a section is dragged by its header: a line shows where it would go, releasing over the +/// sidebar moves it there (`view.sidebarMoveSection`), releasing elsewhere or Esc leaves it. +pub(super) fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection]) { + let Some(dragged) = app.ui.dragging_section else { return }; + let (released, pos, esc) = ui.input(|i| (i.pointer.primary_released(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); + if esc { + // the drag's Esc, not also a dialog's or a view's + crate::widgets::take_escape(ui.ctx()); + app.ui.dragging_section = None; + return; + } + let view = ui.clip_rect(); + let Some(pos) = pos.filter(|p| view.contains(*p)) else { + if released { + app.ui.dragging_section = None; + } + return; + }; + let before = section_drop_before(placed, pos.y); + if released { + app.ui.dragging_section = None; + let before = before.map_or(serde_json::Value::Null, |b| json!(b.id())); + if let Err(e) = app.run("view.sidebarMoveSection", json!({"section": dragged.id(), "before": before})) { + app.toast(ui.ctx(), e); + } + return; + } + // the line: in the gap above the section it would go before, or below the last one + let y = match before { + Some(b) => placed.iter().find(|p| p.section == b).map(|p| p.top - SECTION_GAP / 2.0), + None => placed.last().map(|p| p.bottom + SECTION_GAP / 2.0), + }; + if let Some(y) = y { + let t = Tokens::get(ui.ctx()); + let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right(ui).min(view.right()) - 8.0, y + 1.0)); + ui.painter().rect_filled(line, 1.0, t.accent); + register(ui, "sidebarSectionDrop", line); + } +} + +/// The dragged section's name follows the pointer; a drag that ended anywhere (also outside the +/// sidebar, or with the sidebar closed) is over. +pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(section) = app.ui.dragging_section else { return }; + let (down, pos) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos())); + if !down || !app.ui.left_panel { + app.ui.dragging_section = None; + return; + } + let Some(pos) = pos else { return }; + let t = Tokens::get(ctx); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + egui::Area::new(egui::Id::unique("drag-section")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show( + ctx, + |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr(section.title())).color(egui::Color32::WHITE).font(t.semibold(12.0))); + }); + }, + ); +} + +/// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their +/// chevron), then the bold title; a click folds or unfolds the section (kept in the UI state, so +/// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at +/// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo +/// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). +fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect, rows: usize, pinned: bool) -> HeaderOutcome { + let t = Tokens::get(ui.ctx()); + let id = section.id(); + let title = crate::i18n::tr(section.title()); + let (interact, widget) = if pinned { ("sidebar-section-pinned", "sidebarSectionPinned") } else { ("sidebar-section", "sidebarSection") }; + let resp = ui.interact(r, ui.scope_id().with((interact, id)), Sense::click_and_drag()); + register(ui, format!("{widget}:{id}"), r); + let toggled = resp.clicked(); + if toggled { + app.ui.sidebar.toggle_collapsed(section); + } + // dragged by its header, a section moves among the others (see `section_drag`); a press on + // the header's button is the button's, also when it turns into a drag + let on_button = header_button(section).is_some() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| header_button_rect(ui, r).contains(p)); + if resp.drag_started_by(egui::PointerButton::Primary) && !on_button { + // picked up by the pinned header, the drag starts inside the top edge (see `drag_auto_scroll`) + ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-section-drag-from-pinned"), pinned)); + app.ui.dragging_section = Some(section); + } + crate::widgets::context_menu(&resp, |ui| sections_menu(app, ui, Some(section))); + let open = !app.ui.sidebar.is_collapsed(section); + crate::widgets::announce_disclosure(&resp, true, open, title); + // what is visible of the header: its bar and count end at the panel's edge, like a row's + let edge = r.right().min(visible_right(ui)); + let hovered = resp.hovered() && app.ui.dragging_section.is_none(); + if hovered { + let bar = Rect::from_min_max(r.min + vec2(8.0, 2.0), pos2((edge - 8.0).max(r.left() + 8.0), r.bottom() - 2.0)); + ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6)); + } + let c = pos2(r.left() + 10.0, r.center().y); + register(ui, format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(16.0, 16.0))); + let col = if hovered { t.text } else { t.text_dim }; + paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); + ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); + let button = header_button(section); + // (an album being dragged puts "Top Level" in the Albums header) + let room = !(section == SidebarSection::Albums && app.ui.dragging_album.is_some()); + if !open && rows > 0 && app.ui.show_counts && room { + // folded: how much is in there (left of the header's own button) + let right = edge - 18.0 - if button.is_some() { HEADER_BUTTON + 4.0 } else { 0.0 }; + let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim); + register(ui, format!("{widget}Count:{id}"), count); + } + if section == SidebarSection::Albums { + top_level_drop_target(app, ui, r); + } + if let Some(button) = button { + show_header_button(app, ui, r, &button); + } + HeaderOutcome { open, toggled } +} + +/// Side of a header's button. +const HEADER_BUTTON: f32 = 26.0; + +/// A button at the end of a section's header (see [`header_button`]). +struct HeaderButton { + /// Its widget is `icon:`. + id: &'static str, + icon: Icon, + /// What it does, in English: its tooltip and spoken name. + tooltip: &'static str, + action: HeaderAction, +} + +/// What a header's button does when clicked. +enum HeaderAction { + /// Does it at once (usually: opens a dialog). + Run(fn(&mut LightcraftApp, &egui::Context)), + /// Opens a menu with these items. + Menu(fn(&mut LightcraftApp, &mut egui::Ui)), +} + +/// The button a section has at the end of its header, if it has one. This is the one place that +/// says so: the header (its own and the pinned copy) draws the button at the visible edge, keeps +/// its clicks from folding the section and leaves it room beside a folded section's count. +fn header_button(section: SidebarSection) -> Option { + match section { + SidebarSection::Albums => { + Some(HeaderButton { id: "albumNew", icon: Icon::Plus, tooltip: "Create Album", action: HeaderAction::Menu(new_album_menu) }) + } + SidebarSection::Keywords => { + Some(HeaderButton { id: "keywordNew", icon: Icon::Plus, tooltip: "Create Keyword Tag", action: HeaderAction::Run(new_keyword) }) + } + SidebarSection::Local | SidebarSection::ByDate | SidebarSection::Folders => None, + } +} + +/// The end of `header` kept for its button: at the visible edge, also when the sidebar is +/// scrolled sideways. +fn header_button_rect(ui: &egui::Ui, header: Rect) -> Rect { + let right = visible_right(ui).min(header.right()); + Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom())) +} + +/// Draw `button` at the end of `header` and act on it. +fn show_header_button(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect, button: &HeaderButton) { + let mut hdr = + ui.new_child(egui::UiBuilder::new().max_rect(header_button_rect(ui, header)).layout(egui::Layout::right_to_left(egui::Align::Center))); + let resp = icon_button(&mut hdr, button.id, button.icon, vec2(HEADER_BUTTON, HEADER_BUTTON), false, true, button.tooltip); + match button.action { + HeaderAction::Run(run) => { + if resp.clicked() { + run(app, ui.ctx()); + } + } + HeaderAction::Menu(items) => { + egui::Popup::menu(&resp).show(|ui| items(app, ui)); + } + } +} + +/// Keywords' +: Create Keyword Tag, for a keyword at the usual place for new ones (not inside +/// whatever is picked in the Keyword List, which may not even be on screen). +/// Made from here, the keyword is shown here: Keywords opens down to it (`reveal_keyword`). +fn new_keyword(app: &mut LightcraftApp, _ctx: &egui::Context) { + let mut dialog = crate::panels::keyword_list::create_dialog_inside(app, None); + if let crate::state::Dialog::KeywordTag { reveal, .. } = &mut dialog { + *reveal = true; + } + app.ui.dialog = Some(dialog); +} + +/// The menu of a section's header (`on`) and of the My Photos title (`None`): hide this section, +/// show or hide any section, and go back to the usual arrangement. Its items are widgets +/// `sidebarMenu:hide`, `sidebarMenu:section:` and `sidebarMenu:reset`. +pub(super) fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option) { + let mut run = None; + if let Some(section) = on { + let hide = ui.button(crate::i18n::tr("Hide This Section")); + register(ui, "sidebarMenu:hide", hide.rect); + if hide.clicked() { + run = Some(("view.sidebarSection", json!({"section": section.id(), "show": false}))); + } + for (up, label, widget) in [(true, "Move Up", "sidebarMenu:moveUp"), (false, "Move Down", "sidebarMenu:moveDown")] { + let item = ui.add_enabled(app.ui.sidebar.can_move(section, up), egui::Button::new(crate::i18n::tr(label))); + register(ui, widget, item.rect); + if item.clicked() { + run = Some(("view.sidebarMoveSection", json!({"section": section.id(), "direction": if up { "up" } else { "down" }}))); + } + } + ui.separator(); + } + for s in app.ui.sidebar.sections().iter().filter(|s| s.id.available()) { + let shown = ui.selectable_label(!s.hidden, crate::i18n::tr(s.id.title())); + register(ui, format!("sidebarMenu:section:{}", s.id.id()), shown.rect); + if shown.clicked() { + run = Some(("view.sidebarSection", json!({"section": s.id.id(), "show": s.hidden}))); + } + } + ui.separator(); + let reset = ui.add_enabled(!app.ui.sidebar.is_default(), egui::Button::new(crate::i18n::tr("Reset Sidebar Sections"))); + register(ui, "sidebarMenu:reset", reset.rect); + if reset.clicked() { + run = Some(("view.sidebarReset", json!({}))); + } + if let Some((command, params)) = run { + if let Err(e) = app.run(command, params) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + +/// The menu of Albums' +: Create Album, Smart Album, Folder… +fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { + if ui.button(crate::i18n::tr("Create Album…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }); + } + if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::SmartRules { + id: None, + name: String::new(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + parent: None, + }); + } + if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None }); + } + if ui.button(crate::i18n::tr("Create Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None, include_selected: false }); + } + // only once the albums were put in an order by hand + if app.session.catalog.album_children_are_ordered(None) { + ui.separator(); + if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() { + let _ = app.run("album.sort", json!({})); + } + } +} + +/// How many library photos have no file (Local browse records are not checked; see +/// `cmd::missing::checked_path`). Checking stats every file, which on a network share takes +/// seconds, so it runs on a worker thread: the count shown is the last finished one, refreshed +/// at most every 5 s, and at once (after the running check) when the catalog changed. +pub(super) fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; + #[derive(Clone, Default)] + struct Job { + n: std::sync::Arc, + running: std::sync::Arc, + at: f64, + rev: u64, + } + let id = egui::Id::unique("missing-count"); + let now = ui.input(|i| i.time); + let rev = app.session.catalog.revision; + let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() }); + let fresh = now - job.at < 5.0 && job.rev == rev; + if !fresh && !job.running.load(Relaxed) { + // the same scope as the Missing Photos view: library photos only, never Local browse records + let paths = lightcraft_engine::cmd::missing::candidates(&app.session.catalog); + job.running.store(true, Relaxed); + job.at = now; + job.rev = rev; + let (n, running, ctx) = (job.n.clone(), job.running.clone(), ui.ctx().clone()); + let work = move || { + let missing = if cfg!(target_arch = "wasm32") { 0 } else { paths.iter().filter(|p| !std::path::Path::new(p).exists()).count() }; + n.store(missing, Relaxed); + running.store(false, Relaxed); + ctx.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(work); + #[cfg(target_arch = "wasm32")] + work(); + ui.data_mut(|d| d.insert_temp(id, job.clone())); + } + job.n.load(Relaxed) +} From 8c194d155e5a14d5b6951718769d93da0c6e4ff6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:11:14 +0000 Subject: [PATCH 122/191] refactor(lightcraft-ui-egui): split panels/edit.rs into panels/edit/ --- crates/ui-egui/src/panels/edit.rs | 1753 +------------------ crates/ui-egui/src/panels/edit/curve.rs | 534 ++++++ crates/ui-egui/src/panels/edit/grading.rs | 418 +++++ crates/ui-egui/src/panels/edit/histogram.rs | 353 ++++ crates/ui-egui/src/panels/edit/mixer.rs | 146 ++ crates/ui-egui/src/panels/edit/sliders.rs | 303 ++++ 6 files changed, 1769 insertions(+), 1738 deletions(-) create mode 100644 crates/ui-egui/src/panels/edit/curve.rs create mode 100644 crates/ui-egui/src/panels/edit/grading.rs create mode 100644 crates/ui-egui/src/panels/edit/histogram.rs create mode 100644 crates/ui-egui/src/panels/edit/mixer.rs create mode 100644 crates/ui-egui/src/panels/edit/sliders.rs diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 10542e443..fb47fc148 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -17,63 +17,22 @@ use crate::state::RightPanel; use crate::theme::Tokens; use crate::widgets::{BAND_COLORS, SliderOut, divider, flyout_row, hex, register, section_header, slider, text_button}; -/// Commit a slider interaction: begin → live updates → end, so a drag is one undo step. -pub fn apply_slider_out( - app: &mut LightcraftApp, - spec: &ControlSpec, - out: SliderOut, - mut set: impl FnMut(&mut LightcraftApp, f64) -> Result, -) { - if out.drag_started && !out.reset { - let _ = app.run("develop.beginInteraction", json!({"label": spec.label})); - app.ui.dragging_control = Some(spec.id.to_string()); - } - if let Some(v) = out.value { - let _ = set(app, v); - } - if out.drag_stopped || out.reset { - let _ = app.run("develop.endInteraction", json!({})); - app.ui.dragging_control = None; - } -} +mod curve; +mod grading; +mod histogram; +mod mixer; +mod sliders; -pub(crate) fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, enabled: bool) { - let Some(spec) = controls::find(id) else { return }; - let v = controls::get(d, id).unwrap_or(spec.default); - let out = slider(ui, spec, v, enabled, None); - apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": id, "value": v}))); -} - -/// Relative temperature scale for rendered (non-raw) files: −100..100 ↔ Kelvin via mired shift. -const REL_TEMP: ControlSpec = ControlSpec { - id: "wb.tempRel", - label: "Temp", - section: Section::Color, - min: -100.0, - max: 100.0, - default: 0.0, - step: 1.0, - decimals: 0, - track: Track::Temp, -}; -const REL_TINT: ControlSpec = ControlSpec { - id: "wb.tintRel", - label: "Tint", - section: Section::Color, - min: -100.0, - max: 100.0, - default: 0.0, - step: 1.0, - decimals: 0, - track: Track::Tint, -}; - -fn k_to_rel(k: f64) -> f64 { - ((1e6 / 6500.0 - 1e6 / k) / 0.8).clamp(-100.0, 100.0) -} -fn rel_to_k(r: f64) -> f64 { - 1e6 / (1e6 / 6500.0 - r * 0.8) -} +use curve::curve_editor; +use grading::grading; +pub(crate) use grading::wheel_pointer_frame_end; +#[cfg(test)] +pub(crate) use grading::{WheelGrab, WheelLock, WheelMods, wheel_pick}; +use histogram::{histogram, soft_proofing}; +use mixer::{mixer, point_color}; +use sliders::{REL_TEMP, REL_TINT, hdr_row, k_to_rel, profile_menu, quick_develop, rel_to_k, reset_tip, section}; +pub use sliders::{apply_slider_out, sub_title}; +pub(crate) use sliders::{control, reload_preview_only}; pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let t = Tokens::get(ui.ctx()); @@ -429,1685 +388,3 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("develop.reset", json!({})); } } - -/// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and -/// Browse…. Like the menu bar's menus, the dropdown and each submenu stay inside the window and -/// scroll when their rows don't fit (issue #2059: a folder of many imported profiles ran past the -/// window's bottom edge). `below` is the dropdown's bottom edge, which the menu hangs from. -fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { - crate::menu_level::level(ui, 1, Some(below), |ui| profile_menu_rows(app, ui, d, below)); -} - -fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { - use lightcraft_engine::presets::{PROFILES, profile_groups}; - let t = Tokens::get(ui.ctx()); - ui.set_min_width(200.0); - let cur = d.profile.id.as_str(); - // (clicked, hovered) - let mut pick: (Option, Option) = (None, None); - let item = |ui: &mut egui::Ui, key: &str, id: &str, name: &str, pick: &mut (Option, Option)| { - let r = ui.selectable_label(id == cur, crate::i18n::profile_label(id, name)); - register(ui, format!("profileMenu:{key}:{id}"), r.rect); - if r.clicked() { - pick.0 = Some(id.to_string()); - } - if r.hovered() { - pick.1 = Some(id.to_string()); - } - }; - let heading = |ui: &mut egui::Ui, s: &str| { - ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim)); - }; - for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] { - let list: Vec<_> = ids.iter().filter_map(|id| app.session.profile_info(id).map(|(name, _)| (id.as_str(), name))).collect(); - if list.is_empty() { - continue; - } - heading(ui, title); - for (id, name) in list { - item(ui, key, id, name, &mut pick); - } - ui.separator(); - } - for g in profile_groups() { - let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { - crate::menu_level::level(ui, 2, Some(below), |ui| { - ui.set_min_width(170.0); - for p in PROFILES.iter().filter(|p| p.group == g) { - item(ui, "group", p.id, p.name, &mut pick); - } - }); - }); - register(ui, format!("profileMenu:groupMenu:{g}"), r.response.rect); - if r.inner.is_some() { - crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); - } - } - // imported LUT profile groups - let mut lut_groups: Vec = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); - lut_groups.sort_unstable(); - lut_groups.dedup(); - for g in &lut_groups { - // a folder's name as it is, and its own widget id even when it matches a built-in group - let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { - crate::menu_level::level(ui, 2, Some(below), |ui| { - ui.set_min_width(170.0); - for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { - item(ui, "group", &p.id, &p.name, &mut pick); - } - }); - }); - register(ui, format!("profileMenu:lutGroupMenu:{g}"), r.response.rect); - if r.inner.is_some() { - crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); - } - } - ui.separator(); - if let Some((name, _)) = app.session.profile_info(cur) { - let fav = app.session.profile_favorites.iter().any(|f| f == cur); - let label = if fav { - crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::profile_label(cur, name)) - } else { - crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::profile_label(cur, name)) - }; - let r = ui.button(label); - register(ui, "profileMenu:toggleFavorite", r.rect); - if r.clicked() { - let _ = app.run("profile.favorite", json!({"id": cur})); - } - } - let r = ui.button(crate::i18n::tr("Browse…")); - register(ui, "profileMenu:browse", r.rect); - if r.clicked() { - let _ = app.run("panel.profiles", json!({})); - } - // resting on a profile previews it in the loupe - if let Some(hovered_id) = &pick.1 - && hovered_id != cur - && let Some((name, _)) = app.session.profile_info(hovered_id) - { - let mut s = d.clone(); - s.profile.id = hovered_id.clone(); - s.profile.amount = 100.0; - app.hover_preview = - Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(hovered_id, name)), settings: s }); - } - if let Some(id) = pick.0 { - let _ = app.run("develop.profile", json!({"id": id})); - } -} - -pub fn sub_title(ui: &mut egui::Ui, title: &str) { - let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); - ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label); -} - -fn section( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - d: &DevelopSettings, - id: &str, - title: &str, - body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui, &DevelopSettings), -) { - let open = app.ui.section_open(id); - let (resp, toggled) = section_header(ui, id, title, open, Some(d.section_enabled(id))); - if let Some(on) = toggled { - let _ = app.run("develop.sectionEnabled", json!({"section": id, "enabled": on})); - } else if resp.clicked() { - // ⌥-click opens just this section, as in Lightroom Classic - let solo = ui.input(|i| i.modifiers.alt); - if let Err(e) = app.run(&format!("section.{id}"), json!({"solo": solo})) { - app.toast(ui.ctx(), e); - } - } - crate::widgets::context_menu(&resp, |ui| { - let mut solo = app.ui.single_panel; - let item = ui.checkbox(&mut solo, crate::i18n::tr("Solo Mode")); - register(ui, "sectionMenu:solo", item.rect); - if item.clicked() { - if let Err(e) = app.run("view.soloSections", json!({"on": solo})) { - app.toast(ui.ctx(), e); - } - ui.close(); - } - }); - if open { - body(app, ui, d); - } - divider(ui); -} - -// ------------------------------------------------------------------------------ histogram - -/// The current photo's existing preview histogram, shared by Histogram and Tone Curve. -fn preview_histogram(app: &LightcraftApp, id: PhotoId) -> Option { - app.renderer - .textures - .get(&Slot::Main) - .filter(|t| t.photo == id) - .and_then(|t| t.histogram.clone()) - .or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone())) -} - -/// An HDR edit's histogram stays an HDR histogram: some renders carry only their 8-bit image's -/// (a drag's drafts, a cached thumbnail or stand-in), and showing those would swap the panel to -/// the SDR layout and back while a slider moves. Every render with HDR bins replaces the one kept for the photo, so -/// the histogram still follows the edit live; an SDR edit shows what it gets. -fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist: Option) -> Option { - let key = egui::Id::unique(("hdrHistogram", id)); - match hist { - Some(h) if h.hdr.is_some() => { - ctx.data_mut(|d| d.insert_temp(key, h.clone())); - Some(h) - } - h if hdr_edit => ctx.data(|d| d.get_temp::(key)).or(h), - h => h, - } -} - -fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); - let hdr_edit = app.session.develop_of(id).is_some_and(|d| d.hdr.enabled); - // a drag of an HDR edit: its drafts have no HDR histogram, so a small render beside them - // gives one (at its own pace: the drafts don't wait for it) - let interacting = app.session.interaction.is_some(); - if interacting && hdr_edit { - let edge = lightcraft_pipeline::HDR_HISTOGRAM_DRAG_EDGE; - if let Some(job) = app.session.render_job(id, edge, edge, false, app.ui.right != RightPanel::Crop) - && app.renderer.live_hdr_histogram.as_ref().is_none_or(|l| l.1 != job.key) - { - app.renderer.request(Slot::HdrHistogram, job, 95); - } - } - let live = app.renderer.live_hdr_histogram.as_ref().filter(|l| interacting && l.0 == id).map(|l| l.2.clone()); - // else the loupe render of this photo, else its thumbnail (grid view before the loupe has - // rendered it) - let hist = live.or_else(|| preview_histogram(app, id)); - let hist = steady_hdr_histogram(ui.ctx(), id, hdr_edit, hist); - // an HDR edit's histogram (`pipeline::hdr_histogram`) gets a colour bar and an SDR / HDR label - // row under the plot - let hdr = hist.as_ref().and_then(|h| h.hdr); - let legend = if hdr.is_some() { 26.0 } else { 0.0 }; - let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click_and_drag()); - crate::widgets::announce(&resp, crate::i18n::tr("Histogram")); - register(ui, "histogram", r); - let p = ui.painter(); - p.rect_filled(r, 0.0, t.button); - let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0 - legend)); - // Lightroom Classic's tonal zones (Blacks / Shadows / Exposure / Highlights / Whites across the - // plot): a drag over one moves its control (the band and the readout show which and how far), a - // double-click puts it back to its default, and Shift drags a tenth as far - let zone = super::histogram::interact(app, ui, &resp, plot, id); - if let Some(a) = &zone { - super::histogram::paint_zone(p, plot, a, &t); - } - if let Some(b) = hdr { - // white for SDR, then one band per stop above SDR white: white too, or (Visualize HDR - // range on) in its colours - let x0 = plot.left() + plot.width() * b.from as f32 / 255.0; - let bar = |a: f32, z: f32, c: Color32| p.rect_filled(Rect::from_min_max(pos2(a, plot.bottom() + 2.0), pos2(z, plot.bottom() + 7.0)), 0.0, c); - let white = Color32::from_rgb(228, 228, 230); - bar(plot.left(), x0 - 1.0, white); - let stops = b.stops.round().max(1.0) as usize; - let bands = lightcraft_pipeline::visualize::HDR_BANDS; - let colours = app.ui.hdr_visualize; - for k in 0..stops { - let c = if colours { bands.get(k).or(bands.last()).copied().unwrap_or([255, 255, 255]) } else { [228, 228, 230] }; - let (a, z) = (x0 + (plot.right() - x0) * k as f32 / stops as f32, x0 + (plot.right() - x0) * (k + 1) as f32 / stops as f32); - bar(a + 1.0, z - 1.0, Color32::from_rgb(c[0], c[1], c[2])); - } - let y = plot.bottom() + 18.0; - p.text(pos2((plot.left() + x0) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("SDR"), t.font(12.5), t.text_label); - p.text(pos2((x0 + plot.right()) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("HDR"), t.font(12.5), t.text_label); - let info = Rect::from_center_size(pos2(plot.right() - 16.0, y), vec2(14.0, 14.0)); - paint(p, info, Icon::Info, t.text_label); - ui.interact(info, egui::Id::unique("hdrHistogramInfo"), Sense::hover()).on_hover_text(crate::i18n::tr( - "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.", - )); - } - let clipping = hist.as_ref().map(|h| h.clipping()); - if let Some(h) = hist { - let smooth = |v: &[u32]| -> Vec { - (0..256) - .map(|i| { - let (a, b) = (i.max(2) - 2, (i + 3).min(256)); - v[a..b].iter().map(|x| *x as f32).sum::() / (b - a) as f32 - }) - .collect() - }; - // HDR: SDR on the left half, a solid line at SDR white and dashed ones between the stops - if let Some(b) = h.hdr { - let x0 = plot.left() + plot.width() * b.from as f32 / 255.0; - let stops = b.stops.round().max(1.0) as usize; - for k in 1..stops { - let x = x0 + (plot.right() - x0) * k as f32 / stops as f32; - let mut y = plot.top(); - while y < plot.bottom() { - p.line_segment([pos2(x, y), pos2(x, (y + 5.0).min(plot.bottom()))], Stroke::new(1.0, Color32::from_gray(150))); - y += 9.0; - } - } - p.line_segment([pos2(x0, plot.top()), pos2(x0, plot.bottom())], Stroke::new(1.5, Color32::from_gray(160))); - // the photo's headroom limit on the fixed scale: marked, the range past it dimmed - let limit = app.session.develop_of(id).map_or(b.stops, |d| d.hdr.max_ev as f32); - if limit.is_finite() && limit >= 0.0 && limit < b.stops - 0.05 { - let x = x0 + (plot.right() - x0) * (limit / b.stops).clamp(0.0, 1.0); - p.rect_filled(Rect::from_min_max(pos2(x, plot.top()), plot.max), 0.0, Color32::from_black_alpha(70)); - p.line_segment([pos2(x, plot.top()), pos2(x, plot.bottom())], Stroke::new(1.0, Color32::from_rgb(230, 150, 60))); - } - } - let chans = [(smooth(&h.r), hex("#df3939")), (smooth(&h.g), hex("#44b072")), (smooth(&h.b), hex("#3b6fe0"))]; - let peak = chans.iter().flat_map(|(v, _)| v.iter().skip(2).take(252)).fold(1.0f32, |a, b| a.max(*b)); - let to = |i: usize, v: f32| pos2(plot.left() + plot.width() * i as f32 / 255.0, plot.bottom() - plot.height() * (v / peak).sqrt().min(1.0)); - // luminance fill - let lum = smooth(&h.luma); - let mut mesh = Mesh::default(); - for i in 0..256 { - let top = to(i, lum[i]); - mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) }); - mesh.vertices.push(Vertex { pos: pos2(top.x, plot.bottom()), uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) }); - if i > 0 { - let k = (i as u32) * 2; - mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]); - } - } - p.add(mesh); - // greyscale images: the three channels coincide — draw one neutral line - let spread: f32 = (0..256).map(|i| (chans[0].0[i] - chans[1].0[i]).abs() + (chans[1].0[i] - chans[2].0[i]).abs()).sum(); - let total: f32 = chans[1].0.iter().sum::().max(1.0); - if spread / total < 0.02 { - let pts: Vec = (0..256).map(|i| to(i, chans[1].0[i])).collect(); - p.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::from_gray(200)))); - } else { - for (v, c) in &chans { - let pts: Vec = (0..256).map(|i| to(i, v[i])).collect(); - p.add(egui::Shape::line(pts, Stroke::new(1.5, *c))); - } - } - } - let mut clicked = resp.clicked(); - // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep - // clear of it, the left one too so they stay symmetric (#1640) - let [shadow_rect, highlight_rect] = super::histogram::clipping_rects(ui, plot); - for (name, rect, label, clipped) in [ - ("shadows", shadow_rect, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), - ("highlights", highlight_rect, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), - ] { - let response = ui.interact(rect, ui.scope_id().with(("histogram-clipping", name)), Sense::click()); - register(ui, format!("histogram:{name}"), rect); - response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label))); - clicked |= response.clicked(); - let active = app.ui.show_clipping; - // Keep the hit target comfortable, with a smaller box and clear space to the plot edges. - let box_rect = rect.shrink2(vec2(2.0, 2.0)); - p.rect_filled( - box_rect, - 3.0, - if active { - t.pressed - } else if response.hovered() { - t.hover - } else { - Color32::from_gray(60) - }, - ); - if active { - p.rect_stroke(box_rect, 3.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); - } - paint( - p, - Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), - Icon::ChevronDown, - if clipped == Some(true) || active { t.text } else { t.text_dim }, - ); - let status = match clipped { - Some(true) => "Clipping detected in this preview.", - Some(false) => "No clipping detected in this preview.", - None => "Histogram not yet available.", - }; - response.on_hover_text(format!( - "{}\n{}\n{}", - crate::i18n::tr(label), - crate::i18n::tr(if active { - "Click to hide clipping warnings for shadows and highlights." - } else { - "Click to show clipping warnings for shadows and highlights." - }), - crate::i18n::tr(status) - )); - } - if clicked { - let _ = app.run("view.clipping", json!({})); - } - // the tonal zone the pointer is on replaces the EXIF strip while it is there - if let Some(text) = zone.as_ref().and_then(|a| a.readout()) { - register(ui, "histogramReadout", r); - p.text(pos2(r.left() + r.width() / 2.0, r.bottom() - 13.0), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); - } else if let Some(ph) = app.session.catalog.photo(id) { - let m = &ph.meta; - let parts: Vec = [ - m.iso.map(|i| format!("ISO {i}")), - m.focal_mm.map(|f| format!("{f:.0}mm")), - m.aperture.map(|a| format!("f/{a:.1}")), - if m.shutter.is_empty() { None } else { Some(m.shutter.clone()) }, - ] - .into_iter() - .flatten() - .collect(); - - if !parts.is_empty() { - let text = parts.join(" · "); - let y = r.bottom() - 13.0; - let x = r.left() + r.width() / 2.0; - p.text(pos2(x, y), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); - } - } -} - -// ------------------------------------------------------------------------------ soft proofing - -/// The Soft Proofing strip under the histogram: proof profile, gamut warnings, Create Proof Copy. -fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - use lightcraft_engine::pipeline::OutputSpace; - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 6 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(crate::i18n::tr("Soft Proofing")).color(t.text).strong()); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut dest = app.ui.proof.dest_warning; - let r = ui - .toggle_value(&mut dest, RichText::new("■").color(Color32::from_rgb(255, 40, 40))) - .on_hover_text(crate::i18n::tr("Show destination gamut warning")); - register(ui, "button:proofDestWarning", r.rect); - let mut disp = app.ui.proof.display_warning; - let r2 = ui - .toggle_value(&mut disp, RichText::new("■").color(Color32::from_rgb(40, 90, 255))) - .on_hover_text(crate::i18n::tr("Show display gamut warning")); - register(ui, "button:proofDisplayWarning", r2.rect); - if dest != app.ui.proof.dest_warning || disp != app.ui.proof.display_warning { - let _ = app.run("view.softProof", json!({"destWarning": dest, "displayWarning": disp})); - } - }); - }); - ui.horizontal(|ui| { - ui.label(RichText::new(crate::i18n::tr("Profile:")).color(t.text_label)); - let cur = app.ui.proof.space; - let device = app.ui.proof_profile.as_ref().map(|(_, n)| n.clone()); - let shown = device.clone().unwrap_or_else(|| cur.label().to_string()); - let mut pick = false; - let combo = crate::widgets::combo_box("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { - for s in OutputSpace::ALL { - if ui.selectable_label(device.is_none() && cur == s, s.label()).clicked() { - let _ = app.run("view.softProof", json!({"space": s})); - } - } - if let Some(n) = &device { - let _ = ui.selectable_label(true, n); - } - if app.services.pick_display_profile.is_some() && ui.selectable_label(false, crate::i18n::tr("Other…")).clicked() { - pick = true; - } - }); - crate::widgets::name_combo(&combo.response, crate::i18n::tr("Profile")); - if pick - && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) - && let Err(e) = app.run("view.softProof", json!({"profile": path})) - { - app.toast(ui.ctx(), e); - } - }); - if app.ui.proof_profile.is_some() { - ui.horizontal(|ui| { - ui.label(RichText::new(crate::i18n::tr("Intent:")).color(t.text_label)); - use lightcraft_engine::proof::ProofIntent; - for (i, label) in [(ProofIntent::Perceptual, "Perceptual"), (ProofIntent::Relative, "Relative")] { - let r = ui.selectable_label(app.ui.proof_intent == i, crate::i18n::tr(label)); - register(ui, format!("button:proofIntent-{}", i.name()), r.rect); - if r.clicked() && app.ui.proof_intent != i { - let _ = app.run("view.softProof", json!({"intent": i.name()})); - } - } - }); - let mut paper = app.ui.proof_paper_ink; - let r = ui.checkbox(&mut paper, crate::i18n::tr("Simulate Paper & Ink")); - register(ui, "button:proofPaperInk", r.rect); - if r.changed() { - let _ = app.run("view.softProof", json!({"paperInk": paper})); - } - } - let r = ui.button(crate::i18n::tr("Create Proof Copy")); - register(ui, "button:createProofCopy", r.rect); - if r.clicked() { - let target = app.ui.proof_profile.as_ref().map_or_else(|| app.ui.proof.space.label().to_string(), |(_, n)| n.clone()); - let name = crate::i18n::tr_format!("Proof Copy ({})", target); - if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() { - app.toast(ui.ctx(), crate::i18n::tr("Proof copy created")); - } - } - }); - divider(ui); -} - -// ------------------------------------------------------------------------------ tone curve - -/// A 256-point, peak-normalized display profile of the photo histogram. -/// For HDR, only the SDR portion maps to a curve's input (0..1). -fn curve_histogram_profile(h: &lightcraft_raster::Histogram, channel: &str) -> [f32; 256] { - let source = match channel { - "red" => &h.r, - "green" => &h.g, - "blue" => &h.b, - _ => &h.luma, - }; - let bins = h.hdr.map_or(256, |hdr| hdr.from.clamp(2, 256)); - let samples: [f32; 256] = std::array::from_fn(|i| { - let j = i * (bins - 1) / 255; - let start = j.saturating_sub(2); - let end = (j + 3).min(bins); - let sum: f32 = (start..end).map(|k| source.get(k).copied().unwrap_or(0) as f32).sum(); - sum / (end - start) as f32 - }); - let peak = samples.iter().copied().fold(1.0_f32, f32::max); - samples.map(|v| (v / peak).sqrt()) -} - -/// Show the currently rendered photo's tonal distribution behind the editable -/// curve, without requesting another render or blocking pointer interactions. -fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histogram, channel: &str) { - let profile = curve_histogram_profile(h, channel); - let color = match channel { - "red" => hex("#dd3333"), - "green" => hex("#33bb55"), - "blue" => hex("#3377ee"), - _ => Color32::from_gray(130), - } - .gamma_multiply(0.27); - let mut mesh = Mesh::default(); - for (i, height) in profile.into_iter().enumerate() { - let x = r.left() + r.width() * i as f32 / 255.0; - let top = pos2(x, r.bottom() - r.height() * height); - mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color }); - mesh.vertices.push(Vertex { pos: pos2(x, r.bottom()), uv: Pos2::ZERO, color }); - if i > 0 { - let k = i as u32 * 2; - mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]); - } - } - p.add(mesh); -} - -/// Segments the graph's curve line is drawn with. -pub(crate) const CURVE_SAMPLES: i32 = 96; - -/// Close a graph drag's `develop` interaction, as one undo step. -/// -/// It is closed when the drag ends *or* the pointer comes up without egui reporting `drag_stopped` -/// for this widget (released outside the window, over a popup, or on a frame the widget does not -/// run). A dangling interaction would make every later edit a silent preview — Undo would then -/// appear to do nothing at all. Returns whether the drag is over, so the caller clears its state. -fn commit_interaction(app: &mut LightcraftApp, ui: &egui::Ui, dragging: bool, stopped: bool) -> bool { - let released = ui.input(|i| i.pointer.any_released()) || !ui.input(|i| i.pointer.primary_down()); - if !dragging || !(stopped || released) { - return false; - } - let _ = app.run("develop.endInteraction", json!({})); - true -} - -/// How close (screen points) to the curve a press counts as grabbing it. -const PARAM_GRAB: f32 = 14.0; - -/// The parametric curve's own drag: the graph shows the curve and nothing else, and dragging the -/// line bends the region the pointer is over, with that region's slider following on the same frame. -/// -/// The whole gesture runs inside one `develop` interaction, so it is one undo step (Ctrl+Z takes the -/// gesture back, not the last frame of it), and every write goes through `develop.set`, so the -/// slider, the curve and what is drawn are the same state rather than three things kept in step. -fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::Response, d: &DevelopSettings) { - if r.width() <= 1.0 || r.height() <= 1.0 { - return; - } - let t = Tokens::get(ui.ctx()); - let model = ParametricCurve::new(&d.curve); - let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); - let from_screen_x = |q: Pos2| ((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64; - // the graph x of a point, and how far it is from the drawn curve (in screen points) - let near_curve = |q: Pos2| { - let x = from_screen_x(q); - to_screen(x, model.at(x)).distance(q) - }; - let drag_id = egui::Id::unique("curve-param-drag"); - let cursor_id = egui::Id::unique("curve-param-cursor"); - // (region, x, initial_amount, start_y): the region being dragged, the graph x the drag is anchored - // at, the region's starting amount, and the starting screen Y position. - let mut dragged: Option<(usize, f64, f64, f32)> = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); - let hovered = dragged.is_none() && resp.hover_pos().is_some_and(|q| near_curve(q) <= PARAM_GRAB); - let mut set: Option<(&'static str, f64)> = None; - // the press, not where the pointer is once the drag threshold has passed: a press slightly off - // the line is still a grab, and the pointer has usually left that spot by the time it starts - let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); - if resp.drag_started() - && let Some(q) = press - && near_curve(q) <= PARAM_GRAB - { - let x = from_screen_x(q); - let _ = app.run("develop.beginInteraction", json!({"label": "Parametric Curve"})); - let region = model.region_at(x); - let initial_amount = model.amounts.get(region).copied().unwrap_or(0.0); - let start_pos = to_screen(x, model.at(x)); - dragged = Some((region, x, initial_amount, start_pos.y)); - ui.data_mut(|dd| dd.insert_temp(cursor_id, start_pos)); - } - if let Some((region, x, initial_amount, start_y)) = dragged - && resp.dragged() - { - // Follow the pointer's motion from where the curve was grabbed, so the press itself moves - // nothing and the line tracks the hand. ⌥ = a tenth of the motion, as the point curve does. - let alt = ui.input(|i| i.modifiers.alt); - let dd = ui.input(|i| i.pointer.delta()); - let step = if alt { vec2(dd.x * 0.1, dd.y * 0.1) } else { vec2(dd.x, dd.y) }; - let cursor = ui.data(|d| d.get_temp::(cursor_id)).unwrap_or(pos2(0.0, start_y)) + step; - ui.data_mut(|d| d.insert_temp(cursor_id, cursor)); - let delta_y = start_y - cursor.y; - let delta_level = (delta_y / r.height()) as f64; - let per_unit = model.level_per_unit(region, x); - let new_amount = - if per_unit.abs() > f64::EPSILON { (initial_amount + delta_level / per_unit).clamp(-100.0, 100.0).round() } else { initial_amount }; - if let Some(&id) = PARAM_AMOUNTS.get(region) { - set = Some((id, new_amount)); - } - } - if let Some((id, v)) = set { - let _ = app.run("develop.set", json!({"control": id, "value": v})); - } - let dropped = resp.drag_stopped(); - if commit_interaction(app, ui, dragged.is_some(), dropped) { - dragged = None; - ui.data_mut(|d| { - d.remove::(cursor_id); - d.remove::>(drag_id); - }); - } - ui.data_mut(|dd| dd.insert_temp(drag_id, dragged)); - if dragged.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } else if hovered { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - // the region under the pointer (or the one in hand), named as its slider below reads it - let shown = dragged.map(|(region, ..)| region).or_else(|| hovered.then(|| resp.hover_pos()).flatten().map(|q| model.region_at(from_screen_x(q)))); - if let Some(region) = shown - && let Some(&id) = PARAM_AMOUNTS.get(region) - && let Some(v) = set.and_then(|(s_id, v)| (s_id == id).then_some(v)).or_else(|| controls::get(d, id)) - { - let name = controls::find(id).map_or("", |s| s.label); - ui.painter().text(r.left_top() + vec2(6.0, 4.0), Align2::LEFT_TOP, format!("{name} {v:.0}"), t.font(11.0), t.text_dim); - } -} - -fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { - let t = Tokens::get(ui.ctx()); - // channel selector - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - for (ch, c) in [ - ("parametric", Color32::from_gray(200)), - ("master", Color32::WHITE), - ("red", hex("#dd3333")), - ("green", hex("#33bb55")), - ("blue", hex("#3377ee")), - ] { - let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); - crate::widgets::announce(&resp, format!("{} {ch}", crate::i18n::tr("Curve channel"))); - register(ui, format!("curveChannel:{ch}"), r); - let sel = app.ui.curve_channel == ch; - if ch == "parametric" { - paint(ui.painter(), r.shrink(4.0), Icon::Curve, if sel { t.text } else { t.icon }); - } else { - ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(2.0, c)); - if sel { - ui.painter().circle_filled(r.center(), 3.5, c); - } - } - if sel { - ui.painter().line_segment([r.left_bottom() + vec2(4.0, 1.0), r.right_bottom() + vec2(-4.0, 1.0)], Stroke::new(2.0, t.text)); - } - let name = match ch { - "parametric" => "Parametric curve", - "master" => "Point curve", - "red" => "Red channel", - "green" => "Green channel", - _ => "Blue channel", - }; - let resp = resp.on_hover_text(crate::i18n::tr_format!("{name} — double-click to reset it", name = name)); - if resp.double_clicked() { - app.ui.curve_channel = ch.into(); - let _ = app.run("curve.reset", json!({"channel": ch})); - } else if resp.clicked() { - app.ui.curve_channel = ch.into(); - } - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - tat_button(app, ui, "tat:curve", "Targeted adjustment: drag up/down on the photo to adjust the curve there"); - }); - }); - }); - let ch = app.ui.curve_channel.clone(); - let w = ui.available_width(); - let side = (w - 48.0).max(80.0); - let (outer, _) = ui.allocate_exact_size(vec2(w, side + 12.0), Sense::hover()); - let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side)); - let resp = ui.interact(r, egui::Id::unique(("curve", &ch)), Sense::click_and_drag()); - crate::widgets::announce(&resp, crate::i18n::tr("Tone Curve")); - register(ui, "curve", r); - crate::widgets::context_menu(&resp, |ui| curve_reset_menu(app, ui, &ch)); - let p = ui.painter(); - p.rect_filled(r, 2.0, t.canvas); - if let Some(hist) = preview_histogram(app, id) { - draw_curve_histogram(p, r, &hist, &ch); - } - for i in 1..4 { - let f = i as f32 / 4.0; - p.line_segment( - [pos2(r.left() + r.width() * f, r.top()), pos2(r.left() + r.width() * f, r.bottom())], - Stroke::new(1.0, Color32::from_gray(52)), - ); - p.line_segment( - [pos2(r.left(), r.top() + r.height() * f), pos2(r.right(), r.top() + r.height() * f)], - Stroke::new(1.0, Color32::from_gray(52)), - ); - } - p.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.0, Color32::from_gray(70))); - let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); - let from_screen = |q: Pos2| (((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64, ((r.bottom() - q.y) / r.height()).clamp(0.0, 1.0) as f64); - if ch == "parametric" { - // The Effective curve: the model the renderer builds from the four region sliders, sampled - // here and rebuilt every frame. The graph holds no state of its own, so a slider moved by - // hand, a Reset or an Undo redraws it from the settings in the same frame it changes. - let c = &d.curve; - let model = ParametricCurve::new(c); - let pts: Vec = - (0..=CURVE_SAMPLES).map(|i| to_screen(i as f64 / CURVE_SAMPLES as f64, model.at(i as f64 / CURVE_SAMPLES as f64))).collect(); - p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220)))); - param_drag(app, ui, r, &resp, d); - curve_footer(app, ui, d); - for &c in PARAM_AMOUNTS.iter().rev() { - control(app, ui, d, c, true); - } - control(app, ui, d, "curve.refineSaturation", true); - return; - } - let pts_of = |d: &DevelopSettings| -> Vec { - let v = match ch.as_str() { - "red" => &d.curve.red, - "green" => &d.curve.green, - "blue" => &d.curve.blue, - _ => &d.curve.master, - }; - // in the order the curve is drawn (points from a sidecar, a preset or an agent can come - // unsorted or not finite): the drag below keeps each point between its neighbours - let mut v: Vec = v.iter().copied().filter(|q| q.x.is_finite() && q.y.is_finite()).collect(); - v.sort_by(|a, b| a.x.total_cmp(&b.x)); - if v.len() < 2 { vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)] } else { v } - }; - let pts = pts_of(d); - let curve = lightcraft_color::spline::MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::>()); - let color = match ch.as_str() { - "red" => hex("#dd3333"), - "green" => hex("#33bb55"), - "blue" => hex("#3377ee"), - _ => Color32::from_gray(225), - }; - let line: Vec = (0..=96).map(|i| to_screen(i as f64 / 96.0, curve.eval(i as f64 / 96.0).clamp(0.0, 1.0))).collect(); - p.add(egui::Shape::line(line, Stroke::new(2.0, color))); - // interaction: drag a point (both axes, between its neighbours; ⌥ = fine), drag empty space to add and - // drag a new point, click empty space to add, double-click a point to delete it - let drag_id = egui::Id::unique(("curve-drag", &ch)); - // the point being dragged (stored as `Option` so a stale value can be cleared) - let mut dragging: Option = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); - let nearest = |q: Pos2| pts.iter().enumerate().map(|(i, p)| (i, to_screen(p.x, p.y).distance(q))).min_by(|a, b| a.1.total_cmp(&b.1)); - let hovered = resp.hover_pos().and_then(nearest).filter(|(_, dist)| *dist < 10.0).map(|(i, _)| i); - if dragging.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } else if hovered.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - for (i, q) in pts.iter().enumerate() { - let c = to_screen(q.x, q.y); - if dragging == Some(i) || (dragging.is_none() && hovered == Some(i)) { - p.circle_filled(c, 5.5, color); - p.circle_stroke(c, 5.5, Stroke::new(1.0, t.canvas)); - } else { - p.circle_filled(c, 4.0, color); - } - } - // input / output readout of the point being dragged (0–255 like the histogram) - if let Some(q) = dragging.and_then(|i| pts.get(i)) { - p.text( - r.left_top() + vec2(6.0, 4.0), - egui::Align2::LEFT_TOP, - format!("{} / {}", (q.x * 255.0).round(), (q.y * 255.0).round()), - t.font(11.0), - t.text_dim, - ); - } - let mut new_pts = None; - if resp.double_clicked() - && let Some(q) = resp.interact_pointer_pos() - && let Some((i, dist)) = nearest(q) - && dist < 10.0 - && i != 0 - && i != pts.len() - 1 - { - let mut v = pts.clone(); - v.remove(i); - new_pts = Some(v); - } else if resp.drag_started() - && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) - { - // pick the point under the press, not where the pointer is once the drag threshold passed - let _ = app.run("develop.beginInteraction", json!({"label": "Tone Curve"})); - match nearest(q) { - Some((i, dist)) if dist < 10.0 => dragging = Some(i), - _ => { - let (x, y) = from_screen(q); - let mut v = pts.clone(); - let i = v.iter().position(|p| p.x > x).unwrap_or(v.len()); - v.insert(i, Point::new(x, y)); - dragging = Some(i); - new_pts = Some(v); - } - } - } else if resp.dragged() - && let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos()) - { - let mut v = pts.clone(); - // ⌥-drag = fine adjustment, as in Lightroom: the point moves a tenth of the pointer's - // motion (relative to where it is, so pressing ⌥ mid-drag doesn't make it jump) - let fine = ui.input(|i| i.modifiers.alt); - let (mut x, y) = match v.get(i) { - Some(cur) if fine => { - let dd = ui.input(|i| i.pointer.delta()); - ((cur.x + (dd.x / r.width()) as f64 * 0.1).clamp(0.0, 1.0), (cur.y - (dd.y / r.height()) as f64 * 0.1).clamp(0.0, 1.0)) - } - _ => from_screen(q), - }; - if i < v.len() { - let lo = if i == 0 { 0.0 } else { v[i - 1].x + 0.01 }; - let hi = if i + 1 >= v.len() { 1.0 } else { v[i + 1].x - 0.01 }; - if i == 0 { - x = x.min(hi); - } else if i + 1 == v.len() { - x = x.max(lo); - } else if lo <= hi { - x = x.clamp(lo, hi); - } else { - // neighbours closer than the 0.02 spacing (two clicks side by side, or points - // from a file) leave no room: the point stays midway (`clamp` would panic: min above max) - x = (v[i - 1].x + v[i + 1].x) / 2.0; - } - v[i] = Point::new(x, y); - new_pts = Some(v); - } - } else if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - && nearest(q).is_none_or(|(_, dist)| dist >= 10.0) - { - let (x, y) = from_screen(q); - let mut v = pts.clone(); - let i = v.iter().position(|p| p.x > x).unwrap_or(v.len()); - v.insert(i, Point::new(x, y)); - new_pts = Some(v); - } - if let Some(v) = new_pts { - let arr: Vec<[f64; 2]> = v.iter().map(|p| [p.x, p.y]).collect(); - let _ = app.run("develop.curve", json!({"channel": ch, "points": arr})); - } - // the drag is over when egui says so, or when the pointer comes up without it saying so (a - // release outside the window would otherwise leave the interaction open, and every later edit a - // silent preview that Undo cannot take back) - if commit_interaction(app, ui, dragging.is_some(), resp.drag_stopped()) { - dragging = None; - } - ui.data_mut(|dd| dd.insert_temp(drag_id, dragging)); - curve_footer(app, ui, d); - control(app, ui, d, "curve.refineSaturation", true); - let _ = id; -} - -/// The row under the curve graph: point-curve presets and reset every curve. -fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - use lightcraft_engine::cmd::curves::{all_presets, matching_preset}; - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label)); - let current = matching_preset(&app.session, &d.curve); - let builtin = current.as_ref().is_none_or(|name| all_presets(&app.session).iter().any(|p| p.builtin && &p.name == name)); - let label = crate::i18n::builtin_label(current.as_deref().unwrap_or("Custom"), builtin); - let r = crate::widgets::dropdown(ui, "curvePreset", label, t.font(12.0), t.text); - egui::Popup::menu(&r).show(|ui| { - ui.set_min_width(180.0); - let presets = all_presets(&app.session); - let mut user_seen = false; - for p in &presets { - if !p.builtin && !user_seen { - user_seen = true; - ui.separator(); - } - let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)); - register(ui, format!("curvePreset:{}", p.name), r.rect); - if r.clicked() { - let _ = app.run("curve.applyPreset", json!({"name": p.name})); - ui.close(); - } - if !p.builtin { - crate::widgets::context_menu(&r, |ui| { - if ui.button(crate::i18n::tr("Delete Preset")).clicked() { - let _ = app.run("curve.deletePreset", json!({"name": p.name})); - ui.close(); - } - }); - } - } - ui.separator(); - let shaped = current.as_deref() != Some("Linear"); - let r = ui.add_enabled(shaped, egui::Button::new(crate::i18n::tr("Save Point Curve…"))); - register(ui, "curvePresetMenu:save", r.rect); - if r.clicked() { - crate::panels::dialogs::prompt(app, "Save Point Curve Preset", "Preset name", "", "curve.savePreset", json!({}), "name"); - ui.close(); - } - if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() { - let _ = app.run("file.importCurvePresets", json!({})); - ui.close(); - } - let can_export = app.services.save_curve_preset_file.is_some() && !app.session.curve_presets.is_empty(); - if ui.add_enabled(can_export, egui::Button::new(crate::i18n::tr("Export Presets…"))).clicked() { - let _ = app.run("file.exportCurvePresets", json!({})); - ui.close(); - } - if presets.iter().any(|p| !p.builtin) { - ui.label(RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim)); - } - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = text_button(ui, "curveReset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr( - "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)", - )); - if r.clicked() { - let _ = app.run("curve.reset", json!({"channel": "all"})); - } - }); - }); - }); -} - -/// Right-click menu of the curve graph. -fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) { - let label = if ch == "parametric" { - crate::i18n::tr("Reset Parametric Curve").to_string() - } else { - crate::i18n::tr_format!("Reset {} Channel", crate::i18n::tr(channel_label(ch))) - }; - let r = ui.button(label); - register(ui, "curveMenu:resetChannel", r.rect); - if r.clicked() { - let _ = app.run("curve.reset", json!({"channel": ch})); - ui.close(); - } - let r = ui.button(crate::i18n::tr("Reset All Curves")); - register(ui, "curveMenu:resetAll", r.rect); - if r.clicked() { - let _ = app.run("curve.reset", json!({"channel": "all"})); - ui.close(); - } -} - -fn channel_label(ch: &str) -> &'static str { - match ch { - "red" => "Red", - "green" => "Green", - "blue" => "Blue", - _ => "RGB", - } -} - -/// Toggle for a targeted-adjustment tool (`tool` = `tat:`). -fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) { - let active = app.ui.tool == tool; - let id = tool.replace(':', "-"); - if crate::widgets::icon_button(ui, &id, Icon::Target, vec2(26.0, 26.0), active, true, tip).clicked() { - set_tat(app, ui.ctx(), tool, active); - } -} - -/// Turn targeted-adjustment tool `tool` off (`active`) or on. Turning it on says how to use and -/// leave it: while it is on, dragging on the photo adjusts instead of zooming or panning, which is -/// easy to miss when the button looks like a tab. -fn set_tat(app: &mut LightcraftApp, ctx: &egui::Context, tool: &str, active: bool) { - if active { - app.ui.tool.clear(); - } else { - app.ui.tool = tool.to_string(); - app.toast(ctx, crate::i18n::tr("Targeted adjustment on: drag up or down on the photo · Esc to stop")); - } -} - -// ------------------------------------------------------------------------------ colour mixer - -fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - let t = Tokens::get(ui.ctx()); - let bands = lightcraft_develop::MIXER_BANDS; - let sel = bands.iter().position(|b| *b == app.ui.mixer_mode).unwrap_or(0); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 5.0; - for (i, b) in bands.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr(b)); - register(ui, format!("mixerBand:{b}"), r); - ui.painter().circle_filled(r.center(), 9.0, hex(BAND_COLORS[i])); - if i == sel { - ui.painter().circle_stroke(r.center(), 10.5, Stroke::new(1.5, t.text)); - ui.painter().circle_filled(r.center(), 2.0, Color32::WHITE); - } - if resp.clicked() { - app.ui.mixer_mode = b.to_string(); - } - } - }); - }); - // targeted adjustment: pick the attribute, then drag on the photo - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover()); - paint(ui.painter(), r, Icon::Target, t.icon); - let items: &[(&str, &str)] = - if crate::is_bw(d) { &[("lum", "B&W Mix")] } else { &[("hue", "Hue"), ("sat", "Saturation"), ("lum", "Luminance")] }; - for (k, label) in items { - let tool = format!("tat:{k}"); - let active = app.ui.tool == tool; - if text_button(ui, &format!("tatMixer-{k}"), crate::i18n::tr(label), active) - .on_hover_text(crate::i18n::tr("Targeted adjustment: drag up/down on the photo to adjust the colours there")) - .clicked() - { - set_tat(app, ui.ctx(), &tool, active); - } - } - }); - }); - let b = bands[sel]; - if crate::is_bw(d) { - ui.horizontal(|ui| { - ui.add_space(24.0); - if crate::widgets::text_button(ui, "bwAuto", crate::i18n::tr("Auto"), false) - .on_hover_text(crate::i18n::tr("Set the mix from the photo's colours")) - .clicked() - { - let _ = app.run("develop.autoBwMix", json!({})); - } - }); - control(app, ui, d, &format!("bw.{b}"), true); - } else { - for k in ["hue", "sat", "lum"] { - control(app, ui, d, &format!("mixer.{b}.{k}"), true); - } - } -} - -// ------------------------------------------------------------------------------ point color - -/// Display colour of an OkLCh sample (lightness, chroma, hue in degrees). -fn oklch_color(l: f64, c: f64, h_deg: f64) -> Color32 { - use lightcraft_color::perceptual::{lch_to_lab, oklab_to_2020}; - let lin = oklab_to_2020(lch_to_lab([l as f32, c as f32, (h_deg as f32).to_radians()])); - let s = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).apply_f32(lin); - let e = s.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8); - Color32::from_rgb(e[0], e[1], e[2]) -} - -/// Point Color: swatches of the samples (+ the eyedropper), the selected sample's shifts and range, -/// and "Visualize range". -fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - let t = Tokens::get(ui.ctx()); - let n = d.point_colors.len(); - if app.ui.point_color >= n && n > 0 { - app.ui.point_color = n - 1; - } - let sel = app.ui.point_color; - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 5.0; - let active = app.ui.tool == "pointColor"; - let full = n >= lightcraft_develop::MAX_POINT_COLORS; - if crate::widgets::icon_button( - ui, - "pointColorPicker", - Icon::Picker, - vec2(26.0, 26.0), - active, - !full, - crate::i18n::tr("Sample a colour on the photo"), - ) - .clicked() - { - app.ui.tool = if active { String::new() } else { "pointColor".into() }; - } - for (i, p) in d.point_colors.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Point Color"), i + 1)); - register(ui, format!("pointColor:{i}"), r); - ui.painter().rect_filled(r.shrink(2.0), 3.0, oklch_color(p.lum, p.chroma, p.hue)); - if i == sel { - ui.painter().rect_stroke(r, 4.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); - } - if resp.clicked() { - app.ui.point_color = i; - } - } - }); - }); - if n == 0 { - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 8 }).show(ui, |ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Pick a colour on the photo with the eyedropper.")).size(12.0).color(t.text_dim)); - }); - return; - } - for k in ["hueShift", "satShift", "lumShift", "variance"] { - control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); - } - sub_title(ui, crate::i18n::tr("Range")); - for k in ["range", "hueRange", "satRange", "lumRange"] { - control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); - } - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 8 }).show(ui, |ui| { - ui.horizontal(|ui| { - let mut v = app.ui.point_color_visualize; - if ui.checkbox(&mut v, crate::i18n::tr("Visualize range")).changed() { - app.ui.point_color_visualize = v; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "pointColorDelete", crate::i18n::tr("Delete"), false).clicked() { - let _ = app.run("pointColor.delete", json!({"index": sel})); - } - }); - }); - }); -} - -// ------------------------------------------------------------------------------ HDR - -/// The Light panel's HDR row: the HDR switch and Visualize HDR, then (HDR on) the headroom limit. -/// The Light panel's HDR settings while HDR editing is on (the switch itself is the HDR button -/// next to B&W): Visualize HDR range and the headroom limit. -fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - if !d.hdr.enabled { - return; - } - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| { - let mut v = app.ui.hdr_visualize; - let r = ui.checkbox(&mut v, "Visualize HDR"); - register(ui, "check:hdrVisualize", r.rect); - if r.on_hover_text("Grey below SDR white; yellow, orange, red, magenta up to 1, 2, 3 and over 3 stops above it").changed() { - app.ui.hdr_visualize = v; - } - }); - control(app, ui, d, "hdr.maxEv", true); -} - -// ------------------------------------------------------------------------------ colour grading - -/// The colour grading tabs: key, tooltip, icon. -const GRADING_TABS: [(&str, &str, Icon); 4] = [ - ("3way", "3-Way", Icon::GradeAll), - ("shadows", "Shadows", Icon::GradeShadows), - ("midtones", "Midtones", Icon::GradeMidtones), - ("highlights", "Highlights", Icon::GradeHighlights), -]; - -/// The wheels, in the order of `UiState::grading_hidden`. -const GRADING_WHEELS: [(&str, &str); 3] = [("shadows", "Shadows"), ("midtones", "Midtones"), ("highlights", "Highlights")]; - -fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId) { - let mode = GRADING_TABS.iter().find(|(k, ..)| *k == app.ui.grading_mode).map_or("3way", |(k, ..)| *k); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 2 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (key, tip, icon) in GRADING_TABS { - if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, crate::i18n::tr(tip)) - .clicked() - { - app.ui.grading_mode = key.to_string(); - } - } - }); - }); - let w = ui.available_width(); - if mode == "3way" { - // three wheels of one size: midtones on top, shadows and highlights below - let rad = 40.0; - let (area, _) = ui.allocate_exact_size(vec2(w, 276.0), Sense::hover()); - let at = [ - pos2(area.left() + w * 0.28, area.top() + 196.0), - pos2(area.center().x, area.top() + 70.0), - pos2(area.left() + w * 0.72, area.top() + 196.0), - ]; - for (i, c) in at.into_iter().enumerate() { - grading_wheel(app, ui, d, photo, i, c, rad, true); - } - for c in ["grading.shadows.lum", "grading.midtones.lum", "grading.highlights.lum"] { - control(app, ui, d, c, true); - } - } else { - let i = GRADING_WHEELS.iter().position(|(k, _)| *k == mode).unwrap_or(1); - let rad = (w * 0.3).clamp(40.0, 80.0); - let (area, _) = ui.allocate_exact_size(vec2(w, rad * 2.0 + 40.0), Sense::hover()); - grading_wheel(app, ui, d, photo, i, pos2(area.center().x, area.top() + rad + 10.0), rad, false); - for part in ["hue", "sat", "lum"] { - control(app, ui, d, &format!("grading.{mode}.{part}"), true); - } - } - for c in ["grading.blending", "grading.balance"] { - control(app, ui, d, c, true); - } -} - -/// One colour grading wheel (`i` into [`GRADING_WHEELS`]) centred on `c`: drag to pick hue and -/// saturation, double-click to reset, and an eye at its bottom-left corner that hides its effect -/// on the photo for viewing. `readout`: show hue and saturation centred above the wheel while -/// hovered (3-Way); otherwise the wheel is the large one and gets saturation guide rings. -#[allow(clippy::too_many_arguments)] -fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId, i: usize, c: Pos2, rad: f32, readout: bool) { - let t = Tokens::get(ui.ctx()); - let Some(&(key, label)) = GRADING_WHEELS.get(i) else { return }; - let wh = match key { - "shadows" => d.grading.shadows, - "midtones" => d.grading.midtones, - _ => d.grading.highlights, - }; - let hidden = app.ui.grading_hidden_for(photo).get(i).copied().unwrap_or(false); - let r = Rect::from_center_size(c, vec2(rad * 2.0, rad * 2.0)); - // the corners (eye, readout) count as the wheel for hovering - let zone = r.expand(10.0); - let hovering = ui.rect_contains_pointer(zone); - let resp = ui.interact(r, egui::Id::unique(("wheel", key)), Sense::click_and_drag()); - crate::widgets::announce(&resp, format!("{} {key}", crate::i18n::tr("Color wheel"))); - let resp = resp.on_hover_text(crate::i18n::tr( - "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.", - )); - register(ui, format!("wheel:{key}"), r); - paint_wheel(ui.painter(), c, rad); - if hidden { - ui.painter().circle_filled(c, rad, Color32::from_black_alpha(150)); - } - // the large single wheel: dashed rings at 25 / 50 / 75 % saturation to judge strength by eye - if !readout { - for f in [0.25f32, 0.5, 0.75] { - let ring_rad = rad * f; - let n = (ring_rad * std::f32::consts::TAU / 2.0).max(8.0) as usize; - let ring: Vec = (0..=n).map(|k| c + egui::Vec2::angled(k as f32 / n as f32 * std::f32::consts::TAU) * ring_rad).collect(); - ui.painter().extend(egui::Shape::dashed_line(&ring, Stroke::new(0.8, Color32::from_white_alpha(45)), 4.0, 3.5)); - } - } - let a = (wh.hue as f32).to_radians(); - let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); - // Modifiers (as in Lightroom): Shift keeps the hue, Cmd/Ctrl keeps the saturation, Alt moves - // the puck a tenth as far as the pointer. Pressing or releasing one mid-drag re-grabs the - // puck as it is at that moment. - let grab_id = egui::Id::unique(("wheelGrab", key)); - let mods = ui.input(|i| WheelMods::from_modifiers(i.modifiers)); - let pointer = wheel_pointer(ui, &resp); - let grab = match pointer { - Some(q) if resp.dragged() || resp.clicked() => { - let held = ui.data(|m| m.get_temp::(grab_id)).filter(|g| g.mods == mods && !resp.drag_started()); - Some(held.unwrap_or_else(|| { - // a fine drag counts the pointer's travel from the press, not from the frame - // egui first called it a drag - let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(q) } else { q }; - let g = WheelGrab::new(mods, wh.hue as f32, wh.sat as f32, origin - c, rad); - ui.data_mut(|m| m.insert_temp(grab_id, g)); - g - })) - } - _ => { - ui.data_mut(|m| m.remove::(grab_id)); - None - } - }; - // while a constraint is held: the guide for the one move left (the whole spoke for the hue, - // the circle for the saturation) - let guide = Stroke::new(1.5, Color32::from_white_alpha(170)); - match grab.and_then(|g| g.lock()) { - Some(WheelLock::Hue(h)) => { - let a = h.to_radians(); - ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * rad], guide); - } - Some(WheelLock::Sat(s)) => { - ui.painter().circle_stroke(c, rad * s / 100.0, guide); - } - None => {} - } - // the neutral centre (issue #559), under the point - ui.painter().circle_filled(c, 2.0, Color32::WHITE); - ui.painter().circle_filled(dot, 5.0, if hidden { Color32::from_gray(150) } else { Color32::WHITE }); - ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK)); - ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, crate::i18n::tr(label), t.font(12.0), t.text_label); - if resp.drag_started() { - let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)})); - } - if (resp.dragged() || resp.clicked()) - && let Some(q) = pointer - { - let (hue, sat) = grab.map_or_else(|| wheel_pick(q - c, rad, None), |g| g.pick(q - c, rad)); - let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); - } - if resp.drag_stopped() || resp.clicked() { - let _ = app.run("develop.endInteraction", json!({})); - } - // released, or Esc (egui ends a drag on it) - if resp.drag_stopped() - && let Some(held) = ui.ctx().data_mut(|m| m.remove_temp::(egui::Id::unique(WHEEL_CAPTURE))) - && !held.dropped - { - give_pointer_back(ui.ctx(), held.origin); - } - if resp.double_clicked() { - let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}})); - } - // hue and saturation readout: centred above the wheel, shown like the eye (no fade) - if readout && (hovering || resp.dragged()) { - // set a little below the gap's midpoint: the eye reads it as centred there - ui.painter().text( - pos2(c.x, r.top() - 13.0), - Align2::CENTER_CENTER, - format!("H {}° S {}", wh.hue.round() as i64, wh.sat.round() as i64), - t.font(11.0), - t.text_label, - ); - } - // the eye: on top of the wheel, shown while hovering or while the wheel is hidden - if hovering || hidden { - let eye = Rect::from_center_size(r.left_bottom() + vec2(2.0, -2.0), vec2(16.0, 16.0)); - let er = ui.interact(eye, egui::Id::unique(("wheelEye", key)), Sense::click()); - crate::widgets::announce(&er, crate::i18n::tr("Show / hide")); - register(ui, format!("wheelEye:{key}"), eye); - paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, if hidden { t.text_disabled } else { t.icon }); - let er = er.on_hover_text(crate::i18n::tr(if hidden { "Show this wheel's effect" } else { "Hide this wheel's effect" })); - if er.clicked() { - app.ui.set_grading_hidden(photo, i, !hidden); - } - } -} - -/// A wheel's modifier state. Shift keeps the hue, Cmd/Ctrl the saturation (Shift wins when both -/// are down: keeping both would freeze the puck), Alt fine-adjusts. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) struct WheelMods { - pub keep_hue: bool, - pub keep_sat: bool, - pub fine: bool, -} - -impl WheelMods { - pub(crate) fn from_modifiers(m: egui::Modifiers) -> Self { - Self { keep_hue: m.shift, keep_sat: m.command && !m.shift, fine: m.alt } - } -} - -/// What a constraint holds still while the pointer moves. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) enum WheelLock { - /// Keep this hue (degrees): only the saturation changes, along the spoke at that hue. - Hue(f32), - /// Keep this saturation (0..100): only the hue changes, round the circle at that radius. - Sat(f32), -} - -/// Pointer travel to puck travel with Alt held, as for a slider's fine adjust. -const WHEEL_FINE: f32 = 0.1; - -/// The puck as it was when the pointer grabbed it (or a modifier last changed): the hue and -/// saturation a constraint keeps, and the origin a fine adjust measures the pointer's travel from. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct WheelGrab { - pub mods: WheelMods, - /// The puck's hue (degrees) and saturation (0..100) at the grab. - hue: f32, - sat: f32, - /// The pointer at the grab, as an offset from the wheel's centre. - origin: egui::Vec2, -} - -impl WheelGrab { - /// Grab a puck at `hue` / `sat` with the pointer `origin` from the centre of a wheel of - /// radius `rad`. The kept hue is the puck's own, never the angle the pointer happens to be - /// at. A puck at the centre (saturation under 1) has no hue yet, so there it is the angle of - /// the pointer at the grab; the same rule covers a non-finite hue. - pub(crate) fn new(mods: WheelMods, hue: f32, sat: f32, origin: egui::Vec2, rad: f32) -> Self { - let sat = if sat.is_finite() { sat.clamp(0.0, 100.0) } else { 0.0 }; - let hue = if hue.is_finite() && sat >= 1.0 { hue.rem_euclid(360.0) } else { wheel_pick(origin, rad, None).0 }; - Self { mods, hue, sat, origin } - } - - /// The constraint this grab holds, if any. - pub(crate) fn lock(&self) -> Option { - if self.mods.keep_hue { - Some(WheelLock::Hue(self.hue)) - } else if self.mods.keep_sat { - Some(WheelLock::Sat(self.sat)) - } else { - None - } - } - - /// The new hue and saturation for the pointer at offset `v` from the centre. - pub(crate) fn pick(&self, v: egui::Vec2, rad: f32) -> (f32, f32) { - let v = if self.mods.fine { - // the puck where it was, moved a tenth of the pointer's travel since the grab - let a = self.hue.to_radians(); - let puck = vec2(a.cos(), -a.sin()) * rad.max(1.0) * (self.sat / 100.0); - puck + (v - self.origin) * WHEEL_FINE - } else { - v - }; - wheel_pick(v, rad, self.lock()) - } -} - -/// A wheel drag holds the pointer, as in Lightroom (issue #1899): it is hidden for the drag, the -/// puck follows the mouse's own travel, and on release the pointer is back where the button went -/// down, so a long (Alt) drag doesn't leave it far from the wheels. On macOS the pointer is locked -/// in place for the drag; elsewhere it moves unseen and is put back. The browser can't move it -/// (eframe's web host ignores the command), so there it only shows again where the drag ended. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -struct WheelCapture { - /// Where the button went down: the pointer goes back there. - origin: Pos2, - /// Where the pointer would be had it been free: where the drag began plus the travel since. - pos: Pos2, - /// Let go early (the window lost focus): the rest of this drag leaves the wheel alone. - dropped: bool, - /// The pass that last drew the dragged wheel. - pass: u64, -} - -/// One wheel is dragged at a time, so one capture for them all. -const WHEEL_CAPTURE: &str = "wheelCapture"; - -/// The mouse's travel this frame: the pointer's, or, while the pointer stands still (locked, or -/// against a screen edge), the raw motion the host reports. -fn wheel_travel(delta: egui::Vec2, motion: Option) -> egui::Vec2 { - if delta != egui::Vec2::ZERO { - return delta; - } - motion.filter(|m| m.x.is_finite() && m.y.is_finite()).unwrap_or(egui::Vec2::ZERO) -} - -/// Where the wheel `resp` reads the pointer this frame: where it clicked, or for a drag the held -/// pointer ([`WheelCapture`]), `None` once a lost focus let go of it (Esc ends the drag itself: -/// egui stops a drag on Esc). -fn wheel_pointer(ui: &egui::Ui, resp: &egui::Response) -> Option { - let q = resp.interact_pointer_pos()?; - if !resp.dragged() { - return Some(q); - } - let ctx = ui.ctx(); - let id = egui::Id::unique(WHEEL_CAPTURE); - let held = ctx.data(|m| m.get_temp::(id)).filter(|_| !resp.drag_started()); - let mut cap = held.unwrap_or_else(|| { - if cfg!(target_os = "macos") { - ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::Locked)); - } - WheelCapture { origin: ui.input(|i| i.pointer.press_origin()).unwrap_or(q), pos: q, ..Default::default() } - }); - cap.pass = ctx.cumulative_pass_nr(); - if !cap.dropped { - let (travel, unfocused) = ui.input(|i| { - // the event, not `focused`: a click that activates the window may land before its focus - let unfocused = i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))); - (wheel_travel(i.pointer.delta(), i.pointer.motion()), unfocused) - }); - if !resp.drag_started() { - cap.pos += travel; - } - if unfocused { - give_pointer_back(ctx, cap.origin); - cap.dropped = true; - } else { - ctx.set_cursor_icon(egui::CursorIcon::None); - } - } - ctx.data_mut(|m| m.insert_temp(id, cap)); - (!cap.dropped).then_some(cap.pos) -} - -/// Unlock and show the pointer a wheel drag held, back at `origin`. -fn give_pointer_back(ctx: &egui::Context, origin: Pos2) { - if cfg!(target_os = "macos") { - ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::None)); - } - if cfg!(not(target_arch = "wasm32")) { - ctx.send_viewport_cmd(egui::ViewportCommand::CursorPosition(origin)); - } -} - -/// Once a frame, after the panels: a held pointer stays hidden over whatever set its own cursor, -/// and is given back if no wheel drew this pass (the flyout closed or the view changed under the -/// drag), so it is never left locked or hidden. -pub(crate) fn wheel_pointer_frame_end(ctx: &egui::Context) { - let id = egui::Id::unique(WHEEL_CAPTURE); - let Some(cap) = ctx.data(|m| m.get_temp::(id)) else { return }; - if cap.pass != ctx.cumulative_pass_nr() { - ctx.data_mut(|m| m.remove::(id)); - if !cap.dropped { - give_pointer_back(ctx, cap.origin); - } - } else if !cap.dropped { - ctx.set_cursor_icon(egui::CursorIcon::None); - } -} - -/// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre. -/// `lock`: hold the hue (the saturation is `v` projected onto that hue's spoke) or hold the -/// saturation (the hue is the angle of `v`). A non-finite `v` gives a finite answer (the locked -/// value, else 0). -pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option) -> (f32, f32) { - let rad = rad.max(1.0); - let v = if v.x.is_finite() && v.y.is_finite() { v } else { egui::Vec2::ZERO }; - let angle = || ((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0; - match lock { - Some(WheelLock::Hue(h)) => { - let h = if h.is_finite() { h } else { 0.0 }; - let a = h.to_radians(); - let along = v.x * a.cos() - v.y * a.sin(); - (h, (along / rad * 100.0).clamp(0.0, 100.0)) - } - Some(WheelLock::Sat(s)) => (angle(), if s.is_finite() { s.clamp(0.0, 100.0) } else { 0.0 }), - None => (angle(), (v.length() / rad * 100.0).min(100.0)), - } -} - -fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) { - p.add(wheel_mesh(c, rad)); - p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40))); -} - -/// The picture of a grading wheel: a hue disc whose rim is fully saturated display sRGB at full -/// brightness, fading to a neutral grey at the centre. It is built from concentric rings so the -/// saturation ramps evenly with the radius instead of one fan of triangles blending each hue -/// straight into grey (which made the whole disc look muddy, issue #77). Only the drawing: -/// what a position means for the edit is [`wheel_pick`]. -pub(crate) fn wheel_mesh(c: Pos2, rad: f32) -> Mesh { - const SEGMENTS: u32 = 72; - const RINGS: u32 = 12; - /// Brightness (HSV value) at the centre; the rim is 1.0. - const CENTRE_V: f32 = 0.6; - let colour = |hue_deg: f32, t: f32| { - let rgb = lightcraft_color::perceptual::hsv_to_rgb(hue_deg, t, CENTRE_V + (1.0 - CENTRE_V) * t); - let u = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(u(rgb[0]), u(rgb[1]), u(rgb[2])) - }; - let mut mesh = Mesh::default(); - mesh.vertices.push(Vertex { pos: c, uv: Pos2::ZERO, color: colour(0.0, 0.0) }); - // ring k (1..=RINGS) holds SEGMENTS + 1 vertices starting at index 1 + (k - 1) * (SEGMENTS + 1) - let row = SEGMENTS + 1; - for k in 1..=RINGS { - let t = k as f32 / RINGS as f32; - for i in 0..=SEGMENTS { - let a = i as f32 / SEGMENTS as f32 * std::f32::consts::TAU; - mesh.vertices.push(Vertex { pos: c + vec2(a.cos(), -a.sin()) * rad * t, uv: Pos2::ZERO, color: colour(a.to_degrees(), t) }); - } - } - for i in 0..SEGMENTS { - mesh.indices.extend([0, 1 + i, 2 + i]); - } - for k in 1..RINGS { - let inner = 1 + (k - 1) * row; - let outer = inner + row; - for i in 0..SEGMENTS { - mesh.indices.extend([inner + i, outer + i, outer + i + 1, inner + i, outer + i + 1, inner + i + 1]); - } - } - mesh -} - -/// Quick Develop (grid with several photos selected): relative steps applied to every selected -/// photo from its own value. -fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { - ui.label(egui::RichText::new(crate::i18n::tr_format!("Quick Develop · {n} photos", n = n)).color(t.text_label).size(12.5)); - ui.add_space(4.0); - let rows: [(&str, &str, f64, f64); 9] = [ - ("Exposure", "light.exposure", 1.0 / 3.0, 1.0), - ("Contrast", "light.contrast", 5.0, 20.0), - ("Highlights", "light.highlights", 5.0, 20.0), - ("Shadows", "light.shadows", 5.0, 20.0), - ("Whites", "light.whites", 5.0, 20.0), - ("Blacks", "light.blacks", 5.0, 20.0), - ("Clarity", "effects.clarity", 5.0, 20.0), - ("Vibrance", "color.vibrance", 5.0, 20.0), - ("Temp", "wb.temp", 100.0, 500.0), - ]; - egui::Grid::new("quick-develop").num_columns(5).spacing([4.0, 3.0]).show(ui, |ui| { - for (label, ctl, small, big) in rows { - ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); - // Keep the existing automation ids while drawing all step arrows as vectors. - for (txt, icon, d) in [ - ("◀◀", Icon::ChevronDoubleLeft, -big), - ("◀", Icon::ChevronLeft, -small), - ("▶", Icon::ChevronRight, small), - ("▶▶", Icon::ChevronDoubleRight, big), - ] { - let r = crate::widgets::arrow_button(ui, "", icon, false); - r.widget_info(|| { - egui::WidgetInfo::labeled( - egui::Role::Button, - ui.is_enabled(), - crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label)), - ) - }); - crate::widgets::register(ui, format!("button:quick-{ctl}-{txt}"), r.rect); - if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label))) - .clicked() - { - let _ = app.run("develop.quickAdjust", json!({"control": ctl, "delta": d})); - } - } - ui.end_row(); - } - }); - }); -} - -/// The preview-only notice's Reload from Disk: re-read the photo's file (a raw a newer version can -/// decode, whose drive was offline when the library opened) in the background; a toast says what -/// came of it once it's done (`crate::reload`). -pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { - match app.run("photo.reload", json!({"ids": [id.0]})) { - Err(e) => app.toast_error(ctx, e), - Ok(r) if r["background"] == true => {} - // nothing to read in the background (a photo without a file): answered in place - Ok(_) if app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()) => { - app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")) - } - Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")), - } -} - -/// The Reset button's tooltip with the reset shortcut as this platform writes it (issue #1751). -fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String { - let base = crate::i18n::tr("Reset all edits"); - match crate::shortcuts::shortcut_of(keymap, "develop.reset") { - Some(sc) => format!("{base} ({})", crate::menubar::shortcut_text(sc, mac)), - None => base.to_string(), - } -} - -#[cfg(test)] -mod curve_histogram_tests { - use super::*; - - #[test] - fn curve_backdrop_tracks_luma_and_selected_channel() { - let img = lightcraft_raster::Rgba8::from_fn(64, 32, |x, _| if x < 32 { [32, 100, 32, 255] } else { [224, 100, 224, 255] }); - let h = lightcraft_raster::Histogram::of_srgb8(&img); - let red = curve_histogram_profile(&h, "red"); - let green = curve_histogram_profile(&h, "green"); - let luma = curve_histogram_profile(&h, "master"); - assert!(red[32] > red[150] && red[224] > red[150]); - assert!(green[100] > green[32] && green[100] > green[224]); - assert!(luma.iter().any(|v| *v > 0.0)); - } - - #[test] - fn hdr_backdrop_uses_only_the_sdr_bins_and_never_overflows() { - let h = lightcraft_raster::Histogram::of_hdr(&[[0.1, 0.1, 0.1], [1.0, 1.0, 1.0], [8.0, 8.0, 8.0]], 4.0); - let values = curve_histogram_profile(&h, "parametric"); - assert!(values.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); - assert!(values.iter().any(|v| *v > 0.0)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn an_hdr_edit_keeps_its_hdr_histogram_through_8_bit_renders() { - let ctx = egui::Context::default(); - let id = PhotoId(7); - let sdr = Histogram::of_hdr(&[], 5.0); - let sdr = Histogram { hdr: None, ..sdr }; - let hdr = |v: f32| Histogram::of_hdr(&[[v, v, v]], 5.0); - // nothing kept yet: what arrives - assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(sdr.clone())); - assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(2.0))), Some(hdr(2.0))); - // a cached thumbnail's 8-bit histogram (or none) doesn't swap the panel to SDR - assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(2.0))); - assert_eq!(steady_hdr_histogram(&ctx, id, true, None), Some(hdr(2.0))); - // the next HDR render replaces it: the histogram follows the slider - assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(4.0))), Some(hdr(4.0))); - assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(4.0))); - // HDR turned off: the SDR histogram at once; other photos keep their own - assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(sdr.clone())), Some(sdr.clone())); - assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr)); - } - - /// Issue #1751: Windows and Linux read Ctrl where macOS reads Cmd, and a rebound shortcut shows. - #[test] - fn reset_tooltip_names_the_shortcut_of_this_platform() { - let keymap = crate::shortcuts::Keymap::new(); - assert_eq!(reset_tip(&keymap, false), "Reset all edits (Ctrl+Shift+R)"); - assert_eq!(reset_tip(&keymap, true), "Reset all edits (⌘⇧R)"); - let rebound: crate::shortcuts::Keymap = [("develop.reset".to_string(), "Cmd+Alt+R".to_string())].into(); - assert_eq!(reset_tip(&rebound, false), "Reset all edits (Ctrl+Alt+R)"); - let unbound: crate::shortcuts::Keymap = [("develop.reset".to_string(), String::new())].into(); - assert_eq!(reset_tip(&unbound, false), "Reset all edits"); - } -} diff --git a/crates/ui-egui/src/panels/edit/curve.rs b/crates/ui-egui/src/panels/edit/curve.rs new file mode 100644 index 000000000..856211e66 --- /dev/null +++ b/crates/ui-egui/src/panels/edit/curve.rs @@ -0,0 +1,534 @@ +use super::histogram::preview_histogram; +use super::sliders::control; +use super::*; + +// ------------------------------------------------------------------------------ tone curve + +/// A 256-point, peak-normalized display profile of the photo histogram. +/// For HDR, only the SDR portion maps to a curve's input (0..1). +fn curve_histogram_profile(h: &lightcraft_raster::Histogram, channel: &str) -> [f32; 256] { + let source = match channel { + "red" => &h.r, + "green" => &h.g, + "blue" => &h.b, + _ => &h.luma, + }; + let bins = h.hdr.map_or(256, |hdr| hdr.from.clamp(2, 256)); + let samples: [f32; 256] = std::array::from_fn(|i| { + let j = i * (bins - 1) / 255; + let start = j.saturating_sub(2); + let end = (j + 3).min(bins); + let sum: f32 = (start..end).map(|k| source.get(k).copied().unwrap_or(0) as f32).sum(); + sum / (end - start) as f32 + }); + let peak = samples.iter().copied().fold(1.0_f32, f32::max); + samples.map(|v| (v / peak).sqrt()) +} + +/// Show the currently rendered photo's tonal distribution behind the editable +/// curve, without requesting another render or blocking pointer interactions. +fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histogram, channel: &str) { + let profile = curve_histogram_profile(h, channel); + let color = match channel { + "red" => hex("#dd3333"), + "green" => hex("#33bb55"), + "blue" => hex("#3377ee"), + _ => Color32::from_gray(130), + } + .gamma_multiply(0.27); + let mut mesh = Mesh::default(); + for (i, height) in profile.into_iter().enumerate() { + let x = r.left() + r.width() * i as f32 / 255.0; + let top = pos2(x, r.bottom() - r.height() * height); + mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color }); + mesh.vertices.push(Vertex { pos: pos2(x, r.bottom()), uv: Pos2::ZERO, color }); + if i > 0 { + let k = i as u32 * 2; + mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]); + } + } + p.add(mesh); +} + +/// Segments the graph's curve line is drawn with. +pub(crate) const CURVE_SAMPLES: i32 = 96; + +/// Close a graph drag's `develop` interaction, as one undo step. +/// +/// It is closed when the drag ends *or* the pointer comes up without egui reporting `drag_stopped` +/// for this widget (released outside the window, over a popup, or on a frame the widget does not +/// run). A dangling interaction would make every later edit a silent preview — Undo would then +/// appear to do nothing at all. Returns whether the drag is over, so the caller clears its state. +fn commit_interaction(app: &mut LightcraftApp, ui: &egui::Ui, dragging: bool, stopped: bool) -> bool { + let released = ui.input(|i| i.pointer.any_released()) || !ui.input(|i| i.pointer.primary_down()); + if !dragging || !(stopped || released) { + return false; + } + let _ = app.run("develop.endInteraction", json!({})); + true +} + +/// How close (screen points) to the curve a press counts as grabbing it. +const PARAM_GRAB: f32 = 14.0; + +/// The parametric curve's own drag: the graph shows the curve and nothing else, and dragging the +/// line bends the region the pointer is over, with that region's slider following on the same frame. +/// +/// The whole gesture runs inside one `develop` interaction, so it is one undo step (Ctrl+Z takes the +/// gesture back, not the last frame of it), and every write goes through `develop.set`, so the +/// slider, the curve and what is drawn are the same state rather than three things kept in step. +fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::Response, d: &DevelopSettings) { + if r.width() <= 1.0 || r.height() <= 1.0 { + return; + } + let t = Tokens::get(ui.ctx()); + let model = ParametricCurve::new(&d.curve); + let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); + let from_screen_x = |q: Pos2| ((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64; + // the graph x of a point, and how far it is from the drawn curve (in screen points) + let near_curve = |q: Pos2| { + let x = from_screen_x(q); + to_screen(x, model.at(x)).distance(q) + }; + let drag_id = egui::Id::unique("curve-param-drag"); + let cursor_id = egui::Id::unique("curve-param-cursor"); + // (region, x, initial_amount, start_y): the region being dragged, the graph x the drag is anchored + // at, the region's starting amount, and the starting screen Y position. + let mut dragged: Option<(usize, f64, f64, f32)> = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); + let hovered = dragged.is_none() && resp.hover_pos().is_some_and(|q| near_curve(q) <= PARAM_GRAB); + let mut set: Option<(&'static str, f64)> = None; + // the press, not where the pointer is once the drag threshold has passed: a press slightly off + // the line is still a grab, and the pointer has usually left that spot by the time it starts + let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); + if resp.drag_started() + && let Some(q) = press + && near_curve(q) <= PARAM_GRAB + { + let x = from_screen_x(q); + let _ = app.run("develop.beginInteraction", json!({"label": "Parametric Curve"})); + let region = model.region_at(x); + let initial_amount = model.amounts.get(region).copied().unwrap_or(0.0); + let start_pos = to_screen(x, model.at(x)); + dragged = Some((region, x, initial_amount, start_pos.y)); + ui.data_mut(|dd| dd.insert_temp(cursor_id, start_pos)); + } + if let Some((region, x, initial_amount, start_y)) = dragged + && resp.dragged() + { + // Follow the pointer's motion from where the curve was grabbed, so the press itself moves + // nothing and the line tracks the hand. ⌥ = a tenth of the motion, as the point curve does. + let alt = ui.input(|i| i.modifiers.alt); + let dd = ui.input(|i| i.pointer.delta()); + let step = if alt { vec2(dd.x * 0.1, dd.y * 0.1) } else { vec2(dd.x, dd.y) }; + let cursor = ui.data(|d| d.get_temp::(cursor_id)).unwrap_or(pos2(0.0, start_y)) + step; + ui.data_mut(|d| d.insert_temp(cursor_id, cursor)); + let delta_y = start_y - cursor.y; + let delta_level = (delta_y / r.height()) as f64; + let per_unit = model.level_per_unit(region, x); + let new_amount = + if per_unit.abs() > f64::EPSILON { (initial_amount + delta_level / per_unit).clamp(-100.0, 100.0).round() } else { initial_amount }; + if let Some(&id) = PARAM_AMOUNTS.get(region) { + set = Some((id, new_amount)); + } + } + if let Some((id, v)) = set { + let _ = app.run("develop.set", json!({"control": id, "value": v})); + } + let dropped = resp.drag_stopped(); + if commit_interaction(app, ui, dragged.is_some(), dropped) { + dragged = None; + ui.data_mut(|d| { + d.remove::(cursor_id); + d.remove::>(drag_id); + }); + } + ui.data_mut(|dd| dd.insert_temp(drag_id, dragged)); + if dragged.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + // the region under the pointer (or the one in hand), named as its slider below reads it + let shown = dragged.map(|(region, ..)| region).or_else(|| hovered.then(|| resp.hover_pos()).flatten().map(|q| model.region_at(from_screen_x(q)))); + if let Some(region) = shown + && let Some(&id) = PARAM_AMOUNTS.get(region) + && let Some(v) = set.and_then(|(s_id, v)| (s_id == id).then_some(v)).or_else(|| controls::get(d, id)) + { + let name = controls::find(id).map_or("", |s| s.label); + ui.painter().text(r.left_top() + vec2(6.0, 4.0), Align2::LEFT_TOP, format!("{name} {v:.0}"), t.font(11.0), t.text_dim); + } +} + +pub(super) fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { + let t = Tokens::get(ui.ctx()); + // channel selector + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + for (ch, c) in [ + ("parametric", Color32::from_gray(200)), + ("master", Color32::WHITE), + ("red", hex("#dd3333")), + ("green", hex("#33bb55")), + ("blue", hex("#3377ee")), + ] { + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {ch}", crate::i18n::tr("Curve channel"))); + register(ui, format!("curveChannel:{ch}"), r); + let sel = app.ui.curve_channel == ch; + if ch == "parametric" { + paint(ui.painter(), r.shrink(4.0), Icon::Curve, if sel { t.text } else { t.icon }); + } else { + ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(2.0, c)); + if sel { + ui.painter().circle_filled(r.center(), 3.5, c); + } + } + if sel { + ui.painter().line_segment([r.left_bottom() + vec2(4.0, 1.0), r.right_bottom() + vec2(-4.0, 1.0)], Stroke::new(2.0, t.text)); + } + let name = match ch { + "parametric" => "Parametric curve", + "master" => "Point curve", + "red" => "Red channel", + "green" => "Green channel", + _ => "Blue channel", + }; + let resp = resp.on_hover_text(crate::i18n::tr_format!("{name} — double-click to reset it", name = name)); + if resp.double_clicked() { + app.ui.curve_channel = ch.into(); + let _ = app.run("curve.reset", json!({"channel": ch})); + } else if resp.clicked() { + app.ui.curve_channel = ch.into(); + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + tat_button(app, ui, "tat:curve", "Targeted adjustment: drag up/down on the photo to adjust the curve there"); + }); + }); + }); + let ch = app.ui.curve_channel.clone(); + let w = ui.available_width(); + let side = (w - 48.0).max(80.0); + let (outer, _) = ui.allocate_exact_size(vec2(w, side + 12.0), Sense::hover()); + let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side)); + let resp = ui.interact(r, egui::Id::unique(("curve", &ch)), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Tone Curve")); + register(ui, "curve", r); + crate::widgets::context_menu(&resp, |ui| curve_reset_menu(app, ui, &ch)); + let p = ui.painter(); + p.rect_filled(r, 2.0, t.canvas); + if let Some(hist) = preview_histogram(app, id) { + draw_curve_histogram(p, r, &hist, &ch); + } + for i in 1..4 { + let f = i as f32 / 4.0; + p.line_segment( + [pos2(r.left() + r.width() * f, r.top()), pos2(r.left() + r.width() * f, r.bottom())], + Stroke::new(1.0, Color32::from_gray(52)), + ); + p.line_segment( + [pos2(r.left(), r.top() + r.height() * f), pos2(r.right(), r.top() + r.height() * f)], + Stroke::new(1.0, Color32::from_gray(52)), + ); + } + p.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.0, Color32::from_gray(70))); + let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); + let from_screen = |q: Pos2| (((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64, ((r.bottom() - q.y) / r.height()).clamp(0.0, 1.0) as f64); + if ch == "parametric" { + // The Effective curve: the model the renderer builds from the four region sliders, sampled + // here and rebuilt every frame. The graph holds no state of its own, so a slider moved by + // hand, a Reset or an Undo redraws it from the settings in the same frame it changes. + let c = &d.curve; + let model = ParametricCurve::new(c); + let pts: Vec = + (0..=CURVE_SAMPLES).map(|i| to_screen(i as f64 / CURVE_SAMPLES as f64, model.at(i as f64 / CURVE_SAMPLES as f64))).collect(); + p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220)))); + param_drag(app, ui, r, &resp, d); + curve_footer(app, ui, d); + for &c in PARAM_AMOUNTS.iter().rev() { + control(app, ui, d, c, true); + } + control(app, ui, d, "curve.refineSaturation", true); + return; + } + let pts_of = |d: &DevelopSettings| -> Vec { + let v = match ch.as_str() { + "red" => &d.curve.red, + "green" => &d.curve.green, + "blue" => &d.curve.blue, + _ => &d.curve.master, + }; + // in the order the curve is drawn (points from a sidecar, a preset or an agent can come + // unsorted or not finite): the drag below keeps each point between its neighbours + let mut v: Vec = v.iter().copied().filter(|q| q.x.is_finite() && q.y.is_finite()).collect(); + v.sort_by(|a, b| a.x.total_cmp(&b.x)); + if v.len() < 2 { vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)] } else { v } + }; + let pts = pts_of(d); + let curve = lightcraft_color::spline::MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::>()); + let color = match ch.as_str() { + "red" => hex("#dd3333"), + "green" => hex("#33bb55"), + "blue" => hex("#3377ee"), + _ => Color32::from_gray(225), + }; + let line: Vec = (0..=96).map(|i| to_screen(i as f64 / 96.0, curve.eval(i as f64 / 96.0).clamp(0.0, 1.0))).collect(); + p.add(egui::Shape::line(line, Stroke::new(2.0, color))); + // interaction: drag a point (both axes, between its neighbours; ⌥ = fine), drag empty space to add and + // drag a new point, click empty space to add, double-click a point to delete it + let drag_id = egui::Id::unique(("curve-drag", &ch)); + // the point being dragged (stored as `Option` so a stale value can be cleared) + let mut dragging: Option = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); + let nearest = |q: Pos2| pts.iter().enumerate().map(|(i, p)| (i, to_screen(p.x, p.y).distance(q))).min_by(|a, b| a.1.total_cmp(&b.1)); + let hovered = resp.hover_pos().and_then(nearest).filter(|(_, dist)| *dist < 10.0).map(|(i, _)| i); + if dragging.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if hovered.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + for (i, q) in pts.iter().enumerate() { + let c = to_screen(q.x, q.y); + if dragging == Some(i) || (dragging.is_none() && hovered == Some(i)) { + p.circle_filled(c, 5.5, color); + p.circle_stroke(c, 5.5, Stroke::new(1.0, t.canvas)); + } else { + p.circle_filled(c, 4.0, color); + } + } + // input / output readout of the point being dragged (0–255 like the histogram) + if let Some(q) = dragging.and_then(|i| pts.get(i)) { + p.text( + r.left_top() + vec2(6.0, 4.0), + egui::Align2::LEFT_TOP, + format!("{} / {}", (q.x * 255.0).round(), (q.y * 255.0).round()), + t.font(11.0), + t.text_dim, + ); + } + let mut new_pts = None; + if resp.double_clicked() + && let Some(q) = resp.interact_pointer_pos() + && let Some((i, dist)) = nearest(q) + && dist < 10.0 + && i != 0 + && i != pts.len() - 1 + { + let mut v = pts.clone(); + v.remove(i); + new_pts = Some(v); + } else if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + // pick the point under the press, not where the pointer is once the drag threshold passed + let _ = app.run("develop.beginInteraction", json!({"label": "Tone Curve"})); + match nearest(q) { + Some((i, dist)) if dist < 10.0 => dragging = Some(i), + _ => { + let (x, y) = from_screen(q); + let mut v = pts.clone(); + let i = v.iter().position(|p| p.x > x).unwrap_or(v.len()); + v.insert(i, Point::new(x, y)); + dragging = Some(i); + new_pts = Some(v); + } + } + } else if resp.dragged() + && let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos()) + { + let mut v = pts.clone(); + // ⌥-drag = fine adjustment, as in Lightroom: the point moves a tenth of the pointer's + // motion (relative to where it is, so pressing ⌥ mid-drag doesn't make it jump) + let fine = ui.input(|i| i.modifiers.alt); + let (mut x, y) = match v.get(i) { + Some(cur) if fine => { + let dd = ui.input(|i| i.pointer.delta()); + ((cur.x + (dd.x / r.width()) as f64 * 0.1).clamp(0.0, 1.0), (cur.y - (dd.y / r.height()) as f64 * 0.1).clamp(0.0, 1.0)) + } + _ => from_screen(q), + }; + if i < v.len() { + let lo = if i == 0 { 0.0 } else { v[i - 1].x + 0.01 }; + let hi = if i + 1 >= v.len() { 1.0 } else { v[i + 1].x - 0.01 }; + if i == 0 { + x = x.min(hi); + } else if i + 1 == v.len() { + x = x.max(lo); + } else if lo <= hi { + x = x.clamp(lo, hi); + } else { + // neighbours closer than the 0.02 spacing (two clicks side by side, or points + // from a file) leave no room: the point stays midway (`clamp` would panic: min above max) + x = (v[i - 1].x + v[i + 1].x) / 2.0; + } + v[i] = Point::new(x, y); + new_pts = Some(v); + } + } else if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + && nearest(q).is_none_or(|(_, dist)| dist >= 10.0) + { + let (x, y) = from_screen(q); + let mut v = pts.clone(); + let i = v.iter().position(|p| p.x > x).unwrap_or(v.len()); + v.insert(i, Point::new(x, y)); + new_pts = Some(v); + } + if let Some(v) = new_pts { + let arr: Vec<[f64; 2]> = v.iter().map(|p| [p.x, p.y]).collect(); + let _ = app.run("develop.curve", json!({"channel": ch, "points": arr})); + } + // the drag is over when egui says so, or when the pointer comes up without it saying so (a + // release outside the window would otherwise leave the interaction open, and every later edit a + // silent preview that Undo cannot take back) + if commit_interaction(app, ui, dragging.is_some(), resp.drag_stopped()) { + dragging = None; + } + ui.data_mut(|dd| dd.insert_temp(drag_id, dragging)); + curve_footer(app, ui, d); + control(app, ui, d, "curve.refineSaturation", true); + let _ = id; +} + +/// The row under the curve graph: point-curve presets and reset every curve. +fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + use lightcraft_engine::cmd::curves::{all_presets, matching_preset}; + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label)); + let current = matching_preset(&app.session, &d.curve); + let builtin = current.as_ref().is_none_or(|name| all_presets(&app.session).iter().any(|p| p.builtin && &p.name == name)); + let label = crate::i18n::builtin_label(current.as_deref().unwrap_or("Custom"), builtin); + let r = crate::widgets::dropdown(ui, "curvePreset", label, t.font(12.0), t.text); + egui::Popup::menu(&r).show(|ui| { + ui.set_min_width(180.0); + let presets = all_presets(&app.session); + let mut user_seen = false; + for p in &presets { + if !p.builtin && !user_seen { + user_seen = true; + ui.separator(); + } + let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)); + register(ui, format!("curvePreset:{}", p.name), r.rect); + if r.clicked() { + let _ = app.run("curve.applyPreset", json!({"name": p.name})); + ui.close(); + } + if !p.builtin { + crate::widgets::context_menu(&r, |ui| { + if ui.button(crate::i18n::tr("Delete Preset")).clicked() { + let _ = app.run("curve.deletePreset", json!({"name": p.name})); + ui.close(); + } + }); + } + } + ui.separator(); + let shaped = current.as_deref() != Some("Linear"); + let r = ui.add_enabled(shaped, egui::Button::new(crate::i18n::tr("Save Point Curve…"))); + register(ui, "curvePresetMenu:save", r.rect); + if r.clicked() { + crate::panels::dialogs::prompt(app, "Save Point Curve Preset", "Preset name", "", "curve.savePreset", json!({}), "name"); + ui.close(); + } + if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() { + let _ = app.run("file.importCurvePresets", json!({})); + ui.close(); + } + let can_export = app.services.save_curve_preset_file.is_some() && !app.session.curve_presets.is_empty(); + if ui.add_enabled(can_export, egui::Button::new(crate::i18n::tr("Export Presets…"))).clicked() { + let _ = app.run("file.exportCurvePresets", json!({})); + ui.close(); + } + if presets.iter().any(|p| !p.builtin) { + ui.label(RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim)); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = text_button(ui, "curveReset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr( + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)", + )); + if r.clicked() { + let _ = app.run("curve.reset", json!({"channel": "all"})); + } + }); + }); + }); +} + +/// Right-click menu of the curve graph. +fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) { + let label = if ch == "parametric" { + crate::i18n::tr("Reset Parametric Curve").to_string() + } else { + crate::i18n::tr_format!("Reset {} Channel", crate::i18n::tr(channel_label(ch))) + }; + let r = ui.button(label); + register(ui, "curveMenu:resetChannel", r.rect); + if r.clicked() { + let _ = app.run("curve.reset", json!({"channel": ch})); + ui.close(); + } + let r = ui.button(crate::i18n::tr("Reset All Curves")); + register(ui, "curveMenu:resetAll", r.rect); + if r.clicked() { + let _ = app.run("curve.reset", json!({"channel": "all"})); + ui.close(); + } +} + +fn channel_label(ch: &str) -> &'static str { + match ch { + "red" => "Red", + "green" => "Green", + "blue" => "Blue", + _ => "RGB", + } +} + +/// Toggle for a targeted-adjustment tool (`tool` = `tat:`). +fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) { + let active = app.ui.tool == tool; + let id = tool.replace(':', "-"); + if crate::widgets::icon_button(ui, &id, Icon::Target, vec2(26.0, 26.0), active, true, tip).clicked() { + set_tat(app, ui.ctx(), tool, active); + } +} + +/// Turn targeted-adjustment tool `tool` off (`active`) or on. Turning it on says how to use and +/// leave it: while it is on, dragging on the photo adjusts instead of zooming or panning, which is +/// easy to miss when the button looks like a tab. +pub(super) fn set_tat(app: &mut LightcraftApp, ctx: &egui::Context, tool: &str, active: bool) { + if active { + app.ui.tool.clear(); + } else { + app.ui.tool = tool.to_string(); + app.toast(ctx, crate::i18n::tr("Targeted adjustment on: drag up or down on the photo · Esc to stop")); + } +} + +#[cfg(test)] +mod curve_histogram_tests { + use super::*; + + #[test] + fn curve_backdrop_tracks_luma_and_selected_channel() { + let img = lightcraft_raster::Rgba8::from_fn(64, 32, |x, _| if x < 32 { [32, 100, 32, 255] } else { [224, 100, 224, 255] }); + let h = lightcraft_raster::Histogram::of_srgb8(&img); + let red = curve_histogram_profile(&h, "red"); + let green = curve_histogram_profile(&h, "green"); + let luma = curve_histogram_profile(&h, "master"); + assert!(red[32] > red[150] && red[224] > red[150]); + assert!(green[100] > green[32] && green[100] > green[224]); + assert!(luma.iter().any(|v| *v > 0.0)); + } + + #[test] + fn hdr_backdrop_uses_only_the_sdr_bins_and_never_overflows() { + let h = lightcraft_raster::Histogram::of_hdr(&[[0.1, 0.1, 0.1], [1.0, 1.0, 1.0], [8.0, 8.0, 8.0]], 4.0); + let values = curve_histogram_profile(&h, "parametric"); + assert!(values.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + assert!(values.iter().any(|v| *v > 0.0)); + } +} diff --git a/crates/ui-egui/src/panels/edit/grading.rs b/crates/ui-egui/src/panels/edit/grading.rs new file mode 100644 index 000000000..a0b430acd --- /dev/null +++ b/crates/ui-egui/src/panels/edit/grading.rs @@ -0,0 +1,418 @@ +use super::sliders::control; +use super::*; + +// ------------------------------------------------------------------------------ colour grading + +/// The colour grading tabs: key, tooltip, icon. +const GRADING_TABS: [(&str, &str, Icon); 4] = [ + ("3way", "3-Way", Icon::GradeAll), + ("shadows", "Shadows", Icon::GradeShadows), + ("midtones", "Midtones", Icon::GradeMidtones), + ("highlights", "Highlights", Icon::GradeHighlights), +]; + +/// The wheels, in the order of `UiState::grading_hidden`. +const GRADING_WHEELS: [(&str, &str); 3] = [("shadows", "Shadows"), ("midtones", "Midtones"), ("highlights", "Highlights")]; + +pub(super) fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId) { + let mode = GRADING_TABS.iter().find(|(k, ..)| *k == app.ui.grading_mode).map_or("3way", |(k, ..)| *k); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 2 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (key, tip, icon) in GRADING_TABS { + if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, crate::i18n::tr(tip)) + .clicked() + { + app.ui.grading_mode = key.to_string(); + } + } + }); + }); + let w = ui.available_width(); + if mode == "3way" { + // three wheels of one size: midtones on top, shadows and highlights below + let rad = 40.0; + let (area, _) = ui.allocate_exact_size(vec2(w, 276.0), Sense::hover()); + let at = [ + pos2(area.left() + w * 0.28, area.top() + 196.0), + pos2(area.center().x, area.top() + 70.0), + pos2(area.left() + w * 0.72, area.top() + 196.0), + ]; + for (i, c) in at.into_iter().enumerate() { + grading_wheel(app, ui, d, photo, i, c, rad, true); + } + for c in ["grading.shadows.lum", "grading.midtones.lum", "grading.highlights.lum"] { + control(app, ui, d, c, true); + } + } else { + let i = GRADING_WHEELS.iter().position(|(k, _)| *k == mode).unwrap_or(1); + let rad = (w * 0.3).clamp(40.0, 80.0); + let (area, _) = ui.allocate_exact_size(vec2(w, rad * 2.0 + 40.0), Sense::hover()); + grading_wheel(app, ui, d, photo, i, pos2(area.center().x, area.top() + rad + 10.0), rad, false); + for part in ["hue", "sat", "lum"] { + control(app, ui, d, &format!("grading.{mode}.{part}"), true); + } + } + for c in ["grading.blending", "grading.balance"] { + control(app, ui, d, c, true); + } +} + +/// One colour grading wheel (`i` into [`GRADING_WHEELS`]) centred on `c`: drag to pick hue and +/// saturation, double-click to reset, and an eye at its bottom-left corner that hides its effect +/// on the photo for viewing. `readout`: show hue and saturation centred above the wheel while +/// hovered (3-Way); otherwise the wheel is the large one and gets saturation guide rings. +#[allow(clippy::too_many_arguments)] +fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId, i: usize, c: Pos2, rad: f32, readout: bool) { + let t = Tokens::get(ui.ctx()); + let Some(&(key, label)) = GRADING_WHEELS.get(i) else { return }; + let wh = match key { + "shadows" => d.grading.shadows, + "midtones" => d.grading.midtones, + _ => d.grading.highlights, + }; + let hidden = app.ui.grading_hidden_for(photo).get(i).copied().unwrap_or(false); + let r = Rect::from_center_size(c, vec2(rad * 2.0, rad * 2.0)); + // the corners (eye, readout) count as the wheel for hovering + let zone = r.expand(10.0); + let hovering = ui.rect_contains_pointer(zone); + let resp = ui.interact(r, egui::Id::unique(("wheel", key)), Sense::click_and_drag()); + crate::widgets::announce(&resp, format!("{} {key}", crate::i18n::tr("Color wheel"))); + let resp = resp.on_hover_text(crate::i18n::tr( + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.", + )); + register(ui, format!("wheel:{key}"), r); + paint_wheel(ui.painter(), c, rad); + if hidden { + ui.painter().circle_filled(c, rad, Color32::from_black_alpha(150)); + } + // the large single wheel: dashed rings at 25 / 50 / 75 % saturation to judge strength by eye + if !readout { + for f in [0.25f32, 0.5, 0.75] { + let ring_rad = rad * f; + let n = (ring_rad * std::f32::consts::TAU / 2.0).max(8.0) as usize; + let ring: Vec = (0..=n).map(|k| c + egui::Vec2::angled(k as f32 / n as f32 * std::f32::consts::TAU) * ring_rad).collect(); + ui.painter().extend(egui::Shape::dashed_line(&ring, Stroke::new(0.8, Color32::from_white_alpha(45)), 4.0, 3.5)); + } + } + let a = (wh.hue as f32).to_radians(); + let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); + // Modifiers (as in Lightroom): Shift keeps the hue, Cmd/Ctrl keeps the saturation, Alt moves + // the puck a tenth as far as the pointer. Pressing or releasing one mid-drag re-grabs the + // puck as it is at that moment. + let grab_id = egui::Id::unique(("wheelGrab", key)); + let mods = ui.input(|i| WheelMods::from_modifiers(i.modifiers)); + let pointer = wheel_pointer(ui, &resp); + let grab = match pointer { + Some(q) if resp.dragged() || resp.clicked() => { + let held = ui.data(|m| m.get_temp::(grab_id)).filter(|g| g.mods == mods && !resp.drag_started()); + Some(held.unwrap_or_else(|| { + // a fine drag counts the pointer's travel from the press, not from the frame + // egui first called it a drag + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(q) } else { q }; + let g = WheelGrab::new(mods, wh.hue as f32, wh.sat as f32, origin - c, rad); + ui.data_mut(|m| m.insert_temp(grab_id, g)); + g + })) + } + _ => { + ui.data_mut(|m| m.remove::(grab_id)); + None + } + }; + // while a constraint is held: the guide for the one move left (the whole spoke for the hue, + // the circle for the saturation) + let guide = Stroke::new(1.5, Color32::from_white_alpha(170)); + match grab.and_then(|g| g.lock()) { + Some(WheelLock::Hue(h)) => { + let a = h.to_radians(); + ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * rad], guide); + } + Some(WheelLock::Sat(s)) => { + ui.painter().circle_stroke(c, rad * s / 100.0, guide); + } + None => {} + } + // the neutral centre (issue #559), under the point + ui.painter().circle_filled(c, 2.0, Color32::WHITE); + ui.painter().circle_filled(dot, 5.0, if hidden { Color32::from_gray(150) } else { Color32::WHITE }); + ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK)); + ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, crate::i18n::tr(label), t.font(12.0), t.text_label); + if resp.drag_started() { + let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)})); + } + if (resp.dragged() || resp.clicked()) + && let Some(q) = pointer + { + let (hue, sat) = grab.map_or_else(|| wheel_pick(q - c, rad, None), |g| g.pick(q - c, rad)); + let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); + } + if resp.drag_stopped() || resp.clicked() { + let _ = app.run("develop.endInteraction", json!({})); + } + // released, or Esc (egui ends a drag on it) + if resp.drag_stopped() + && let Some(held) = ui.ctx().data_mut(|m| m.remove_temp::(egui::Id::unique(WHEEL_CAPTURE))) + && !held.dropped + { + give_pointer_back(ui.ctx(), held.origin); + } + if resp.double_clicked() { + let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}})); + } + // hue and saturation readout: centred above the wheel, shown like the eye (no fade) + if readout && (hovering || resp.dragged()) { + // set a little below the gap's midpoint: the eye reads it as centred there + ui.painter().text( + pos2(c.x, r.top() - 13.0), + Align2::CENTER_CENTER, + format!("H {}° S {}", wh.hue.round() as i64, wh.sat.round() as i64), + t.font(11.0), + t.text_label, + ); + } + // the eye: on top of the wheel, shown while hovering or while the wheel is hidden + if hovering || hidden { + let eye = Rect::from_center_size(r.left_bottom() + vec2(2.0, -2.0), vec2(16.0, 16.0)); + let er = ui.interact(eye, egui::Id::unique(("wheelEye", key)), Sense::click()); + crate::widgets::announce(&er, crate::i18n::tr("Show / hide")); + register(ui, format!("wheelEye:{key}"), eye); + paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, if hidden { t.text_disabled } else { t.icon }); + let er = er.on_hover_text(crate::i18n::tr(if hidden { "Show this wheel's effect" } else { "Hide this wheel's effect" })); + if er.clicked() { + app.ui.set_grading_hidden(photo, i, !hidden); + } + } +} + +/// A wheel's modifier state. Shift keeps the hue, Cmd/Ctrl the saturation (Shift wins when both +/// are down: keeping both would freeze the puck), Alt fine-adjusts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct WheelMods { + pub keep_hue: bool, + pub keep_sat: bool, + pub fine: bool, +} + +impl WheelMods { + pub(crate) fn from_modifiers(m: egui::Modifiers) -> Self { + Self { keep_hue: m.shift, keep_sat: m.command && !m.shift, fine: m.alt } + } +} + +/// What a constraint holds still while the pointer moves. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum WheelLock { + /// Keep this hue (degrees): only the saturation changes, along the spoke at that hue. + Hue(f32), + /// Keep this saturation (0..100): only the hue changes, round the circle at that radius. + Sat(f32), +} + +/// Pointer travel to puck travel with Alt held, as for a slider's fine adjust. +const WHEEL_FINE: f32 = 0.1; + +/// The puck as it was when the pointer grabbed it (or a modifier last changed): the hue and +/// saturation a constraint keeps, and the origin a fine adjust measures the pointer's travel from. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct WheelGrab { + pub mods: WheelMods, + /// The puck's hue (degrees) and saturation (0..100) at the grab. + hue: f32, + sat: f32, + /// The pointer at the grab, as an offset from the wheel's centre. + origin: egui::Vec2, +} + +impl WheelGrab { + /// Grab a puck at `hue` / `sat` with the pointer `origin` from the centre of a wheel of + /// radius `rad`. The kept hue is the puck's own, never the angle the pointer happens to be + /// at. A puck at the centre (saturation under 1) has no hue yet, so there it is the angle of + /// the pointer at the grab; the same rule covers a non-finite hue. + pub(crate) fn new(mods: WheelMods, hue: f32, sat: f32, origin: egui::Vec2, rad: f32) -> Self { + let sat = if sat.is_finite() { sat.clamp(0.0, 100.0) } else { 0.0 }; + let hue = if hue.is_finite() && sat >= 1.0 { hue.rem_euclid(360.0) } else { wheel_pick(origin, rad, None).0 }; + Self { mods, hue, sat, origin } + } + + /// The constraint this grab holds, if any. + pub(crate) fn lock(&self) -> Option { + if self.mods.keep_hue { + Some(WheelLock::Hue(self.hue)) + } else if self.mods.keep_sat { + Some(WheelLock::Sat(self.sat)) + } else { + None + } + } + + /// The new hue and saturation for the pointer at offset `v` from the centre. + pub(crate) fn pick(&self, v: egui::Vec2, rad: f32) -> (f32, f32) { + let v = if self.mods.fine { + // the puck where it was, moved a tenth of the pointer's travel since the grab + let a = self.hue.to_radians(); + let puck = vec2(a.cos(), -a.sin()) * rad.max(1.0) * (self.sat / 100.0); + puck + (v - self.origin) * WHEEL_FINE + } else { + v + }; + wheel_pick(v, rad, self.lock()) + } +} + +/// A wheel drag holds the pointer, as in Lightroom (issue #1899): it is hidden for the drag, the +/// puck follows the mouse's own travel, and on release the pointer is back where the button went +/// down, so a long (Alt) drag doesn't leave it far from the wheels. On macOS the pointer is locked +/// in place for the drag; elsewhere it moves unseen and is put back. The browser can't move it +/// (eframe's web host ignores the command), so there it only shows again where the drag ended. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct WheelCapture { + /// Where the button went down: the pointer goes back there. + origin: Pos2, + /// Where the pointer would be had it been free: where the drag began plus the travel since. + pos: Pos2, + /// Let go early (the window lost focus): the rest of this drag leaves the wheel alone. + dropped: bool, + /// The pass that last drew the dragged wheel. + pass: u64, +} + +/// One wheel is dragged at a time, so one capture for them all. +const WHEEL_CAPTURE: &str = "wheelCapture"; + +/// The mouse's travel this frame: the pointer's, or, while the pointer stands still (locked, or +/// against a screen edge), the raw motion the host reports. +fn wheel_travel(delta: egui::Vec2, motion: Option) -> egui::Vec2 { + if delta != egui::Vec2::ZERO { + return delta; + } + motion.filter(|m| m.x.is_finite() && m.y.is_finite()).unwrap_or(egui::Vec2::ZERO) +} + +/// Where the wheel `resp` reads the pointer this frame: where it clicked, or for a drag the held +/// pointer ([`WheelCapture`]), `None` once a lost focus let go of it (Esc ends the drag itself: +/// egui stops a drag on Esc). +fn wheel_pointer(ui: &egui::Ui, resp: &egui::Response) -> Option { + let q = resp.interact_pointer_pos()?; + if !resp.dragged() { + return Some(q); + } + let ctx = ui.ctx(); + let id = egui::Id::unique(WHEEL_CAPTURE); + let held = ctx.data(|m| m.get_temp::(id)).filter(|_| !resp.drag_started()); + let mut cap = held.unwrap_or_else(|| { + if cfg!(target_os = "macos") { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::Locked)); + } + WheelCapture { origin: ui.input(|i| i.pointer.press_origin()).unwrap_or(q), pos: q, ..Default::default() } + }); + cap.pass = ctx.cumulative_pass_nr(); + if !cap.dropped { + let (travel, unfocused) = ui.input(|i| { + // the event, not `focused`: a click that activates the window may land before its focus + let unfocused = i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))); + (wheel_travel(i.pointer.delta(), i.pointer.motion()), unfocused) + }); + if !resp.drag_started() { + cap.pos += travel; + } + if unfocused { + give_pointer_back(ctx, cap.origin); + cap.dropped = true; + } else { + ctx.set_cursor_icon(egui::CursorIcon::None); + } + } + ctx.data_mut(|m| m.insert_temp(id, cap)); + (!cap.dropped).then_some(cap.pos) +} + +/// Unlock and show the pointer a wheel drag held, back at `origin`. +fn give_pointer_back(ctx: &egui::Context, origin: Pos2) { + if cfg!(target_os = "macos") { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::None)); + } + if cfg!(not(target_arch = "wasm32")) { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorPosition(origin)); + } +} + +/// Once a frame, after the panels: a held pointer stays hidden over whatever set its own cursor, +/// and is given back if no wheel drew this pass (the flyout closed or the view changed under the +/// drag), so it is never left locked or hidden. +pub(crate) fn wheel_pointer_frame_end(ctx: &egui::Context) { + let id = egui::Id::unique(WHEEL_CAPTURE); + let Some(cap) = ctx.data(|m| m.get_temp::(id)) else { return }; + if cap.pass != ctx.cumulative_pass_nr() { + ctx.data_mut(|m| m.remove::(id)); + if !cap.dropped { + give_pointer_back(ctx, cap.origin); + } + } else if !cap.dropped { + ctx.set_cursor_icon(egui::CursorIcon::None); + } +} + +/// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre. +/// `lock`: hold the hue (the saturation is `v` projected onto that hue's spoke) or hold the +/// saturation (the hue is the angle of `v`). A non-finite `v` gives a finite answer (the locked +/// value, else 0). +pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option) -> (f32, f32) { + let rad = rad.max(1.0); + let v = if v.x.is_finite() && v.y.is_finite() { v } else { egui::Vec2::ZERO }; + let angle = || ((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0; + match lock { + Some(WheelLock::Hue(h)) => { + let h = if h.is_finite() { h } else { 0.0 }; + let a = h.to_radians(); + let along = v.x * a.cos() - v.y * a.sin(); + (h, (along / rad * 100.0).clamp(0.0, 100.0)) + } + Some(WheelLock::Sat(s)) => (angle(), if s.is_finite() { s.clamp(0.0, 100.0) } else { 0.0 }), + None => (angle(), (v.length() / rad * 100.0).min(100.0)), + } +} + +fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) { + p.add(wheel_mesh(c, rad)); + p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40))); +} + +/// The picture of a grading wheel: a hue disc whose rim is fully saturated display sRGB at full +/// brightness, fading to a neutral grey at the centre. It is built from concentric rings so the +/// saturation ramps evenly with the radius instead of one fan of triangles blending each hue +/// straight into grey (which made the whole disc look muddy, issue #77). Only the drawing: +/// what a position means for the edit is [`wheel_pick`]. +pub(crate) fn wheel_mesh(c: Pos2, rad: f32) -> Mesh { + const SEGMENTS: u32 = 72; + const RINGS: u32 = 12; + /// Brightness (HSV value) at the centre; the rim is 1.0. + const CENTRE_V: f32 = 0.6; + let colour = |hue_deg: f32, t: f32| { + let rgb = lightcraft_color::perceptual::hsv_to_rgb(hue_deg, t, CENTRE_V + (1.0 - CENTRE_V) * t); + let u = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(u(rgb[0]), u(rgb[1]), u(rgb[2])) + }; + let mut mesh = Mesh::default(); + mesh.vertices.push(Vertex { pos: c, uv: Pos2::ZERO, color: colour(0.0, 0.0) }); + // ring k (1..=RINGS) holds SEGMENTS + 1 vertices starting at index 1 + (k - 1) * (SEGMENTS + 1) + let row = SEGMENTS + 1; + for k in 1..=RINGS { + let t = k as f32 / RINGS as f32; + for i in 0..=SEGMENTS { + let a = i as f32 / SEGMENTS as f32 * std::f32::consts::TAU; + mesh.vertices.push(Vertex { pos: c + vec2(a.cos(), -a.sin()) * rad * t, uv: Pos2::ZERO, color: colour(a.to_degrees(), t) }); + } + } + for i in 0..SEGMENTS { + mesh.indices.extend([0, 1 + i, 2 + i]); + } + for k in 1..RINGS { + let inner = 1 + (k - 1) * row; + let outer = inner + row; + for i in 0..SEGMENTS { + mesh.indices.extend([inner + i, outer + i, outer + i + 1, inner + i, outer + i + 1, inner + i + 1]); + } + } + mesh +} diff --git a/crates/ui-egui/src/panels/edit/histogram.rs b/crates/ui-egui/src/panels/edit/histogram.rs new file mode 100644 index 000000000..44d791c4a --- /dev/null +++ b/crates/ui-egui/src/panels/edit/histogram.rs @@ -0,0 +1,353 @@ +#[cfg(test)] +use super::sliders::reset_tip; +use super::*; + +// ------------------------------------------------------------------------------ histogram + +/// The current photo's existing preview histogram, shared by Histogram and Tone Curve. +pub(super) fn preview_histogram(app: &LightcraftApp, id: PhotoId) -> Option { + app.renderer + .textures + .get(&Slot::Main) + .filter(|t| t.photo == id) + .and_then(|t| t.histogram.clone()) + .or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone())) +} + +/// An HDR edit's histogram stays an HDR histogram: some renders carry only their 8-bit image's +/// (a drag's drafts, a cached thumbnail or stand-in), and showing those would swap the panel to +/// the SDR layout and back while a slider moves. Every render with HDR bins replaces the one kept for the photo, so +/// the histogram still follows the edit live; an SDR edit shows what it gets. +fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist: Option) -> Option { + let key = egui::Id::unique(("hdrHistogram", id)); + match hist { + Some(h) if h.hdr.is_some() => { + ctx.data_mut(|d| d.insert_temp(key, h.clone())); + Some(h) + } + h if hdr_edit => ctx.data(|d| d.get_temp::(key)).or(h), + h => h, + } +} + +pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let hdr_edit = app.session.develop_of(id).is_some_and(|d| d.hdr.enabled); + // a drag of an HDR edit: its drafts have no HDR histogram, so a small render beside them + // gives one (at its own pace: the drafts don't wait for it) + let interacting = app.session.interaction.is_some(); + if interacting && hdr_edit { + let edge = lightcraft_pipeline::HDR_HISTOGRAM_DRAG_EDGE; + if let Some(job) = app.session.render_job(id, edge, edge, false, app.ui.right != RightPanel::Crop) + && app.renderer.live_hdr_histogram.as_ref().is_none_or(|l| l.1 != job.key) + { + app.renderer.request(Slot::HdrHistogram, job, 95); + } + } + let live = app.renderer.live_hdr_histogram.as_ref().filter(|l| interacting && l.0 == id).map(|l| l.2.clone()); + // else the loupe render of this photo, else its thumbnail (grid view before the loupe has + // rendered it) + let hist = live.or_else(|| preview_histogram(app, id)); + let hist = steady_hdr_histogram(ui.ctx(), id, hdr_edit, hist); + // an HDR edit's histogram (`pipeline::hdr_histogram`) gets a colour bar and an SDR / HDR label + // row under the plot + let hdr = hist.as_ref().and_then(|h| h.hdr); + let legend = if hdr.is_some() { 26.0 } else { 0.0 }; + let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Histogram")); + register(ui, "histogram", r); + let p = ui.painter(); + p.rect_filled(r, 0.0, t.button); + let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0 - legend)); + // Lightroom Classic's tonal zones (Blacks / Shadows / Exposure / Highlights / Whites across the + // plot): a drag over one moves its control (the band and the readout show which and how far), a + // double-click puts it back to its default, and Shift drags a tenth as far + let zone = crate::panels::histogram::interact(app, ui, &resp, plot, id); + if let Some(a) = &zone { + crate::panels::histogram::paint_zone(p, plot, a, &t); + } + if let Some(b) = hdr { + // white for SDR, then one band per stop above SDR white: white too, or (Visualize HDR + // range on) in its colours + let x0 = plot.left() + plot.width() * b.from as f32 / 255.0; + let bar = |a: f32, z: f32, c: Color32| p.rect_filled(Rect::from_min_max(pos2(a, plot.bottom() + 2.0), pos2(z, plot.bottom() + 7.0)), 0.0, c); + let white = Color32::from_rgb(228, 228, 230); + bar(plot.left(), x0 - 1.0, white); + let stops = b.stops.round().max(1.0) as usize; + let bands = lightcraft_pipeline::visualize::HDR_BANDS; + let colours = app.ui.hdr_visualize; + for k in 0..stops { + let c = if colours { bands.get(k).or(bands.last()).copied().unwrap_or([255, 255, 255]) } else { [228, 228, 230] }; + let (a, z) = (x0 + (plot.right() - x0) * k as f32 / stops as f32, x0 + (plot.right() - x0) * (k + 1) as f32 / stops as f32); + bar(a + 1.0, z - 1.0, Color32::from_rgb(c[0], c[1], c[2])); + } + let y = plot.bottom() + 18.0; + p.text(pos2((plot.left() + x0) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("SDR"), t.font(12.5), t.text_label); + p.text(pos2((x0 + plot.right()) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("HDR"), t.font(12.5), t.text_label); + let info = Rect::from_center_size(pos2(plot.right() - 16.0, y), vec2(14.0, 14.0)); + paint(p, info, Icon::Info, t.text_label); + ui.interact(info, egui::Id::unique("hdrHistogramInfo"), Sense::hover()).on_hover_text(crate::i18n::tr( + "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.", + )); + } + let clipping = hist.as_ref().map(|h| h.clipping()); + if let Some(h) = hist { + let smooth = |v: &[u32]| -> Vec { + (0..256) + .map(|i| { + let (a, b) = (i.max(2) - 2, (i + 3).min(256)); + v[a..b].iter().map(|x| *x as f32).sum::() / (b - a) as f32 + }) + .collect() + }; + // HDR: SDR on the left half, a solid line at SDR white and dashed ones between the stops + if let Some(b) = h.hdr { + let x0 = plot.left() + plot.width() * b.from as f32 / 255.0; + let stops = b.stops.round().max(1.0) as usize; + for k in 1..stops { + let x = x0 + (plot.right() - x0) * k as f32 / stops as f32; + let mut y = plot.top(); + while y < plot.bottom() { + p.line_segment([pos2(x, y), pos2(x, (y + 5.0).min(plot.bottom()))], Stroke::new(1.0, Color32::from_gray(150))); + y += 9.0; + } + } + p.line_segment([pos2(x0, plot.top()), pos2(x0, plot.bottom())], Stroke::new(1.5, Color32::from_gray(160))); + // the photo's headroom limit on the fixed scale: marked, the range past it dimmed + let limit = app.session.develop_of(id).map_or(b.stops, |d| d.hdr.max_ev as f32); + if limit.is_finite() && limit >= 0.0 && limit < b.stops - 0.05 { + let x = x0 + (plot.right() - x0) * (limit / b.stops).clamp(0.0, 1.0); + p.rect_filled(Rect::from_min_max(pos2(x, plot.top()), plot.max), 0.0, Color32::from_black_alpha(70)); + p.line_segment([pos2(x, plot.top()), pos2(x, plot.bottom())], Stroke::new(1.0, Color32::from_rgb(230, 150, 60))); + } + } + let chans = [(smooth(&h.r), hex("#df3939")), (smooth(&h.g), hex("#44b072")), (smooth(&h.b), hex("#3b6fe0"))]; + let peak = chans.iter().flat_map(|(v, _)| v.iter().skip(2).take(252)).fold(1.0f32, |a, b| a.max(*b)); + let to = |i: usize, v: f32| pos2(plot.left() + plot.width() * i as f32 / 255.0, plot.bottom() - plot.height() * (v / peak).sqrt().min(1.0)); + // luminance fill + let lum = smooth(&h.luma); + let mut mesh = Mesh::default(); + for i in 0..256 { + let top = to(i, lum[i]); + mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) }); + mesh.vertices.push(Vertex { pos: pos2(top.x, plot.bottom()), uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) }); + if i > 0 { + let k = (i as u32) * 2; + mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]); + } + } + p.add(mesh); + // greyscale images: the three channels coincide — draw one neutral line + let spread: f32 = (0..256).map(|i| (chans[0].0[i] - chans[1].0[i]).abs() + (chans[1].0[i] - chans[2].0[i]).abs()).sum(); + let total: f32 = chans[1].0.iter().sum::().max(1.0); + if spread / total < 0.02 { + let pts: Vec = (0..256).map(|i| to(i, chans[1].0[i])).collect(); + p.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::from_gray(200)))); + } else { + for (v, c) in &chans { + let pts: Vec = (0..256).map(|i| to(i, v[i])).collect(); + p.add(egui::Shape::line(pts, Stroke::new(1.5, *c))); + } + } + } + let mut clicked = resp.clicked(); + // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep + // clear of it, the left one too so they stay symmetric (#1640) + let [shadow_rect, highlight_rect] = crate::panels::histogram::clipping_rects(ui, plot); + for (name, rect, label, clipped) in [ + ("shadows", shadow_rect, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), + ("highlights", highlight_rect, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), + ] { + let response = ui.interact(rect, ui.scope_id().with(("histogram-clipping", name)), Sense::click()); + register(ui, format!("histogram:{name}"), rect); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label))); + clicked |= response.clicked(); + let active = app.ui.show_clipping; + // Keep the hit target comfortable, with a smaller box and clear space to the plot edges. + let box_rect = rect.shrink2(vec2(2.0, 2.0)); + p.rect_filled( + box_rect, + 3.0, + if active { + t.pressed + } else if response.hovered() { + t.hover + } else { + Color32::from_gray(60) + }, + ); + if active { + p.rect_stroke(box_rect, 3.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + } + paint( + p, + Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), + Icon::ChevronDown, + if clipped == Some(true) || active { t.text } else { t.text_dim }, + ); + let status = match clipped { + Some(true) => "Clipping detected in this preview.", + Some(false) => "No clipping detected in this preview.", + None => "Histogram not yet available.", + }; + response.on_hover_text(format!( + "{}\n{}\n{}", + crate::i18n::tr(label), + crate::i18n::tr(if active { + "Click to hide clipping warnings for shadows and highlights." + } else { + "Click to show clipping warnings for shadows and highlights." + }), + crate::i18n::tr(status) + )); + } + if clicked { + let _ = app.run("view.clipping", json!({})); + } + // the tonal zone the pointer is on replaces the EXIF strip while it is there + if let Some(text) = zone.as_ref().and_then(|a| a.readout()) { + register(ui, "histogramReadout", r); + p.text(pos2(r.left() + r.width() / 2.0, r.bottom() - 13.0), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); + } else if let Some(ph) = app.session.catalog.photo(id) { + let m = &ph.meta; + let parts: Vec = [ + m.iso.map(|i| format!("ISO {i}")), + m.focal_mm.map(|f| format!("{f:.0}mm")), + m.aperture.map(|a| format!("f/{a:.1}")), + if m.shutter.is_empty() { None } else { Some(m.shutter.clone()) }, + ] + .into_iter() + .flatten() + .collect(); + + if !parts.is_empty() { + let text = parts.join(" · "); + let y = r.bottom() - 13.0; + let x = r.left() + r.width() / 2.0; + p.text(pos2(x, y), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); + } + } +} + +// ------------------------------------------------------------------------------ soft proofing + +/// The Soft Proofing strip under the histogram: proof profile, gamut warnings, Create Proof Copy. +pub(super) fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + use lightcraft_engine::pipeline::OutputSpace; + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 6 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Soft Proofing")).color(t.text).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut dest = app.ui.proof.dest_warning; + let r = ui + .toggle_value(&mut dest, RichText::new("■").color(Color32::from_rgb(255, 40, 40))) + .on_hover_text(crate::i18n::tr("Show destination gamut warning")); + register(ui, "button:proofDestWarning", r.rect); + let mut disp = app.ui.proof.display_warning; + let r2 = ui + .toggle_value(&mut disp, RichText::new("■").color(Color32::from_rgb(40, 90, 255))) + .on_hover_text(crate::i18n::tr("Show display gamut warning")); + register(ui, "button:proofDisplayWarning", r2.rect); + if dest != app.ui.proof.dest_warning || disp != app.ui.proof.display_warning { + let _ = app.run("view.softProof", json!({"destWarning": dest, "displayWarning": disp})); + } + }); + }); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Profile:")).color(t.text_label)); + let cur = app.ui.proof.space; + let device = app.ui.proof_profile.as_ref().map(|(_, n)| n.clone()); + let shown = device.clone().unwrap_or_else(|| cur.label().to_string()); + let mut pick = false; + let combo = crate::widgets::combo_box("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { + for s in OutputSpace::ALL { + if ui.selectable_label(device.is_none() && cur == s, s.label()).clicked() { + let _ = app.run("view.softProof", json!({"space": s})); + } + } + if let Some(n) = &device { + let _ = ui.selectable_label(true, n); + } + if app.services.pick_display_profile.is_some() && ui.selectable_label(false, crate::i18n::tr("Other…")).clicked() { + pick = true; + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Profile")); + if pick + && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) + && let Err(e) = app.run("view.softProof", json!({"profile": path})) + { + app.toast(ui.ctx(), e); + } + }); + if app.ui.proof_profile.is_some() { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Intent:")).color(t.text_label)); + use lightcraft_engine::proof::ProofIntent; + for (i, label) in [(ProofIntent::Perceptual, "Perceptual"), (ProofIntent::Relative, "Relative")] { + let r = ui.selectable_label(app.ui.proof_intent == i, crate::i18n::tr(label)); + register(ui, format!("button:proofIntent-{}", i.name()), r.rect); + if r.clicked() && app.ui.proof_intent != i { + let _ = app.run("view.softProof", json!({"intent": i.name()})); + } + } + }); + let mut paper = app.ui.proof_paper_ink; + let r = ui.checkbox(&mut paper, crate::i18n::tr("Simulate Paper & Ink")); + register(ui, "button:proofPaperInk", r.rect); + if r.changed() { + let _ = app.run("view.softProof", json!({"paperInk": paper})); + } + } + let r = ui.button(crate::i18n::tr("Create Proof Copy")); + register(ui, "button:createProofCopy", r.rect); + if r.clicked() { + let target = app.ui.proof_profile.as_ref().map_or_else(|| app.ui.proof.space.label().to_string(), |(_, n)| n.clone()); + let name = crate::i18n::tr_format!("Proof Copy ({})", target); + if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() { + app.toast(ui.ctx(), crate::i18n::tr("Proof copy created")); + } + } + }); + divider(ui); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_hdr_edit_keeps_its_hdr_histogram_through_8_bit_renders() { + let ctx = egui::Context::default(); + let id = PhotoId(7); + let sdr = Histogram::of_hdr(&[], 5.0); + let sdr = Histogram { hdr: None, ..sdr }; + let hdr = |v: f32| Histogram::of_hdr(&[[v, v, v]], 5.0); + // nothing kept yet: what arrives + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(sdr.clone())); + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(2.0))), Some(hdr(2.0))); + // a cached thumbnail's 8-bit histogram (or none) doesn't swap the panel to SDR + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(2.0))); + assert_eq!(steady_hdr_histogram(&ctx, id, true, None), Some(hdr(2.0))); + // the next HDR render replaces it: the histogram follows the slider + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(4.0))), Some(hdr(4.0))); + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(4.0))); + // HDR turned off: the SDR histogram at once; other photos keep their own + assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(sdr.clone())), Some(sdr.clone())); + assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr)); + } + + /// Issue #1751: Windows and Linux read Ctrl where macOS reads Cmd, and a rebound shortcut shows. + #[test] + fn reset_tooltip_names_the_shortcut_of_this_platform() { + let keymap = crate::shortcuts::Keymap::new(); + assert_eq!(reset_tip(&keymap, false), "Reset all edits (Ctrl+Shift+R)"); + assert_eq!(reset_tip(&keymap, true), "Reset all edits (⌘⇧R)"); + let rebound: crate::shortcuts::Keymap = [("develop.reset".to_string(), "Cmd+Alt+R".to_string())].into(); + assert_eq!(reset_tip(&rebound, false), "Reset all edits (Ctrl+Alt+R)"); + let unbound: crate::shortcuts::Keymap = [("develop.reset".to_string(), String::new())].into(); + assert_eq!(reset_tip(&unbound, false), "Reset all edits"); + } +} diff --git a/crates/ui-egui/src/panels/edit/mixer.rs b/crates/ui-egui/src/panels/edit/mixer.rs new file mode 100644 index 000000000..b869b32dd --- /dev/null +++ b/crates/ui-egui/src/panels/edit/mixer.rs @@ -0,0 +1,146 @@ +use super::curve::set_tat; +use super::sliders::{control, sub_title}; +use super::*; + +// ------------------------------------------------------------------------------ colour mixer + +pub(super) fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + let t = Tokens::get(ui.ctx()); + let bands = lightcraft_develop::MIXER_BANDS; + let sel = bands.iter().position(|b| *b == app.ui.mixer_mode).unwrap_or(0); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + for (i, b) in bands.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(b)); + register(ui, format!("mixerBand:{b}"), r); + ui.painter().circle_filled(r.center(), 9.0, hex(BAND_COLORS[i])); + if i == sel { + ui.painter().circle_stroke(r.center(), 10.5, Stroke::new(1.5, t.text)); + ui.painter().circle_filled(r.center(), 2.0, Color32::WHITE); + } + if resp.clicked() { + app.ui.mixer_mode = b.to_string(); + } + } + }); + }); + // targeted adjustment: pick the attribute, then drag on the photo + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover()); + paint(ui.painter(), r, Icon::Target, t.icon); + let items: &[(&str, &str)] = + if crate::is_bw(d) { &[("lum", "B&W Mix")] } else { &[("hue", "Hue"), ("sat", "Saturation"), ("lum", "Luminance")] }; + for (k, label) in items { + let tool = format!("tat:{k}"); + let active = app.ui.tool == tool; + if text_button(ui, &format!("tatMixer-{k}"), crate::i18n::tr(label), active) + .on_hover_text(crate::i18n::tr("Targeted adjustment: drag up/down on the photo to adjust the colours there")) + .clicked() + { + set_tat(app, ui.ctx(), &tool, active); + } + } + }); + }); + let b = bands[sel]; + if crate::is_bw(d) { + ui.horizontal(|ui| { + ui.add_space(24.0); + if crate::widgets::text_button(ui, "bwAuto", crate::i18n::tr("Auto"), false) + .on_hover_text(crate::i18n::tr("Set the mix from the photo's colours")) + .clicked() + { + let _ = app.run("develop.autoBwMix", json!({})); + } + }); + control(app, ui, d, &format!("bw.{b}"), true); + } else { + for k in ["hue", "sat", "lum"] { + control(app, ui, d, &format!("mixer.{b}.{k}"), true); + } + } +} + +// ------------------------------------------------------------------------------ point color + +/// Display colour of an OkLCh sample (lightness, chroma, hue in degrees). +fn oklch_color(l: f64, c: f64, h_deg: f64) -> Color32 { + use lightcraft_color::perceptual::{lch_to_lab, oklab_to_2020}; + let lin = oklab_to_2020(lch_to_lab([l as f32, c as f32, (h_deg as f32).to_radians()])); + let s = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).apply_f32(lin); + let e = s.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8); + Color32::from_rgb(e[0], e[1], e[2]) +} + +/// Point Color: swatches of the samples (+ the eyedropper), the selected sample's shifts and range, +/// and "Visualize range". +pub(super) fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + let t = Tokens::get(ui.ctx()); + let n = d.point_colors.len(); + if app.ui.point_color >= n && n > 0 { + app.ui.point_color = n - 1; + } + let sel = app.ui.point_color; + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + let active = app.ui.tool == "pointColor"; + let full = n >= lightcraft_develop::MAX_POINT_COLORS; + if crate::widgets::icon_button( + ui, + "pointColorPicker", + Icon::Picker, + vec2(26.0, 26.0), + active, + !full, + crate::i18n::tr("Sample a colour on the photo"), + ) + .clicked() + { + app.ui.tool = if active { String::new() } else { "pointColor".into() }; + } + for (i, p) in d.point_colors.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Point Color"), i + 1)); + register(ui, format!("pointColor:{i}"), r); + ui.painter().rect_filled(r.shrink(2.0), 3.0, oklch_color(p.lum, p.chroma, p.hue)); + if i == sel { + ui.painter().rect_stroke(r, 4.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); + } + if resp.clicked() { + app.ui.point_color = i; + } + } + }); + }); + if n == 0 { + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 8 }).show(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Pick a colour on the photo with the eyedropper.")).size(12.0).color(t.text_dim)); + }); + return; + } + for k in ["hueShift", "satShift", "lumShift", "variance"] { + control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); + } + sub_title(ui, crate::i18n::tr("Range")); + for k in ["range", "hueRange", "satRange", "lumRange"] { + control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); + } + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 8 }).show(ui, |ui| { + ui.horizontal(|ui| { + let mut v = app.ui.point_color_visualize; + if ui.checkbox(&mut v, crate::i18n::tr("Visualize range")).changed() { + app.ui.point_color_visualize = v; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if text_button(ui, "pointColorDelete", crate::i18n::tr("Delete"), false).clicked() { + let _ = app.run("pointColor.delete", json!({"index": sel})); + } + }); + }); + }); +} diff --git a/crates/ui-egui/src/panels/edit/sliders.rs b/crates/ui-egui/src/panels/edit/sliders.rs new file mode 100644 index 000000000..aad9cdf6a --- /dev/null +++ b/crates/ui-egui/src/panels/edit/sliders.rs @@ -0,0 +1,303 @@ +use super::*; + +/// Commit a slider interaction: begin → live updates → end, so a drag is one undo step. +pub fn apply_slider_out( + app: &mut LightcraftApp, + spec: &ControlSpec, + out: SliderOut, + mut set: impl FnMut(&mut LightcraftApp, f64) -> Result, +) { + if out.drag_started && !out.reset { + let _ = app.run("develop.beginInteraction", json!({"label": spec.label})); + app.ui.dragging_control = Some(spec.id.to_string()); + } + if let Some(v) = out.value { + let _ = set(app, v); + } + if out.drag_stopped || out.reset { + let _ = app.run("develop.endInteraction", json!({})); + app.ui.dragging_control = None; + } +} + +pub(crate) fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, enabled: bool) { + let Some(spec) = controls::find(id) else { return }; + let v = controls::get(d, id).unwrap_or(spec.default); + let out = slider(ui, spec, v, enabled, None); + apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": id, "value": v}))); +} + +/// Relative temperature scale for rendered (non-raw) files: −100..100 ↔ Kelvin via mired shift. +pub(super) const REL_TEMP: ControlSpec = ControlSpec { + id: "wb.tempRel", + label: "Temp", + section: Section::Color, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Track::Temp, +}; + +pub(super) const REL_TINT: ControlSpec = ControlSpec { + id: "wb.tintRel", + label: "Tint", + section: Section::Color, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Track::Tint, +}; + +pub(super) fn k_to_rel(k: f64) -> f64 { + ((1e6 / 6500.0 - 1e6 / k) / 0.8).clamp(-100.0, 100.0) +} + +pub(super) fn rel_to_k(r: f64) -> f64 { + 1e6 / (1e6 / 6500.0 - r * 0.8) +} + +/// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and +/// Browse…. Like the menu bar's menus, the dropdown and each submenu stay inside the window and +/// scroll when their rows don't fit (issue #2059: a folder of many imported profiles ran past the +/// window's bottom edge). `below` is the dropdown's bottom edge, which the menu hangs from. +pub(super) fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { + crate::menu_level::level(ui, 1, Some(below), |ui| profile_menu_rows(app, ui, d, below)); +} + +fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { + use lightcraft_engine::presets::{PROFILES, profile_groups}; + let t = Tokens::get(ui.ctx()); + ui.set_min_width(200.0); + let cur = d.profile.id.as_str(); + // (clicked, hovered) + let mut pick: (Option, Option) = (None, None); + let item = |ui: &mut egui::Ui, key: &str, id: &str, name: &str, pick: &mut (Option, Option)| { + let r = ui.selectable_label(id == cur, crate::i18n::profile_label(id, name)); + register(ui, format!("profileMenu:{key}:{id}"), r.rect); + if r.clicked() { + pick.0 = Some(id.to_string()); + } + if r.hovered() { + pick.1 = Some(id.to_string()); + } + }; + let heading = |ui: &mut egui::Ui, s: &str| { + ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim)); + }; + for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] { + let list: Vec<_> = ids.iter().filter_map(|id| app.session.profile_info(id).map(|(name, _)| (id.as_str(), name))).collect(); + if list.is_empty() { + continue; + } + heading(ui, title); + for (id, name) in list { + item(ui, key, id, name, &mut pick); + } + ui.separator(); + } + for g in profile_groups() { + let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in PROFILES.iter().filter(|p| p.group == g) { + item(ui, "group", p.id, p.name, &mut pick); + } + }); + }); + register(ui, format!("profileMenu:groupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } + } + // imported LUT profile groups + let mut lut_groups: Vec = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); + lut_groups.sort_unstable(); + lut_groups.dedup(); + for g in &lut_groups { + // a folder's name as it is, and its own widget id even when it matches a built-in group + let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { + item(ui, "group", &p.id, &p.name, &mut pick); + } + }); + }); + register(ui, format!("profileMenu:lutGroupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } + } + ui.separator(); + if let Some((name, _)) = app.session.profile_info(cur) { + let fav = app.session.profile_favorites.iter().any(|f| f == cur); + let label = if fav { + crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::profile_label(cur, name)) + } else { + crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::profile_label(cur, name)) + }; + let r = ui.button(label); + register(ui, "profileMenu:toggleFavorite", r.rect); + if r.clicked() { + let _ = app.run("profile.favorite", json!({"id": cur})); + } + } + let r = ui.button(crate::i18n::tr("Browse…")); + register(ui, "profileMenu:browse", r.rect); + if r.clicked() { + let _ = app.run("panel.profiles", json!({})); + } + // resting on a profile previews it in the loupe + if let Some(hovered_id) = &pick.1 + && hovered_id != cur + && let Some((name, _)) = app.session.profile_info(hovered_id) + { + let mut s = d.clone(); + s.profile.id = hovered_id.clone(); + s.profile.amount = 100.0; + app.hover_preview = + Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(hovered_id, name)), settings: s }); + } + if let Some(id) = pick.0 { + let _ = app.run("develop.profile", json!({"id": id})); + } +} + +pub fn sub_title(ui: &mut egui::Ui, title: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); + ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label); +} + +pub(super) fn section( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + d: &DevelopSettings, + id: &str, + title: &str, + body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui, &DevelopSettings), +) { + let open = app.ui.section_open(id); + let (resp, toggled) = section_header(ui, id, title, open, Some(d.section_enabled(id))); + if let Some(on) = toggled { + let _ = app.run("develop.sectionEnabled", json!({"section": id, "enabled": on})); + } else if resp.clicked() { + // ⌥-click opens just this section, as in Lightroom Classic + let solo = ui.input(|i| i.modifiers.alt); + if let Err(e) = app.run(&format!("section.{id}"), json!({"solo": solo})) { + app.toast(ui.ctx(), e); + } + } + crate::widgets::context_menu(&resp, |ui| { + let mut solo = app.ui.single_panel; + let item = ui.checkbox(&mut solo, crate::i18n::tr("Solo Mode")); + register(ui, "sectionMenu:solo", item.rect); + if item.clicked() { + if let Err(e) = app.run("view.soloSections", json!({"on": solo})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }); + if open { + body(app, ui, d); + } + divider(ui); +} + +// ------------------------------------------------------------------------------ HDR + +/// The Light panel's HDR row: the HDR switch and Visualize HDR, then (HDR on) the headroom limit. +/// The Light panel's HDR settings while HDR editing is on (the switch itself is the HDR button +/// next to B&W): Visualize HDR range and the headroom limit. +pub(super) fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + if !d.hdr.enabled { + return; + } + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| { + let mut v = app.ui.hdr_visualize; + let r = ui.checkbox(&mut v, "Visualize HDR"); + register(ui, "check:hdrVisualize", r.rect); + if r.on_hover_text("Grey below SDR white; yellow, orange, red, magenta up to 1, 2, 3 and over 3 stops above it").changed() { + app.ui.hdr_visualize = v; + } + }); + control(app, ui, d, "hdr.maxEv", true); +} + +/// Quick Develop (grid with several photos selected): relative steps applied to every selected +/// photo from its own value. +pub(super) fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr_format!("Quick Develop · {n} photos", n = n)).color(t.text_label).size(12.5)); + ui.add_space(4.0); + let rows: [(&str, &str, f64, f64); 9] = [ + ("Exposure", "light.exposure", 1.0 / 3.0, 1.0), + ("Contrast", "light.contrast", 5.0, 20.0), + ("Highlights", "light.highlights", 5.0, 20.0), + ("Shadows", "light.shadows", 5.0, 20.0), + ("Whites", "light.whites", 5.0, 20.0), + ("Blacks", "light.blacks", 5.0, 20.0), + ("Clarity", "effects.clarity", 5.0, 20.0), + ("Vibrance", "color.vibrance", 5.0, 20.0), + ("Temp", "wb.temp", 100.0, 500.0), + ]; + egui::Grid::new("quick-develop").num_columns(5).spacing([4.0, 3.0]).show(ui, |ui| { + for (label, ctl, small, big) in rows { + ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); + // Keep the existing automation ids while drawing all step arrows as vectors. + for (txt, icon, d) in [ + ("◀◀", Icon::ChevronDoubleLeft, -big), + ("◀", Icon::ChevronLeft, -small), + ("▶", Icon::ChevronRight, small), + ("▶▶", Icon::ChevronDoubleRight, big), + ] { + let r = crate::widgets::arrow_button(ui, "", icon, false); + r.widget_info(|| { + egui::WidgetInfo::labeled( + egui::Role::Button, + ui.is_enabled(), + crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label)), + ) + }); + crate::widgets::register(ui, format!("button:quick-{ctl}-{txt}"), r.rect); + if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label))) + .clicked() + { + let _ = app.run("develop.quickAdjust", json!({"control": ctl, "delta": d})); + } + } + ui.end_row(); + } + }); + }); +} + +/// The preview-only notice's Reload from Disk: re-read the photo's file (a raw a newer version can +/// decode, whose drive was offline when the library opened) in the background; a toast says what +/// came of it once it's done (`crate::reload`). +pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { + match app.run("photo.reload", json!({"ids": [id.0]})) { + Err(e) => app.toast_error(ctx, e), + Ok(r) if r["background"] == true => {} + // nothing to read in the background (a photo without a file): answered in place + Ok(_) if app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()) => { + app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")) + } + Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")), + } +} + +/// The Reset button's tooltip with the reset shortcut as this platform writes it (issue #1751). +pub(super) fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String { + let base = crate::i18n::tr("Reset all edits"); + match crate::shortcuts::shortcut_of(keymap, "develop.reset") { + Some(sc) => format!("{base} ({})", crate::menubar::shortcut_text(sc, mac)), + None => base.to_string(), + } +} From db31bf3e592c8d626fb9e66154befd648d89d47a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:11:14 +0000 Subject: [PATCH 123/191] refactor(lightcraft-ui-egui): split panels/right.rs into panels/right/ --- crates/ui-egui/src/panels/right.rs | 1472 +------------------ crates/ui-egui/src/panels/right/crop.rs | 483 ++++++ crates/ui-egui/src/panels/right/history.rs | 179 +++ crates/ui-egui/src/panels/right/info.rs | 467 ++++++ crates/ui-egui/src/panels/right/keywords.rs | 330 +++++ 5 files changed, 1476 insertions(+), 1455 deletions(-) create mode 100644 crates/ui-egui/src/panels/right/crop.rs create mode 100644 crates/ui-egui/src/panels/right/history.rs create mode 100644 crates/ui-egui/src/panels/right/info.rs create mode 100644 crates/ui-egui/src/panels/right/keywords.rs diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 341259dbc..ec178223d 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -11,6 +11,23 @@ use crate::state::RightPanel; use crate::theme::Tokens; use crate::widgets::{divider, register, slider, text_button}; +mod crop; +mod history; +mod info; +mod keywords; + +#[cfg(test)] +use crop::aspect_label; +pub(crate) use crop::tool_overlay_options; +use crop::{crop, red_eye, remove}; +#[cfg(test)] +use history::{HistoryRow, history_row, short_time}; +use history::{activity, versions}; +use info::info; +#[cfg(test)] +pub(crate) use info::mixed_meta; +use keywords::keywords; + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); @@ -140,1460 +157,5 @@ fn padded(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 6 }).show(ui, add); } -/// The aspect presets of the Crop panel: (menu label, `crop.aspect` value). -const ASPECT_PRESETS: [(&str, &str); 10] = [ - ("Free", "free"), - ("Original", "original"), - ("1 × 1", "1x1"), - ("4 × 5 / 8 × 10", "4x5"), - ("8.5 × 11", "8.5x11"), - ("5 × 7", "5x7"), - ("2 × 3 / 4 × 6", "2x3"), - ("4 × 3", "4x3"), - ("16 × 9", "16x9"), - ("16 × 10", "16x10"), -]; - -/// What the aspect button says for the stored lock (`aspect` = width × 100, height × 100): -/// a preset's name when the ratio matches one in either orientation, "Original" for the photo's own -/// shape, otherwise the ratio as `1.37 : 1`. -fn aspect_label(aspect: Option<(u32, u32)>, original: Option) -> String { - let Some((w, h)) = aspect else { return "Free".into() }; - if w == 0 || h == 0 { - return "Free".into(); - } - let r = w as f64 / h as f64; - let is = |q: f64| q.is_finite() && q > 0.0 && ((r - q).abs() / q < 0.004 || (1.0 / r - q).abs() / q < 0.004); - if original.is_some_and(is) { - return "Original".into(); - } - for (label, key) in ASPECT_PRESETS { - if let Some((x, y)) = key.split_once('x') - && let (Ok(x), Ok(y)) = (x.parse::(), y.parse::()) - && is(x / y) - { - return label.to_string(); - } - } - format!("{:.2} : 1", r.max(1.0 / r)) -} - -/// "Custom" row of the aspect menu: two number fields and an Apply button (`crop.aspect` `[w, h]`). -fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let key = egui::Id::unique("crop-custom-aspect"); - let (mut w, mut h): (String, String) = ui.data_mut(|d| d.get_temp(key)).unwrap_or_else(|| ("3".into(), "2".into())); - let mut apply = false; - ui.horizontal(|ui| { - ui.label(crate::i18n::tr("Custom")); - let a = ui.add(egui::TextEdit::singleline(&mut w).desired_width(34.0)); - crate::widgets::screen_reader_name(ui.ctx(), a.id, crate::i18n::tr("Width")); - register(ui, "field:cropCustomW", a.rect); - ui.label("×"); - let b = ui.add(egui::TextEdit::singleline(&mut h).desired_width(34.0)); - crate::widgets::screen_reader_name(ui.ctx(), b.id, crate::i18n::tr("Height")); - register(ui, "field:cropCustomH", b.rect); - let go = text_button(ui, "cropCustomApply", "Apply", false); - apply = go.clicked() || ((a.lost_focus() || b.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter))); - }); - if apply { - // Accept `3`, `2.5` and `3,5`; anything else (zero, negative, text, huge) is ignored. - let num = |t: &str| t.trim().replace(',', ".").parse::().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 1000.0); - if let (Some(x), Some(y)) = (num(&w), num(&h)) - && (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&(x / y)) - { - let _ = app.run("crop.aspect", json!({"aspect": [x, y]})); - ui.close(); - } - } - ui.data_mut(|d| d.insert_temp(key, (w, h))); -} - -fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let d = app.session.develop_of(id).unwrap_or_default(); - header(ui, "Crop"); - padded(ui, |ui| { - let original = app.session.catalog.photo(id).map(|p| { - let (w, h) = (p.width.max(1) as f64, p.height.max(1) as f64); - if d.orientation.swaps_axes() { h / w } else { w / h } - }); - ui.horizontal(|ui| { - ui.label(crate::i18n::tr("Aspect Ratio")); - let cur = aspect_label(app.crop_tool_aspect(), original); - let t = Tokens::get(ui.ctx()); - let r = crate::widgets::dropdown(ui, "cropAspect", crate::i18n::tr(&cur), t.font(12.5), t.text); - - egui::Popup::menu(&r).show(|ui| { - for (label, a) in ASPECT_PRESETS { - if ui.button(crate::i18n::tr(label)).clicked() { - let _ = app.run("crop.aspect", json!({"aspect": a})); - } - } - ui.separator(); - custom_aspect(app, ui); - }); - let locked = app.crop_tool_aspect().is_some(); - if text_button(ui, "cropLock", if locked { "Locked" } else { "Lock" }, locked).clicked() { - let _ = app.run("crop.aspect", json!({"aspect": "toggle"})); - } - }); - ui.add_space(6.0); - ui.horizontal_wrapped(|ui| { - if text_button(ui, "cropRotateLeft", crate::i18n::tr("Rotate Left"), false).clicked() { - let _ = app.run("photo.rotateLeft", json!({})); - } - if text_button(ui, "cropRotateRight", crate::i18n::tr("Rotate Right"), false).clicked() { - let _ = app.run("photo.rotateRight", json!({})); - } - }); - ui.add_space(6.0); - ui.horizontal_wrapped(|ui| { - if text_button(ui, "cropFlipH", crate::i18n::tr("Flip H"), d.crop.flip_h).clicked() { - let _ = app.run("photo.flipHorizontal", json!({})); - } - if text_button(ui, "cropFlipV", crate::i18n::tr("Flip V"), d.crop.flip_v).clicked() { - let _ = app.run("photo.flipVertical", json!({})); - } - if text_button(ui, "cropSwapAspect", crate::i18n::tr("Swap Aspect (X)"), false).clicked() { - let _ = app.run("crop.rotateAspect", json!({})); - } - if text_button(ui, "cropReset", crate::i18n::tr("Reset"), false).clicked() { - let _ = app.run("crop.reset", json!({})); - } - }); - }); - let ang = lightcraft_develop::controls::find("crop.angle").copied(); - if let Some(spec) = ang { - let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten")); - super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v}))); - // an exact angle, typed (issue #534): a field of its own, besides the slider's value - padded(ui, |ui| { - ui.horizontal(|ui| { - ui.label(crate::i18n::tr("Angle")); - let mut angle = d.crop.geometry.angle; - let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); - let r = ui.add( - egui::DragValue::new(&mut angle) - .range(spec.min..=spec.max) - .clamp_existing_to_range(false) - .speed(0.05) - .fixed_decimals(2) - // as the readout writes it: "+2.50", and every zero "0.00" - .custom_formatter(|v, _| super::detail::crop_angle_label(v).trim_end_matches('°').to_string()) - .suffix("°") - // read as the Straighten value reads a typed one: "-3,25" too - .custom_parser(|text| crate::widgets::typed_value(&spec, text.trim().trim_end_matches('°'))) - // a typed angle applies on Return (or leaving the field), not keystroke by - // keystroke: no half-typed angles, one undo step - .update_while_editing(false), - ); - crate::widgets::register(ui, "cropAngleField", r.rect); - // a drag on the field is one undo step, like the slider's - if r.drag_started() { - let _ = app.run("develop.beginInteraction", json!({"label": "Straighten"})); - } - // egui reports a change while text is typed even when the value isn't updated yet: - // apply only a new angle - // Esc keeps the old angle, but egui applies the typed text on the frame after the - // one where Esc took the focus away: remember the Esc for that frame - let cancel = egui::Id::unique("cropAngleFieldEscaped"); - let escaped = ui.data_mut(|m| m.remove_temp::(cancel)).unwrap_or(false); - if escape && r.lost_focus() { - ui.data_mut(|m| m.insert_temp(cancel, true)); - } - if r.changed() && !escaped && angle.is_finite() && angle != d.crop.geometry.angle { - let _ = app.run("crop.straighten", json!({"angle": (angle.clamp(spec.min, spec.max) * 100.0).round() / 100.0})); - } - if r.drag_stopped() { - let _ = app.run("develop.endInteraction", json!({})); - } - }); - }); - } - padded(ui, |ui| { - ui.horizontal(|ui| { - let on = app.ui.tool == "straighten"; - if text_button(ui, "straightenTool", crate::i18n::tr("Straighten Tool"), on) - .on_hover_text(crate::i18n::tr("Drag along the horizon; double-click for Auto")) - .clicked() - { - app.ui.tool = if on { String::new() } else { "straighten".into() }; - } - if text_button(ui, "straightenAuto", crate::i18n::tr("Auto"), false).clicked() { - app.auto_straighten(ui.ctx()); - } - }); - }); - padded(ui, |ui| { - ui.label(crate::i18n::tr("Overlay")); - { - use crate::state::CropOverlay as O; - let opts = [ - (O::Thirds, "Thirds", "thirds"), - (O::Grid, "Grid", "grid"), - (O::Golden, "Golden", "golden"), - (O::Diagonal, "Diagonal", "diagonal"), - (O::Triangle, "Triangle", "triangle"), - (O::Spiral, "Spiral", "spiral"), - (O::None, "None", "none"), - ]; - let items: Vec<(&str, &str)> = opts.iter().map(|(_, l, k)| (*l, *k)).collect(); - let active = opts.iter().position(|(o, _, _)| *o == app.ui.crop_overlay); - if let Some(i) = crate::widgets::segmented(ui, "cropOverlay", &items, active, 4) { - app.ui.crop_overlay = opts[i].0; - } - if matches!(app.ui.crop_overlay, O::Triangle | O::Spiral) - && text_button(ui, "cropOverlayOrient", crate::i18n::tr("Flip Overlay (⇧O)"), false).clicked() - { - let _ = app.run("view.cropOverlayOrientation", json!({})); - } - } - }); - divider(ui); - header(ui, "Geometry"); - padded(ui, |ui| { - ui.label(crate::i18n::tr("Upright")); - { - use lightcraft_develop::Upright; - let modes = [ - ("Off", Upright::Off, "off"), - ("Auto", Upright::Auto, "auto"), - ("Guided", Upright::Guided, "guided"), - ("Level", Upright::Level, "level"), - ("Vertical", Upright::Vertical, "vertical"), - ("Full", Upright::Full, "full"), - ]; - let items: Vec<(&str, &str)> = modes.iter().map(|(l, _, k)| (*l, *k)).collect(); - let active = modes.iter().position(|(_, m, _)| *m == d.geometry.upright); - if let Some(i) = crate::widgets::segmented(ui, "upright", &items, active, 3) { - let (_, mode, key) = modes[i]; - let _ = app.run("geometry.upright", json!({"mode": key})); - app.ui.tool = if mode == Upright::Guided { "guidedUpright".into() } else { String::new() }; - } - } - let guided = d.geometry.upright == lightcraft_develop::Upright::Guided; - ui.horizontal_wrapped(|ui| { - if !guided - && d.geometry.upright != lightcraft_develop::Upright::Off - && text_button(ui, "uprightUpdate", crate::i18n::tr("Update"), false).clicked() - { - let mode = serde_json::to_value(d.geometry.upright).unwrap_or_default(); - let _ = app.run("geometry.upright", json!({"mode": mode})); - } - if guided { - let drawing = app.ui.tool == "guidedUpright"; - if text_button(ui, "uprightDraw", crate::i18n::tr("Draw Guides"), drawing).clicked() { - app.ui.tool = if drawing { String::new() } else { "guidedUpright".into() }; - } - if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", crate::i18n::tr("Clear Guides"), false).clicked() { - let _ = app.run("geometry.guides", json!({"guides": []})); - } - } - }); - if guided { - ui.label( - egui::RichText::new(crate::i18n::tr_format!( - "{} of 4 guides — drag along lines that should be vertical or horizontal.", - d.geometry.guides.len() - )) - .size(11.0) - .color(Tokens::get(ui.ctx()).text_dim), - ); - } - ui.add_space(4.0); - let mut c = d.geometry.constrain_crop; - if ui.checkbox(&mut c, crate::i18n::tr("Constrain Crop")).changed() { - let _ = app.run("develop.merge", json!({"settings": {"geometry": {"constrain_crop": c}}, "label": "Constrain Crop"})); - } - }); - for spec in lightcraft_develop::controls::in_section(lightcraft_develop::Section::Geometry).filter(|c| c.id != "crop.angle") { - let v = lightcraft_develop::controls::get(&d, spec.id).unwrap_or(spec.default); - let out = slider(ui, spec, v, true, None); - super::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v}))); - } -} - -fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let d = app.session.develop_of(id).unwrap_or_default(); - header(ui, "Remove"); - padded(ui, |ui| { - ui.horizontal_wrapped(|ui| { - for (label, tool) in [("Remove", "remove"), ("Content-Aware", "fill"), ("Heal", "heal"), ("Clone", "clone")] { - if text_button(ui, &format!("removeMode-{tool}"), label, app.ui.tool == tool).clicked() { - app.ui.tool = tool.into(); - } - } - }); - ui.add_space(8.0); - ui.label(crate::i18n::tr_format!("{} spot(s) on this photo", d.spots.len())); - ui.add_space(4.0); - if text_button(ui, "findDust", crate::i18n::tr("Find Dust Spots"), false) - .on_hover_text(crate::i18n::tr("Heal the small, soft dark spots sensor dust leaves on smooth areas")) - .clicked() - { - match app.run("spot.findDust", json!({})) { - Ok(r) => { - let n = r["added"].as_u64().unwrap_or(0); - app.toast( - ui.ctx(), - if n == 0 { - crate::i18n::tr("No dust spots found").to_string() - } else { - crate::i18n::tr_format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }, n = n) - }, - ); - } - Err(e) => app.toast(ui.ctx(), e), - } - } - ui.add_space(4.0); - ui.label(egui::RichText::new(crate::i18n::tr("Paint over a distraction on the photo to remove it.")).color(Tokens::get(ui.ctx()).text_dim)); - }); - // brush settings; with a spot selected they edit that spot too - let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone()))); - if let Some((i, sp)) = &sel { - (app.ui.remove_size, app.ui.remove_feather, app.ui.remove_opacity) = (sp.size as f32, sp.feather as f32, sp.opacity as f32); - divider(ui); - let mode = match sp.mode { - lightcraft_develop::SpotMode::Heal => "Heal", - lightcraft_develop::SpotMode::Clone => "Clone", - lightcraft_develop::SpotMode::Remove => "Remove", - lightcraft_develop::SpotMode::Fill => "Content-Aware", - }; - super::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = crate::i18n::tr(mode))); - } - let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec { - id, - label, - section: lightcraft_develop::Section::Detail, - min, - max, - default, - step: 1.0, - decimals: 0, - track: lightcraft_develop::Track::Plain, - }; - let sliders = [ - (plain("ui.removeSize", "Size", 1.0, 250.0, 20.0), "size", (app.ui.remove_size * 1000.0) as f64), - (plain("ui.removeFeather", "Feather", 0.0, 100.0, 50.0), "feather", app.ui.remove_feather as f64), - (plain("ui.removeOpacity", "Opacity", 0.0, 100.0, 100.0), "opacity", app.ui.remove_opacity as f64), - ]; - for (spec, key, v) in sliders { - let out = slider(ui, &spec, v, true, None); - let scale = if key == "size" { 1000.0 } else { 1.0 }; - if let Some(v) = out.value { - match key { - "size" => app.ui.remove_size = (v / scale) as f32, - "feather" => app.ui.remove_feather = v as f32, - _ => app.ui.remove_opacity = v as f32, - } - } - if sel.is_some() { - super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("spot.update", json!({key: v / scale}))); - } - } - if sel.is_some() { - padded(ui, |ui| { - ui.horizontal(|ui| { - if text_button(ui, "spotRefresh", crate::i18n::tr("Refresh Source (/)"), false).clicked() { - let _ = app.run("spot.refreshSource", json!({})); - } - if text_button(ui, "spotDelete", crate::i18n::tr("Delete (⌫)"), false).clicked() { - let _ = app.run("spot.delete", json!({})); - } - }); - }); - } - // Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out - padded(ui, |ui| { - let mut v = app.ui.visualize_spots; - if ui.checkbox(&mut v, crate::i18n::tr("Visualize Spots (A)")).changed() { - app.ui.visualize_spots = v; - } - }); - let spec = lightcraft_develop::ControlSpec { - id: "ui.spotsThreshold", - label: "Threshold", - section: lightcraft_develop::Section::Detail, - min: 0.0, - max: 100.0, - default: 50.0, - step: 1.0, - decimals: 0, - track: lightcraft_develop::Track::Plain, - }; - let out = slider(ui, &spec, app.ui.spots_threshold as f64, app.ui.visualize_spots, None); - if let Some(v) = out.value { - app.ui.spots_threshold = v as f32; - } - padded(ui, |ui| { - tool_overlay_options(app, ui); - ui.add_space(4.0); - if !d.spots.is_empty() && text_button(ui, "removeClear", crate::i18n::tr("Delete all spots"), false).clicked() { - for i in (0..d.spots.len()).rev() { - let _ = app.run("spot.delete", json!({"index": i})); - } - } - }); -} - -/// Tool Overlay: when the spot and mask pins show on the photo (Auto / Always / Selected / Never; -/// Shift+H steps through them). Remove and Masking panels. -pub(crate) fn tool_overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { - use crate::state::ToolOverlay; - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Tool Overlay")).color(t.text_dim)); - let current = app.ui.tool_overlay; - let r = crate::widgets::dropdown(ui, "toolOverlay", crate::i18n::tr(current.label()), t.font(12.5), t.text_label) - .on_hover_text(crate::i18n::tr("When pins show on the photo (Shift+H cycles)")); - egui::Popup::menu(&r).show(|ui| { - for m in ToolOverlay::ALL { - let item = ui.selectable_label(m == current, crate::i18n::tr(m.label())); - register(ui, format!("toolOverlay:{}", m.name()), item.rect); - if item.clicked() { - let _ = app.run("view.toolOverlay", json!({"mode": m.name()})); - } - } - }); - }); -} - -fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let d = app.session.develop_of(id).unwrap_or_default(); - header(ui, "Red Eye"); - padded(ui, |ui| { - ui.horizontal_wrapped(|ui| { - for (label, pet) in [("Red Eye", false), ("Pet Eye", true)] { - if text_button(ui, &format!("eyeMode-{}", if pet { "pet" } else { "red" }), label, app.ui.eye_pet == pet).clicked() { - app.ui.eye_pet = pet; - } - } - }); - ui.add_space(8.0); - ui.label( - egui::RichText::new(crate::i18n::tr("Drag over an eye on the photo; the pupil inside is found automatically.")) - .color(Tokens::get(ui.ctx()).text_dim), - ); - }); - let n = d.red_eye.len(); - if n == 0 { - return; - } - if app.ui.eye >= n { - app.ui.eye = n - 1; - } - let i = app.ui.eye; - let eye = d.red_eye[i]; - divider(ui); - super::edit::sub_title( - ui, - &crate::i18n::tr_format!( - "{label} {index} of {n}", - label = crate::i18n::tr(if eye.pet { "Pet Eye" } else { "Red Eye" }), - index = i + 1, - n = n - ), - ); - for k in ["pupilSize", "darken"] { - let cid = format!("redEye.{i}.{k}"); - let Some(spec) = lightcraft_develop::controls::find(&cid) else { continue }; - let v = lightcraft_develop::controls::get(&d, &cid).unwrap_or(spec.default); - let out = slider(ui, spec, v, true, None); - super::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": cid, "value": v}))); - } - padded(ui, |ui| { - if eye.pet { - let mut on = eye.catchlight.is_some(); - if ui.checkbox(&mut on, crate::i18n::tr("Add Catchlight")).changed() { - let _ = app.run("redeye.catchlight", json!({"index": i, "on": on})); - } - ui.add_space(4.0); - } - ui.horizontal(|ui| { - if text_button(ui, "eyeDelete", crate::i18n::tr("Delete"), false).clicked() { - let _ = app.run("redeye.delete", json!({"index": i})); - } - if n > 1 && text_button(ui, "eyeDeleteAll", crate::i18n::tr("Delete all"), false).clicked() { - for k in (0..n).rev() { - let _ = app.run("redeye.delete", json!({"index": k})); - } - } - }); - }); -} - -fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let Some(p) = app.session.catalog.photo(id).cloned() else { return }; - header(ui, "Info"); - let t = Tokens::get(ui.ctx()); - padded(ui, |ui| { - camera_card(ui, &p); - ui.add_space(6.0); - if let Some(why) = &p.preview_only { - if crate::widgets::preview_only_notice(ui, "info", why) { - crate::panels::edit::reload_preview_only(app, ui.ctx(), id); - } - ui.add_space(6.0); - } - if let Some(name) = &p.copy_name { - let of = p - .copy_of - .and_then(|m| app.session.catalog.photo(m)) - .map(|m| m.file_name.clone()) - .unwrap_or_else(|| crate::i18n::tr("a removed photo").into()); - ui.label(egui::RichText::new(crate::i18n::tr_format!("Virtual copy “{name}” of {of}", name = name, of = of)).color(t.text_label)); - } - ui.add_space(8.0); - if let Some(r) = ui.horizontal(|ui| crate::rating::Rating::new("info", p.rating).show(ui)).inner { - let mut params = json!({"rating": r}); - crate::panels::compare::target_active(app, &mut params); - let _ = app.run("photo.rate", params); - } - ui.add_space(6.0); - // colour label: one swatch per label (hover shows its name); clicking the current one clears it - ui.horizontal(|ui| { - for l in lightcraft_catalog::ColorLabel::ALL { - let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr(&format!("{l:?}"))); - let key = format!("{l:?}").to_lowercase(); - register(ui, format!("label:{key}"), r); - let on = p.label == Some(l); - ui.painter().circle_filled(r.center(), if on || resp.hovered() { 8.0 } else { 6.5 }, crate::panels::grid::label_color(l)); - if on { - ui.painter().circle_stroke(r.center(), 10.0, egui::Stroke::new(1.5, t.text)); - } - let resp = resp.on_hover_text(crate::i18n::color_label(&app.session.catalog, l)); - if resp.clicked() { - // in Compare / Survey only the active photo, as the rating keys and the bottom pill do - let mut params = json!({"label": if on { "none".to_string() } else { key }}); - crate::panels::compare::target_active(app, &mut params); - let _ = app.run("photo.label", params); - } - } - if let Some(l) = p.label { - ui.label(egui::RichText::new(app.session.catalog.label_name(l)).color(t.text_label)); - } - }); - }); - divider(ui); - // with several photos selected: Target Photo shows this photo's values, Selected Photos what - // the selection shares (a field where they differ is empty and says Mixed) - let selected = app.session.selected(); - let several = selected.len() > 1; - let for_selected = several && app.ui.settings.info_selected_photos; - let m = shown_meta(&app.session.catalog, &p, &selected, for_selected); - let mixed = if for_selected { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() }; - // the photos an edit goes to: Target Photo edits the photo it shows - let targets = if several && !for_selected { vec![id] } else { selected.clone() }; - padded(ui, |ui| { - if several { - metadata_for(app, ui); - } - for (label, key, value, lines) in [ - ("Title", "title", &m.title, 1), - ("Caption", "caption", &m.caption, 2), - ("Alt Text", "altText", &m.alt_text, 2), - ("Extended Description", "extendedDescription", &m.extended_description, 3), - ("Copyright", "copyright", &m.copyright, 1), - ] { - meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets); - } - copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus"), &targets); - for (label, key, value, lines) in [ - ("Rights Usage Terms", "usageTerms", &m.usage_terms, 2), - ("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1), - ("Creator", "creator", &m.creator, 1), - ] { - meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets); - } - // file: name (rename), path (reveal), capture time (edit) - let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim)); - small(ui, "File Name"); - ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::icon_button(ui, "infoRename", Icon::Pencil, vec2(20.0, 20.0), false, true, "Rename…").clicked() { - let _ = app.run("dialog.rename", json!({})); - } - ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.add(egui::Label::new(egui::RichText::new(&p.file_name).color(t.text)).truncate()); - }); - }); - ui.add_space(4.0); - small(ui, "File Path"); - ui.horizontal(|ui| { - let path = match &p.source { - lightcraft_catalog::Source::File { path } => path.clone(), - lightcraft_catalog::Source::Demo { .. } => crate::i18n::tr("Generated demo photo").into(), - }; - ui.add(egui::Label::new(egui::RichText::new(path).size(12.0).color(t.text_label)).truncate()); - if crate::menus::ui_enabled(app, "app.showInFinder") { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::icon_button(ui, "infoReveal", Icon::Folder, vec2(20.0, 20.0), false, true, crate::menus::reveal_label()) - .clicked() - { - let _ = app.run("app.showInFinder", json!({})); - } - }); - } - }); - ui.add_space(4.0); - small(ui, "Captured"); - ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() { - let _ = app.run("dialog.captureTime", json!({})); - } - let cap = p.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| "—".into()); - ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate()); - }); - }); - ui.add_space(6.0); - for (label, key, value) in [ - ("Location", "location", &m.location), - ("City", "city", &m.city), - ("State / Province", "state", &m.state), - ("Country", "country", &m.country), - ] { - meta_field(app, ui, label, key, value, 1, mixed.contains(&key), &targets); - } - // GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it - let gps_mixed = mixed.contains(&"gps"); - let gps = if gps_mixed { String::new() } else { m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default() }; - let gid = egui::Id::unique("info-gps"); - ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); - let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &targets)); - let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" }); - let r = crate::text_field::TextField::singleline("field:gps", &mut edit.text).hint(hint).label("GPS").width(f32::INFINITY).show(ui); - edit.typed |= edit.text != edit.start; - if r.editing { - ui.data_mut(|d| d.insert_temp(gid, edit.clone())); - } else { - ui.data_mut(|d| d.remove::(gid)); - } - // Return or leaving saves; Esc gives the edit up - let text = edit.text.trim(); - if r.committed() - && (text != edit.start || edit.clears()) - && let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text})) - { - app.toast(ui.ctx(), e); - } - // (one photo's place: not shown while the selected photos' places differ) - if let Some((la, lo)) = m.gps.filter(|_| !gps_mixed) { - let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" }); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim)); - if text_button(ui, "showOnMap", crate::i18n::tr("Show on Map"), false) - .on_hover_text(crate::i18n::tr("Open the place in OpenStreetMap")) - .clicked() - { - let url = format!("https://www.openstreetmap.org/?mlat={la:.6}&mlon={lo:.6}#map=15/{la:.6}/{lo:.6}"); - if let Err(e) = crate::links::open(app, &url) { - app.toast(ui.ctx(), e); - } - } - }); - if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false) - .on_hover_text(format!( - "{}\n{}", - crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)"), - crate::i18n::tr("The place names list towns of 15,000 people or more: in a smaller one, City gets the nearest larger town.") - )) - .clicked() - && let Err(e) = app.run("metadata.reverseGeocode", json!({})) - { - app.toast_error(ui.ctx(), e); - } - } - if let Some(a) = p.analysis { - let mut line = crate::i18n::tr_format!("Focus {:.0}", a.sharpness); - if a.clipped > 0.02 { - line.push_str(&crate::i18n::tr_format!(" · {:.0}% clipped", a.clipped * 100.0)); - } - if a.group.is_some() { - line.push_str(if a.best { " · best of its burst" } else { " · in a burst" }); - } - ui.add_space(4.0); - ui.label(egui::RichText::new(line).color(t.text_dim)); - } - // offline originals / smart previews - // (cached answers, checked off the UI thread: unknown counts as online / no smart preview) - if let lightcraft_catalog::Source::File { path } = &p.source { - let avail = &app.session.media.availability; - let online = !avail.is_offline(path); - let smart = app.session.media.smart_dir.as_ref().is_some_and(|d| { - let shard = lightcraft_engine::smart::sharded_file_path(d, &p); - let legacy = d.join(lightcraft_engine::smart::file_name(&p)); - avail.exists(&shard.to_string_lossy()) == Some(true) || avail.exists(&legacy.to_string_lossy()) == Some(true) - }); - if !online || smart { - let text = match (online, smart) { - (false, true) => "Original offline · editing the smart preview", - (false, false) => "Original offline · no smart preview", - _ => "Smart preview available", - }; - ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::tr(text)).color(if online { t.text_dim } else { t.accent })); - } - } - ui.add_space(10.0); - if text_button(ui, "allMetadata", crate::i18n::tr("All Metadata…"), false) - .on_hover_text(crate::i18n::tr("Every EXIF, GPS and XMP field in the file")) - .clicked() - { - let _ = app.run("dialog.allMetadata", json!({})); - } - }); -} - -/// The camera card at the top of Info: camera, lens, size · file size and format, then the -/// capture settings. -fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { - let t = Tokens::get(ui.ctx()); - let m = &p.meta; - egui::Frame::NONE.fill(t.canvas).corner_radius(6.0).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| { - ui.set_width(ui.available_width()); - let dim = |s: &str| egui::RichText::new(s.to_string()).size(12.0).color(t.text_label); - if !m.camera.is_empty() { - ui.label(dim(&m.camera)); - } - if !m.lens.is_empty() { - ui.add(egui::Label::new(dim(&m.lens)).truncate()); - } - ui.horizontal(|ui| { - ui.label(dim(&format!("{} × {} · {}", p.width, p.height, human_size(p.file_size)))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - egui::Frame::NONE.fill(t.chrome).corner_radius(3.0).inner_margin(egui::Margin::symmetric(5, 1)).show(ui, |ui| { - ui.label(egui::RichText::new(p.format.to_uppercase()).size(10.5).color(t.text_label)); - }); - }); - }); - let rows = [ - ("Focal length", m.focal_mm.map(|f| format!("{f:.1} mm").replace(".0 mm", " mm"))), - ("Shutter speed", (!m.shutter.is_empty()).then(|| crate::i18n::tr_format!("{} sec", m.shutter))), - ("Aperture", m.aperture.map(|f| format!("f / {f:.1}").replace(".0", ""))), - ("ISO", m.iso.map(|i| i.to_string())), - ]; - if rows.iter().any(|r| r.1.is_some()) { - ui.add_space(6.0); - egui::Grid::new("info-capture").num_columns(2).spacing([24.0, 3.0]).show(ui, |ui| { - for (k, v) in rows { - ui.label(dim(crate::i18n::tr(k))); - ui.label(dim(&v.unwrap_or_else(|| "—".into()))); - ui.end_row(); - } - }); - } - // where the camera focused, and how (View ▸ Focus Point draws it): only when known; the - // whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits - if let Some(text) = focus_text(m.focus.as_ref()) { - ui.add_space(3.0); - let r = ui.horizontal_wrapped(|ui| { - ui.label(dim(crate::i18n::tr("Focus"))); - ui.add_space(12.0); - ui.label(egui::RichText::new(text).size(12.0).color(t.text)); - }); - register(ui, "info:focus", r.response.rect); - } - }); -} - -/// The camera's focus mode, AF area and tracking in the interface language ("AF-C · Wide · Animal Eye -/// Tracking"), or `None` when the photo has no focus point. -pub(crate) fn focus_text(f: Option<&lightcraft_meta::FocusPoint>) -> Option { - let f = f?; - let words: Vec<&str> = f.words().into_iter().map(crate::i18n::tr).collect(); - Some(if words.is_empty() { crate::i18n::tr("Focus point").to_string() } else { words.join(" · ") }) -} - -fn human_size(bytes: u64) -> String { - match bytes { - b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), - b if b >= 1 << 10 => format!("{} KB", b >> 10), - b => format!("{b} B"), - } -} - -/// What a metadata field shows while the selected photos' values differ (Selected Photos). -const MIXED: &str = "Mixed"; - -/// An Info panel field's edit under way, kept in egui memory between frames. A field saves when -/// its text is not the text the edit started from, so a field left as it was saves nothing, even -/// when what the panel shows changed during the edit. It saves to the photos it edited when the -/// edit started (the selected photos, or Target Photo's photo alone), also when the selection -/// changed since. -#[derive(Clone)] -struct FieldEdit { - text: String, - start: String, - /// The photos the field edited when the edit started (`photo.setMeta` `ids`). - ids: Vec, - /// The field said Mixed when the edit started. - mixed: bool, - /// Something was typed or deleted since. - typed: bool, -} - -impl FieldEdit { - fn new(value: &str, mixed: bool, targets: &[PhotoId]) -> Self { - let ids = targets.iter().map(|id| id.0).collect(); - FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false } - } - - /// A Mixed field that was typed in and left empty: it clears the field on every selected photo. - fn clears(&self) -> bool { - self.mixed && self.typed - } -} - -/// The Info panel's text fields, by their `photo.setMeta` key. -const META_TEXT_KEYS: [&str; 12] = [ - "title", - "caption", - "altText", - "extendedDescription", - "copyright", - "usageTerms", - "copyrightUrl", - "creator", - "location", - "city", - "state", - "country", -]; - -/// The value of one of [`META_TEXT_KEYS`]. -fn meta_text<'a>(m: &'a lightcraft_catalog::Meta, key: &str) -> Option<&'a str> { - Some(match key { - "title" => &m.title, - "caption" => &m.caption, - "altText" => &m.alt_text, - "extendedDescription" => &m.extended_description, - "copyright" => &m.copyright, - "usageTerms" => &m.usage_terms, - "copyrightUrl" => &m.copyright_url, - "creator" => &m.creator, - "location" => &m.location, - "city" => &m.city, - "state" => &m.state, - "country" => &m.country, - _ => return None, - }) -} - -/// The metadata the Info panel's fields show: the active photo `p`'s, or with `for_selected` -/// (Selected Photos) the active photo's when it is one of the `selected` photos, else the first -/// selected photo's. -pub(crate) fn shown_meta( - catalog: &lightcraft_catalog::Catalog, - p: &lightcraft_catalog::Photo, - selected: &[PhotoId], - for_selected: bool, -) -> lightcraft_catalog::Meta { - if !for_selected || selected.contains(&p.id) { - return p.meta.clone(); - } - selected.iter().find_map(|&id| catalog.photo(id)).map_or_else(|| p.meta.clone(), |q| q.meta.clone()) -} - -/// The Info panel fields (`photo.setMeta` keys, plus `copyrightStatus` and `gps`) where one of -/// the photos `ids` has a value other than `shown`'s, the metadata the panel draws. -pub(crate) fn mixed_meta(catalog: &lightcraft_catalog::Catalog, ids: &[PhotoId], shown: &lightcraft_catalog::Meta) -> Vec<&'static str> { - let mut mixed = Vec::new(); - for m in ids.iter().filter_map(|&id| catalog.photo(id)).map(|p| &p.meta) { - for key in META_TEXT_KEYS { - if !mixed.contains(&key) && meta_text(m, key) != meta_text(shown, key) { - mixed.push(key); - } - } - if !mixed.contains(&"copyrightStatus") && m.copyright_status != shown.copyright_status { - mixed.push("copyrightStatus"); - } - if !mixed.contains(&"gps") && m.gps != shown.gps { - mixed.push("gps"); - } - if mixed.len() == META_TEXT_KEYS.len() + 2 { - break; - } - } - mixed -} - -/// Target Photo / Selected Photos: whose metadata the fields below show while several photos -/// are selected (`view.infoSelectedPhotos`). -fn metadata_for(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(crate::i18n::tr("Show Metadata For")).size(11.5).color(t.text_dim)); - ui.horizontal_wrapped(|ui| { - let on = app.ui.settings.info_selected_photos; - for (id, label, selected) in [("infoFor-target", "Target Photo", false), ("infoFor-selected", "Selected Photos", true)] { - if text_button(ui, id, label, on == selected).clicked() && on != selected { - let _ = app.run("view.infoSelectedPhotos", json!({"on": selected})); - } - } - }); - ui.add_space(6.0); -} - -/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). `mixed`: the -/// selected photos' statuses differ, and any status picked is given to all of them. A status -/// picked goes to the photos `targets`. -fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool, targets: &[PhotoId]) { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); - let shown = crate::i18n::tr(if mixed { MIXED } else { current.label() }); - let r = crate::widgets::combo_box("info-copyright-status").selected_text(shown).show_ui(ui, |ui| { - for st in lightcraft_catalog::CopyrightStatus::ALL { - let on = !mixed && st == current; - if ui.selectable_label(on, crate::i18n::tr(st.label())).clicked() && !on { - let ids: Vec = targets.iter().map(|id| id.0).collect(); - let _ = app.run("photo.setMeta", json!({"ids": ids, "copyrightStatus": st.id()})); - } - } - }); - crate::widgets::name_combo(&r.response, crate::i18n::tr("Copyright Status")); - register(ui, "field:copyrightStatus", r.response.rect); - ui.add_space(6.0); -} - -/// A labeled metadata text field: the typed text lives in egui memory while focused and is -/// saved (photo.setMeta `key`) when the field loses focus. `mixed`: the selected photos' values -/// differ, so the field starts empty and says so, and is saved only when it was typed in (left -/// empty, it clears the field on every selected photo). The edit is saved to the photos `targets`. -#[allow(clippy::too_many_arguments)] -fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool, targets: &[PhotoId]) { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); - let id = egui::Id::unique(("info-field", key)); - let value = if mixed { "" } else { value }; - let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, targets)); - let widget = format!("field:{key}"); - let field = if lines > 1 { - crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines) - } else { - crate::text_field::TextField::singleline(&widget, &mut edit.text) - }; - let field = if mixed { field.hint(crate::i18n::tr(MIXED)) } else { field }; - let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui); - edit.typed |= edit.text != edit.start; - // the typed text outlives frames while it is edited (the photo's value is drawn otherwise) - if r.editing { - ui.data_mut(|d| d.insert_temp(id, edit.clone())); - } else { - ui.data_mut(|d| d.remove::(id)); - } - // Return (one line) or leaving saves; Esc gives the edit up - if r.committed() && (edit.text != edit.start || edit.clears()) { - let _ = app.run("photo.setMeta", json!({"ids": edit.ids, key: edit.text})); - } - ui.add_space(6.0); -} - -fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - // (the box is the selection's: the active photo only says that there is one) - if app.session.catalog.photo(id).is_none() { - return; - } - let top = ui.cursor().top(); - if fold_header(app, ui, "keywording", "Keywords") { - keywording(app, ui); - } - hold_list_in_place(ui, top); - super::keyword_list::show(app, ui); -} - -/// The Keywording box: the field that adds keywords, the selection's keywords, the keyword set, -/// the painter and the suggestions. -fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - padded(ui, |ui| { - let kid = egui::Id::unique("kw-input"); - let mut text = ui.data_mut(|d| d.get_temp::(kid).unwrap_or_default()); - // the keywords every selected photo has: those only some have are still offered and suggested - let selection = app.session.selected(); - let have: Vec = - app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); - // while the box has the cursor it offers the library's keywords for what is being typed, - // right under itself; it stays for a click on one of them, which takes the cursor away - let field = crate::text_field::id("field:keyword"); - let oid = egui::Id::unique("kw-offers"); - let pass = ui.ctx().cumulative_pass_nr(); - // (remembered from the last frame: the text the highlight was for and the keyword it was - // on, where the offers were drawn, and whether the box was in sight) - let (typed_then, at_then, drawn, drawn_pass, hidden): (String, Option, Rect, u64, bool) = - ui.data(|d| d.get_temp(oid)).unwrap_or((String::new(), None, Rect::NOTHING, 0, false)); - let last_frame = drawn_pass.checked_add(1) == Some(pass); - let clicking = last_frame - && ui.input(|i| (i.pointer.any_down() || i.pointer.any_released()) && i.pointer.interact_pos().is_some_and(|p| drawn.contains(p))); - let typed = super::keywording::typing(&text).to_string(); - // (none while the box is scrolled out of the panel: they would float over what is there) - let offers = ((ui.memory(|m| m.has_focus(field)) || clicking) && !(last_frame && hidden)) - .then(|| app.caches.completions(&app.session.catalog, &have, &typed)) - .filter(|o| !o.keywords.is_empty() || o.create.is_some()); - // ↓ / ↑ move through the offers (none is highlighted at first, and ↑ from the first goes - // back to none: Return then gives what was typed, as without offers); Return gives the - // highlighted one. The highlight is on a keyword, not a line: it stays with it when the - // library changes under it, and goes when the keyword does. With Shift or Alt the keys - // are the text's own. - let mut at = at_then.filter(|k| typed_then == typed && offers.as_ref().is_some_and(|o| o.keywords.contains(k))); - let mut picked = None; - if let Some(o) = offers.as_ref().filter(|o| !o.keywords.is_empty()) { - let pressed = |ui: &mut egui::Ui, key: egui::Key| { - ui.input_mut(|i| { - let plain = - |e: &egui::Event| matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && modifiers.is_none()); - let found = i.events.iter().any(plain); - i.events.retain(|e| !plain(e)); - found - }) - }; - let line = at.as_ref().and_then(|k| o.keywords.iter().position(|x| x == k)); - if pressed(ui, egui::Key::ArrowDown) { - let next = line.map_or(0, |a| a.saturating_add(1)).min(o.keywords.len().saturating_sub(1)); - at = o.keywords.get(next).cloned(); - } else if pressed(ui, egui::Key::ArrowUp) { - at = line.and_then(|a| a.checked_sub(1)).and_then(|a| o.keywords.get(a).cloned()); - } - if let Some(k) = &at - && pressed(ui, egui::Key::Enter) - { - picked = Some(super::keywording::Picked::Keyword(k.clone())); - } - } - // (after an offer was taken the box starts a new edit, empty: Esc has nothing to bring back) - // (asked for on the frame before this one only: a box that wasn't drawn then, its panel - // switched away, doesn't claim the cursor whenever it is next drawn) - let bid = egui::Id::unique("kw-begin"); - let begin = ui.data_mut(|d| d.remove_temp::(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass)); - // Add Keywords (⌘K) asks for the cursor too, and for the box to be scrolled into sight - let asked = app.ui.focus_keywords.take().is_some_and(|at| pass <= at.saturating_add(crate::state::FOCUS_KEYWORDS_PASSES)); - let r = crate::text_field::TextField::singleline("field:keyword", &mut text) - .hint(crate::i18n::tr("Add keyword")) - .width(f32::INFINITY) - .begin_edit(begin || asked) - .show(ui); - if asked { - r.response.scroll_to_me(None); - } - let in_sight = ui.clip_rect().contains(r.response.rect.center()); - let mut drawn = Rect::NOTHING; - // (what isn't drawn can't be taken: Return on the frame the box scrolls out gives nothing) - if !in_sight { - picked = None; - } - if offers.is_none() { - // (not over the offers) - r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos.")); - } - if let Some(o) = offers.as_ref().filter(|_| in_sight) { - let (clicked, rect) = super::keywording::offers_below(ui, r.response.rect, o, at.as_deref()); - drawn = rect; - picked = picked.or(clicked); - } - ui.data_mut(|d| d.insert_temp(oid, (typed.clone(), at, drawn, pass, !in_sight))); - // Return gives the keywords to the selected photos; Esc gives back what was there before - // this edit (the shared field does) - let returned = r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty(); - // a new name goes inside the default parent (Put New Keywords Inside This Keyword) - let as_typed = |app: &LightcraftApp, text: &str| -> Vec { - text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect() - }; - let adding: Option> = match picked { - // an offer: it, and the keywords typed before the last comma - Some(super::keywording::Picked::Keyword(k)) => { - let mut kws = super::keywording::typed_before(&app.session.catalog, &text); - kws.push(k); - Some(kws) - } - Some(super::keywording::Picked::Typed) => Some(as_typed(app, &text)), - None if returned => Some(as_typed(app, &text)), - None => None, - }; - if let Some(kws) = adding { - // (refused, the text stays to be put right, and the refusal is said) - match app.run("photo.setMeta", json!({"addKeywords": kws})) { - Ok(_) => { - text.clear(); - // an offer taken leaves the cursor in the box, for the next keyword - if !returned { - ui.data_mut(|d| d.insert_temp(bid, pass)); - } - } - Err(e) => app.toast_error(ui.ctx(), e), - } - } - ui.data_mut(|d| d.insert_temp(kid, text)); - ui.add_space(8.0); - super::keywording::view_switch(app, ui); - ui.add_space(4.0); - super::keywording::scope(app, ui, &selection); - if app.ui.keywording_view == crate::state::KeywordingView::Keywords { - super::keywording::chip_row(app, ui, &selection); - } else { - super::keywording::names_row(app, ui); - } - ui.add_space(10.0); - keyword_set(app, ui); - ui.add_space(8.0); - // the painter: click photos in the grid to give them (or take away) a keyword - ui.horizontal(|ui| { - let pid = egui::Id::unique("kw-painter"); - let painting = app.ui.keyword_painter.clone(); - let mut k: String = ui.data(|d| d.get_temp(pid)).unwrap_or_else(|| painting.clone().unwrap_or_default()); - // (fixed while painting: Stop first) - let field = ui - .add_enabled_ui(painting.is_none(), |ui| { - crate::text_field::TextField::singleline("field:keywordPainter", &mut k) - .hint(crate::i18n::tr("Keyword to paint")) - .width(130.0) - .show(ui) - }) - .inner; - ui.data_mut(|d| d.insert_temp(pid, k.clone())); - let label = if painting.is_some() { "Stop" } else { "Paint" }; - let returned = field.ending == Some(crate::text_field::Ending::Return) && painting.is_none() && !k.trim().is_empty(); - if text_button(ui, "keywordPaint", label, painting.is_some()) - .on_hover_text(crate::i18n::tr("Click photos in the grid to toggle the keyword; Esc stops")) - .clicked() - || returned - { - let _ = app.run("tool.keywordPainter", json!({"keyword": if painting.is_some() { serde_json::Value::Null } else { json!(k) }})); - } - }); - ui.add_space(10.0); - // suggestions: keywords used together with the selection's keywords, else the most used - // ones (what matches the text being typed is offered under the box itself) - let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, "", 12)).clone(); - if !suggestions.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim)); - // grouped: the keyword's own name, its whole path in the tooltip - let grouped = app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped; - let names = if grouped { super::keywording::suggestion_names(&suggestions) } else { Vec::new() }; - ui.horizontal_wrapped(|ui| { - if grouped { - ui.spacing_mut().item_spacing = vec2(6.0, 4.0); - } - for (i, k) in suggestions.into_iter().enumerate() { - let whole = k.replace('|', " › "); - let name = names.get(i).unwrap_or(&whole); - let r = ui.add(egui::Button::new(egui::RichText::new(format!("+ {name}")).color(t.text_label)).corner_radius(10.0)); - let r = if grouped { r.on_hover_text(&whole) } else { r }; - register(ui, format!("kwSuggest:{k}"), r.rect); - if r.clicked() { - let _ = app.run("photo.setMeta", json!({"addKeywords": [k]})); - } - } - }); - } - }); -} - -/// Keeps the Keyword List where it is on screen when the Keywords box above it (starting at -/// `top`) grows or shrinks: a keyword ticked or unticked in the list, another selection. Without it -/// a long tree slides away from under the pointer by a row of chips each time. It holds only while -/// the box's top is scrolled out of sight (at the top of the panel the box is watched growing) and -/// while the change does not come from the box itself (the pointer on it, or typing in its field). -fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { - let height = ui.cursor().top() - top; - let key = egui::Id::unique("kw-box-height"); - let pass = ui.ctx().cumulative_pass_nr(); - // (the height drawn on the pass before: one from an earlier visit to the panel says nothing) - let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h); - ui.data_mut(|d| d.insert_temp(key, (height, pass))); - let Some(before) = before else { return }; - let grew = height - before; - if !grew.is_finite() || grew.abs() < 0.5 || top >= ui.clip_rect().top() - 0.5 { - return; - } - // (only the part of it in sight: the pointer on the top bar above a half-hidden box isn't on it) - let old_box = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + before).intersect(ui.clip_rect()); - let on_box = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| old_box.contains(p)); - let typing = ui.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))); - if on_box || typing { - return; - } - ui.data_mut(|d| d.insert_temp(egui::Id::unique(HOLD), (grew, pass))); - // (drawn again at once at the new offset, so the list never shows a frame out of place) - ui.ctx().request_discard("the Keyword List holds its place"); -} - -/// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the -/// selected photos; "Save as Set…" keeps the current nine under a name. -fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { - // on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do - let selection = app.session.selected(); - let chips = app.caches.keyword_chips(&app.session.catalog, &selection); - let t = Tokens::get(ui.ctx()); - let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); - let current = sets["current"].as_str().unwrap_or_default().to_string(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim)); - let combo = crate::widgets::combo_box("kw-set") - .selected_text(crate::i18n::builtin_label(¤t, current == lightcraft_engine::cmd::keywords::RECENT)) - .show_ui(ui, |ui| { - for set in sets["sets"].as_array().into_iter().flatten() { - let name = set["name"].as_str().unwrap_or_default(); - if ui - .selectable_label(name == current, crate::i18n::builtin_label(name, name == lightcraft_engine::cmd::keywords::RECENT)) - .clicked() - { - let _ = app.run("keyword.useSet", json!({"name": name})); - } - } - ui.separator(); - // Edit Set…: the current nine, slot by slot (named, Recent Keywords becomes a set) - let edit = ui.button(crate::i18n::tr("Edit Set…")); - register(ui, "keywordSetMenu:edit", edit.rect); - if edit.clicked() { - let mut slots: Vec = - sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); - slots.resize(9, String::new()); - let named = current != lightcraft_engine::cmd::keywords::RECENT; - app.ui.dialog = Some(crate::state::Dialog::KeywordSet { - replaces: named.then(|| current.clone()), - name: if named { current.clone() } else { String::new() }, - slots, - as_new: false, - }); - } - if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: "Save Keyword Set".into(), - hint: "Set name".into(), - value: String::new(), - command: "keyword.saveSet".into(), - params: json!({}), - key: "name".into(), - }); - } - if current != lightcraft_engine::cmd::keywords::RECENT - && ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked() - { - let _ = app.run("keyword.deleteSet", json!({"name": current})); - } - }); - crate::widgets::name_combo(&combo.response, crate::i18n::tr("Keyword Set")); - register(ui, "keywordSetCombo", combo.response.rect); - }); - let kws: Vec = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); - if kws.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr("Keywords you add appear here; ⌥1–⌥9 apply them.")).color(t.text_dim)); - return; - } - let bw = ((ui.available_width() - 8.0) / 3.0).floor().max(40.0); - egui::Grid::new("kw-set-grid").num_columns(3).spacing([4.0, 4.0]).show(ui, |ui| { - for (i, k) in kws.iter().enumerate() { - // an empty slot: an idle button, so the others keep their ⌥ keys - if k.is_empty() { - let r = ui.add_enabled_ui(false, |ui| ui.add_sized([bw, 22.0], egui::Button::new(""))).inner; - register(ui, format!("kwSetEmpty:{}", i + 1), r.rect); - if i % 3 == 2 { - ui.end_row(); - } - continue; - } - let chip = chips.iter().find(|c| lightcraft_catalog::keywords::same(&c.path, &lightcraft_catalog::keywords::clean(k))); - let on = chip.is_some_and(|c| c.on_all()); - let some = chip.is_some() && !on; - let short = format!("{}{}", k.rsplit('|').next().unwrap_or(k), if some { " *" } else { "" }); - let r = ui - .add_sized( - [bw, 22.0], - egui::Button::new(egui::RichText::new(short).size(11.5).color(if on { t.text } else { t.text_label })).selected(on).truncate(), - ) - .on_hover_text(format!("{} — ⌥{}", k.replace('|', " › "), i + 1)); - register(ui, format!("kwSet:{}", i + 1), r.rect); - if on { - register(ui, format!("kwSetOn:{}", i + 1), r.rect); - } else if some { - register(ui, format!("kwSetSome:{}", i + 1), r.rect); - } - if r.clicked() { - let _ = app.run("keyword.toggleFromSet", json!({"index": i + 1})); - } - if i % 3 == 2 { - ui.end_row(); - } - } - }); -} - -fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let Some(p) = app.session.catalog.photo(id).cloned() else { return }; - header(ui, "Versions"); - let t = Tokens::get(ui.ctx()); - padded(ui, |ui| { - if text_button(ui, "versionCreate", crate::i18n::tr("Create Version"), false).clicked() { - let _ = app.run("version.create", json!({})); - } - ui.add_space(8.0); - // Named (made by you) and Auto (made by LightCraft) versions - let tab_id = egui::Id::unique("versions-tab"); - let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false); - let named_n = p.versions.iter().filter(|v| !v.auto).count(); - let items = [("Named", "named"), ("Auto", "auto")]; - let labels = - [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), crate::i18n::tr_format!("Auto ({})", p.versions.len() - named_n)]; - let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect(); - if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) { - auto = i == 1; - ui.data_mut(|d| d.insert_temp(tab_id, auto)); - } - ui.add_space(6.0); - let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect(); - if shown.is_empty() { - let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." }; - ui.label(egui::RichText::new(crate::i18n::tr(msg)).color(t.text_dim)); - } - // newest first - for (i, v) in shown.into_iter().rev() { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), Sense::click()); - crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Version"), i + 1)); - register(ui, format!("version:{i}"), r); - let current = *v.settings == *p.develop; - if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); - } - // thumbnail of the version's look - let tr = Rect::from_min_size(r.min + vec2(4.0, 5.0), vec2(72.0, 48.0)); - ui.painter().rect_filled(tr, 2.0, t.canvas); - if let Some(job) = app.session.variant_job(id, &v.settings, 160) - && let Some(tex) = app.renderer.variant(job) - { - ui.painter().image(tex.tex.id(), tr, crate::panels::presets::cover_uv(tr, tex.size), egui::Color32::WHITE); - } - let x = tr.right() + 10.0; - ui.painter().with_clip_rect(r.with_max_x(r.right() - 24.0)).text( - pos2(x, r.top() + 18.0), - Align2::LEFT_CENTER, - &v.name, - t.semibold(13.0), - t.text, - ); - let painter = ui.painter().with_clip_rect(r); - painter.text(pos2(x, r.top() + 38.0), Align2::LEFT_CENTER, short_time(&v.created), t.font(11.0), t.text_dim); - if current { - paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Check, t.accent); - } - // resting on a version shows it in the loupe (nothing is changed) - if resp.hovered() && !current { - app.hover_preview = - Some(crate::HoverPreview { label: crate::i18n::tr_format!("Version: {}", v.name), settings: (*v.settings).clone() }); - } - if resp.double_clicked() { - crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); - } else if resp.clicked() && !current { - let _ = app.run("version.restore", json!({"index": i})); - } - let resp = resp.on_hover_text(crate::i18n::tr(if current { - "The photo has these settings" - } else { - "Click to restore · double-click to rename" - })); - crate::widgets::context_menu(&resp, |ui| { - if ui.button(crate::i18n::tr("Restore")).clicked() { - let _ = app.run("version.restore", json!({"index": i})); - } - if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() { - let _ = app.run("version.update", json!({"index": i})); - } - if ui.button(crate::i18n::tr("Rename…")).clicked() { - crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); - } - if ui.button(crate::i18n::tr("Set as Before")).clicked() { - let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name})); - } - ui.separator(); - if ui.button(crate::i18n::tr("Delete")).clicked() { - let _ = app.run("version.delete", json!({"index": i})); - } - }); - } - }); -} - -/// "Sep 30, 2026, 12:00 PM" from an ISO time. -fn short_time(iso: &str) -> String { - // Other languages use their own date patterns (`i18n::display_time`). - if crate::i18n::language() != crate::i18n::Locale::En { - return crate::i18n::display_time(iso); - } - let long = lightcraft_catalog::dates::display_time(iso); - // "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped - let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t))); - let date = match date.split_once(' ') { - Some((m, rest)) => format!("{} {rest}", m.chars().take(3).collect::()), - None => date.to_string(), - }; - match time.and_then(|t| t.rsplit_once(' ')) { - Some((hms, ampm)) => format!("{date}, {} {ampm}", hms.rsplit_once(':').map_or(hms, |x| x.0)), - None => date, - } -} - -/// How a History row is drawn: the step the settings are at is highlighted, the steps after it -/// (still there to return to until a new edit) are greyed out. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum HistoryRow { - Done, - Current, - Undone, -} - -fn history_row(i: usize, cursor: Option) -> HistoryRow { - match cursor { - Some(c) if i == c => HistoryRow::Current, - Some(c) if i > c => HistoryRow::Undone, - _ => HistoryRow::Done, - } -} - -fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let Some(p) = app.session.catalog.photo(id).cloned() else { return }; - header(ui, "History"); - let t = Tokens::get(ui.ctx()); - padded(ui, |ui| { - if p.history.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim)); - } - let cursor = p.history_index(); - for (i, h) in p.history.iter().enumerate().rev() { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - crate::widgets::announce(&resp, crate::i18n::tr(&h.label)); - register(ui, format!("history:{i}"), r); - let row = history_row(i, cursor); - if row == HistoryRow::Current { - ui.painter().rect_filled(r, 3.0, t.pressed); - } else if resp.hovered() { - ui.painter().rect_filled(r, 3.0, t.hover); - } - let (icon, text) = if row == HistoryRow::Undone { (t.text_disabled, t.text_disabled) } else { (t.icon, t.text_label) }; - paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, icon); - ui.painter().text( - pos2(r.left() + 24.0, r.center().y), - Align2::LEFT_CENTER, - crate::i18n::history_label(&h.label, &app.session.presets), - t.font(12.5), - text, - ); - if resp.clicked() { - let _ = app.run("history.restore", json!({"index": i})); - } - crate::widgets::context_menu(&resp, |ui| { - if ui.button(crate::i18n::tr("Copy History Step Settings to Before")).clicked() { - let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i})); - } - if ui.button(crate::i18n::tr("Create Version from Step")).clicked() { - let _ = app.run("history.restore", json!({"index": i})); - let _ = app.run("version.create", json!({"name": h.label})); - } - ui.separator(); - if ui.button(crate::i18n::tr("Clear History")).clicked() { - let _ = app.run("history.clear", json!({})); - } - }); - } - }); -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/right/crop.rs b/crates/ui-egui/src/panels/right/crop.rs new file mode 100644 index 000000000..67efd91f6 --- /dev/null +++ b/crates/ui-egui/src/panels/right/crop.rs @@ -0,0 +1,483 @@ +use super::*; + +/// The aspect presets of the Crop panel: (menu label, `crop.aspect` value). +const ASPECT_PRESETS: [(&str, &str); 10] = [ + ("Free", "free"), + ("Original", "original"), + ("1 × 1", "1x1"), + ("4 × 5 / 8 × 10", "4x5"), + ("8.5 × 11", "8.5x11"), + ("5 × 7", "5x7"), + ("2 × 3 / 4 × 6", "2x3"), + ("4 × 3", "4x3"), + ("16 × 9", "16x9"), + ("16 × 10", "16x10"), +]; + +/// What the aspect button says for the stored lock (`aspect` = width × 100, height × 100): +/// a preset's name when the ratio matches one in either orientation, "Original" for the photo's own +/// shape, otherwise the ratio as `1.37 : 1`. +pub(super) fn aspect_label(aspect: Option<(u32, u32)>, original: Option) -> String { + let Some((w, h)) = aspect else { return "Free".into() }; + if w == 0 || h == 0 { + return "Free".into(); + } + let r = w as f64 / h as f64; + let is = |q: f64| q.is_finite() && q > 0.0 && ((r - q).abs() / q < 0.004 || (1.0 / r - q).abs() / q < 0.004); + if original.is_some_and(is) { + return "Original".into(); + } + for (label, key) in ASPECT_PRESETS { + if let Some((x, y)) = key.split_once('x') + && let (Ok(x), Ok(y)) = (x.parse::(), y.parse::()) + && is(x / y) + { + return label.to_string(); + } + } + format!("{:.2} : 1", r.max(1.0 / r)) +} + +/// "Custom" row of the aspect menu: two number fields and an Apply button (`crop.aspect` `[w, h]`). +fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let key = egui::Id::unique("crop-custom-aspect"); + let (mut w, mut h): (String, String) = ui.data_mut(|d| d.get_temp(key)).unwrap_or_else(|| ("3".into(), "2".into())); + let mut apply = false; + ui.horizontal(|ui| { + ui.label(crate::i18n::tr("Custom")); + let a = ui.add(egui::TextEdit::singleline(&mut w).desired_width(34.0)); + crate::widgets::screen_reader_name(ui.ctx(), a.id, crate::i18n::tr("Width")); + register(ui, "field:cropCustomW", a.rect); + ui.label("×"); + let b = ui.add(egui::TextEdit::singleline(&mut h).desired_width(34.0)); + crate::widgets::screen_reader_name(ui.ctx(), b.id, crate::i18n::tr("Height")); + register(ui, "field:cropCustomH", b.rect); + let go = text_button(ui, "cropCustomApply", "Apply", false); + apply = go.clicked() || ((a.lost_focus() || b.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter))); + }); + if apply { + // Accept `3`, `2.5` and `3,5`; anything else (zero, negative, text, huge) is ignored. + let num = |t: &str| t.trim().replace(',', ".").parse::().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 1000.0); + if let (Some(x), Some(y)) = (num(&w), num(&h)) + && (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&(x / y)) + { + let _ = app.run("crop.aspect", json!({"aspect": [x, y]})); + ui.close(); + } + } + ui.data_mut(|d| d.insert_temp(key, (w, h))); +} + +pub(super) fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let d = app.session.develop_of(id).unwrap_or_default(); + header(ui, "Crop"); + padded(ui, |ui| { + let original = app.session.catalog.photo(id).map(|p| { + let (w, h) = (p.width.max(1) as f64, p.height.max(1) as f64); + if d.orientation.swaps_axes() { h / w } else { w / h } + }); + ui.horizontal(|ui| { + ui.label(crate::i18n::tr("Aspect Ratio")); + let cur = aspect_label(app.crop_tool_aspect(), original); + let t = Tokens::get(ui.ctx()); + let r = crate::widgets::dropdown(ui, "cropAspect", crate::i18n::tr(&cur), t.font(12.5), t.text); + + egui::Popup::menu(&r).show(|ui| { + for (label, a) in ASPECT_PRESETS { + if ui.button(crate::i18n::tr(label)).clicked() { + let _ = app.run("crop.aspect", json!({"aspect": a})); + } + } + ui.separator(); + custom_aspect(app, ui); + }); + let locked = app.crop_tool_aspect().is_some(); + if text_button(ui, "cropLock", if locked { "Locked" } else { "Lock" }, locked).clicked() { + let _ = app.run("crop.aspect", json!({"aspect": "toggle"})); + } + }); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if text_button(ui, "cropRotateLeft", crate::i18n::tr("Rotate Left"), false).clicked() { + let _ = app.run("photo.rotateLeft", json!({})); + } + if text_button(ui, "cropRotateRight", crate::i18n::tr("Rotate Right"), false).clicked() { + let _ = app.run("photo.rotateRight", json!({})); + } + }); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if text_button(ui, "cropFlipH", crate::i18n::tr("Flip H"), d.crop.flip_h).clicked() { + let _ = app.run("photo.flipHorizontal", json!({})); + } + if text_button(ui, "cropFlipV", crate::i18n::tr("Flip V"), d.crop.flip_v).clicked() { + let _ = app.run("photo.flipVertical", json!({})); + } + if text_button(ui, "cropSwapAspect", crate::i18n::tr("Swap Aspect (X)"), false).clicked() { + let _ = app.run("crop.rotateAspect", json!({})); + } + if text_button(ui, "cropReset", crate::i18n::tr("Reset"), false).clicked() { + let _ = app.run("crop.reset", json!({})); + } + }); + }); + let ang = lightcraft_develop::controls::find("crop.angle").copied(); + if let Some(spec) = ang { + let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten")); + crate::panels::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v}))); + // an exact angle, typed (issue #534): a field of its own, besides the slider's value + padded(ui, |ui| { + ui.horizontal(|ui| { + ui.label(crate::i18n::tr("Angle")); + let mut angle = d.crop.geometry.angle; + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let r = ui.add( + egui::DragValue::new(&mut angle) + .range(spec.min..=spec.max) + .clamp_existing_to_range(false) + .speed(0.05) + .fixed_decimals(2) + // as the readout writes it: "+2.50", and every zero "0.00" + .custom_formatter(|v, _| crate::panels::detail::crop_angle_label(v).trim_end_matches('°').to_string()) + .suffix("°") + // read as the Straighten value reads a typed one: "-3,25" too + .custom_parser(|text| crate::widgets::typed_value(&spec, text.trim().trim_end_matches('°'))) + // a typed angle applies on Return (or leaving the field), not keystroke by + // keystroke: no half-typed angles, one undo step + .update_while_editing(false), + ); + crate::widgets::register(ui, "cropAngleField", r.rect); + // a drag on the field is one undo step, like the slider's + if r.drag_started() { + let _ = app.run("develop.beginInteraction", json!({"label": "Straighten"})); + } + // egui reports a change while text is typed even when the value isn't updated yet: + // apply only a new angle + // Esc keeps the old angle, but egui applies the typed text on the frame after the + // one where Esc took the focus away: remember the Esc for that frame + let cancel = egui::Id::unique("cropAngleFieldEscaped"); + let escaped = ui.data_mut(|m| m.remove_temp::(cancel)).unwrap_or(false); + if escape && r.lost_focus() { + ui.data_mut(|m| m.insert_temp(cancel, true)); + } + if r.changed() && !escaped && angle.is_finite() && angle != d.crop.geometry.angle { + let _ = app.run("crop.straighten", json!({"angle": (angle.clamp(spec.min, spec.max) * 100.0).round() / 100.0})); + } + if r.drag_stopped() { + let _ = app.run("develop.endInteraction", json!({})); + } + }); + }); + } + padded(ui, |ui| { + ui.horizontal(|ui| { + let on = app.ui.tool == "straighten"; + if text_button(ui, "straightenTool", crate::i18n::tr("Straighten Tool"), on) + .on_hover_text(crate::i18n::tr("Drag along the horizon; double-click for Auto")) + .clicked() + { + app.ui.tool = if on { String::new() } else { "straighten".into() }; + } + if text_button(ui, "straightenAuto", crate::i18n::tr("Auto"), false).clicked() { + app.auto_straighten(ui.ctx()); + } + }); + }); + padded(ui, |ui| { + ui.label(crate::i18n::tr("Overlay")); + { + use crate::state::CropOverlay as O; + let opts = [ + (O::Thirds, "Thirds", "thirds"), + (O::Grid, "Grid", "grid"), + (O::Golden, "Golden", "golden"), + (O::Diagonal, "Diagonal", "diagonal"), + (O::Triangle, "Triangle", "triangle"), + (O::Spiral, "Spiral", "spiral"), + (O::None, "None", "none"), + ]; + let items: Vec<(&str, &str)> = opts.iter().map(|(_, l, k)| (*l, *k)).collect(); + let active = opts.iter().position(|(o, _, _)| *o == app.ui.crop_overlay); + if let Some(i) = crate::widgets::segmented(ui, "cropOverlay", &items, active, 4) { + app.ui.crop_overlay = opts[i].0; + } + if matches!(app.ui.crop_overlay, O::Triangle | O::Spiral) + && text_button(ui, "cropOverlayOrient", crate::i18n::tr("Flip Overlay (⇧O)"), false).clicked() + { + let _ = app.run("view.cropOverlayOrientation", json!({})); + } + } + }); + divider(ui); + header(ui, "Geometry"); + padded(ui, |ui| { + ui.label(crate::i18n::tr("Upright")); + { + use lightcraft_develop::Upright; + let modes = [ + ("Off", Upright::Off, "off"), + ("Auto", Upright::Auto, "auto"), + ("Guided", Upright::Guided, "guided"), + ("Level", Upright::Level, "level"), + ("Vertical", Upright::Vertical, "vertical"), + ("Full", Upright::Full, "full"), + ]; + let items: Vec<(&str, &str)> = modes.iter().map(|(l, _, k)| (*l, *k)).collect(); + let active = modes.iter().position(|(_, m, _)| *m == d.geometry.upright); + if let Some(i) = crate::widgets::segmented(ui, "upright", &items, active, 3) { + let (_, mode, key) = modes[i]; + let _ = app.run("geometry.upright", json!({"mode": key})); + app.ui.tool = if mode == Upright::Guided { "guidedUpright".into() } else { String::new() }; + } + } + let guided = d.geometry.upright == lightcraft_develop::Upright::Guided; + ui.horizontal_wrapped(|ui| { + if !guided + && d.geometry.upright != lightcraft_develop::Upright::Off + && text_button(ui, "uprightUpdate", crate::i18n::tr("Update"), false).clicked() + { + let mode = serde_json::to_value(d.geometry.upright).unwrap_or_default(); + let _ = app.run("geometry.upright", json!({"mode": mode})); + } + if guided { + let drawing = app.ui.tool == "guidedUpright"; + if text_button(ui, "uprightDraw", crate::i18n::tr("Draw Guides"), drawing).clicked() { + app.ui.tool = if drawing { String::new() } else { "guidedUpright".into() }; + } + if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", crate::i18n::tr("Clear Guides"), false).clicked() { + let _ = app.run("geometry.guides", json!({"guides": []})); + } + } + }); + if guided { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "{} of 4 guides — drag along lines that should be vertical or horizontal.", + d.geometry.guides.len() + )) + .size(11.0) + .color(Tokens::get(ui.ctx()).text_dim), + ); + } + ui.add_space(4.0); + let mut c = d.geometry.constrain_crop; + if ui.checkbox(&mut c, crate::i18n::tr("Constrain Crop")).changed() { + let _ = app.run("develop.merge", json!({"settings": {"geometry": {"constrain_crop": c}}, "label": "Constrain Crop"})); + } + }); + for spec in lightcraft_develop::controls::in_section(lightcraft_develop::Section::Geometry).filter(|c| c.id != "crop.angle") { + let v = lightcraft_develop::controls::get(&d, spec.id).unwrap_or(spec.default); + let out = slider(ui, spec, v, true, None); + crate::panels::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v}))); + } +} + +pub(super) fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let d = app.session.develop_of(id).unwrap_or_default(); + header(ui, "Remove"); + padded(ui, |ui| { + ui.horizontal_wrapped(|ui| { + for (label, tool) in [("Remove", "remove"), ("Content-Aware", "fill"), ("Heal", "heal"), ("Clone", "clone")] { + if text_button(ui, &format!("removeMode-{tool}"), label, app.ui.tool == tool).clicked() { + app.ui.tool = tool.into(); + } + } + }); + ui.add_space(8.0); + ui.label(crate::i18n::tr_format!("{} spot(s) on this photo", d.spots.len())); + ui.add_space(4.0); + if text_button(ui, "findDust", crate::i18n::tr("Find Dust Spots"), false) + .on_hover_text(crate::i18n::tr("Heal the small, soft dark spots sensor dust leaves on smooth areas")) + .clicked() + { + match app.run("spot.findDust", json!({})) { + Ok(r) => { + let n = r["added"].as_u64().unwrap_or(0); + app.toast( + ui.ctx(), + if n == 0 { + crate::i18n::tr("No dust spots found").to_string() + } else { + crate::i18n::tr_format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }, n = n) + }, + ); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + ui.add_space(4.0); + ui.label(egui::RichText::new(crate::i18n::tr("Paint over a distraction on the photo to remove it.")).color(Tokens::get(ui.ctx()).text_dim)); + }); + // brush settings; with a spot selected they edit that spot too + let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone()))); + if let Some((i, sp)) = &sel { + (app.ui.remove_size, app.ui.remove_feather, app.ui.remove_opacity) = (sp.size as f32, sp.feather as f32, sp.opacity as f32); + divider(ui); + let mode = match sp.mode { + lightcraft_develop::SpotMode::Heal => "Heal", + lightcraft_develop::SpotMode::Clone => "Clone", + lightcraft_develop::SpotMode::Remove => "Remove", + lightcraft_develop::SpotMode::Fill => "Content-Aware", + }; + crate::panels::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = crate::i18n::tr(mode))); + } + let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec { + id, + label, + section: lightcraft_develop::Section::Detail, + min, + max, + default, + step: 1.0, + decimals: 0, + track: lightcraft_develop::Track::Plain, + }; + let sliders = [ + (plain("ui.removeSize", "Size", 1.0, 250.0, 20.0), "size", (app.ui.remove_size * 1000.0) as f64), + (plain("ui.removeFeather", "Feather", 0.0, 100.0, 50.0), "feather", app.ui.remove_feather as f64), + (plain("ui.removeOpacity", "Opacity", 0.0, 100.0, 100.0), "opacity", app.ui.remove_opacity as f64), + ]; + for (spec, key, v) in sliders { + let out = slider(ui, &spec, v, true, None); + let scale = if key == "size" { 1000.0 } else { 1.0 }; + if let Some(v) = out.value { + match key { + "size" => app.ui.remove_size = (v / scale) as f32, + "feather" => app.ui.remove_feather = v as f32, + _ => app.ui.remove_opacity = v as f32, + } + } + if sel.is_some() { + crate::panels::edit::apply_slider_out(app, &spec, out, |app, v| app.run("spot.update", json!({key: v / scale}))); + } + } + if sel.is_some() { + padded(ui, |ui| { + ui.horizontal(|ui| { + if text_button(ui, "spotRefresh", crate::i18n::tr("Refresh Source (/)"), false).clicked() { + let _ = app.run("spot.refreshSource", json!({})); + } + if text_button(ui, "spotDelete", crate::i18n::tr("Delete (⌫)"), false).clicked() { + let _ = app.run("spot.delete", json!({})); + } + }); + }); + } + // Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out + padded(ui, |ui| { + let mut v = app.ui.visualize_spots; + if ui.checkbox(&mut v, crate::i18n::tr("Visualize Spots (A)")).changed() { + app.ui.visualize_spots = v; + } + }); + let spec = lightcraft_develop::ControlSpec { + id: "ui.spotsThreshold", + label: "Threshold", + section: lightcraft_develop::Section::Detail, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: lightcraft_develop::Track::Plain, + }; + let out = slider(ui, &spec, app.ui.spots_threshold as f64, app.ui.visualize_spots, None); + if let Some(v) = out.value { + app.ui.spots_threshold = v as f32; + } + padded(ui, |ui| { + tool_overlay_options(app, ui); + ui.add_space(4.0); + if !d.spots.is_empty() && text_button(ui, "removeClear", crate::i18n::tr("Delete all spots"), false).clicked() { + for i in (0..d.spots.len()).rev() { + let _ = app.run("spot.delete", json!({"index": i})); + } + } + }); +} + +/// Tool Overlay: when the spot and mask pins show on the photo (Auto / Always / Selected / Never; +/// Shift+H steps through them). Remove and Masking panels. +pub(crate) fn tool_overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { + use crate::state::ToolOverlay; + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Tool Overlay")).color(t.text_dim)); + let current = app.ui.tool_overlay; + let r = crate::widgets::dropdown(ui, "toolOverlay", crate::i18n::tr(current.label()), t.font(12.5), t.text_label) + .on_hover_text(crate::i18n::tr("When pins show on the photo (Shift+H cycles)")); + egui::Popup::menu(&r).show(|ui| { + for m in ToolOverlay::ALL { + let item = ui.selectable_label(m == current, crate::i18n::tr(m.label())); + register(ui, format!("toolOverlay:{}", m.name()), item.rect); + if item.clicked() { + let _ = app.run("view.toolOverlay", json!({"mode": m.name()})); + } + } + }); + }); +} + +pub(super) fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let d = app.session.develop_of(id).unwrap_or_default(); + header(ui, "Red Eye"); + padded(ui, |ui| { + ui.horizontal_wrapped(|ui| { + for (label, pet) in [("Red Eye", false), ("Pet Eye", true)] { + if text_button(ui, &format!("eyeMode-{}", if pet { "pet" } else { "red" }), label, app.ui.eye_pet == pet).clicked() { + app.ui.eye_pet = pet; + } + } + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new(crate::i18n::tr("Drag over an eye on the photo; the pupil inside is found automatically.")) + .color(Tokens::get(ui.ctx()).text_dim), + ); + }); + let n = d.red_eye.len(); + if n == 0 { + return; + } + if app.ui.eye >= n { + app.ui.eye = n - 1; + } + let i = app.ui.eye; + let eye = d.red_eye[i]; + divider(ui); + crate::panels::edit::sub_title( + ui, + &crate::i18n::tr_format!( + "{label} {index} of {n}", + label = crate::i18n::tr(if eye.pet { "Pet Eye" } else { "Red Eye" }), + index = i + 1, + n = n + ), + ); + for k in ["pupilSize", "darken"] { + let cid = format!("redEye.{i}.{k}"); + let Some(spec) = lightcraft_develop::controls::find(&cid) else { continue }; + let v = lightcraft_develop::controls::get(&d, &cid).unwrap_or(spec.default); + let out = slider(ui, spec, v, true, None); + crate::panels::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": cid, "value": v}))); + } + padded(ui, |ui| { + if eye.pet { + let mut on = eye.catchlight.is_some(); + if ui.checkbox(&mut on, crate::i18n::tr("Add Catchlight")).changed() { + let _ = app.run("redeye.catchlight", json!({"index": i, "on": on})); + } + ui.add_space(4.0); + } + ui.horizontal(|ui| { + if text_button(ui, "eyeDelete", crate::i18n::tr("Delete"), false).clicked() { + let _ = app.run("redeye.delete", json!({"index": i})); + } + if n > 1 && text_button(ui, "eyeDeleteAll", crate::i18n::tr("Delete all"), false).clicked() { + for k in (0..n).rev() { + let _ = app.run("redeye.delete", json!({"index": k})); + } + } + }); + }); +} diff --git a/crates/ui-egui/src/panels/right/history.rs b/crates/ui-egui/src/panels/right/history.rs new file mode 100644 index 000000000..49229783b --- /dev/null +++ b/crates/ui-egui/src/panels/right/history.rs @@ -0,0 +1,179 @@ +use super::*; + +pub(super) fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let Some(p) = app.session.catalog.photo(id).cloned() else { return }; + header(ui, "Versions"); + let t = Tokens::get(ui.ctx()); + padded(ui, |ui| { + if text_button(ui, "versionCreate", crate::i18n::tr("Create Version"), false).clicked() { + let _ = app.run("version.create", json!({})); + } + ui.add_space(8.0); + // Named (made by you) and Auto (made by LightCraft) versions + let tab_id = egui::Id::unique("versions-tab"); + let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false); + let named_n = p.versions.iter().filter(|v| !v.auto).count(); + let items = [("Named", "named"), ("Auto", "auto")]; + let labels = + [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), crate::i18n::tr_format!("Auto ({})", p.versions.len() - named_n)]; + let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect(); + if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) { + auto = i == 1; + ui.data_mut(|d| d.insert_temp(tab_id, auto)); + } + ui.add_space(6.0); + let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect(); + if shown.is_empty() { + let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." }; + ui.label(egui::RichText::new(crate::i18n::tr(msg)).color(t.text_dim)); + } + // newest first + for (i, v) in shown.into_iter().rev() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Version"), i + 1)); + register(ui, format!("version:{i}"), r); + let current = *v.settings == *p.develop; + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); + } + // thumbnail of the version's look + let tr = Rect::from_min_size(r.min + vec2(4.0, 5.0), vec2(72.0, 48.0)); + ui.painter().rect_filled(tr, 2.0, t.canvas); + if let Some(job) = app.session.variant_job(id, &v.settings, 160) + && let Some(tex) = app.renderer.variant(job) + { + ui.painter().image(tex.tex.id(), tr, crate::panels::presets::cover_uv(tr, tex.size), egui::Color32::WHITE); + } + let x = tr.right() + 10.0; + ui.painter().with_clip_rect(r.with_max_x(r.right() - 24.0)).text( + pos2(x, r.top() + 18.0), + Align2::LEFT_CENTER, + &v.name, + t.semibold(13.0), + t.text, + ); + let painter = ui.painter().with_clip_rect(r); + painter.text(pos2(x, r.top() + 38.0), Align2::LEFT_CENTER, short_time(&v.created), t.font(11.0), t.text_dim); + if current { + paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Check, t.accent); + } + // resting on a version shows it in the loupe (nothing is changed) + if resp.hovered() && !current { + app.hover_preview = + Some(crate::HoverPreview { label: crate::i18n::tr_format!("Version: {}", v.name), settings: (*v.settings).clone() }); + } + if resp.double_clicked() { + crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); + } else if resp.clicked() && !current { + let _ = app.run("version.restore", json!({"index": i})); + } + let resp = resp.on_hover_text(crate::i18n::tr(if current { + "The photo has these settings" + } else { + "Click to restore · double-click to rename" + })); + crate::widgets::context_menu(&resp, |ui| { + if ui.button(crate::i18n::tr("Restore")).clicked() { + let _ = app.run("version.restore", json!({"index": i})); + } + if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() { + let _ = app.run("version.update", json!({"index": i})); + } + if ui.button(crate::i18n::tr("Rename…")).clicked() { + crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); + } + if ui.button(crate::i18n::tr("Set as Before")).clicked() { + let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name})); + } + ui.separator(); + if ui.button(crate::i18n::tr("Delete")).clicked() { + let _ = app.run("version.delete", json!({"index": i})); + } + }); + } + }); +} + +/// "Sep 30, 2026, 12:00 PM" from an ISO time. +pub(super) fn short_time(iso: &str) -> String { + // Other languages use their own date patterns (`i18n::display_time`). + if crate::i18n::language() != crate::i18n::Locale::En { + return crate::i18n::display_time(iso); + } + let long = lightcraft_catalog::dates::display_time(iso); + // "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped + let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t))); + let date = match date.split_once(' ') { + Some((m, rest)) => format!("{} {rest}", m.chars().take(3).collect::()), + None => date.to_string(), + }; + match time.and_then(|t| t.rsplit_once(' ')) { + Some((hms, ampm)) => format!("{date}, {} {ampm}", hms.rsplit_once(':').map_or(hms, |x| x.0)), + None => date, + } +} + +/// How a History row is drawn: the step the settings are at is highlighted, the steps after it +/// (still there to return to until a new edit) are greyed out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum HistoryRow { + Done, + Current, + Undone, +} + +pub(super) fn history_row(i: usize, cursor: Option) -> HistoryRow { + match cursor { + Some(c) if i == c => HistoryRow::Current, + Some(c) if i > c => HistoryRow::Undone, + _ => HistoryRow::Done, + } +} + +pub(super) fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let Some(p) = app.session.catalog.photo(id).cloned() else { return }; + header(ui, "History"); + let t = Tokens::get(ui.ctx()); + padded(ui, |ui| { + if p.history.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim)); + } + let cursor = p.history_index(); + for (i, h) in p.history.iter().enumerate().rev() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(&h.label)); + register(ui, format!("history:{i}"), r); + let row = history_row(i, cursor); + if row == HistoryRow::Current { + ui.painter().rect_filled(r, 3.0, t.pressed); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let (icon, text) = if row == HistoryRow::Undone { (t.text_disabled, t.text_disabled) } else { (t.icon, t.text_label) }; + paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, icon); + ui.painter().text( + pos2(r.left() + 24.0, r.center().y), + Align2::LEFT_CENTER, + crate::i18n::history_label(&h.label, &app.session.presets), + t.font(12.5), + text, + ); + if resp.clicked() { + let _ = app.run("history.restore", json!({"index": i})); + } + crate::widgets::context_menu(&resp, |ui| { + if ui.button(crate::i18n::tr("Copy History Step Settings to Before")).clicked() { + let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i})); + } + if ui.button(crate::i18n::tr("Create Version from Step")).clicked() { + let _ = app.run("history.restore", json!({"index": i})); + let _ = app.run("version.create", json!({"name": h.label})); + } + ui.separator(); + if ui.button(crate::i18n::tr("Clear History")).clicked() { + let _ = app.run("history.clear", json!({})); + } + }); + } + }); +} diff --git a/crates/ui-egui/src/panels/right/info.rs b/crates/ui-egui/src/panels/right/info.rs new file mode 100644 index 000000000..4d7c198a0 --- /dev/null +++ b/crates/ui-egui/src/panels/right/info.rs @@ -0,0 +1,467 @@ +use super::*; + +pub(super) fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let Some(p) = app.session.catalog.photo(id).cloned() else { return }; + header(ui, "Info"); + let t = Tokens::get(ui.ctx()); + padded(ui, |ui| { + camera_card(ui, &p); + ui.add_space(6.0); + if let Some(why) = &p.preview_only { + if crate::widgets::preview_only_notice(ui, "info", why) { + crate::panels::edit::reload_preview_only(app, ui.ctx(), id); + } + ui.add_space(6.0); + } + if let Some(name) = &p.copy_name { + let of = p + .copy_of + .and_then(|m| app.session.catalog.photo(m)) + .map(|m| m.file_name.clone()) + .unwrap_or_else(|| crate::i18n::tr("a removed photo").into()); + ui.label(egui::RichText::new(crate::i18n::tr_format!("Virtual copy “{name}” of {of}", name = name, of = of)).color(t.text_label)); + } + ui.add_space(8.0); + if let Some(r) = ui.horizontal(|ui| crate::rating::Rating::new("info", p.rating).show(ui)).inner { + let mut params = json!({"rating": r}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.rate", params); + } + ui.add_space(6.0); + // colour label: one swatch per label (hover shows its name); clicking the current one clears it + ui.horizontal(|ui| { + for l in lightcraft_catalog::ColorLabel::ALL { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(&format!("{l:?}"))); + let key = format!("{l:?}").to_lowercase(); + register(ui, format!("label:{key}"), r); + let on = p.label == Some(l); + ui.painter().circle_filled(r.center(), if on || resp.hovered() { 8.0 } else { 6.5 }, crate::panels::grid::label_color(l)); + if on { + ui.painter().circle_stroke(r.center(), 10.0, egui::Stroke::new(1.5, t.text)); + } + let resp = resp.on_hover_text(crate::i18n::color_label(&app.session.catalog, l)); + if resp.clicked() { + // in Compare / Survey only the active photo, as the rating keys and the bottom pill do + let mut params = json!({"label": if on { "none".to_string() } else { key }}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.label", params); + } + } + if let Some(l) = p.label { + ui.label(egui::RichText::new(app.session.catalog.label_name(l)).color(t.text_label)); + } + }); + }); + divider(ui); + // with several photos selected: Target Photo shows this photo's values, Selected Photos what + // the selection shares (a field where they differ is empty and says Mixed) + let selected = app.session.selected(); + let several = selected.len() > 1; + let for_selected = several && app.ui.settings.info_selected_photos; + let m = shown_meta(&app.session.catalog, &p, &selected, for_selected); + let mixed = if for_selected { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() }; + // the photos an edit goes to: Target Photo edits the photo it shows + let targets = if several && !for_selected { vec![id] } else { selected.clone() }; + padded(ui, |ui| { + if several { + metadata_for(app, ui); + } + for (label, key, value, lines) in [ + ("Title", "title", &m.title, 1), + ("Caption", "caption", &m.caption, 2), + ("Alt Text", "altText", &m.alt_text, 2), + ("Extended Description", "extendedDescription", &m.extended_description, 3), + ("Copyright", "copyright", &m.copyright, 1), + ] { + meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets); + } + copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus"), &targets); + for (label, key, value, lines) in [ + ("Rights Usage Terms", "usageTerms", &m.usage_terms, 2), + ("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1), + ("Creator", "creator", &m.creator, 1), + ] { + meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets); + } + // file: name (rename), path (reveal), capture time (edit) + let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim)); + small(ui, "File Name"); + ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "infoRename", Icon::Pencil, vec2(20.0, 20.0), false, true, "Rename…").clicked() { + let _ = app.run("dialog.rename", json!({})); + } + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(&p.file_name).color(t.text)).truncate()); + }); + }); + ui.add_space(4.0); + small(ui, "File Path"); + ui.horizontal(|ui| { + let path = match &p.source { + lightcraft_catalog::Source::File { path } => path.clone(), + lightcraft_catalog::Source::Demo { .. } => crate::i18n::tr("Generated demo photo").into(), + }; + ui.add(egui::Label::new(egui::RichText::new(path).size(12.0).color(t.text_label)).truncate()); + if crate::menus::ui_enabled(app, "app.showInFinder") { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "infoReveal", Icon::Folder, vec2(20.0, 20.0), false, true, crate::menus::reveal_label()) + .clicked() + { + let _ = app.run("app.showInFinder", json!({})); + } + }); + } + }); + ui.add_space(4.0); + small(ui, "Captured"); + ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() { + let _ = app.run("dialog.captureTime", json!({})); + } + let cap = p.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| "—".into()); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate()); + }); + }); + ui.add_space(6.0); + for (label, key, value) in [ + ("Location", "location", &m.location), + ("City", "city", &m.city), + ("State / Province", "state", &m.state), + ("Country", "country", &m.country), + ] { + meta_field(app, ui, label, key, value, 1, mixed.contains(&key), &targets); + } + // GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it + let gps_mixed = mixed.contains(&"gps"); + let gps = if gps_mixed { String::new() } else { m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default() }; + let gid = egui::Id::unique("info-gps"); + ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); + let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &targets)); + let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" }); + let r = crate::text_field::TextField::singleline("field:gps", &mut edit.text).hint(hint).label("GPS").width(f32::INFINITY).show(ui); + edit.typed |= edit.text != edit.start; + if r.editing { + ui.data_mut(|d| d.insert_temp(gid, edit.clone())); + } else { + ui.data_mut(|d| d.remove::(gid)); + } + // Return or leaving saves; Esc gives the edit up + let text = edit.text.trim(); + if r.committed() + && (text != edit.start || edit.clears()) + && let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text})) + { + app.toast(ui.ctx(), e); + } + // (one photo's place: not shown while the selected photos' places differ) + if let Some((la, lo)) = m.gps.filter(|_| !gps_mixed) { + let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" }); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim)); + if text_button(ui, "showOnMap", crate::i18n::tr("Show on Map"), false) + .on_hover_text(crate::i18n::tr("Open the place in OpenStreetMap")) + .clicked() + { + let url = format!("https://www.openstreetmap.org/?mlat={la:.6}&mlon={lo:.6}#map=15/{la:.6}/{lo:.6}"); + if let Err(e) = crate::links::open(app, &url) { + app.toast(ui.ctx(), e); + } + } + }); + if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false) + .on_hover_text(format!( + "{}\n{}", + crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)"), + crate::i18n::tr("The place names list towns of 15,000 people or more: in a smaller one, City gets the nearest larger town.") + )) + .clicked() + && let Err(e) = app.run("metadata.reverseGeocode", json!({})) + { + app.toast_error(ui.ctx(), e); + } + } + if let Some(a) = p.analysis { + let mut line = crate::i18n::tr_format!("Focus {:.0}", a.sharpness); + if a.clipped > 0.02 { + line.push_str(&crate::i18n::tr_format!(" · {:.0}% clipped", a.clipped * 100.0)); + } + if a.group.is_some() { + line.push_str(if a.best { " · best of its burst" } else { " · in a burst" }); + } + ui.add_space(4.0); + ui.label(egui::RichText::new(line).color(t.text_dim)); + } + // offline originals / smart previews + // (cached answers, checked off the UI thread: unknown counts as online / no smart preview) + if let lightcraft_catalog::Source::File { path } = &p.source { + let avail = &app.session.media.availability; + let online = !avail.is_offline(path); + let smart = app.session.media.smart_dir.as_ref().is_some_and(|d| { + let shard = lightcraft_engine::smart::sharded_file_path(d, &p); + let legacy = d.join(lightcraft_engine::smart::file_name(&p)); + avail.exists(&shard.to_string_lossy()) == Some(true) || avail.exists(&legacy.to_string_lossy()) == Some(true) + }); + if !online || smart { + let text = match (online, smart) { + (false, true) => "Original offline · editing the smart preview", + (false, false) => "Original offline · no smart preview", + _ => "Smart preview available", + }; + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::tr(text)).color(if online { t.text_dim } else { t.accent })); + } + } + ui.add_space(10.0); + if text_button(ui, "allMetadata", crate::i18n::tr("All Metadata…"), false) + .on_hover_text(crate::i18n::tr("Every EXIF, GPS and XMP field in the file")) + .clicked() + { + let _ = app.run("dialog.allMetadata", json!({})); + } + }); +} + +/// The camera card at the top of Info: camera, lens, size · file size and format, then the +/// capture settings. +fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { + let t = Tokens::get(ui.ctx()); + let m = &p.meta; + egui::Frame::NONE.fill(t.canvas).corner_radius(6.0).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + let dim = |s: &str| egui::RichText::new(s.to_string()).size(12.0).color(t.text_label); + if !m.camera.is_empty() { + ui.label(dim(&m.camera)); + } + if !m.lens.is_empty() { + ui.add(egui::Label::new(dim(&m.lens)).truncate()); + } + ui.horizontal(|ui| { + ui.label(dim(&format!("{} × {} · {}", p.width, p.height, human_size(p.file_size)))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::Frame::NONE.fill(t.chrome).corner_radius(3.0).inner_margin(egui::Margin::symmetric(5, 1)).show(ui, |ui| { + ui.label(egui::RichText::new(p.format.to_uppercase()).size(10.5).color(t.text_label)); + }); + }); + }); + let rows = [ + ("Focal length", m.focal_mm.map(|f| format!("{f:.1} mm").replace(".0 mm", " mm"))), + ("Shutter speed", (!m.shutter.is_empty()).then(|| crate::i18n::tr_format!("{} sec", m.shutter))), + ("Aperture", m.aperture.map(|f| format!("f / {f:.1}").replace(".0", ""))), + ("ISO", m.iso.map(|i| i.to_string())), + ]; + if rows.iter().any(|r| r.1.is_some()) { + ui.add_space(6.0); + egui::Grid::new("info-capture").num_columns(2).spacing([24.0, 3.0]).show(ui, |ui| { + for (k, v) in rows { + ui.label(dim(crate::i18n::tr(k))); + ui.label(dim(&v.unwrap_or_else(|| "—".into()))); + ui.end_row(); + } + }); + } + // where the camera focused, and how (View ▸ Focus Point draws it): only when known; the + // whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits + if let Some(text) = focus_text(m.focus.as_ref()) { + ui.add_space(3.0); + let r = ui.horizontal_wrapped(|ui| { + ui.label(dim(crate::i18n::tr("Focus"))); + ui.add_space(12.0); + ui.label(egui::RichText::new(text).size(12.0).color(t.text)); + }); + register(ui, "info:focus", r.response.rect); + } + }); +} + +/// The camera's focus mode, AF area and tracking in the interface language ("AF-C · Wide · Animal Eye +/// Tracking"), or `None` when the photo has no focus point. +pub(crate) fn focus_text(f: Option<&lightcraft_meta::FocusPoint>) -> Option { + let f = f?; + let words: Vec<&str> = f.words().into_iter().map(crate::i18n::tr).collect(); + Some(if words.is_empty() { crate::i18n::tr("Focus point").to_string() } else { words.join(" · ") }) +} + +fn human_size(bytes: u64) -> String { + match bytes { + b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), + b if b >= 1 << 10 => format!("{} KB", b >> 10), + b => format!("{b} B"), + } +} + +/// What a metadata field shows while the selected photos' values differ (Selected Photos). +const MIXED: &str = "Mixed"; + +/// An Info panel field's edit under way, kept in egui memory between frames. A field saves when +/// its text is not the text the edit started from, so a field left as it was saves nothing, even +/// when what the panel shows changed during the edit. It saves to the photos it edited when the +/// edit started (the selected photos, or Target Photo's photo alone), also when the selection +/// changed since. +#[derive(Clone)] +struct FieldEdit { + text: String, + start: String, + /// The photos the field edited when the edit started (`photo.setMeta` `ids`). + ids: Vec, + /// The field said Mixed when the edit started. + mixed: bool, + /// Something was typed or deleted since. + typed: bool, +} + +impl FieldEdit { + fn new(value: &str, mixed: bool, targets: &[PhotoId]) -> Self { + let ids = targets.iter().map(|id| id.0).collect(); + FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false } + } + + /// A Mixed field that was typed in and left empty: it clears the field on every selected photo. + fn clears(&self) -> bool { + self.mixed && self.typed + } +} + +/// The Info panel's text fields, by their `photo.setMeta` key. +const META_TEXT_KEYS: [&str; 12] = [ + "title", + "caption", + "altText", + "extendedDescription", + "copyright", + "usageTerms", + "copyrightUrl", + "creator", + "location", + "city", + "state", + "country", +]; + +/// The value of one of [`META_TEXT_KEYS`]. +fn meta_text<'a>(m: &'a lightcraft_catalog::Meta, key: &str) -> Option<&'a str> { + Some(match key { + "title" => &m.title, + "caption" => &m.caption, + "altText" => &m.alt_text, + "extendedDescription" => &m.extended_description, + "copyright" => &m.copyright, + "usageTerms" => &m.usage_terms, + "copyrightUrl" => &m.copyright_url, + "creator" => &m.creator, + "location" => &m.location, + "city" => &m.city, + "state" => &m.state, + "country" => &m.country, + _ => return None, + }) +} + +/// The metadata the Info panel's fields show: the active photo `p`'s, or with `for_selected` +/// (Selected Photos) the active photo's when it is one of the `selected` photos, else the first +/// selected photo's. +pub(crate) fn shown_meta( + catalog: &lightcraft_catalog::Catalog, + p: &lightcraft_catalog::Photo, + selected: &[PhotoId], + for_selected: bool, +) -> lightcraft_catalog::Meta { + if !for_selected || selected.contains(&p.id) { + return p.meta.clone(); + } + selected.iter().find_map(|&id| catalog.photo(id)).map_or_else(|| p.meta.clone(), |q| q.meta.clone()) +} + +/// The Info panel fields (`photo.setMeta` keys, plus `copyrightStatus` and `gps`) where one of +/// the photos `ids` has a value other than `shown`'s, the metadata the panel draws. +pub(crate) fn mixed_meta(catalog: &lightcraft_catalog::Catalog, ids: &[PhotoId], shown: &lightcraft_catalog::Meta) -> Vec<&'static str> { + let mut mixed = Vec::new(); + for m in ids.iter().filter_map(|&id| catalog.photo(id)).map(|p| &p.meta) { + for key in META_TEXT_KEYS { + if !mixed.contains(&key) && meta_text(m, key) != meta_text(shown, key) { + mixed.push(key); + } + } + if !mixed.contains(&"copyrightStatus") && m.copyright_status != shown.copyright_status { + mixed.push("copyrightStatus"); + } + if !mixed.contains(&"gps") && m.gps != shown.gps { + mixed.push("gps"); + } + if mixed.len() == META_TEXT_KEYS.len() + 2 { + break; + } + } + mixed +} + +/// Target Photo / Selected Photos: whose metadata the fields below show while several photos +/// are selected (`view.infoSelectedPhotos`). +fn metadata_for(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(crate::i18n::tr("Show Metadata For")).size(11.5).color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + let on = app.ui.settings.info_selected_photos; + for (id, label, selected) in [("infoFor-target", "Target Photo", false), ("infoFor-selected", "Selected Photos", true)] { + if text_button(ui, id, label, on == selected).clicked() && on != selected { + let _ = app.run("view.infoSelectedPhotos", json!({"on": selected})); + } + } + }); + ui.add_space(6.0); +} + +/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). `mixed`: the +/// selected photos' statuses differ, and any status picked is given to all of them. A status +/// picked goes to the photos `targets`. +fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool, targets: &[PhotoId]) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); + let shown = crate::i18n::tr(if mixed { MIXED } else { current.label() }); + let r = crate::widgets::combo_box("info-copyright-status").selected_text(shown).show_ui(ui, |ui| { + for st in lightcraft_catalog::CopyrightStatus::ALL { + let on = !mixed && st == current; + if ui.selectable_label(on, crate::i18n::tr(st.label())).clicked() && !on { + let ids: Vec = targets.iter().map(|id| id.0).collect(); + let _ = app.run("photo.setMeta", json!({"ids": ids, "copyrightStatus": st.id()})); + } + } + }); + crate::widgets::name_combo(&r.response, crate::i18n::tr("Copyright Status")); + register(ui, "field:copyrightStatus", r.response.rect); + ui.add_space(6.0); +} + +/// A labeled metadata text field: the typed text lives in egui memory while focused and is +/// saved (photo.setMeta `key`) when the field loses focus. `mixed`: the selected photos' values +/// differ, so the field starts empty and says so, and is saved only when it was typed in (left +/// empty, it clears the field on every selected photo). The edit is saved to the photos `targets`. +#[allow(clippy::too_many_arguments)] +fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool, targets: &[PhotoId]) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); + let id = egui::Id::unique(("info-field", key)); + let value = if mixed { "" } else { value }; + let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, targets)); + let widget = format!("field:{key}"); + let field = if lines > 1 { + crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines) + } else { + crate::text_field::TextField::singleline(&widget, &mut edit.text) + }; + let field = if mixed { field.hint(crate::i18n::tr(MIXED)) } else { field }; + let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui); + edit.typed |= edit.text != edit.start; + // the typed text outlives frames while it is edited (the photo's value is drawn otherwise) + if r.editing { + ui.data_mut(|d| d.insert_temp(id, edit.clone())); + } else { + ui.data_mut(|d| d.remove::(id)); + } + // Return (one line) or leaving saves; Esc gives the edit up + if r.committed() && (edit.text != edit.start || edit.clears()) { + let _ = app.run("photo.setMeta", json!({"ids": edit.ids, key: edit.text})); + } + ui.add_space(6.0); +} diff --git a/crates/ui-egui/src/panels/right/keywords.rs b/crates/ui-egui/src/panels/right/keywords.rs new file mode 100644 index 000000000..6d7d5868b --- /dev/null +++ b/crates/ui-egui/src/panels/right/keywords.rs @@ -0,0 +1,330 @@ +use super::*; + +pub(super) fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + // (the box is the selection's: the active photo only says that there is one) + if app.session.catalog.photo(id).is_none() { + return; + } + let top = ui.cursor().top(); + if fold_header(app, ui, "keywording", "Keywords") { + keywording(app, ui); + } + hold_list_in_place(ui, top); + crate::panels::keyword_list::show(app, ui); +} + +/// The Keywording box: the field that adds keywords, the selection's keywords, the keyword set, +/// the painter and the suggestions. +fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + padded(ui, |ui| { + let kid = egui::Id::unique("kw-input"); + let mut text = ui.data_mut(|d| d.get_temp::(kid).unwrap_or_default()); + // the keywords every selected photo has: those only some have are still offered and suggested + let selection = app.session.selected(); + let have: Vec = + app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); + // while the box has the cursor it offers the library's keywords for what is being typed, + // right under itself; it stays for a click on one of them, which takes the cursor away + let field = crate::text_field::id("field:keyword"); + let oid = egui::Id::unique("kw-offers"); + let pass = ui.ctx().cumulative_pass_nr(); + // (remembered from the last frame: the text the highlight was for and the keyword it was + // on, where the offers were drawn, and whether the box was in sight) + let (typed_then, at_then, drawn, drawn_pass, hidden): (String, Option, Rect, u64, bool) = + ui.data(|d| d.get_temp(oid)).unwrap_or((String::new(), None, Rect::NOTHING, 0, false)); + let last_frame = drawn_pass.checked_add(1) == Some(pass); + let clicking = last_frame + && ui.input(|i| (i.pointer.any_down() || i.pointer.any_released()) && i.pointer.interact_pos().is_some_and(|p| drawn.contains(p))); + let typed = crate::panels::keywording::typing(&text).to_string(); + // (none while the box is scrolled out of the panel: they would float over what is there) + let offers = ((ui.memory(|m| m.has_focus(field)) || clicking) && !(last_frame && hidden)) + .then(|| app.caches.completions(&app.session.catalog, &have, &typed)) + .filter(|o| !o.keywords.is_empty() || o.create.is_some()); + // ↓ / ↑ move through the offers (none is highlighted at first, and ↑ from the first goes + // back to none: Return then gives what was typed, as without offers); Return gives the + // highlighted one. The highlight is on a keyword, not a line: it stays with it when the + // library changes under it, and goes when the keyword does. With Shift or Alt the keys + // are the text's own. + let mut at = at_then.filter(|k| typed_then == typed && offers.as_ref().is_some_and(|o| o.keywords.contains(k))); + let mut picked = None; + if let Some(o) = offers.as_ref().filter(|o| !o.keywords.is_empty()) { + let pressed = |ui: &mut egui::Ui, key: egui::Key| { + ui.input_mut(|i| { + let plain = + |e: &egui::Event| matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && modifiers.is_none()); + let found = i.events.iter().any(plain); + i.events.retain(|e| !plain(e)); + found + }) + }; + let line = at.as_ref().and_then(|k| o.keywords.iter().position(|x| x == k)); + if pressed(ui, egui::Key::ArrowDown) { + let next = line.map_or(0, |a| a.saturating_add(1)).min(o.keywords.len().saturating_sub(1)); + at = o.keywords.get(next).cloned(); + } else if pressed(ui, egui::Key::ArrowUp) { + at = line.and_then(|a| a.checked_sub(1)).and_then(|a| o.keywords.get(a).cloned()); + } + if let Some(k) = &at + && pressed(ui, egui::Key::Enter) + { + picked = Some(crate::panels::keywording::Picked::Keyword(k.clone())); + } + } + // (after an offer was taken the box starts a new edit, empty: Esc has nothing to bring back) + // (asked for on the frame before this one only: a box that wasn't drawn then, its panel + // switched away, doesn't claim the cursor whenever it is next drawn) + let bid = egui::Id::unique("kw-begin"); + let begin = ui.data_mut(|d| d.remove_temp::(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass)); + // Add Keywords (⌘K) asks for the cursor too, and for the box to be scrolled into sight + let asked = app.ui.focus_keywords.take().is_some_and(|at| pass <= at.saturating_add(crate::state::FOCUS_KEYWORDS_PASSES)); + let r = crate::text_field::TextField::singleline("field:keyword", &mut text) + .hint(crate::i18n::tr("Add keyword")) + .width(f32::INFINITY) + .begin_edit(begin || asked) + .show(ui); + if asked { + r.response.scroll_to_me(None); + } + let in_sight = ui.clip_rect().contains(r.response.rect.center()); + let mut drawn = Rect::NOTHING; + // (what isn't drawn can't be taken: Return on the frame the box scrolls out gives nothing) + if !in_sight { + picked = None; + } + if offers.is_none() { + // (not over the offers) + r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos.")); + } + if let Some(o) = offers.as_ref().filter(|_| in_sight) { + let (clicked, rect) = crate::panels::keywording::offers_below(ui, r.response.rect, o, at.as_deref()); + drawn = rect; + picked = picked.or(clicked); + } + ui.data_mut(|d| d.insert_temp(oid, (typed.clone(), at, drawn, pass, !in_sight))); + // Return gives the keywords to the selected photos; Esc gives back what was there before + // this edit (the shared field does) + let returned = r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty(); + // a new name goes inside the default parent (Put New Keywords Inside This Keyword) + let as_typed = |app: &LightcraftApp, text: &str| -> Vec { + text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect() + }; + let adding: Option> = match picked { + // an offer: it, and the keywords typed before the last comma + Some(crate::panels::keywording::Picked::Keyword(k)) => { + let mut kws = crate::panels::keywording::typed_before(&app.session.catalog, &text); + kws.push(k); + Some(kws) + } + Some(crate::panels::keywording::Picked::Typed) => Some(as_typed(app, &text)), + None if returned => Some(as_typed(app, &text)), + None => None, + }; + if let Some(kws) = adding { + // (refused, the text stays to be put right, and the refusal is said) + match app.run("photo.setMeta", json!({"addKeywords": kws})) { + Ok(_) => { + text.clear(); + // an offer taken leaves the cursor in the box, for the next keyword + if !returned { + ui.data_mut(|d| d.insert_temp(bid, pass)); + } + } + Err(e) => app.toast_error(ui.ctx(), e), + } + } + ui.data_mut(|d| d.insert_temp(kid, text)); + ui.add_space(8.0); + crate::panels::keywording::view_switch(app, ui); + ui.add_space(4.0); + crate::panels::keywording::scope(app, ui, &selection); + if app.ui.keywording_view == crate::state::KeywordingView::Keywords { + crate::panels::keywording::chip_row(app, ui, &selection); + } else { + crate::panels::keywording::names_row(app, ui); + } + ui.add_space(10.0); + keyword_set(app, ui); + ui.add_space(8.0); + // the painter: click photos in the grid to give them (or take away) a keyword + ui.horizontal(|ui| { + let pid = egui::Id::unique("kw-painter"); + let painting = app.ui.keyword_painter.clone(); + let mut k: String = ui.data(|d| d.get_temp(pid)).unwrap_or_else(|| painting.clone().unwrap_or_default()); + // (fixed while painting: Stop first) + let field = ui + .add_enabled_ui(painting.is_none(), |ui| { + crate::text_field::TextField::singleline("field:keywordPainter", &mut k) + .hint(crate::i18n::tr("Keyword to paint")) + .width(130.0) + .show(ui) + }) + .inner; + ui.data_mut(|d| d.insert_temp(pid, k.clone())); + let label = if painting.is_some() { "Stop" } else { "Paint" }; + let returned = field.ending == Some(crate::text_field::Ending::Return) && painting.is_none() && !k.trim().is_empty(); + if text_button(ui, "keywordPaint", label, painting.is_some()) + .on_hover_text(crate::i18n::tr("Click photos in the grid to toggle the keyword; Esc stops")) + .clicked() + || returned + { + let _ = app.run("tool.keywordPainter", json!({"keyword": if painting.is_some() { serde_json::Value::Null } else { json!(k) }})); + } + }); + ui.add_space(10.0); + // suggestions: keywords used together with the selection's keywords, else the most used + // ones (what matches the text being typed is offered under the box itself) + let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, "", 12)).clone(); + if !suggestions.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim)); + // grouped: the keyword's own name, its whole path in the tooltip + let grouped = app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped; + let names = if grouped { crate::panels::keywording::suggestion_names(&suggestions) } else { Vec::new() }; + ui.horizontal_wrapped(|ui| { + if grouped { + ui.spacing_mut().item_spacing = vec2(6.0, 4.0); + } + for (i, k) in suggestions.into_iter().enumerate() { + let whole = k.replace('|', " › "); + let name = names.get(i).unwrap_or(&whole); + let r = ui.add(egui::Button::new(egui::RichText::new(format!("+ {name}")).color(t.text_label)).corner_radius(10.0)); + let r = if grouped { r.on_hover_text(&whole) } else { r }; + register(ui, format!("kwSuggest:{k}"), r.rect); + if r.clicked() { + let _ = app.run("photo.setMeta", json!({"addKeywords": [k]})); + } + } + }); + } + }); +} + +/// Keeps the Keyword List where it is on screen when the Keywords box above it (starting at +/// `top`) grows or shrinks: a keyword ticked or unticked in the list, another selection. Without it +/// a long tree slides away from under the pointer by a row of chips each time. It holds only while +/// the box's top is scrolled out of sight (at the top of the panel the box is watched growing) and +/// while the change does not come from the box itself (the pointer on it, or typing in its field). +fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { + let height = ui.cursor().top() - top; + let key = egui::Id::unique("kw-box-height"); + let pass = ui.ctx().cumulative_pass_nr(); + // (the height drawn on the pass before: one from an earlier visit to the panel says nothing) + let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h); + ui.data_mut(|d| d.insert_temp(key, (height, pass))); + let Some(before) = before else { return }; + let grew = height - before; + if !grew.is_finite() || grew.abs() < 0.5 || top >= ui.clip_rect().top() - 0.5 { + return; + } + // (only the part of it in sight: the pointer on the top bar above a half-hidden box isn't on it) + let old_box = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + before).intersect(ui.clip_rect()); + let on_box = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| old_box.contains(p)); + let typing = ui.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))); + if on_box || typing { + return; + } + ui.data_mut(|d| d.insert_temp(egui::Id::unique(HOLD), (grew, pass))); + // (drawn again at once at the new offset, so the list never shows a frame out of place) + ui.ctx().request_discard("the Keyword List holds its place"); +} + +/// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the +/// selected photos; "Save as Set…" keeps the current nine under a name. +fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { + // on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do + let selection = app.session.selected(); + let chips = app.caches.keyword_chips(&app.session.catalog, &selection); + let t = Tokens::get(ui.ctx()); + let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); + let current = sets["current"].as_str().unwrap_or_default().to_string(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim)); + let combo = crate::widgets::combo_box("kw-set") + .selected_text(crate::i18n::builtin_label(¤t, current == lightcraft_engine::cmd::keywords::RECENT)) + .show_ui(ui, |ui| { + for set in sets["sets"].as_array().into_iter().flatten() { + let name = set["name"].as_str().unwrap_or_default(); + if ui + .selectable_label(name == current, crate::i18n::builtin_label(name, name == lightcraft_engine::cmd::keywords::RECENT)) + .clicked() + { + let _ = app.run("keyword.useSet", json!({"name": name})); + } + } + ui.separator(); + // Edit Set…: the current nine, slot by slot (named, Recent Keywords becomes a set) + let edit = ui.button(crate::i18n::tr("Edit Set…")); + register(ui, "keywordSetMenu:edit", edit.rect); + if edit.clicked() { + let mut slots: Vec = + sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); + slots.resize(9, String::new()); + let named = current != lightcraft_engine::cmd::keywords::RECENT; + app.ui.dialog = Some(crate::state::Dialog::KeywordSet { + replaces: named.then(|| current.clone()), + name: if named { current.clone() } else { String::new() }, + slots, + as_new: false, + }); + } + if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: "Save Keyword Set".into(), + hint: "Set name".into(), + value: String::new(), + command: "keyword.saveSet".into(), + params: json!({}), + key: "name".into(), + }); + } + if current != lightcraft_engine::cmd::keywords::RECENT + && ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked() + { + let _ = app.run("keyword.deleteSet", json!({"name": current})); + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Keyword Set")); + register(ui, "keywordSetCombo", combo.response.rect); + }); + let kws: Vec = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); + if kws.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("Keywords you add appear here; ⌥1–⌥9 apply them.")).color(t.text_dim)); + return; + } + let bw = ((ui.available_width() - 8.0) / 3.0).floor().max(40.0); + egui::Grid::new("kw-set-grid").num_columns(3).spacing([4.0, 4.0]).show(ui, |ui| { + for (i, k) in kws.iter().enumerate() { + // an empty slot: an idle button, so the others keep their ⌥ keys + if k.is_empty() { + let r = ui.add_enabled_ui(false, |ui| ui.add_sized([bw, 22.0], egui::Button::new(""))).inner; + register(ui, format!("kwSetEmpty:{}", i + 1), r.rect); + if i % 3 == 2 { + ui.end_row(); + } + continue; + } + let chip = chips.iter().find(|c| lightcraft_catalog::keywords::same(&c.path, &lightcraft_catalog::keywords::clean(k))); + let on = chip.is_some_and(|c| c.on_all()); + let some = chip.is_some() && !on; + let short = format!("{}{}", k.rsplit('|').next().unwrap_or(k), if some { " *" } else { "" }); + let r = ui + .add_sized( + [bw, 22.0], + egui::Button::new(egui::RichText::new(short).size(11.5).color(if on { t.text } else { t.text_label })).selected(on).truncate(), + ) + .on_hover_text(format!("{} — ⌥{}", k.replace('|', " › "), i + 1)); + register(ui, format!("kwSet:{}", i + 1), r.rect); + if on { + register(ui, format!("kwSetOn:{}", i + 1), r.rect); + } else if some { + register(ui, format!("kwSetSome:{}", i + 1), r.rect); + } + if r.clicked() { + let _ = app.run("keyword.toggleFromSet", json!({"index": i + 1})); + } + if i % 3 == 2 { + ui.end_row(); + } + } + }); +} From 0896aa86ce388aeb8c882a336ee9d463b5234f8d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:13:11 +0000 Subject: [PATCH 124/191] refactor(lightcraft-ui-egui): split state.rs into state/ --- crates/ui-egui/src/state.rs | 1221 +------------------------- crates/ui-egui/src/state/dialog.rs | 375 ++++++++ crates/ui-egui/src/state/settings.rs | 464 ++++++++++ crates/ui-egui/src/state/view.rs | 379 ++++++++ 4 files changed, 1233 insertions(+), 1206 deletions(-) create mode 100644 crates/ui-egui/src/state/dialog.rs create mode 100644 crates/ui-egui/src/state/settings.rs create mode 100644 crates/ui-egui/src/state/view.rs diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 310cac90a..a84e926c3 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -2,775 +2,21 @@ use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ViewMode { - /// Justified rows ("Photo Grid"). - #[default] - PhotoGrid, - SquareGrid, - Detail, - /// Two photos side by side (select | candidate), synced zoom. - Compare, - /// The selected photos tiled. - Survey, - /// A reference photo (left, fixed) beside the active photo (right, being edited). - Reference, - /// A card per person named on faces (close-up, name, photo count). - People, -} - -/// The right-hand tool/panel shown next to the tool strip. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RightPanel { - #[default] - None, - Edit, - Crop, - Remove, - Masking, - RedEye, - Versions, - Activity, - Keywords, - Info, - /// The profile browser (part of Edit). - Profiles, -} - -impl RightPanel { - pub fn is_edit_tool(self) -> bool { - matches!(self, RightPanel::Edit | RightPanel::Profiles | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye) - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Zoom { - #[default] - Fit, - Fill, - /// 100 % = one image pixel per physical screen pixel. - Percent(f32), -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BeforeAfter { - #[default] - Off, - /// Hold `\`: show the original. - Original, - SideBySide, - Split, - /// Before above after. - TopBottom, - /// One image split horizontally: before above the line. - SplitTopBottom, -} - -impl BeforeAfter { - /// One of the Before / After layouts (two views or a split line), which the bottom bar's - /// Before/After (Y) button stands for. Show Original (`\`) is a view of its own, not a layout. - pub fn is_layout(self) -> bool { - !matches!(self, BeforeAfter::Off | BeforeAfter::Original) - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum CropOverlay { - #[default] - Thirds, - Grid, - Golden, - Diagonal, - Triangle, - Spiral, - None, -} - -/// Tool Overlay: when the retouching tools' pins (Remove / Heal / Clone spots, mask pins) show on -/// the photo. Shift+H steps through the modes in this order (`view.toolOverlay`). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ToolOverlay { - /// While the pointer is over the photo (hidden while a new spot or brush stroke is painted). - #[default] - Auto, - Always, - /// Only the selected spot's or mask's. - Selected, - /// None; the tool still works (a click still adds a spot). - Never, -} - -impl ToolOverlay { - pub const ALL: [ToolOverlay; 4] = [ToolOverlay::Auto, ToolOverlay::Always, ToolOverlay::Selected, ToolOverlay::Never]; - - /// The id used by `view.toolOverlay {mode}` and the saved settings. - pub fn name(self) -> &'static str { - match self { - ToolOverlay::Auto => "auto", - ToolOverlay::Always => "always", - ToolOverlay::Selected => "selected", - ToolOverlay::Never => "never", - } - } - - pub fn parse(s: &str) -> Option { - ToolOverlay::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s.trim())) - } - - /// Menu label. - pub fn label(self) -> &'static str { - match self { - ToolOverlay::Auto => "Auto", - ToolOverlay::Always => "Always", - ToolOverlay::Selected => "Selected", - ToolOverlay::Never => "Never", - } - } - - /// The mode Shift+H steps to. - pub fn next(self) -> ToolOverlay { - match self { - ToolOverlay::Auto => ToolOverlay::Always, - ToolOverlay::Always => ToolOverlay::Selected, - ToolOverlay::Selected => ToolOverlay::Never, - ToolOverlay::Never => ToolOverlay::Auto, - } - } - - /// Whether a pin shows: `selected` = its spot or mask is the selected one, `live` = the pointer - /// is over the photo (or a pin is being dragged), which is what Auto waits for. - pub fn shows(self, selected: bool, live: bool) -> bool { - match self { - ToolOverlay::Auto => live, - ToolOverlay::Always => true, - ToolOverlay::Selected => selected, - ToolOverlay::Never => false, - } - } -} - -/// Which view the app opens in. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StartupView { - /// Whatever was showing at quit. - #[default] - Last, - Grid, - Detail, -} - -/// What the desktop app opens at launch (Settings ▸ General ▸ At launch; issue #1290). `--library` -/// and `LIGHTCRAFT_LIBRARY` come first either way. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LaunchLibrary { - /// The library open when the app last quit (holding Alt/Option as the window appears asks once). - #[default] - Last, - /// A window lists the recent libraries to choose from. - Ask, -} - -/// When grid cells show their rating / flag / edited badges. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum GridBadges { - /// On hover, on the selection, and on rated or flagged photos. - #[default] - Auto, - Always, - Never, -} - -/// The loupe's info overlay (Cmd+I cycles; I in the full-screen preview). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum InfoOverlay { - #[default] - Off, - /// File name, capture date and dimensions. - Basic, - /// File name, camera, lens and exposure (shutter, aperture, ISO, focal length). - Exposure, -} - -impl InfoOverlay { - pub fn next(self) -> InfoOverlay { - match self { - InfoOverlay::Off => InfoOverlay::Basic, - InfoOverlay::Basic => InfoOverlay::Exposure, - InfoOverlay::Exposure => InfoOverlay::Off, - } - } -} - -/// One line of the loupe's info overlay, chosen in View ▸ View Options (Lightroom Classic's Loupe -/// Info 1 / Loupe Info 2). A line with nothing to show for a photo is left out. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LoupeInfoField { - #[default] - None, - FileName, - /// The capture date and time (the import date when the photo has none). - CaptureDate, - /// Width × height in pixels. - Dimensions, - /// Shutter speed, aperture, ISO and focal length. - Exposure, - Camera, - Lens, - /// Camera and lens on one line. - CameraAndLens, -} - -impl LoupeInfoField { - /// Every field, in the order View Options lists them. - pub const ALL: &'static [LoupeInfoField] = &[ - LoupeInfoField::None, - LoupeInfoField::FileName, - LoupeInfoField::CaptureDate, - LoupeInfoField::Dimensions, - LoupeInfoField::Exposure, - LoupeInfoField::Camera, - LoupeInfoField::Lens, - LoupeInfoField::CameraAndLens, - ]; - - pub fn label(self) -> &'static str { - match self { - LoupeInfoField::None => "None", - LoupeInfoField::FileName => "File Name", - LoupeInfoField::CaptureDate => "Capture Date", - LoupeInfoField::Dimensions => "Dimensions", - LoupeInfoField::Exposure => "Exposure", - LoupeInfoField::Camera => "Camera", - LoupeInfoField::Lens => "Lens", - LoupeInfoField::CameraAndLens => "Camera & Lens", - } - } -} - -/// The three lines each mode of the info overlay shows: Info 1 ([`InfoOverlay::Basic`]) and -/// Info 2 ([`InfoOverlay::Exposure`]), and which of them Show Briefly When Photo Changes shows. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct LoupeInfo { - pub info1: [LoupeInfoField; 3], - pub info2: [LoupeInfoField; 3], - /// Show Briefly When Photo Changes: while the info overlay is off, this Info shows for - /// [`BRIEF_INFO_SECS`] each time the loupe changes to another photo ([`InfoOverlay::Off`]: - /// neither). - pub briefly: InfoOverlay, -} - -/// Passes after Add Keywords within which the Add keyword box takes the cursor (the panel may -/// need a pass to open first). -pub const FOCUS_KEYWORDS_PASSES: u64 = 2; - -/// How long Show Briefly When Photo Changes shows the info overlay (seconds). -pub const BRIEF_INFO_SECS: f64 = 3.0; - -/// When the loupe last changed photo, for Show Briefly When Photo Changes -/// ([`LoupeInfo::briefly`]). -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct BriefInfo { - /// The photo the loupe showed last. - pub photo: Option, - /// When the loupe changed to it (seconds on egui's clock). - pub since: f64, - /// The brief info overlay shows this frame. - pub shown: bool, -} - -impl LoupeInfo { - /// Info 1's lines before they are changed (and after Use Defaults). - pub const INFO1: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::CaptureDate, LoupeInfoField::Dimensions]; - /// Info 2's lines before they are changed (and after Use Defaults). - pub const INFO2: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::Exposure, LoupeInfoField::CameraAndLens]; -} - -impl Default for LoupeInfo { - fn default() -> Self { - LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2, briefly: InfoOverlay::Off } - } -} - -/// View ▸ Loupe Overlay ▸ Layout Image (Lightroom Classic's loupe overlay): a graphic with -/// transparency, such as a magazine cover's masthead and cover lines, drawn over the photo in the -/// loupe (`panels::layout_overlay`). Saved with the UI state, so the layout stays chosen across -/// launches. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct LayoutImage { - /// The graphic's file; empty until one is chosen. - pub path: String, - /// Drawn over the loupe (⌥⌘O). - pub show: bool, - /// The graphic's opacity, 0..=1. - pub opacity: f32, - /// How dark the photo is outside the graphic's box, 0..=1 (0: not darkened). - pub matte: f32, - /// Bumped by each Choose Layout Image…, so choosing the same file again reads it again (after - /// it was edited elsewhere). - #[serde(skip)] - pub revision: u32, -} - -impl Default for LayoutImage { - fn default() -> Self { - LayoutImage { path: String::new(), show: false, opacity: 1.0, matte: 0.0, revision: 0 } - } -} - -impl LayoutImage { - /// The opacity and matte steps View ▸ Loupe Overlay offers. - pub const OPACITY_STEPS: [f32; 4] = [0.25, 0.5, 0.75, 1.0]; - pub const MATTE_STEPS: [f32; 4] = [0.0, 0.25, 0.5, 0.75]; -} - -/// Lights Out (View ▸ Lights Out, Shift+L cycles On → Dim → Off → On; Esc turns the lights back -/// on): everything but the photo is veiled in black, the panels stay where they are. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LightsOut { - #[default] - On, - /// The interface at 20 % brightness. - Dim, - /// The interface black. - Off, -} - -impl LightsOut { - pub fn next(self) -> LightsOut { - match self { - LightsOut::On => LightsOut::Dim, - LightsOut::Dim => LightsOut::Off, - LightsOut::Off => LightsOut::On, - } - } - - /// Opacity of the black veil over everything but the photo. - pub fn veil(self) -> f32 { - match self { - LightsOut::On => 0.0, - LightsOut::Dim => 0.8, - LightsOut::Off => 1.0, - } - } -} -/// How many libraries File ▸ Open Recent Library remembers. -pub const RECENT_LIBRARIES: usize = 8; - -/// Whether the interface uses the dark or the light theme (Settings ▸ Interface ▸ Appearance). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum AppearanceMode { - /// Follow the operating system's dark/light setting (dark when it gives no answer). - Auto, - /// The dark theme, whatever the system says (the default: LightCraft's look so far). - #[default] - Dark, - Light, -} - -impl AppearanceMode { - /// The header button's order: Auto, Light, Dark, then Auto again. - pub fn next(self) -> AppearanceMode { - match self { - AppearanceMode::Auto => AppearanceMode::Light, - AppearanceMode::Light => AppearanceMode::Dark, - AppearanceMode::Dark => AppearanceMode::Auto, - } - } -} - -/// The theme the dark appearance uses. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DarkTheme { - #[default] - Charcoal, - Midnight, -} - -/// The theme the light appearance uses. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LightTheme { - #[default] - Silver, - Paper, -} - -impl DarkTheme { - pub const ALL: [DarkTheme; 2] = [DarkTheme::Charcoal, DarkTheme::Midnight]; - pub fn kind(self) -> crate::theme::ThemeKind { - match self { - DarkTheme::Charcoal => crate::theme::ThemeKind::Charcoal, - DarkTheme::Midnight => crate::theme::ThemeKind::Midnight, - } - } -} - -impl LightTheme { - pub const ALL: [LightTheme; 2] = [LightTheme::Silver, LightTheme::Paper]; - pub fn kind(self) -> crate::theme::ThemeKind { - match self { - LightTheme::Silver => crate::theme::ThemeKind::Silver, - LightTheme::Paper => crate::theme::ThemeKind::Paper, - } - } -} - -/// App-level preferences (Settings dialog). They belong to the app, not a library, and are -/// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library -/// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct AppSettings { - /// Library opened at launch when no `--library` is given (empty = the default location). - pub library_path: String, - /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most - /// [`RECENT_LIBRARIES`]). - pub recent_libraries: Vec, - /// When each recent library was last opened (path → the session clock's ISO 8601 time), for - /// the launch window. A separate key so files from earlier versions, and earlier versions - /// reading this one, keep their list (issue #1290). - #[serde(rename = "recentLibrariesOpened", deserialize_with = "lenient")] - pub recent_opened: std::collections::BTreeMap, - /// Open the last library at launch, or ask (a value this version doesn't know reads as the - /// default). - #[serde(deserialize_with = "lenient")] - pub at_launch: LaunchLibrary, - pub startup_view: StartupView, - /// Ask before moving photos to Recently Deleted. Saved under a new key: earlier versions saved - /// `confirmDelete: false` by default (#1239). - #[serde(rename = "askBeforeDelete")] - pub confirm_delete: bool, - /// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask - /// again" turns it off). - pub ask_before_move: bool, - /// A click on the dimmed backdrop around a dialog closes it, like Esc (off: a stray click - /// never throws away a dialog's choices). - pub close_dialogs_outside: bool, - /// Photos and folders dropped on the window import at once, without the import review. - pub import_dropped_directly: bool, - /// GPU rendering allowed (`app.gpu`). - pub gpu: bool, - /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is - /// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old - /// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323). - pub preview_limit: u32, - /// Edit in Other Editor: the application ("" = the system's default for the file type). - pub external_editor: String, - /// The file Edit in Other Editor hands over. - pub external_file: EditFile, - /// Edit in PhotoCraft: where PhotoCraft is ("" = found where its installer put it; a portable - /// copy needs its path). - pub photocraft_path: String, - /// The file Edit in PhotoCraft hands over. - pub photocraft_file: EditFile, - /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). - pub memory_mb: u32, - /// The Info panel's metadata fields with several photos selected: true (the default) shows the - /// values the selected photos share and marks each field where they differ (Selected Photos), - /// false shows the active photo's values (Target Photo) (`view.infoSelectedPhotos`). - pub info_selected_photos: bool, - /// Filmstrip: file names above the thumbnails. - pub film_names: bool, - /// Filmstrip: rating / flag / edited badges on the thumbnails. - pub film_badges: bool, - /// Grid: when to show the rating / flag / edited badges. - pub grid_badges: GridBadges, - /// View ▸ View Options ▸ Show message when loading or rendering photos: the loupe says - /// "Rendering…" while the photo it shows is not the finished render. - pub loupe_messages: bool, - /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. - pub keymap: crate::shortcuts::Keymap, - /// Auto (follow the system), Dark or Light. - pub appearance_mode: AppearanceMode, - /// The theme of the dark appearance. - pub dark_theme: DarkTheme, - /// The theme of the light appearance. - pub light_theme: LightTheme, - /// Theme shortcuts in the appearance context menu. - #[serde(deserialize_with = "deserialize_favorite_themes")] - pub favorite_themes: Vec, - /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: - /// the display is treated as sRGB). - pub display_profile: String, - /// The Keywording box: whole keyword paths, or grouped by the keyword they are inside. - #[serde(deserialize_with = "lenient")] - pub keyword_chips: KeywordChips, - /// View ▸ Focus Point: draw the box the camera focused on (`view.focusPoint`). - pub focus_point: bool, - /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point - /// when the photo has one (`view.zoomToFocus`). - pub zoom_to_focus: bool, - /// View ▸ View Options: the lines the loupe's info overlay shows in Info 1 and Info 2. - #[serde(deserialize_with = "lenient")] - pub loupe_info: LoupeInfo, - /// File ▸ Import Lightroom Catalog…: ask whether the catalog merges into the open library or - /// gets a new one, or always merge (issue #2672; the window's "Don't ask again" picks that). - #[serde(deserialize_with = "lenient")] - pub lightroom_import: LightroomImport, -} - -/// Where File ▸ Import Lightroom Catalog… puts a catalog (Settings ▸ General ▸ Library). Saved as -/// `"ask"` / `"alwaysMerge"`; anything else in a hand-edited `ui.json` reads as [`Self::Ask`]. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LightroomImport { - /// A window asks each time: merge into the open library, or create a new library for it. - #[default] - Ask, - /// Merge into the open library without asking (how imports worked before the window). - AlwaysMerge, -} - -impl Default for AppSettings { - fn default() -> Self { - AppSettings { - library_path: String::new(), - recent_libraries: Vec::new(), - recent_opened: Default::default(), - at_launch: LaunchLibrary::Last, - startup_view: StartupView::Last, - confirm_delete: true, - ask_before_move: true, - close_dialogs_outside: false, - import_dropped_directly: false, - gpu: true, - preview_limit: 0, - external_editor: String::new(), - external_file: EditFile::default(), - photocraft_path: String::new(), - photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, - memory_mb: 0, - info_selected_photos: true, - film_names: true, - film_badges: true, - grid_badges: GridBadges::Auto, - loupe_messages: true, - keymap: Default::default(), - appearance_mode: AppearanceMode::Dark, - dark_theme: DarkTheme::Charcoal, - light_theme: LightTheme::Silver, - favorite_themes: Vec::new(), - display_profile: String::new(), - keyword_chips: KeywordChips::default(), - focus_point: false, - zoom_to_focus: true, - loupe_info: LoupeInfo::default(), - lightroom_import: LightroomImport::Ask, - } - } -} - -/// The editor name that means PhotoCraft (Photo ▸ Edit In ▸ Edit in PhotoCraft): the host finds -/// where it is installed. -pub const PHOTOCRAFT: &str = "PhotoCraft"; - -/// The file format Edit In renders the copy as. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum EditFormat { - #[default] - Tiff, - /// A flat PSD: PhotoCraft saves back to it in place (a TIFF it saves only through Save As). - Psd, -} - -/// The colour space of the Edit In copy. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum EditSpace { - #[default] - AdobeRgb, - ProPhoto, - DisplayP3, - Srgb, -} - -/// The file Photo ▸ Edit In renders for an editor (Settings ▸ External Editing). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct EditFile { - pub format: EditFormat, - pub color_space: EditSpace, - /// 16 or 8. - pub bit_depth: u8, -} - -impl Default for EditFile { - fn default() -> Self { - EditFile { format: EditFormat::Tiff, color_space: EditSpace::AdobeRgb, bit_depth: 16 } - } -} - -impl EditFile { - /// The `photo.editExternal` params for this file. - pub fn params(&self) -> serde_json::Value { - let format = match self.format { - EditFormat::Tiff => "tiff", - EditFormat::Psd => "psd", - }; - let space = match self.color_space { - EditSpace::AdobeRgb => "adobeRgb", - EditSpace::ProPhoto => "proPhoto", - EditSpace::DisplayP3 => "displayP3", - EditSpace::Srgb => "srgb", - }; - serde_json::json!({"format": format, "colorSpace": space, "bitDepth": if self.bit_depth == 8 { 8 } else { 16 }}) - } -} - -/// Preview size limits offered in Settings → Performance (0 = Automatic). -pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; - -/// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting. -pub const LOUPE_EDGE_CEILING: u32 = 8192; - -/// The most a zoomed frame (the one a window is cut from) may be along its long edge: far more -/// than the photos there are (a window holds only what is on screen, so the frame costs nothing). -pub const WINDOW_FRAME_CEILING: usize = 65536; - -/// The smallest GPU texture side the loupe plans for, whatever the host reports. -pub const MIN_TEXTURE_SIDE: usize = 512; - -/// Loupe render sizes move in steps of this many pixels. -pub const LOUPE_EDGE_STEP: usize = 64; - -/// Long edge of Build Standard-Sized Previews when the limit is Automatic. -pub const STANDARD_PREVIEW_EDGE: u32 = 2560; - -impl AppSettings { - /// The theme to show: the light theme in Light mode, or in Auto when `system` is light; the - /// dark theme otherwise (Auto with no answer from the system included). - pub fn theme(&self, system: Option) -> crate::theme::ThemeKind { - let light = match self.appearance_mode { - AppearanceMode::Light => true, - AppearanceMode::Dark => false, - AppearanceMode::Auto => system == Some(egui::Theme::Light), - }; - if light { self.light_theme.kind() } else { self.dark_theme.kind() } - } - - /// Pick `kind` and fix the mode to its family (the View ▸ Theme items). - pub fn select_theme(&mut self, kind: crate::theme::ThemeKind) { - use crate::theme::ThemeKind; - match kind { - ThemeKind::Charcoal => self.dark_theme = DarkTheme::Charcoal, - ThemeKind::Midnight => self.dark_theme = DarkTheme::Midnight, - ThemeKind::Silver => self.light_theme = LightTheme::Silver, - ThemeKind::Paper => self.light_theme = LightTheme::Paper, - } - self.appearance_mode = if kind.is_dark() { AppearanceMode::Dark } else { AppearanceMode::Light }; - } - - /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: - /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at - /// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling - /// or `texture_side`, the largest texture the GPU allows (egui_glow, i.e. the browser build, - /// panics on a bigger one, and many WebGL devices allow only 2048 or 4096). - pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize { - let limit = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize; - let ceiling = limit.min(texture_side.max(MIN_TEXTURE_SIDE)); - let wanted = if wanted_px.is_nan() { 8.0 } else { wanted_px.clamp(8.0, ceiling as f32) } as usize; - let stepped = wanted.div_ceil(LOUPE_EDGE_STEP) * LOUPE_EDGE_STEP; - stepped.min(native_long_edge.max(8)).min(ceiling) - } - - /// Long edge for warming a neighbouring photo: its loupe size but never above the preview - /// source level. A full-size decode would replace the open photo's single full-resolution - /// source in the cache, and nothing is gained by it. - pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize { - self.loupe_edge(wanted_px, native_long_edge, texture_side).min(lightcraft_engine::SourceLevel::Preview.max_edge()) - } - - /// Long edge for the hover (preset / profile) and Before renders, which are stand-ins drawn - /// over the loupe: capped at the preview source level like the old default. - pub fn stand_in_edge(&self, loupe_edge: usize, texture_side: usize) -> usize { - loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()).min(texture_side.max(MIN_TEXTURE_SIDE)) - } - - /// Long edge of the zoomed frame a window render is cut from, for a photo drawn `drawn_long` - /// px along its long edge: as drawn, but never above the photo's own pixels (beyond 100 % the - /// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about - /// the whole-frame render (see [`crate::region::plan`]). - pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize { - let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, WINDOW_FRAME_CEILING as f32) } as usize; - wanted.min(native_long_edge.max(8)).min(WINDOW_FRAME_CEILING) - } - - /// The edge Build Standard-Sized Previews uses. - pub fn standard_preview_edge(&self) -> u32 { - if self.preview_limit == 0 { STANDARD_PREVIEW_EDGE } else { self.preview_limit } - } -} - -/// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1. -pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800]; - -/// Grid thumbnail size (points): the bottom-bar slider's range, and the `=` / `-` keys' step -/// (`view.thumbSizeUp` / `view.thumbSizeDown`), about a thirteenth of the range. -pub const THUMB_SIZE_MIN: f32 = 90.0; -pub const THUMB_SIZE_MAX: f32 = 480.0; -pub const THUMB_SIZE_STEP: f32 = 30.0; - -/// Width limits of a side panel the user resizes (points). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct PanelWidth { - pub min: f32, - pub default: f32, - pub max: f32, -} - -impl PanelWidth { - /// `w` within the limits (the default when it isn't a number). - pub fn clamp(&self, w: f32) -> f32 { - if w.is_finite() { w.clamp(self.min, self.max) } else { self.default } - } -} - -/// The left sidebar (sources, albums, folders). -pub const LEFT_WIDTH: PanelWidth = PanelWidth { min: 200.0, default: 268.0, max: 480.0 }; -/// The right panel (Edit, Masking, Info, …). -pub const RIGHT_WIDTH: PanelWidth = PanelWidth { min: 250.0, default: 270.0, max: 520.0 }; -/// The photo area the side panels always leave free (as far as their minimum widths allow). -pub const MIN_PHOTO_WIDTH: f32 = 360.0; - -/// What the Keywording box shows: the keywords (chips to edit), them with the keywords containing -/// them, or what exported files will carry (read only). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum KeywordingView { - #[default] - Keywords, - Containing, - WillExport, -} - -/// How the Keywording box lays out the selection's keywords. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum KeywordChips { - /// One chip per keyword, each with its whole path ("Places › Portugal › Lisbon"). - #[default] - Paths, - /// Keywords inside the same keyword share a line naming it, and their chips say only what is - /// below it; long lists and the suggestions are shortened. - Grouped, -} +mod dialog; +mod settings; +mod view; + +pub use dialog::{Dialog, NameEdit, SyncCounts, default_preset_groups}; +pub use settings::{ + AppSettings, AppearanceMode, DarkTheme, EditFile, EditFormat, EditSpace, GridBadges, KeywordChips, LOUPE_EDGE_CEILING, LOUPE_EDGE_STEP, + LaunchLibrary, LightTheme, LightroomImport, MIN_TEXTURE_SIDE, PHOTOCRAFT, PREVIEW_LIMITS, RECENT_LIBRARIES, STANDARD_PREVIEW_EDGE, StartupView, + WINDOW_FRAME_CEILING, +}; +pub use view::{ + BRIEF_INFO_SECS, BeforeAfter, BriefInfo, CLICK_ZOOMS, CropOverlay, FOCUS_KEYWORDS_PASSES, InfoOverlay, KeywordingView, LEFT_WIDTH, LayoutImage, + LightsOut, LoupeInfo, LoupeInfoField, MIN_PHOTO_WIDTH, PanelWidth, RIGHT_WIDTH, RightPanel, THUMB_SIZE_MAX, THUMB_SIZE_MIN, THUMB_SIZE_STEP, + ToolOverlay, ViewMode, Zoom, +}; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -1108,380 +354,6 @@ pub struct UiState { pub print: crate::print_view::PrintUi, } -/// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything -/// but crop, masks, spots and red eye. -pub fn default_preset_groups() -> Vec { - lightcraft_develop::SettingsGroup::default_copy().iter().filter_map(|g| serde_json::to_value(g).ok()?.as_str().map(str::to_string)).collect() -} - -impl Dialog { - /// A fresh Create Preset dialog. - pub fn create_preset() -> Dialog { - Dialog::CreatePreset { name: String::new(), group: "User Presets".into(), groups: default_preset_groups() } - } -} - -/// A face's name being typed in the loupe: which face, and what has been typed so far. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct NameEdit { - pub photo: u64, - pub index: usize, - pub text: String, - /// Just opened: the text box takes the keyboard focus once. - pub fresh: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum Dialog { - /// A Lightroom import result stays readable after its activity row disappears. - LightroomResult { - summary: String, - library: Option, - details: Vec, - first: Option, - }, - /// File ▸ Print…: the Print module. Its state is the session's (`print.*`); the page on show - /// and the selected cell are [`UiState::print`]. - Print, - ContactSheet { - options: lightcraft_engine::contact_sheet::Options, - }, - /// View ▸ Export Slideshow…: format, size, quality, frame rate, container. - SlideshowExport { - settings: lightcraft_engine::slideshow_export::ExportSettings, - }, - /// `parent`: the folder to create it in (none: the top level). `include_selected`: the new album - /// holds the selected photos (Lightroom's "Include selected photos"; folders hold none). - NewAlbum { - name: String, - folder: bool, - #[serde(default)] - parent: Option, - #[serde(default = "yes")] - include_selected: bool, - }, - RenameAlbum { - id: u64, - name: String, - }, - /// One text field; OK runs `command` with `params` plus `{key: value}` (rename a preset or a - /// version, move a preset to a new group…). - TextPrompt { - title: String, - hint: String, - value: String, - command: String, - params: serde_json::Value, - key: String, - }, - /// Edit Capture Time: `mode` "set" (`time`; the others shift along), "shift" (by `days`, - /// `hours`, `minutes`) or "zone" (time-zone shift by `zone` hours). - CaptureTime { - mode: String, - time: String, - days: i32, - hours: i32, - minutes: i32, - zone: f32, - }, - /// The import review (File → Import Photos…). - Import { - opts: Box, - }, - /// Edit the colour label names (red, yellow, green, blue, purple; empty = the colour's name). - LabelNames { - names: Vec, - /// Also save the names as a label set of this name (empty = don't). - save_as: String, - }, - /// Batch rename the selected photos with a file-name template. - Rename { - template: String, - start: u32, - }, - /// Rename a keyword on every photo (children included). - RenameKeyword { - from: String, - to: String, - }, - /// Rename every face of `from` to `to`. `merge` is the user's yes to combining with the person - /// `to` already names. - RenamePerson { - from: String, - to: String, - merge: bool, - }, - /// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag. - KeywordTag { - /// The keyword being edited; `None`: a new one. - editing: Option, - name: String, - /// A new keyword goes inside this one when `inside` is on (the keyword picked in the - /// list, or the default parent). - parent: Option, - inside: bool, - /// Comma-separated. - synonyms: String, - include_on_export: bool, - export_containing: bool, - export_synonyms: bool, - person: bool, - /// A new keyword is given to the selected photos. - add_to_selected: bool, - /// Opened from the sidebar's Keywords header: the new keyword is shown there once made. - #[serde(default)] - reveal: bool, - }, - /// Edit Keyword Set: a set's name and its nine slots (an empty one is an empty slot). `replaces`: - /// the set being edited (renamed when the name changes); `None` saves a new set (from Recent - /// Keywords). - KeywordSet { - replaces: Option, - name: String, - slots: Vec, - /// Save as a new set, leaving the edited one (Lightroom Classic's Save as New Preset). - #[serde(default)] - as_new: bool, - }, - /// Move a keyword inside `parent` (`None`: the top level) where one of its name is already: - /// asks before merging the two. - MoveKeyword { - keyword: String, - parent: Option, - }, - /// Delete a keyword from every photo and the keyword list, after asking. - DeleteKeyword { - keyword: String, - /// Photos that have it (or one below it). - count: usize, - }, - /// Merge keywords into another one on every photo. - MergeKeywords { - from: Vec, - into: String, - }, - /// Auto-stack by capture time: the largest gap between consecutive shots, in seconds. - AutoStack { - gap: f32, - }, - /// Save the current view (source + filter) as a smart album. - NewSmartAlbum { - name: String, - #[serde(default)] - parent: Option, - }, - /// Help ▸ What's New. - WhatsNew, - /// Photo ▸ Assisted Culling: reject photos below this focus score (0 = none), pick the best - /// of each burst; shots at most `burst_gap` seconds apart and at least `similarity` alike are - /// a burst. - Cull { - reject_below: f32, - pick_best: bool, - burst_gap: f32, - similarity: f32, - }, - /// Help ▸ System Info: (label, value) rows. - DenoiseModel { - info: serde_json::Value, - accepted: bool, - }, - /// A face model file the user chose: what it is, its licence terms, and the "I accept" box. - FaceModel { - path: String, - /// The engine's `faces.models.inspect` answer. - info: serde_json::Value, - accepted: bool, - }, - SystemInfo { - rows: Vec<(String, String)>, - }, - /// Every metadata field of a photo's file (`photo.allMetadata`), filtered by `search`. - AllMetadata { - title: String, - rows: serde_json::Value, - search: String, - }, - /// Create (`id` None) or edit a smart album's rules. - SmartRules { - id: Option, - name: String, - rules: lightcraft_catalog::RuleSet, - /// The folder a new smart album is created in (ignored when editing). - #[serde(default)] - parent: Option, - }, - /// `groups`: the settings groups the preset includes (`SettingsGroup` ids). - CreatePreset { - name: String, - group: String, - #[serde(default = "default_preset_groups")] - groups: Vec, - }, - CopySettings { - groups: Vec, - }, - /// Paste Selected Settings: which of the copied groups to paste. - PasteSettings { - groups: Vec, - }, - /// `resize` is used unless `full_size`; `limit_kb` 0 = no limit; `dir` empty = default export folder. - Export { - opts: lightcraft_engine::export::ExportOptions, - full_size: bool, - resize: lightcraft_engine::export::Resize, - /// Name typed for "Save as Preset". - #[serde(default)] - preset_name: String, - limit_kb: u32, - dir: String, - }, - /// Photo Merge (HDR / Panorama / HDR Panorama) options; the preview lives in the app. - Merge { - opts: crate::merge::MergeDialog, - }, - /// Settings (preferences): `tab` = general | import | performance | interface. - Settings { - tab: String, - }, - /// Object and Describe masks need the SAM 3 model, which isn't installed: offer to download - /// it (size, licence, progress). `then`: the AI mask to start once it is there (`kind` - /// object|prompt, `op` new|add|subtract|intersect). - SamModel { - then: Option<(String, String)>, - /// Why the download couldn't start (shown in the dialog). - #[serde(default)] - error: Option, - }, - /// Open Library was given a Lightroom Classic catalog (issue #891): offer File ▸ Import - /// Lightroom Catalog… with `catalog` (the `.lrcat` found; `None`: ask for it) instead. - /// `folder`: what was chosen. - LightroomCatalog { - folder: String, - catalog: Option, - }, - /// File ▸ Import Lightroom Catalog…: where the catalog at `catalog` goes (issue #2672, see - /// [`crate::lightroom_destination`]). Merge into the open library `library` (`photos` in it), or - /// (`new_library`) create the library `name` in the folder `location`, switch to it and import - /// there. `dont_ask`: merge from now on without asking. `generation`: the library the window - /// was opened for (`Session::library_generation`): another one opened meanwhile isn't merged into. - LightroomDestination { - /// The `.lrcat` file. - catalog: String, - /// The open library's name (its folder's name). - library: String, - /// The photos in the open library. - photos: usize, - /// "Create a new library" is chosen (else "Merge into the open library"). - new_library: bool, - /// The new library's name (prefilled from the catalog's file name). - name: String, - /// The folder the new library goes in (prefilled with the open library's parent folder). - location: String, - /// "Don't ask again (always merge into the open library)". - dont_ask: bool, - /// `Session::library_generation` when the window opened. - generation: u64, - }, - /// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names, - /// the colour as `color_sat`), whether it carries Amount and the brush settings. - SaveEffect { - name: String, - group: String, - fields: Vec, - amount: bool, - brush: bool, - }, - /// Manage Effect Presets: search, the selected effects, an inline rename (old, new), - /// a delete waiting for confirmation, a new group's name being typed. - ManageEffects { - search: String, - selected: Vec, - rename: Option<(String, String)>, - confirm_delete: bool, - new_group: String, - }, - /// Confirm deleting mask effect presets. - DeleteEffects { - names: Vec, - }, - /// Confirm moving photos to Recently Deleted. - ConfirmDelete { - count: usize, - }, - /// Confirm moving photos dropped on a Folders row into that folder on disk - /// (`photo.moveToFolder`). `name`: the folder's name, as the question says it. - MovePhotos { - ids: Vec, - folder: String, - name: String, - dont_ask: bool, - /// The library the photos were dropped in (`Session::library_generation`): ids are - /// numbered per library, so in another one they name other photos. - library: u64, - }, - /// Confirm taking a folder's photos out of the library (`library.removeFolder`). - RemoveFolder { - path: String, - /// What the question calls it (a folder's last two names, a disk's name). - name: String, - count: usize, - /// A whole disk or share (`library.removeFolder` takes it only on request). - disk: bool, - }, - /// Synchronize Folder: what changed in a library folder on disk, and what to do about it - /// (`folder.synchronize`; the scan runs in [`crate::sync`]). - SynchronizeFolder { - path: String, - /// What the dialog calls it (a folder's last two names). - name: String, - /// A whole disk, or a folder holding disks (`folder.synchronize` `disk`). - #[serde(default)] - disk: bool, - /// What the scan found (`None` while it runs). - counts: Option, - import_new: bool, - #[serde(default = "yes")] - relink_moved: bool, - remove_missing: bool, - read_metadata: bool, - }, - About, - Shortcuts, - /// View ▸ View Options (⌘J): the info overlay and the lines of Info 1 and Info 2. Changes apply - /// as they are made. - ViewOptions, -} - -fn yes() -> bool { - true -} - -/// How many changes a Synchronize Folder scan found, by kind. -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SyncCounts { - /// The folder isn't there (moved, renamed, or on a disk that isn't connected). - #[serde(default)] - pub offline: bool, - pub new: usize, - pub duplicates: usize, - pub unreadable: usize, - pub missing: usize, - pub metadata: usize, - #[serde(default)] - pub moved: usize, -} - -impl SyncCounts { - /// The folder is there and the scan found nothing to bring in, relink, remove or read: the - /// dialog only says so (files it skipped are not changes). - pub fn up_to_date(&self) -> bool { - !self.offline && self.new + self.moved + self.missing + self.metadata == 0 - } -} - impl Default for UiState { fn default() -> Self { UiState { @@ -1715,71 +587,8 @@ impl UiState { } } -/// A setting read leniently: a value that isn't one of its own (a hand-edited file, a newer -/// build's choice) is the default, and the rest of the settings file is still read. -fn lenient<'de, D: serde::Deserializer<'de>, T: Default + serde::de::DeserializeOwned>(d: D) -> Result { - let value = serde_json::Value::deserialize(d)?; - Ok(serde_json::from_value(value).unwrap_or_default()) -} - /// A list of ids read leniently: what isn't a list is none, and what isn't text in it is left out. fn lenient_ids<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { let value = serde_json::Value::deserialize(d)?; Ok(value.as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect()) } - -fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { - let values = Vec::::deserialize(d)?; - let mut seen = std::collections::BTreeSet::new(); - for id in &values { - if !crate::theme::ThemeKind::ALL.iter().any(|k| k.id() == id) || !seen.insert(id) { - return Err(serde::de::Error::custom("unknown or duplicate favorite theme")); - } - } - Ok(values) -} - -#[cfg(test)] -mod loupe_info_tests { - use super::*; - - /// The lines chosen in View Options are saved with the app's settings; a settings file from - /// before they existed, or with a line this build doesn't know, gets the default lines and - /// keeps the rest of its settings. - #[test] - fn view_options_lines_are_saved_with_the_settings() { - let mut settings = AppSettings::default(); - settings.loupe_info.info2 = [LoupeInfoField::Lens, LoupeInfoField::None, LoupeInfoField::FileName]; - settings.loupe_info.briefly = InfoOverlay::Exposure; - let saved = serde_json::to_value(&settings).unwrap(); - assert_eq!(saved["loupeInfo"]["info2"], serde_json::json!(["lens", "none", "fileName"])); - assert_eq!(saved["loupeInfo"]["briefly"], "exposure"); - let read: AppSettings = serde_json::from_value(saved).unwrap(); - assert_eq!(read.loupe_info, settings.loupe_info); - - let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); - assert_eq!(old.loupe_info, LoupeInfo::default()); - // lines saved before Show Briefly When Photo Changes existed are kept, with it off - let lines: AppSettings = serde_json::from_value(serde_json::json!({"loupeInfo": {"info2": ["lens", "none", "fileName"]}})).unwrap(); - assert_eq!(lines.loupe_info.info2, settings.loupe_info.info2); - assert_eq!(lines.loupe_info.briefly, InfoOverlay::Off); - let newer: AppSettings = - serde_json::from_value(serde_json::json!({"filmNames": false, "loupeInfo": {"info1": ["rating", "fileName", "none"]}})).unwrap(); - assert_eq!(newer.loupe_info, LoupeInfo::default()); - assert!(!newer.film_names, "the rest of the settings are still read"); - } - - /// Show message when loading or rendering photos is on until it is turned off, and is saved - /// with the app's settings. - #[test] - fn the_loading_message_choice_is_saved_with_the_settings() { - assert!(AppSettings::default().loupe_messages); - let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); - assert!(old.loupe_messages, "a settings file from before the choice existed keeps the message"); - let settings = AppSettings { loupe_messages: false, ..AppSettings::default() }; - let saved = serde_json::to_value(&settings).unwrap(); - assert_eq!(saved["loupeMessages"], false); - let read: AppSettings = serde_json::from_value(saved).unwrap(); - assert!(!read.loupe_messages); - } -} diff --git a/crates/ui-egui/src/state/dialog.rs b/crates/ui-egui/src/state/dialog.rs new file mode 100644 index 000000000..edc4946cd --- /dev/null +++ b/crates/ui-egui/src/state/dialog.rs @@ -0,0 +1,375 @@ +use super::*; + +/// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything +/// but crop, masks, spots and red eye. +pub fn default_preset_groups() -> Vec { + lightcraft_develop::SettingsGroup::default_copy().iter().filter_map(|g| serde_json::to_value(g).ok()?.as_str().map(str::to_string)).collect() +} + +impl Dialog { + /// A fresh Create Preset dialog. + pub fn create_preset() -> Dialog { + Dialog::CreatePreset { name: String::new(), group: "User Presets".into(), groups: default_preset_groups() } + } +} + +/// A face's name being typed in the loupe: which face, and what has been typed so far. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NameEdit { + pub photo: u64, + pub index: usize, + pub text: String, + /// Just opened: the text box takes the keyboard focus once. + pub fresh: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Dialog { + /// A Lightroom import result stays readable after its activity row disappears. + LightroomResult { + summary: String, + library: Option, + details: Vec, + first: Option, + }, + /// File ▸ Print…: the Print module. Its state is the session's (`print.*`); the page on show + /// and the selected cell are [`UiState::print`]. + Print, + ContactSheet { + options: lightcraft_engine::contact_sheet::Options, + }, + /// View ▸ Export Slideshow…: format, size, quality, frame rate, container. + SlideshowExport { + settings: lightcraft_engine::slideshow_export::ExportSettings, + }, + /// `parent`: the folder to create it in (none: the top level). `include_selected`: the new album + /// holds the selected photos (Lightroom's "Include selected photos"; folders hold none). + NewAlbum { + name: String, + folder: bool, + #[serde(default)] + parent: Option, + #[serde(default = "yes")] + include_selected: bool, + }, + RenameAlbum { + id: u64, + name: String, + }, + /// One text field; OK runs `command` with `params` plus `{key: value}` (rename a preset or a + /// version, move a preset to a new group…). + TextPrompt { + title: String, + hint: String, + value: String, + command: String, + params: serde_json::Value, + key: String, + }, + /// Edit Capture Time: `mode` "set" (`time`; the others shift along), "shift" (by `days`, + /// `hours`, `minutes`) or "zone" (time-zone shift by `zone` hours). + CaptureTime { + mode: String, + time: String, + days: i32, + hours: i32, + minutes: i32, + zone: f32, + }, + /// The import review (File → Import Photos…). + Import { + opts: Box, + }, + /// Edit the colour label names (red, yellow, green, blue, purple; empty = the colour's name). + LabelNames { + names: Vec, + /// Also save the names as a label set of this name (empty = don't). + save_as: String, + }, + /// Batch rename the selected photos with a file-name template. + Rename { + template: String, + start: u32, + }, + /// Rename a keyword on every photo (children included). + RenameKeyword { + from: String, + to: String, + }, + /// Rename every face of `from` to `to`. `merge` is the user's yes to combining with the person + /// `to` already names. + RenamePerson { + from: String, + to: String, + merge: bool, + }, + /// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag. + KeywordTag { + /// The keyword being edited; `None`: a new one. + editing: Option, + name: String, + /// A new keyword goes inside this one when `inside` is on (the keyword picked in the + /// list, or the default parent). + parent: Option, + inside: bool, + /// Comma-separated. + synonyms: String, + include_on_export: bool, + export_containing: bool, + export_synonyms: bool, + person: bool, + /// A new keyword is given to the selected photos. + add_to_selected: bool, + /// Opened from the sidebar's Keywords header: the new keyword is shown there once made. + #[serde(default)] + reveal: bool, + }, + /// Edit Keyword Set: a set's name and its nine slots (an empty one is an empty slot). `replaces`: + /// the set being edited (renamed when the name changes); `None` saves a new set (from Recent + /// Keywords). + KeywordSet { + replaces: Option, + name: String, + slots: Vec, + /// Save as a new set, leaving the edited one (Lightroom Classic's Save as New Preset). + #[serde(default)] + as_new: bool, + }, + /// Move a keyword inside `parent` (`None`: the top level) where one of its name is already: + /// asks before merging the two. + MoveKeyword { + keyword: String, + parent: Option, + }, + /// Delete a keyword from every photo and the keyword list, after asking. + DeleteKeyword { + keyword: String, + /// Photos that have it (or one below it). + count: usize, + }, + /// Merge keywords into another one on every photo. + MergeKeywords { + from: Vec, + into: String, + }, + /// Auto-stack by capture time: the largest gap between consecutive shots, in seconds. + AutoStack { + gap: f32, + }, + /// Save the current view (source + filter) as a smart album. + NewSmartAlbum { + name: String, + #[serde(default)] + parent: Option, + }, + /// Help ▸ What's New. + WhatsNew, + /// Photo ▸ Assisted Culling: reject photos below this focus score (0 = none), pick the best + /// of each burst; shots at most `burst_gap` seconds apart and at least `similarity` alike are + /// a burst. + Cull { + reject_below: f32, + pick_best: bool, + burst_gap: f32, + similarity: f32, + }, + /// Help ▸ System Info: (label, value) rows. + DenoiseModel { + info: serde_json::Value, + accepted: bool, + }, + /// A face model file the user chose: what it is, its licence terms, and the "I accept" box. + FaceModel { + path: String, + /// The engine's `faces.models.inspect` answer. + info: serde_json::Value, + accepted: bool, + }, + SystemInfo { + rows: Vec<(String, String)>, + }, + /// Every metadata field of a photo's file (`photo.allMetadata`), filtered by `search`. + AllMetadata { + title: String, + rows: serde_json::Value, + search: String, + }, + /// Create (`id` None) or edit a smart album's rules. + SmartRules { + id: Option, + name: String, + rules: lightcraft_catalog::RuleSet, + /// The folder a new smart album is created in (ignored when editing). + #[serde(default)] + parent: Option, + }, + /// `groups`: the settings groups the preset includes (`SettingsGroup` ids). + CreatePreset { + name: String, + group: String, + #[serde(default = "default_preset_groups")] + groups: Vec, + }, + CopySettings { + groups: Vec, + }, + /// Paste Selected Settings: which of the copied groups to paste. + PasteSettings { + groups: Vec, + }, + /// `resize` is used unless `full_size`; `limit_kb` 0 = no limit; `dir` empty = default export folder. + Export { + opts: lightcraft_engine::export::ExportOptions, + full_size: bool, + resize: lightcraft_engine::export::Resize, + /// Name typed for "Save as Preset". + #[serde(default)] + preset_name: String, + limit_kb: u32, + dir: String, + }, + /// Photo Merge (HDR / Panorama / HDR Panorama) options; the preview lives in the app. + Merge { + opts: crate::merge::MergeDialog, + }, + /// Settings (preferences): `tab` = general | import | performance | interface. + Settings { + tab: String, + }, + /// Object and Describe masks need the SAM 3 model, which isn't installed: offer to download + /// it (size, licence, progress). `then`: the AI mask to start once it is there (`kind` + /// object|prompt, `op` new|add|subtract|intersect). + SamModel { + then: Option<(String, String)>, + /// Why the download couldn't start (shown in the dialog). + #[serde(default)] + error: Option, + }, + /// Open Library was given a Lightroom Classic catalog (issue #891): offer File ▸ Import + /// Lightroom Catalog… with `catalog` (the `.lrcat` found; `None`: ask for it) instead. + /// `folder`: what was chosen. + LightroomCatalog { + folder: String, + catalog: Option, + }, + /// File ▸ Import Lightroom Catalog…: where the catalog at `catalog` goes (issue #2672, see + /// [`crate::lightroom_destination`]). Merge into the open library `library` (`photos` in it), or + /// (`new_library`) create the library `name` in the folder `location`, switch to it and import + /// there. `dont_ask`: merge from now on without asking. `generation`: the library the window + /// was opened for (`Session::library_generation`): another one opened meanwhile isn't merged into. + LightroomDestination { + /// The `.lrcat` file. + catalog: String, + /// The open library's name (its folder's name). + library: String, + /// The photos in the open library. + photos: usize, + /// "Create a new library" is chosen (else "Merge into the open library"). + new_library: bool, + /// The new library's name (prefilled from the catalog's file name). + name: String, + /// The folder the new library goes in (prefilled with the open library's parent folder). + location: String, + /// "Don't ask again (always merge into the open library)". + dont_ask: bool, + /// `Session::library_generation` when the window opened. + generation: u64, + }, + /// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names, + /// the colour as `color_sat`), whether it carries Amount and the brush settings. + SaveEffect { + name: String, + group: String, + fields: Vec, + amount: bool, + brush: bool, + }, + /// Manage Effect Presets: search, the selected effects, an inline rename (old, new), + /// a delete waiting for confirmation, a new group's name being typed. + ManageEffects { + search: String, + selected: Vec, + rename: Option<(String, String)>, + confirm_delete: bool, + new_group: String, + }, + /// Confirm deleting mask effect presets. + DeleteEffects { + names: Vec, + }, + /// Confirm moving photos to Recently Deleted. + ConfirmDelete { + count: usize, + }, + /// Confirm moving photos dropped on a Folders row into that folder on disk + /// (`photo.moveToFolder`). `name`: the folder's name, as the question says it. + MovePhotos { + ids: Vec, + folder: String, + name: String, + dont_ask: bool, + /// The library the photos were dropped in (`Session::library_generation`): ids are + /// numbered per library, so in another one they name other photos. + library: u64, + }, + /// Confirm taking a folder's photos out of the library (`library.removeFolder`). + RemoveFolder { + path: String, + /// What the question calls it (a folder's last two names, a disk's name). + name: String, + count: usize, + /// A whole disk or share (`library.removeFolder` takes it only on request). + disk: bool, + }, + /// Synchronize Folder: what changed in a library folder on disk, and what to do about it + /// (`folder.synchronize`; the scan runs in [`crate::sync`]). + SynchronizeFolder { + path: String, + /// What the dialog calls it (a folder's last two names). + name: String, + /// A whole disk, or a folder holding disks (`folder.synchronize` `disk`). + #[serde(default)] + disk: bool, + /// What the scan found (`None` while it runs). + counts: Option, + import_new: bool, + #[serde(default = "yes")] + relink_moved: bool, + remove_missing: bool, + read_metadata: bool, + }, + About, + Shortcuts, + /// View ▸ View Options (⌘J): the info overlay and the lines of Info 1 and Info 2. Changes apply + /// as they are made. + ViewOptions, +} + +fn yes() -> bool { + true +} + +/// How many changes a Synchronize Folder scan found, by kind. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SyncCounts { + /// The folder isn't there (moved, renamed, or on a disk that isn't connected). + #[serde(default)] + pub offline: bool, + pub new: usize, + pub duplicates: usize, + pub unreadable: usize, + pub missing: usize, + pub metadata: usize, + #[serde(default)] + pub moved: usize, +} + +impl SyncCounts { + /// The folder is there and the scan found nothing to bring in, relink, remove or read: the + /// dialog only says so (files it skipped are not changes). + pub fn up_to_date(&self) -> bool { + !self.offline && self.new + self.moved + self.missing + self.metadata == 0 + } +} diff --git a/crates/ui-egui/src/state/settings.rs b/crates/ui-egui/src/state/settings.rs new file mode 100644 index 000000000..2d748f5a7 --- /dev/null +++ b/crates/ui-egui/src/state/settings.rs @@ -0,0 +1,464 @@ +use super::view::LoupeInfo; +#[cfg(test)] +use super::view::{InfoOverlay, LoupeInfoField}; +use super::*; + +/// Which view the app opens in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StartupView { + /// Whatever was showing at quit. + #[default] + Last, + Grid, + Detail, +} + +/// What the desktop app opens at launch (Settings ▸ General ▸ At launch; issue #1290). `--library` +/// and `LIGHTCRAFT_LIBRARY` come first either way. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LaunchLibrary { + /// The library open when the app last quit (holding Alt/Option as the window appears asks once). + #[default] + Last, + /// A window lists the recent libraries to choose from. + Ask, +} + +/// When grid cells show their rating / flag / edited badges. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GridBadges { + /// On hover, on the selection, and on rated or flagged photos. + #[default] + Auto, + Always, + Never, +} + +/// How many libraries File ▸ Open Recent Library remembers. +pub const RECENT_LIBRARIES: usize = 8; + +/// Whether the interface uses the dark or the light theme (Settings ▸ Interface ▸ Appearance). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AppearanceMode { + /// Follow the operating system's dark/light setting (dark when it gives no answer). + Auto, + /// The dark theme, whatever the system says (the default: LightCraft's look so far). + #[default] + Dark, + Light, +} + +impl AppearanceMode { + /// The header button's order: Auto, Light, Dark, then Auto again. + pub fn next(self) -> AppearanceMode { + match self { + AppearanceMode::Auto => AppearanceMode::Light, + AppearanceMode::Light => AppearanceMode::Dark, + AppearanceMode::Dark => AppearanceMode::Auto, + } + } +} + +/// The theme the dark appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DarkTheme { + #[default] + Charcoal, + Midnight, +} + +/// The theme the light appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightTheme { + #[default] + Silver, + Paper, +} + +impl DarkTheme { + pub const ALL: [DarkTheme; 2] = [DarkTheme::Charcoal, DarkTheme::Midnight]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + DarkTheme::Charcoal => crate::theme::ThemeKind::Charcoal, + DarkTheme::Midnight => crate::theme::ThemeKind::Midnight, + } + } +} + +impl LightTheme { + pub const ALL: [LightTheme; 2] = [LightTheme::Silver, LightTheme::Paper]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + LightTheme::Silver => crate::theme::ThemeKind::Silver, + LightTheme::Paper => crate::theme::ThemeKind::Paper, + } + } +} + +/// App-level preferences (Settings dialog). They belong to the app, not a library, and are +/// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library +/// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AppSettings { + /// Library opened at launch when no `--library` is given (empty = the default location). + pub library_path: String, + /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most + /// [`RECENT_LIBRARIES`]). + pub recent_libraries: Vec, + /// When each recent library was last opened (path → the session clock's ISO 8601 time), for + /// the launch window. A separate key so files from earlier versions, and earlier versions + /// reading this one, keep their list (issue #1290). + #[serde(rename = "recentLibrariesOpened", deserialize_with = "lenient")] + pub recent_opened: std::collections::BTreeMap, + /// Open the last library at launch, or ask (a value this version doesn't know reads as the + /// default). + #[serde(deserialize_with = "lenient")] + pub at_launch: LaunchLibrary, + pub startup_view: StartupView, + /// Ask before moving photos to Recently Deleted. Saved under a new key: earlier versions saved + /// `confirmDelete: false` by default (#1239). + #[serde(rename = "askBeforeDelete")] + pub confirm_delete: bool, + /// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask + /// again" turns it off). + pub ask_before_move: bool, + /// A click on the dimmed backdrop around a dialog closes it, like Esc (off: a stray click + /// never throws away a dialog's choices). + pub close_dialogs_outside: bool, + /// Photos and folders dropped on the window import at once, without the import review. + pub import_dropped_directly: bool, + /// GPU rendering allowed (`app.gpu`). + pub gpu: bool, + /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is + /// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old + /// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323). + pub preview_limit: u32, + /// Edit in Other Editor: the application ("" = the system's default for the file type). + pub external_editor: String, + /// The file Edit in Other Editor hands over. + pub external_file: EditFile, + /// Edit in PhotoCraft: where PhotoCraft is ("" = found where its installer put it; a portable + /// copy needs its path). + pub photocraft_path: String, + /// The file Edit in PhotoCraft hands over. + pub photocraft_file: EditFile, + /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). + pub memory_mb: u32, + /// The Info panel's metadata fields with several photos selected: true (the default) shows the + /// values the selected photos share and marks each field where they differ (Selected Photos), + /// false shows the active photo's values (Target Photo) (`view.infoSelectedPhotos`). + pub info_selected_photos: bool, + /// Filmstrip: file names above the thumbnails. + pub film_names: bool, + /// Filmstrip: rating / flag / edited badges on the thumbnails. + pub film_badges: bool, + /// Grid: when to show the rating / flag / edited badges. + pub grid_badges: GridBadges, + /// View ▸ View Options ▸ Show message when loading or rendering photos: the loupe says + /// "Rendering…" while the photo it shows is not the finished render. + pub loupe_messages: bool, + /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. + pub keymap: crate::shortcuts::Keymap, + /// Auto (follow the system), Dark or Light. + pub appearance_mode: AppearanceMode, + /// The theme of the dark appearance. + pub dark_theme: DarkTheme, + /// The theme of the light appearance. + pub light_theme: LightTheme, + /// Theme shortcuts in the appearance context menu. + #[serde(deserialize_with = "deserialize_favorite_themes")] + pub favorite_themes: Vec, + /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: + /// the display is treated as sRGB). + pub display_profile: String, + /// The Keywording box: whole keyword paths, or grouped by the keyword they are inside. + #[serde(deserialize_with = "lenient")] + pub keyword_chips: KeywordChips, + /// View ▸ Focus Point: draw the box the camera focused on (`view.focusPoint`). + pub focus_point: bool, + /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point + /// when the photo has one (`view.zoomToFocus`). + pub zoom_to_focus: bool, + /// View ▸ View Options: the lines the loupe's info overlay shows in Info 1 and Info 2. + #[serde(deserialize_with = "lenient")] + pub loupe_info: LoupeInfo, + /// File ▸ Import Lightroom Catalog…: ask whether the catalog merges into the open library or + /// gets a new one, or always merge (issue #2672; the window's "Don't ask again" picks that). + #[serde(deserialize_with = "lenient")] + pub lightroom_import: LightroomImport, +} + +/// Where File ▸ Import Lightroom Catalog… puts a catalog (Settings ▸ General ▸ Library). Saved as +/// `"ask"` / `"alwaysMerge"`; anything else in a hand-edited `ui.json` reads as [`Self::Ask`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightroomImport { + /// A window asks each time: merge into the open library, or create a new library for it. + #[default] + Ask, + /// Merge into the open library without asking (how imports worked before the window). + AlwaysMerge, +} + +impl Default for AppSettings { + fn default() -> Self { + AppSettings { + library_path: String::new(), + recent_libraries: Vec::new(), + recent_opened: Default::default(), + at_launch: LaunchLibrary::Last, + startup_view: StartupView::Last, + confirm_delete: true, + ask_before_move: true, + close_dialogs_outside: false, + import_dropped_directly: false, + gpu: true, + preview_limit: 0, + external_editor: String::new(), + external_file: EditFile::default(), + photocraft_path: String::new(), + photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, + memory_mb: 0, + info_selected_photos: true, + film_names: true, + film_badges: true, + grid_badges: GridBadges::Auto, + loupe_messages: true, + keymap: Default::default(), + appearance_mode: AppearanceMode::Dark, + dark_theme: DarkTheme::Charcoal, + light_theme: LightTheme::Silver, + favorite_themes: Vec::new(), + display_profile: String::new(), + keyword_chips: KeywordChips::default(), + focus_point: false, + zoom_to_focus: true, + loupe_info: LoupeInfo::default(), + lightroom_import: LightroomImport::Ask, + } + } +} + +/// The editor name that means PhotoCraft (Photo ▸ Edit In ▸ Edit in PhotoCraft): the host finds +/// where it is installed. +pub const PHOTOCRAFT: &str = "PhotoCraft"; + +/// The file format Edit In renders the copy as. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditFormat { + #[default] + Tiff, + /// A flat PSD: PhotoCraft saves back to it in place (a TIFF it saves only through Save As). + Psd, +} + +/// The colour space of the Edit In copy. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditSpace { + #[default] + AdobeRgb, + ProPhoto, + DisplayP3, + Srgb, +} + +/// The file Photo ▸ Edit In renders for an editor (Settings ▸ External Editing). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct EditFile { + pub format: EditFormat, + pub color_space: EditSpace, + /// 16 or 8. + pub bit_depth: u8, +} + +impl Default for EditFile { + fn default() -> Self { + EditFile { format: EditFormat::Tiff, color_space: EditSpace::AdobeRgb, bit_depth: 16 } + } +} + +impl EditFile { + /// The `photo.editExternal` params for this file. + pub fn params(&self) -> serde_json::Value { + let format = match self.format { + EditFormat::Tiff => "tiff", + EditFormat::Psd => "psd", + }; + let space = match self.color_space { + EditSpace::AdobeRgb => "adobeRgb", + EditSpace::ProPhoto => "proPhoto", + EditSpace::DisplayP3 => "displayP3", + EditSpace::Srgb => "srgb", + }; + serde_json::json!({"format": format, "colorSpace": space, "bitDepth": if self.bit_depth == 8 { 8 } else { 16 }}) + } +} + +/// Preview size limits offered in Settings → Performance (0 = Automatic). +pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; + +/// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting. +pub const LOUPE_EDGE_CEILING: u32 = 8192; + +/// The most a zoomed frame (the one a window is cut from) may be along its long edge: far more +/// than the photos there are (a window holds only what is on screen, so the frame costs nothing). +pub const WINDOW_FRAME_CEILING: usize = 65536; + +/// The smallest GPU texture side the loupe plans for, whatever the host reports. +pub const MIN_TEXTURE_SIDE: usize = 512; + +/// Loupe render sizes move in steps of this many pixels. +pub const LOUPE_EDGE_STEP: usize = 64; + +/// Long edge of Build Standard-Sized Previews when the limit is Automatic. +pub const STANDARD_PREVIEW_EDGE: u32 = 2560; + +impl AppSettings { + /// The theme to show: the light theme in Light mode, or in Auto when `system` is light; the + /// dark theme otherwise (Auto with no answer from the system included). + pub fn theme(&self, system: Option) -> crate::theme::ThemeKind { + let light = match self.appearance_mode { + AppearanceMode::Light => true, + AppearanceMode::Dark => false, + AppearanceMode::Auto => system == Some(egui::Theme::Light), + }; + if light { self.light_theme.kind() } else { self.dark_theme.kind() } + } + + /// Pick `kind` and fix the mode to its family (the View ▸ Theme items). + pub fn select_theme(&mut self, kind: crate::theme::ThemeKind) { + use crate::theme::ThemeKind; + match kind { + ThemeKind::Charcoal => self.dark_theme = DarkTheme::Charcoal, + ThemeKind::Midnight => self.dark_theme = DarkTheme::Midnight, + ThemeKind::Silver => self.light_theme = LightTheme::Silver, + ThemeKind::Paper => self.light_theme = LightTheme::Paper, + } + self.appearance_mode = if kind.is_dark() { AppearanceMode::Dark } else { AppearanceMode::Light }; + } + + /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: + /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at + /// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling + /// or `texture_side`, the largest texture the GPU allows (egui_glow, i.e. the browser build, + /// panics on a bigger one, and many WebGL devices allow only 2048 or 4096). + pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize { + let limit = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize; + let ceiling = limit.min(texture_side.max(MIN_TEXTURE_SIDE)); + let wanted = if wanted_px.is_nan() { 8.0 } else { wanted_px.clamp(8.0, ceiling as f32) } as usize; + let stepped = wanted.div_ceil(LOUPE_EDGE_STEP) * LOUPE_EDGE_STEP; + stepped.min(native_long_edge.max(8)).min(ceiling) + } + + /// Long edge for warming a neighbouring photo: its loupe size but never above the preview + /// source level. A full-size decode would replace the open photo's single full-resolution + /// source in the cache, and nothing is gained by it. + pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize { + self.loupe_edge(wanted_px, native_long_edge, texture_side).min(lightcraft_engine::SourceLevel::Preview.max_edge()) + } + + /// Long edge for the hover (preset / profile) and Before renders, which are stand-ins drawn + /// over the loupe: capped at the preview source level like the old default. + pub fn stand_in_edge(&self, loupe_edge: usize, texture_side: usize) -> usize { + loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()).min(texture_side.max(MIN_TEXTURE_SIDE)) + } + + /// Long edge of the zoomed frame a window render is cut from, for a photo drawn `drawn_long` + /// px along its long edge: as drawn, but never above the photo's own pixels (beyond 100 % the + /// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about + /// the whole-frame render (see [`crate::region::plan`]). + pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize { + let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, WINDOW_FRAME_CEILING as f32) } as usize; + wanted.min(native_long_edge.max(8)).min(WINDOW_FRAME_CEILING) + } + + /// The edge Build Standard-Sized Previews uses. + pub fn standard_preview_edge(&self) -> u32 { + if self.preview_limit == 0 { STANDARD_PREVIEW_EDGE } else { self.preview_limit } + } +} + +/// How the Keywording box lays out the selection's keywords. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KeywordChips { + /// One chip per keyword, each with its whole path ("Places › Portugal › Lisbon"). + #[default] + Paths, + /// Keywords inside the same keyword share a line naming it, and their chips say only what is + /// below it; long lists and the suggestions are shortened. + Grouped, +} + +/// A setting read leniently: a value that isn't one of its own (a hand-edited file, a newer +/// build's choice) is the default, and the rest of the settings file is still read. +fn lenient<'de, D: serde::Deserializer<'de>, T: Default + serde::de::DeserializeOwned>(d: D) -> Result { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let values = Vec::::deserialize(d)?; + let mut seen = std::collections::BTreeSet::new(); + for id in &values { + if !crate::theme::ThemeKind::ALL.iter().any(|k| k.id() == id) || !seen.insert(id) { + return Err(serde::de::Error::custom("unknown or duplicate favorite theme")); + } + } + Ok(values) +} + +#[cfg(test)] +mod loupe_info_tests { + use super::*; + + /// The lines chosen in View Options are saved with the app's settings; a settings file from + /// before they existed, or with a line this build doesn't know, gets the default lines and + /// keeps the rest of its settings. + #[test] + fn view_options_lines_are_saved_with_the_settings() { + let mut settings = AppSettings::default(); + settings.loupe_info.info2 = [LoupeInfoField::Lens, LoupeInfoField::None, LoupeInfoField::FileName]; + settings.loupe_info.briefly = InfoOverlay::Exposure; + let saved = serde_json::to_value(&settings).unwrap(); + assert_eq!(saved["loupeInfo"]["info2"], serde_json::json!(["lens", "none", "fileName"])); + assert_eq!(saved["loupeInfo"]["briefly"], "exposure"); + let read: AppSettings = serde_json::from_value(saved).unwrap(); + assert_eq!(read.loupe_info, settings.loupe_info); + + let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); + assert_eq!(old.loupe_info, LoupeInfo::default()); + // lines saved before Show Briefly When Photo Changes existed are kept, with it off + let lines: AppSettings = serde_json::from_value(serde_json::json!({"loupeInfo": {"info2": ["lens", "none", "fileName"]}})).unwrap(); + assert_eq!(lines.loupe_info.info2, settings.loupe_info.info2); + assert_eq!(lines.loupe_info.briefly, InfoOverlay::Off); + let newer: AppSettings = + serde_json::from_value(serde_json::json!({"filmNames": false, "loupeInfo": {"info1": ["rating", "fileName", "none"]}})).unwrap(); + assert_eq!(newer.loupe_info, LoupeInfo::default()); + assert!(!newer.film_names, "the rest of the settings are still read"); + } + + /// Show message when loading or rendering photos is on until it is turned off, and is saved + /// with the app's settings. + #[test] + fn the_loading_message_choice_is_saved_with_the_settings() { + assert!(AppSettings::default().loupe_messages); + let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); + assert!(old.loupe_messages, "a settings file from before the choice existed keeps the message"); + let settings = AppSettings { loupe_messages: false, ..AppSettings::default() }; + let saved = serde_json::to_value(&settings).unwrap(); + assert_eq!(saved["loupeMessages"], false); + let read: AppSettings = serde_json::from_value(saved).unwrap(); + assert!(!read.loupe_messages); + } +} diff --git a/crates/ui-egui/src/state/view.rs b/crates/ui-egui/src/state/view.rs new file mode 100644 index 000000000..1bbfd4349 --- /dev/null +++ b/crates/ui-egui/src/state/view.rs @@ -0,0 +1,379 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ViewMode { + /// Justified rows ("Photo Grid"). + #[default] + PhotoGrid, + SquareGrid, + Detail, + /// Two photos side by side (select | candidate), synced zoom. + Compare, + /// The selected photos tiled. + Survey, + /// A reference photo (left, fixed) beside the active photo (right, being edited). + Reference, + /// A card per person named on faces (close-up, name, photo count). + People, +} + +/// The right-hand tool/panel shown next to the tool strip. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RightPanel { + #[default] + None, + Edit, + Crop, + Remove, + Masking, + RedEye, + Versions, + Activity, + Keywords, + Info, + /// The profile browser (part of Edit). + Profiles, +} + +impl RightPanel { + pub fn is_edit_tool(self) -> bool { + matches!(self, RightPanel::Edit | RightPanel::Profiles | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Zoom { + #[default] + Fit, + Fill, + /// 100 % = one image pixel per physical screen pixel. + Percent(f32), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BeforeAfter { + #[default] + Off, + /// Hold `\`: show the original. + Original, + SideBySide, + Split, + /// Before above after. + TopBottom, + /// One image split horizontally: before above the line. + SplitTopBottom, +} + +impl BeforeAfter { + /// One of the Before / After layouts (two views or a split line), which the bottom bar's + /// Before/After (Y) button stands for. Show Original (`\`) is a view of its own, not a layout. + pub fn is_layout(self) -> bool { + !matches!(self, BeforeAfter::Off | BeforeAfter::Original) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CropOverlay { + #[default] + Thirds, + Grid, + Golden, + Diagonal, + Triangle, + Spiral, + None, +} + +/// Tool Overlay: when the retouching tools' pins (Remove / Heal / Clone spots, mask pins) show on +/// the photo. Shift+H steps through the modes in this order (`view.toolOverlay`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ToolOverlay { + /// While the pointer is over the photo (hidden while a new spot or brush stroke is painted). + #[default] + Auto, + Always, + /// Only the selected spot's or mask's. + Selected, + /// None; the tool still works (a click still adds a spot). + Never, +} + +impl ToolOverlay { + pub const ALL: [ToolOverlay; 4] = [ToolOverlay::Auto, ToolOverlay::Always, ToolOverlay::Selected, ToolOverlay::Never]; + + /// The id used by `view.toolOverlay {mode}` and the saved settings. + pub fn name(self) -> &'static str { + match self { + ToolOverlay::Auto => "auto", + ToolOverlay::Always => "always", + ToolOverlay::Selected => "selected", + ToolOverlay::Never => "never", + } + } + + pub fn parse(s: &str) -> Option { + ToolOverlay::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s.trim())) + } + + /// Menu label. + pub fn label(self) -> &'static str { + match self { + ToolOverlay::Auto => "Auto", + ToolOverlay::Always => "Always", + ToolOverlay::Selected => "Selected", + ToolOverlay::Never => "Never", + } + } + + /// The mode Shift+H steps to. + pub fn next(self) -> ToolOverlay { + match self { + ToolOverlay::Auto => ToolOverlay::Always, + ToolOverlay::Always => ToolOverlay::Selected, + ToolOverlay::Selected => ToolOverlay::Never, + ToolOverlay::Never => ToolOverlay::Auto, + } + } + + /// Whether a pin shows: `selected` = its spot or mask is the selected one, `live` = the pointer + /// is over the photo (or a pin is being dragged), which is what Auto waits for. + pub fn shows(self, selected: bool, live: bool) -> bool { + match self { + ToolOverlay::Auto => live, + ToolOverlay::Always => true, + ToolOverlay::Selected => selected, + ToolOverlay::Never => false, + } + } +} + +/// The loupe's info overlay (Cmd+I cycles; I in the full-screen preview). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InfoOverlay { + #[default] + Off, + /// File name, capture date and dimensions. + Basic, + /// File name, camera, lens and exposure (shutter, aperture, ISO, focal length). + Exposure, +} + +impl InfoOverlay { + pub fn next(self) -> InfoOverlay { + match self { + InfoOverlay::Off => InfoOverlay::Basic, + InfoOverlay::Basic => InfoOverlay::Exposure, + InfoOverlay::Exposure => InfoOverlay::Off, + } + } +} + +/// One line of the loupe's info overlay, chosen in View ▸ View Options (Lightroom Classic's Loupe +/// Info 1 / Loupe Info 2). A line with nothing to show for a photo is left out. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LoupeInfoField { + #[default] + None, + FileName, + /// The capture date and time (the import date when the photo has none). + CaptureDate, + /// Width × height in pixels. + Dimensions, + /// Shutter speed, aperture, ISO and focal length. + Exposure, + Camera, + Lens, + /// Camera and lens on one line. + CameraAndLens, +} + +impl LoupeInfoField { + /// Every field, in the order View Options lists them. + pub const ALL: &'static [LoupeInfoField] = &[ + LoupeInfoField::None, + LoupeInfoField::FileName, + LoupeInfoField::CaptureDate, + LoupeInfoField::Dimensions, + LoupeInfoField::Exposure, + LoupeInfoField::Camera, + LoupeInfoField::Lens, + LoupeInfoField::CameraAndLens, + ]; + + pub fn label(self) -> &'static str { + match self { + LoupeInfoField::None => "None", + LoupeInfoField::FileName => "File Name", + LoupeInfoField::CaptureDate => "Capture Date", + LoupeInfoField::Dimensions => "Dimensions", + LoupeInfoField::Exposure => "Exposure", + LoupeInfoField::Camera => "Camera", + LoupeInfoField::Lens => "Lens", + LoupeInfoField::CameraAndLens => "Camera & Lens", + } + } +} + +/// The three lines each mode of the info overlay shows: Info 1 ([`InfoOverlay::Basic`]) and +/// Info 2 ([`InfoOverlay::Exposure`]), and which of them Show Briefly When Photo Changes shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoupeInfo { + pub info1: [LoupeInfoField; 3], + pub info2: [LoupeInfoField; 3], + /// Show Briefly When Photo Changes: while the info overlay is off, this Info shows for + /// [`BRIEF_INFO_SECS`] each time the loupe changes to another photo ([`InfoOverlay::Off`]: + /// neither). + pub briefly: InfoOverlay, +} + +/// Passes after Add Keywords within which the Add keyword box takes the cursor (the panel may +/// need a pass to open first). +pub const FOCUS_KEYWORDS_PASSES: u64 = 2; + +/// How long Show Briefly When Photo Changes shows the info overlay (seconds). +pub const BRIEF_INFO_SECS: f64 = 3.0; + +/// When the loupe last changed photo, for Show Briefly When Photo Changes +/// ([`LoupeInfo::briefly`]). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct BriefInfo { + /// The photo the loupe showed last. + pub photo: Option, + /// When the loupe changed to it (seconds on egui's clock). + pub since: f64, + /// The brief info overlay shows this frame. + pub shown: bool, +} + +impl LoupeInfo { + /// Info 1's lines before they are changed (and after Use Defaults). + pub const INFO1: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::CaptureDate, LoupeInfoField::Dimensions]; + /// Info 2's lines before they are changed (and after Use Defaults). + pub const INFO2: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::Exposure, LoupeInfoField::CameraAndLens]; +} + +impl Default for LoupeInfo { + fn default() -> Self { + LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2, briefly: InfoOverlay::Off } + } +} + +/// View ▸ Loupe Overlay ▸ Layout Image (Lightroom Classic's loupe overlay): a graphic with +/// transparency, such as a magazine cover's masthead and cover lines, drawn over the photo in the +/// loupe (`panels::layout_overlay`). Saved with the UI state, so the layout stays chosen across +/// launches. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LayoutImage { + /// The graphic's file; empty until one is chosen. + pub path: String, + /// Drawn over the loupe (⌥⌘O). + pub show: bool, + /// The graphic's opacity, 0..=1. + pub opacity: f32, + /// How dark the photo is outside the graphic's box, 0..=1 (0: not darkened). + pub matte: f32, + /// Bumped by each Choose Layout Image…, so choosing the same file again reads it again (after + /// it was edited elsewhere). + #[serde(skip)] + pub revision: u32, +} + +impl Default for LayoutImage { + fn default() -> Self { + LayoutImage { path: String::new(), show: false, opacity: 1.0, matte: 0.0, revision: 0 } + } +} + +impl LayoutImage { + /// The opacity and matte steps View ▸ Loupe Overlay offers. + pub const OPACITY_STEPS: [f32; 4] = [0.25, 0.5, 0.75, 1.0]; + pub const MATTE_STEPS: [f32; 4] = [0.0, 0.25, 0.5, 0.75]; +} + +/// Lights Out (View ▸ Lights Out, Shift+L cycles On → Dim → Off → On; Esc turns the lights back +/// on): everything but the photo is veiled in black, the panels stay where they are. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightsOut { + #[default] + On, + /// The interface at 20 % brightness. + Dim, + /// The interface black. + Off, +} + +impl LightsOut { + pub fn next(self) -> LightsOut { + match self { + LightsOut::On => LightsOut::Dim, + LightsOut::Dim => LightsOut::Off, + LightsOut::Off => LightsOut::On, + } + } + + /// Opacity of the black veil over everything but the photo. + pub fn veil(self) -> f32 { + match self { + LightsOut::On => 0.0, + LightsOut::Dim => 0.8, + LightsOut::Off => 1.0, + } + } +} + +/// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1. +pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800]; + +/// Grid thumbnail size (points): the bottom-bar slider's range, and the `=` / `-` keys' step +/// (`view.thumbSizeUp` / `view.thumbSizeDown`), about a thirteenth of the range. +pub const THUMB_SIZE_MIN: f32 = 90.0; + +pub const THUMB_SIZE_MAX: f32 = 480.0; + +pub const THUMB_SIZE_STEP: f32 = 30.0; + +/// Width limits of a side panel the user resizes (points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PanelWidth { + pub min: f32, + pub default: f32, + pub max: f32, +} + +impl PanelWidth { + /// `w` within the limits (the default when it isn't a number). + pub fn clamp(&self, w: f32) -> f32 { + if w.is_finite() { w.clamp(self.min, self.max) } else { self.default } + } +} + +/// The left sidebar (sources, albums, folders). +pub const LEFT_WIDTH: PanelWidth = PanelWidth { min: 200.0, default: 268.0, max: 480.0 }; + +/// The right panel (Edit, Masking, Info, …). +pub const RIGHT_WIDTH: PanelWidth = PanelWidth { min: 250.0, default: 270.0, max: 520.0 }; + +/// The photo area the side panels always leave free (as far as their minimum widths allow). +pub const MIN_PHOTO_WIDTH: f32 = 360.0; + +/// What the Keywording box shows: the keywords (chips to edit), them with the keywords containing +/// them, or what exported files will carry (read only). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KeywordingView { + #[default] + Keywords, + Containing, + WillExport, +} From 56494a1308241e42ce7974f75495a306e8a8391c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:13:11 +0000 Subject: [PATCH 125/191] refactor(lightcraft-ui-egui): split dialogs.rs into dialogs/ --- crates/ui-egui/src/dialogs.rs | 1216 +---------------- crates/ui-egui/src/dialogs/confirm.rs | 235 ++++ crates/ui-egui/src/dialogs/cull.rs | 201 +++ crates/ui-egui/src/dialogs/export.rs | 302 ++++ crates/ui-egui/src/dialogs/frame.rs | 214 +++ crates/ui-egui/src/dialogs/rename.rs | 96 ++ crates/ui-egui/src/dialogs/sam_model.rs | 114 ++ .../ui-egui/src/dialogs/slideshow_export.rs | 57 + 8 files changed, 1244 insertions(+), 1191 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/confirm.rs create mode 100644 crates/ui-egui/src/dialogs/cull.rs create mode 100644 crates/ui-egui/src/dialogs/export.rs create mode 100644 crates/ui-egui/src/dialogs/frame.rs create mode 100644 crates/ui-egui/src/dialogs/rename.rs create mode 100644 crates/ui-egui/src/dialogs/sam_model.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 00b131713..772303ffb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7,6 +7,31 @@ use crate::LightcraftApp; use crate::state::Dialog; use crate::theme::Tokens; +mod confirm; +mod cull; +mod export; +mod frame; +mod rename; +mod sam_model; + +pub(crate) use confirm::album_entries; +pub use confirm::confirm_dialog; +pub use cull::{MAX_BURST_GAP, clamp_cull_burst, cull_params, fmt_gap, parse_gap}; +use export::{ + LIMIT_KB, QUALITY, START_NUMBER, WM_IMAGE_WIDTH, WM_INSET_X, WM_INSET_Y, WM_OPACITY, WM_POSITIONS, WM_SIZE, export_dialog_params, export_size, + group_checklist, watermark_preview, +}; +pub use frame::{ConfirmStyle, confirm_style, list_height, prompt}; +use frame::{ + DIALOG_CHROME_GUESS, DIALOG_MIN_BODY, DIALOG_SCREEN_MARGIN, choices, clicked_backdrop, dialog_body, dialog_field, field, just_opened, num, + only_shows, return_confirms, trailing_button_row, +}; +pub(crate) use rename::rename_preview; +use rename::{RENAME_PREVIEW_ROWS, person_rename_plan}; +pub(crate) use sam_model::show_model_folder; +use sam_model::{sam_by_hand, sam_model_body}; +use slideshow_export::slideshow_export_body; + pub mod credits; pub mod faces; pub mod import; @@ -19,171 +44,6 @@ pub mod settings; pub mod slideshow_export; pub mod updates_ui; -/// Rows the Rename dialog previews. -const RENAME_PREVIEW_ROWS: usize = 6; - -/// The Rename dialog's preview: its first rows (`None` while being planned) and how many files -/// are renamed. Which names are taken is checked on disk, which can block on a slow drive, so the -/// rows are planned on a worker thread, and only when the template, start number, photos or -/// catalog change — never once per frame. -pub(crate) fn rename_preview( - app: &mut LightcraftApp, - ctx: &egui::Context, - template: &str, - start: usize, -) -> (Option>, usize) { - use std::hash::{Hash, Hasher}; - type Rows = std::sync::Arc>>>; - let ids = app.session.selected(); - let photos = lightcraft_engine::rename::rename_photos(&app.session.catalog, &ids); - let total = photos.len(); - let mut h = std::collections::hash_map::DefaultHasher::new(); - (template, start, app.session.catalog.revision, &ids).hash(&mut h); - let key = h.finish(); - let id = egui::Id::unique("rename-preview-rows"); - let read = |rows: &Rows| rows.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone(); - if let Some((k, rows)) = ctx.data(|d| d.get_temp::<(u64, Rows)>(id)) - && k == key - { - return (read(&rows), total); - } - let rows: Rows = Default::default(); - let first: Vec<_> = photos.into_iter().take(RENAME_PREVIEW_ROWS).collect(); - let (out, template, repaint) = (rows.clone(), template.to_string(), ctx.clone()); - let exists = app.session.media.availability.probe(); - let work = move || { - let plans = lightcraft_engine::rename::plan_rename_photos(&first, &template, start, &|f| exists(f)); - *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(plans); - repaint.request_repaint(); - }; - #[cfg(not(target_arch = "wasm32"))] - if let Err(e) = std::thread::Builder::new().name("lc-rename-preview".into()).spawn(work) { - log::warn!("rename preview: {e}"); - } - #[cfg(target_arch = "wasm32")] - work(); - ctx.data_mut(|d| d.insert_temp(id, (key, rows.clone()))); - (read(&rows), total) -} - -/// The Export dialog's watermark preview: the first exported photo, as wide as the dialog whatever -/// its shape, with the watermark drawn on it as the file will have it at size `resize` (`None` = -/// full size). On a worker thread, once per photo, catalog change and size step, the cached -/// thumbnail comes first (at once, a little soft) and then a render at the preview's own pixel size -/// replaces it. The watermark is redrawn on a copy only when it, the output size or the picture -/// changes, which is quick at this size, so the preview follows the sliders. -fn watermark_preview( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - wm: &lightcraft_engine::export::Watermark, - resize: Option, -) { - use std::hash::{Hash, Hasher}; - // (pictures received so far: 1 the thumbnail, 2 the sharp render; the latest, or why none came) - type Base = std::sync::Arc>)>>; - type Shown = (u64, u8, lightcraft_engine::export::Watermark, usize, egui::TextureHandle); - let t = Tokens::get(ui.ctx()); - let Some(&id) = app.session.selected().first() else { return }; - let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; - let (out_w, out_h) = lightcraft_engine::export::output_size(&photo, &lightcraft_engine::export::ExportOptions { resize, ..Default::default() }); - let export_short = out_w.min(out_h); - // pixels across the dialog, in steps so a scroll bar or a point of layout doesn't re-render - let step = 128.0; - let px = ((ui.available_width() * ui.ctx().pixels_per_point() / step).ceil() * step).clamp(step, 2048.0) as usize; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (id, app.session.catalog.revision, px).hash(&mut h); - let key = h.finish(); - let ctx = ui.ctx().clone(); - let (base_id, shown_id) = (egui::Id::unique("export-wm-preview-base"), egui::Id::unique("export-wm-preview-shown")); - let base = match ctx.data(|d| d.get_temp::<(u64, Base)>(base_id)) { - Some((k, base)) if k == key => base, - _ => { - let base: Base = Default::default(); - // the thumbnail, then as wide as the dialog (a tall photo up to three times that high) - let jobs: Vec<_> = [app.session.thumb_job(id, 512), app.session.render_job(id, px, px * 3, false, true)].into_iter().flatten().collect(); - // the web build has no worker thread: a full render there would hold up the page - #[cfg(target_arch = "wasm32")] - let jobs: Vec<_> = jobs.into_iter().take(1).collect(); - if jobs.is_empty() { - *base.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = (1, Some(Err("no such photo".into()))); - } else { - let (out, repaint) = (base.clone(), ctx.clone()); - let work = move || { - for job in jobs { - // (a panic in the render is its failure: `RenderJob::run`) - let r = job.run().rendered.map(|r| r.image); - let mut got = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - // a failed sharp render keeps the thumbnail - if r.is_ok() || !matches!(got.1, Some(Ok(_))) { - *got = (got.0.saturating_add(1), Some(r)); - repaint.request_repaint(); - } - } - }; - #[cfg(not(target_arch = "wasm32"))] - if let Err(e) = std::thread::Builder::new().name("lc-watermark-preview".into()).spawn(work) { - log::warn!("watermark preview: {e}"); - } - #[cfg(target_arch = "wasm32")] - work(); - } - ctx.data_mut(|d| d.insert_temp(base_id, (key, base.clone()))); - base - } - }; - let shown = ctx.data(|d| d.get_temp::(shown_id)); - let stage = base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).0; - let tex = match shown { - Some((k, g, w, s, tex)) if k == key && g == stage && &w == wm && s == export_short => tex, - _ => { - let photo_px = match &base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).1 { - Some(Ok(img)) => Ok(img.clone()), - Some(Err(e)) => Err(Some(e.clone())), - None => Err(None), - }; - let mut img = match photo_px { - Ok(img) => img, - Err(e) => { - let note = if e.is_some() { "Preview unavailable" } else { "Loading preview…" }; - let r = ui - .horizontal(|ui| { - if e.is_none() { - crate::progress::spinner(ui, 12.0); - } - ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)) - }) - .inner; - if let Some(e) = e { - r.on_hover_text(e); - } - return; - } - }; - lightcraft_engine::export::draw_watermark_preview(&mut img, wm, export_short); - // shown through the monitor profile like every other preview - if let Some(d) = app.renderer.display() - && let Err(e) = d.profile.from_srgb(&mut img) - { - log::warn!("display profile: {e}"); - } - let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); - let tex = match shown { - Some((.., mut tex)) => { - tex.set(color, egui::TextureOptions::LINEAR); - tex - } - None => ctx.load_texture("export-wm-preview", color, egui::TextureOptions::LINEAR), - }; - ctx.data_mut(|d| d.insert_temp(shown_id, (key, stage, wm.clone(), export_short, tex.clone()))); - tex - } - }; - let size = tex.size_vec2(); - let scale = ui.available_width() / size.x.max(1.0); - let r = ui.vertical_centered(|ui| ui.add(egui::Image::new((tex.id(), size * scale)))).inner; - crate::widgets::register(ui, "image:exportWatermarkPreview", r.rect); -} - /// About dialog tabs: (widget id suffix, label). The credits come from `crate::credits`. pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", "Contributors"), ("models", "Models")]; @@ -1630,1034 +1490,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { /// The last frame a dialog was drawn and stayed open, for [`clicked_backdrop`]. const BACKDROP_SHOWN: &str = "dialog-backdrop-shown"; -/// A click on the dimmed backdrop around the dialog: it closes the dialog like Esc. Pressed and -/// released outside the window, so a slider dragged past its edge doesn't close it; a press while a -/// popup is open (a dropdown, a date picker's calendar) only closes the popup: `popup_was_open` is -/// read before the window is drawn, since drawing it already closes the popup. Only a press made -/// while the dialog is shown counts: in the frame a dialog appears, its input is the click that -/// opened it (a quick click on a slow frame presses and releases in that one frame), so it never -/// closes it. (Read from the raw events: egui forgets the press origin on release.) -fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, popup_was_open: bool) -> bool { - let press_id = egui::Id::unique("dialog-backdrop-press"); - let frame = ctx.cumulative_frame_nr(); - // the same frame too: a second pass of it gets no input - let shown = ctx.data(|d| d.get_temp::(egui::Id::unique(BACKDROP_SHOWN))).is_some_and(|f| f + 1 >= frame); - if !shown { - ctx.data_mut(|d| d.remove_temp::(press_id)); - return false; - } - let (press, release, clicked) = ctx.input(|i| { - let at = |down: bool| { - i.events.iter().rev().find_map(|e| match e { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } if *pressed == down => Some(*pos), - _ => None, - }) - }; - (at(true), at(false), i.pointer.primary_clicked()) - }); - if let Some(p) = press { - let outside = !window.contains(p) && !popup_was_open; - ctx.data_mut(|d| d.insert_temp(press_id, outside)); - } - let Some(release) = release else { return false }; - let pressed_outside = ctx.data_mut(|d| d.remove_temp::(press_id)).unwrap_or(false); - clicked && pressed_outside && !window.contains(release) -} - -/// Room kept free above and below a dialog (#781). -const DIALOG_SCREEN_MARGIN: f32 = 12.0; -/// A dialog's title bar, frame and button row before the first frame measures them. -const DIALOG_CHROME_GUESS: f32 = 100.0; -/// A dialog's options never get less room than this, however short the window. -const DIALOG_MIN_BODY: f32 = 80.0; - -/// A list that scrolls on its own never gets shorter than this to make its dialog fit. -const DIALOG_MIN_LIST: f32 = 120.0; - -fn list_shrink_id() -> egui::Id { - egui::Id::unique("dialog-list-shrink") -} - -fn list_applied_id() -> egui::Id { - egui::Id::unique("dialog-list-applied") -} - -/// The height for a list in a dialog that scrolls on its own (What's New, All Metadata, the -/// shortcuts, smart-album rules): `preferred`, less what the dialog needs to fit the window, so -/// on a short window the list gets shorter rather than scrolling inside a scrolling dialog. -pub fn list_height(ui: &egui::Ui, preferred: f32) -> f32 { - let shrink = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); - let height = (preferred - shrink).max(preferred.min(DIALOG_MIN_LIST)); - ui.data_mut(|d| *d.get_temp_mut_or_default::(list_applied_id()) += preferred - height); - height -} - -/// A dialog's options: in a vertical scroll area at most `max_height` tall when `scroll` names -/// the dialog (it shrinks to its content, so a dialog that fits looks as it would without it; -/// each dialog keeps a scroll position of its own), drawn directly otherwise. Returns the height -/// they took when scrolled. -fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: impl FnOnce(&mut egui::Ui)) -> Option { - let Some(name) = scroll else { - ui.data_mut(|d| d.remove_temp::(list_shrink_id())); - body(ui); - return None; - }; - ui.data_mut(|d| d.insert_temp(list_applied_id(), 0.0_f32)); - let top = ui.cursor().top(); - // (an auto-sized window offers its content last frame's height; the minimum lets a dialog - // that grows take its full height at once, and the area still shrinks to fit its content) - let out = egui::ScrollArea::vertical() - .id_salt(("dialog-body", name)) - .max_height(max_height) - .min_scrolled_height(max_height) - .auto_shrink([true, true]) - .show(ui, body); - // what the lists that scroll on their own gave up this frame, and what they should next frame - let applied = ui.data(|d| d.get_temp::(list_applied_id())).unwrap_or(0.0); - let was = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); - let shrink = (out.content_size.y + applied - max_height).max(0.0); - if (shrink - was).abs() > 0.5 { - ui.data_mut(|d| d.insert_temp(list_shrink_id(), shrink)); - ui.ctx().request_repaint(); - } - // (the room the scroll area took: its `inner_rect` can be taller than what it allocated) - Some(ui.min_rect().bottom() - top) -} - -/// Apply a dialog's action (also used by `ui.dialog.confirm`). -/// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day` -/// (units `s`, `min`, `h`, `day`, each optional after a plain number of seconds). -pub fn parse_gap(text: &str) -> Option { - // the one translated text `fmt_gap` writes ("1 Tag") - if text.trim() == crate::i18n::tr("1 day") { - return Some(86400.0); - } - let mut total = 0.0; - let mut seen = false; - let mut words = text.split_whitespace().peekable(); - while let Some(word) = words.next() { - // "90s" as well as "90 s" - let split = word.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(word.len()); - let (num, unit) = word.split_at(split); - let n: f64 = num.parse().ok().filter(|n: &f64| n.is_finite())?; - let unit = if unit.is_empty() { - match words.peek() { - Some(u) if u.parse::().is_err() => words.next().unwrap_or_default(), - _ => "s", - } - } else { - unit - }; - let scale = match unit.trim_end_matches('.').to_lowercase().as_str() { - "s" | "sec" | "secs" | "second" | "seconds" => 1.0, - "m" | "min" | "mins" | "minute" | "minutes" => 60.0, - "h" | "hr" | "hrs" | "hour" | "hours" => 3600.0, - "d" | "day" | "days" => 86400.0, - _ => return None, - }; - total += n * scale; - seen = true; - } - seen.then_some(total) -} - -/// The longest burst gap `photo.analyze` accepts: a day. -pub const MAX_BURST_GAP: f32 = 86_400.0; - -/// Keep what was typed in the Assisted Culling value fields within what `photo.analyze` accepts -/// (a gap of 0 s to a day, a similarity of 0 to 1), not within the sliders' drag range. -pub fn clamp_cull_burst(gap: &mut f32, similarity: &mut f32) { - *gap = if gap.is_finite() { gap.clamp(0.0, MAX_BURST_GAP) } else { 10.0 }; - *similarity = if similarity.is_finite() { similarity.clamp(0.0, 1.0) } else { 0.93 }; -} - -/// The `photo.analyze` parameters of the Assisted Culling dialog, its values as they are. -pub fn cull_params(reject_below: f32, pick_best: bool, burst_gap: f32, similarity: f32) -> serde_json::Value { - let mut p = json!({"pickBest": pick_best, "burstGap": burst_gap, "similarity": similarity}); - if reject_below > 0.0 { - p["rejectBelow"] = json!(reject_below); - } - p -} - -/// `90` → `1 min 30 s`. -pub fn fmt_gap(v: f64) -> String { - let v = v.round() as u64; - match v { - 0..60 => format!("{v} s"), - 60..3600 if v.is_multiple_of(60) => format!("{} min", v / 60), - 60..3600 => format!("{} min {} s", v / 60, v % 60), - 3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600), - 3600..86400 if v.is_multiple_of(60) => format!("{} h {} min", v / 3600, v % 3600 / 60), - // the seconds too, so editing the text reads back the same gap - 3600..86400 => format!("{} h {} min {} s", v / 3600, v % 3600 / 60, v % 60), - _ => crate::i18n::tr("1 day").into(), - } -} - /// Whether a dialog stays open after its action succeeded (the SAM 3 dialog while the model /// downloads). pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool { matches!(dlg, Dialog::SamModel { .. }) && !app.session.segmenter.installed() } - -/// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is -/// all the dialog can offer. -fn sam_by_hand(sam: &lightcraft_engine::segment::Segmenter) -> bool { - !sam.installed() && !sam.download_status().running && sam.mirrors().is_empty() -} - -/// The model installation guide. -const SAM_HELP: &str = "https://github.com/storytold/lightcraft/blob/main/docs/ai-masks.md#getting-the-model"; - -/// Open the configured SAM 3 folder itself; revealing a file opens its parent instead. -pub(crate) fn show_model_folder(app: &mut LightcraftApp) -> Result { - let dir = app.session.segmenter.dir.as_ref().ok_or("no folder is set for the SAM 3 model")?; - let path = dir.to_str().ok_or("the model folder path is not valid UTF-8")?; - let open = app.services.open_with.as_mut().ok_or("opening model folders is not available here")?; - // Create only after the host action is available. Existing model files are left alone. - std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; - #[cfg(target_os = "linux")] - if let Some(open_folder) = app.services.open_model_folder.clone() { - let path = path.to_string(); - let folder = path.clone(); - crate::tasks::spawn( - app, - "Show in File Manager", - Some("openModelFolder"), - move || open_folder(&folder), - |app, ctx, result| { - if let Err(e) = result { - app.toast_error(ctx, e); - } - }, - )?; - return Ok(json!({"path": path, "background": true})); - } - open(path, "")?; - Ok(json!({"path": path})) -} - -/// The SAM 3 dialog: what the model is, its size and licence, and the download's progress. -fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str>) { - use lightcraft_engine::segment::{LICENSE_NAME, LICENSE_URL, MODEL_BYTES}; - let t = Tokens::get(ui.ctx()); - let seg = &app.session.segmenter; - let d = seg.download_status(); - if seg.installed() { - ui.label(crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready.")); - return; - } - let gb = |b: u64| b as f64 / 1e9; - ui.label(crate::i18n::tr( - "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.", - )); - let dir = seg.dir.as_ref().map(|d| d.display().to_string()).unwrap_or_default(); - ui.label(format!("{} {:.1} GB, {} {dir}", crate::i18n::tr("A one-time download of about"), gb(MODEL_BYTES), crate::i18n::tr("saved in"))); - ui.label( - egui::RichText::new(format!( - "{} {LICENSE_NAME} — {}", - crate::i18n::tr("Licence:"), - crate::i18n::tr("Meta's terms, not LightCraft's. Downloading it means accepting them.") - )) - .color(t.text_label), - ); - let r = ui.link(crate::i18n::tr("Read the SAM License")).on_hover_text(LICENSE_URL); - crate::widgets::register(ui, "link:samLicense", r.rect); - if r.clicked() { - let _ = crate::links::open(app, LICENSE_URL); - } - let seg = &app.session.segmenter; - if d.running { - ui.add_space(4.0); - let text = format!("{:.2} / {:.2} GB · {}", gb(d.done), gb(d.total), d.file); - crate::progress::Progress::new(crate::progress::fraction_of(d.done, d.total)).label(text).widget("progress:samDownload").show(ui); - let r = ui.button(crate::i18n::tr("Cancel Download")); - crate::widgets::register(ui, "button:samCancel", r.rect); - if r.clicked() { - app.session.segmenter.cancel_download(); - } - ui.label(egui::RichText::new(crate::i18n::tr("You can close this: the download continues, and resumes if interrupted.")).color(t.text_dim)); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); - return; - } - if seg.mirrors().is_empty() { - // nothing to download from: say how to install it by hand, with the folder and the guide a click away - let dir = seg.dir.clone(); - ui.label( - egui::RichText::new(crate::i18n::tr( - "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.", - )) - .color(t.text_dim), - ); - ui.horizontal_wrapped(|ui| { - if dir.is_some() && crate::menus::ui_enabled(app, "app.openSamModelFolder") { - let r = ui.button(crate::i18n::tr(crate::menus::reveal_label())); - crate::widgets::register(ui, "button:samFolder", r.rect); - if r.clicked() - && let Err(e) = app.run("app.openSamModelFolder", json!({})) - { - app.toast_error(ui.ctx(), e); - } - } - let r = ui.link(crate::i18n::tr("How to install the model")).on_hover_text(SAM_HELP); - crate::widgets::register(ui, "link:samHelp", r.rect); - if r.clicked() - && let Err(e) = crate::links::open(app, SAM_HELP) - { - app.toast(ui.ctx(), e); - } - }); - } - if let Some(e) = error.map(str::to_string).or(d.error) { - ui.label(egui::RichText::new(format!("{} {e}", crate::i18n::tr("The download didn't work:"))).color(egui::Color32::from_rgb(230, 90, 80))); - } -} - -/// Runs a command that creates an album and asks the Albums tree to open the folders down to it. -fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) -> Result { - let r = app.run(command, params)?; - app.ui.reveal_album = r.get("id").and_then(serde_json::Value::as_u64); - Ok(r) -} - -/// The albums an Album rule in smart album `editing` picks from, in the sidebar's order: the album -/// itself and any smart album that tests it (testing it back would loop) are blocked, with why. -pub(crate) fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option) -> Vec { - let editing = editing.map(lightcraft_catalog::AlbumId); - crate::album_picker::entries_from(cat, |a| match editing { - Some(e) if a.id == e => Some(crate::i18n::tr("This is the album you're editing").to_string()), - Some(e) if cat.album_reaches(a.id, e) => Some(crate::i18n::tr("It tests this album, so testing it back would loop").to_string()), - _ => None, - }) -} - -pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { - match dlg { - Dialog::LightroomResult { first, .. } => { - if let Some(first) = first { - app.run("library.source", json!({"kind": "all"}))?; - let mut filter = serde_json::to_value(lightcraft_catalog::Filter::default()).map_err(|e| e.to_string())?; - // The filter command merges a patch; these defaults are omitted by serde. - filter["only"] = json!([]); - filter["labels"] = json!([]); - filter["ruleSet"] = serde_json::Value::Null; - app.run("library.filter", filter)?; - app.run("library.select", json!({"ids": [first]}))?; - app.ui.view = crate::state::ViewMode::PhotoGrid; - } - Ok(serde_json::Value::Null) - } - Dialog::SamModel { then, .. } => { - if app.session.segmenter.installed() { - // installed: start what the user was doing - if let Some((kind, op)) = then { - crate::panels::masking::begin_ai(app, kind, op)?; - } - return Ok(serde_json::Value::Null); - } - let r = app.run("segment.model.download", json!({"acknowledged": true})); - if r.is_ok() { - app.ui.sam_downloading = true; - } - r - } - Dialog::NewAlbum { name, folder, parent, include_selected } => { - created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder && *include_selected, "parent": parent})) - } - Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), - Dialog::TextPrompt { value, command, params, key, .. } => { - let mut p = params.clone(); - p[key.as_str()] = json!(value.trim()); - app.run(command, p) - } - Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => app.run( - "photo.setCaptureTime", - match mode.as_str() { - "set" => json!({"time": time}), - "shift" => json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}), - _ => json!({"hours": zone}), - }, - ), - Dialog::LabelNames { names, save_as } => { - let mut m = serde_json::Map::new(); - for (l, n) in lightcraft_catalog::ColorLabel::ALL.iter().zip(names) { - m.insert(format!("{l:?}").to_lowercase(), if n.trim().is_empty() { serde_json::Value::Null } else { json!(n.trim()) }); - } - let r = app.run("label.setNames", json!({"names": m})); - if r.is_ok() && !save_as.trim().is_empty() { - return app.run("label.saveSet", json!({"name": save_as.trim()})); - } - r - } - Dialog::Rename { template, start } => app.run("photo.rename", json!({"template": template, "start": start})), - Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), - Dialog::RenamePerson { from, to, merge } => app.run("faces.renamePerson", json!({"from": from, "to": to.trim(), "merge": merge})), - Dialog::KeywordTag { - editing, - name, - parent, - inside, - synonyms, - include_on_export, - export_containing, - export_synonyms, - person, - add_to_selected, - reveal, - } => { - let mut params = json!({ - "synonyms": synonyms.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::>(), - "includeOnExport": include_on_export, - "exportContaining": export_containing, - "exportSynonyms": export_synonyms, - "person": person, - }); - match editing { - Some(keyword) => { - params["keyword"] = json!(keyword); - params["name"] = json!(name); - let r = app.run("keyword.edit", params); - if r.is_ok() { - let to = match keyword.rsplit_once('|') { - Some((parent, _)) => format!("{parent}|{}", name.trim()), - None => name.trim().to_string(), - }; - crate::panels::keyword_list::follow(app, keyword, &to); - } - r - } - None => { - params["name"] = json!(name); - params["parent"] = if *inside { json!(parent) } else { serde_json::Value::Null }; - params["addToSelected"] = json!(add_to_selected); - let r = app.run("keyword.create", params); - if *reveal && let Ok(made) = &r { - app.ui.reveal_keyword = made["keyword"].as_str().map(str::to_string); - } - r - } - } - } - Dialog::DeleteKeyword { keyword, .. } => app.run("keyword.delete", json!({"keyword": keyword})), - Dialog::KeywordSet { replaces, name, slots, as_new } => { - use lightcraft_catalog::keywords::same; - let name = name.trim(); - if name.is_empty() { - return Err(crate::i18n::tr("A keyword set needs a name").to_string()); - } - if same(name, lightcraft_engine::cmd::keywords::RECENT) { - return Err(crate::i18n::tr("Recent Keywords is built in: choose another name").to_string()); - } - // the set it renames: none when saving as new, or when it was deleted meanwhile (then it is - // saved anew rather than refused) - let sets = &app.session.keyword_sets; - let renames = replaces.as_deref().filter(|_| !as_new).filter(|old| sets.iter().any(|x| same(&x.name, old))); - if sets.iter().any(|x| same(&x.name, name) && !renames.is_some_and(|old| same(&x.name, old))) { - return Err(crate::i18n::tr_format!("There is a keyword set “{name}” already", name = name)); - } - let mut params = json!({"name": name, "keywords": slots}); - match renames { - Some(old) => params["replace"] = json!(old), - None => params["new"] = json!(true), - } - app.run("keyword.saveSet", params) - } - Dialog::MoveKeyword { keyword, parent } => { - let r = app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true})); - if r.is_ok() { - let leaf = keyword.rsplit('|').next().unwrap_or(keyword); - let to = parent.as_deref().map_or_else(|| leaf.to_string(), |p| format!("{p}|{leaf}")); - crate::panels::keyword_list::follow(app, keyword, &to); - } - r - } - Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), - Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), - Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted), - Dialog::FaceModel { path, info, accepted } => crate::panels::faces::install(app, path, info, *accepted), - Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null), - Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { - app.ui.cull_burst = Some((*burst_gap, *similarity)); - app.run("photo.analyze", cull_params(*reject_below, *pick_best, *burst_gap, *similarity)) - } - Dialog::SmartRules { id, name, rules, parent } => { - // refused before anything changes (not even the name) - if let Some(problem) = rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).first() { - return Err(problem.to_string()); - } - let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() }; - match id { - // the name and the rules together: one undo step, both or neither - Some(id) => app.run("album.setRules", json!({"id": id, "name": name, "replace": true, "rules": {"ruleSet": rules}})), - None => created_in(app, "album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}, "parent": parent})), - } - } - Dialog::NewSmartAlbum { name, parent } => { - created_in(app, "album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }, "parent": parent})) - } - Dialog::CreatePreset { name, group, groups } => app.run( - "preset.create", - json!({ - "name": if name.trim().is_empty() { "My Preset" } else { name }, - "group": if group.trim().is_empty() { "User Presets" } else { group }, - "groups": groups, - }), - ), - Dialog::ContactSheet { options } => app.run("app.contactSheet", json!(options)), - Dialog::SlideshowExport { settings } => app.run("app.slideshowExport", json!(settings)), - Dialog::Print => app.run("app.print", json!({})), - Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), - Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), - Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { - let mut p = export_dialog_params(opts, *full_size, resize, *limit_kb); - p["dir"] = json!(dir); - p["background"] = json!(true); - app.run("app.export", p) - } - Dialog::Merge { opts } => crate::merge::start_final(app, opts), - Dialog::Import { opts } => crate::import::start(app, opts), - Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), - Dialog::MovePhotos { ids, folder, dont_ask, library, .. } => { - // another library opened meanwhile (from the menu bar, an agent): these numbers are - // other photos there - if *library != app.session.library_generation() { - return Err(crate::i18n::tr("Another library was opened since the photos were dropped: nothing was moved").to_string()); - } - if *dont_ask { - app.ui.settings.ask_before_move = false; - } - crate::panels::left::move_photos(app, Some(ids), folder).map(|n| json!({"moved": n})) - } - // the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it - Dialog::LightroomCatalog { catalog, .. } => { - app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) - } - d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::confirm(app, d), - Dialog::SaveEffect { name, group, fields, amount, brush } => { - crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush) - } - Dialog::DeleteEffects { names } => app.run("mask.deleteEffect", json!({"names": names})), - Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), - d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } | Dialog::ViewOptions => { - Ok(serde_json::Value::Null) - } - } -} - -/// Open a one-field dialog that runs `command` with `params` + `{key: typed value}`. -pub fn prompt(app: &mut LightcraftApp, title: &str, hint: &str, value: &str, command: &str, params: serde_json::Value, key: &str) { - app.ui.dialog = - Some(Dialog::TextPrompt { title: title.into(), hint: hint.into(), value: value.into(), command: command.into(), params, key: key.into() }); -} - -/// The Export dialog's choices as `app.export` params (without the folder). -fn export_dialog_params( - opts: &lightcraft_engine::export::ExportOptions, - full_size: bool, - resize: &lightcraft_engine::export::Resize, - limit_kb: u32, -) -> serde_json::Value { - let o = lightcraft_engine::export::ExportOptions { - resize: (!full_size).then_some(*resize), - limit_kb: (limit_kb > 0).then_some(limit_kb), - ..opts.clone() - }; - o.to_json() -} - -// ------------------------------------------------------------------------------------------ dialog widgets - -/// Two columns of settings-group checkboxes (Copy Settings, Create Preset) plus All / None. -fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec) { - let key_of = |g: &SettingsGroup| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); - ui.columns(2, |cols| { - for (i, g) in SettingsGroup::ALL.iter().enumerate() { - let key = key_of(g); - let mut on = groups.contains(&key); - let r = cols[i % 2].checkbox(&mut on, crate::i18n::tr(g.label())); - crate::widgets::register_response(format!("{tag}:{key}"), r.rect, &r); - if r.changed() { - if on { - groups.push(key); - } else { - groups.retain(|x| *x != key); - } - } - } - }); - ui.horizontal(|ui| { - let all = ui.small_button(crate::i18n::tr("All")); - crate::widgets::register(ui, format!("{tag}:all"), all.rect); - if all.clicked() { - *groups = SettingsGroup::ALL.iter().map(key_of).collect(); - } - let none = ui.small_button(crate::i18n::tr("None")); - crate::widgets::register(ui, format!("{tag}:none"), none.rect); - if none.clicked() { - groups.clear(); - } - }); -} - -/// Width of the label column in dialogs. -const LABEL_W: f32 = 78.0; - -const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, default: f64, step: f64) -> ControlSpec { - ControlSpec { id, label, section: Section::Light, min, max, default, step, decimals: 0, track: Track::Plain } -} -const QUALITY: ControlSpec = spec("export.quality", "Quality", 1.0, 100.0, 90.0, 1.0); -const LIMIT_KB: ControlSpec = spec("export.limitKb", "Limit file size (KB, 0 = off)", 0.0, 20_000.0, 0.0, 10.0); -const SIZE_PX: ControlSpec = spec("export.sizePx", "Pixels", 16.0, 20_000.0, 2048.0, 16.0); -const SIZE_W: ControlSpec = spec("export.sizeW", "Width (px)", 16.0, 20_000.0, 2048.0, 16.0); -const SIZE_H: ControlSpec = spec("export.sizeH", "Height (px)", 16.0, 20_000.0, 2048.0, 16.0); -const SIZE_MP: ControlSpec = ControlSpec { decimals: 1, ..spec("export.sizeMp", "Megapixels", 0.1, 100.0, 12.0, 0.1) }; -const SIZE_PCT: ControlSpec = spec("export.sizePercent", "Percent", 1.0, 400.0, 50.0, 1.0); -const START_NUMBER: ControlSpec = spec("export.startNumber", "Start number", 1.0, 9999.0, 1.0, 1.0); -const PPI: ControlSpec = spec("export.ppi", "Resolution (ppi)", 1.0, 1200.0, 240.0, 1.0); - -/// Image Sizing: full size, or a resize mode and its value(s), don't enlarge, ppi. -fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::export::Resize, ppi: &mut u16) { - use lightcraft_engine::export::ResizeMode as R; - const MODES: [(R, &str); 8] = [ - (R::LongEdge, "Long Edge"), - (R::ShortEdge, "Short Edge"), - (R::Width, "Width"), - (R::Height, "Height"), - (R::WidthHeight, "Width & Height"), - (R::Dimensions, "Dimensions"), - (R::Megapixels, "Megapixels"), - (R::Percent, "Percentage"), - ]; - let r_full = ui.checkbox(full, crate::i18n::tr("Full size")); - crate::widgets::register(ui, "check:exportFullSize", r_full.rect); - if !*full { - field(ui, "Resize to", |ui| { - let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1); - let before = r.mode; - let c = crate::widgets::combo_box("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { - for (m, l) in MODES { - ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l)); - } - }); - crate::widgets::name_combo(&c.response, crate::i18n::tr("Resize")); - crate::widgets::register(ui, "combo:exportResizeMode", c.response.rect); - if r.mode != before { - // a sensible value for the new unit - r.value = match r.mode { - R::Megapixels => 12.0, - R::Percent => 50.0, - _ if matches!(before, R::Megapixels | R::Percent) => 2048.0, - _ => r.value, - }; - if matches!(r.mode, R::Dimensions | R::WidthHeight) && r.height == 0 { - r.height = r.value as u32; - } - } - }); - let mut v = r.value as f64; - let spec = match r.mode { - R::Megapixels => &SIZE_MP, - R::Percent => &SIZE_PCT, - R::Dimensions | R::WidthHeight => &SIZE_W, - _ => &SIZE_PX, - }; - if num(ui, spec, &mut v) { - r.value = v as f32; - } - if matches!(r.mode, R::Dimensions | R::WidthHeight) { - let mut h = r.height as f64; - if num(ui, &SIZE_H, &mut h) { - r.height = h as u32; - } - } - let c = ui.checkbox(&mut r.dont_enlarge, crate::i18n::tr("Don't enlarge")); - crate::widgets::register(ui, "check:exportDontEnlarge", c.rect); - } - let mut p = *ppi as f64; - if num(ui, &PPI, &mut p) { - *ppi = p as u16; - } -} -/// All nine positions supported by both the text and graphic watermark renderer. -/// Keep them in grid order so bottom-centre and edge-centre anchors are available in Export. -const WM_POSITIONS: [(lightcraft_engine::export::Anchor, &str); 9] = { - use lightcraft_engine::export::Anchor::*; - [(TopLeft, "↖"), (Top, "↑"), (TopRight, "↗"), (Left, "←"), (Center, "•"), (Right, "→"), (BottomLeft, "↙"), (Bottom, "↓"), (BottomRight, "↘")] -}; - -const WM_IMAGE_WIDTH: ControlSpec = spec("export.watermarkImageWidth", "Width (% of photo)", 2.0, 100.0, 20.0, 1.0); -const WM_SIZE: ControlSpec = spec("export.watermarkSize", "Size (% of short edge)", 1.0, 15.0, 3.5, 0.5); -const WM_OPACITY: ControlSpec = spec("export.watermarkOpacity", "Opacity (%)", 5.0, 100.0, 70.0, 1.0); -const WM_INSET_X: ControlSpec = - ControlSpec { decimals: 1, ..spec("export.watermarkInsetX", "Horizontal inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; -const WM_INSET_Y: ControlSpec = - ControlSpec { decimals: 1, ..spec("export.watermarkInsetY", "Vertical inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; - -/// A themed slider row editing `v`; true when it changed. -fn num(ui: &mut egui::Ui, spec: &ControlSpec, v: &mut f64) -> bool { - match crate::widgets::slider(ui, spec, *v, true, None).value { - Some(n) => { - *v = n; - true - } - None => false, - } -} - -/// A labelled row (fixed label column). -/// View ▸ Export Slideshow…: what to write (PDF, JPEG sequence, video) and how big; where comes -/// after (a save dialog). -fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, settings: &mut lightcraft_engine::slideshow_export::ExportSettings) { - use lightcraft_engine::slideshow_export::{ExportSettings, Format}; - let t = Tokens::get(ui.ctx()); - let photos = app.session.visible_cloned().len(); - let slide = app.session.slideshow.slide_duration; - let (intro, ending) = app.session.slide_titles.durations(slide); - let titles = usize::from(intro.is_some()) + usize::from(ending.is_some()); - let seconds = photos as f64 * slide + intro.unwrap_or(0.0) + ending.unwrap_or(0.0); - let summary = crate::i18n::tr_format!( - "{n} photos in view · {slides} slides · {time}", - n = photos, - slides = photos + titles, - time = format!("{}:{:02}", (seconds / 60.0).floor() as u64, (seconds % 60.0).round() as u64) - ); - ui.label(egui::RichText::new(summary).color(t.text_dim)); - let before = settings.format; - choices(ui, "Format", "slideshowExportFormat", &[(Format::Pdf, "PDF"), (Format::Jpeg, "JPEG"), (Format::Video, "Video")], &mut settings.format); - if settings.format != before { - *settings = ExportSettings { full_screen: settings.full_screen, ..ExportSettings::for_format(settings.format) }; - } - match settings.format { - Format::Video => { - choices(ui, "Size", "slideshowExportSize", &[(720, "720p"), (1080, "1080p")], &mut settings.size); - choices(ui, "Frame rate", "slideshowExportFps", &[(24, "24 fps"), (30, "30 fps")], &mut settings.fps); - let mut webm = settings.container == "webm"; - choices(ui, "Container", "slideshowExportContainer", &[(false, "MP4"), (true, "WebM")], &mut webm); - settings.container = if webm { "webm".into() } else { "mp4".into() }; - } - _ => { - choices(ui, "Size", "slideshowExportSize", &[(1024, "1024 px"), (1920, "1920 px"), (3840, "3840 px")], &mut settings.size); - } - } - field(ui, "Quality", |ui| { - let r = ui.add(egui::Slider::new(&mut settings.quality, 1..=100)); - crate::widgets::register(ui, "slider:slideshowExportQuality", r.rect); - }); - let note = match settings.format { - Format::Pdf => { - let r = ui.checkbox(&mut settings.full_screen, crate::i18n::tr("Automatically show full screen")); - crate::widgets::register(ui, "check:slideshowExportFullScreen", r.rect); - "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode." - } - Format::Jpeg => "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.", - Format::Video => "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.", - }; - ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); - ui.label( - egui::RichText::new(crate::i18n::tr("Slides use the slideshow's layout, overlays and intro and ending screens, in view order.")) - .color(t.text_dim), - ); -} - -fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(LABEL_W); - ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); - }); - add(ui) - }) - .inner -} - -/// The rest of a [`field`] row, laid out right to left: `add` places its trailing button(s) first, -/// then a text field taking exactly the width that's left. (Subtracting a guessed button width -/// instead made the auto-sized dialog grow every frame when the real button was wider, #8.) -fn trailing_button_row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { - let h = ui.spacing().interact_size.y.max(24.0); - ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), h), egui::Layout::right_to_left(egui::Align::Center), add); -} - -/// A labelled row of mutually exclusive choice buttons (ids `button:{id}-{index}`). -fn choices(ui: &mut egui::Ui, label: &str, id: &str, options: &[(V, &str)], value: &mut V) { - field(ui, label, |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (v, l)) in options.iter().enumerate() { - if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() { - *value = *v; - } - } - }); -} - -/// Dialogs that Return confirms with nothing focused (those with text fields confirm from the -/// field: `dialog_field`). -fn return_confirms(dlg: &Dialog) -> bool { - matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. }) -} - -struct PersonRenamePlan { - faces: usize, - needs_merge: bool, - /// Why Rename stays disabled. `None` when the typed name can be applied. - block: Option<&'static str>, -} - -/// What renaming `from` to `to` would do, using the same rules as `faces.renamePerson`. -fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) -> PersonRenamePlan { - let to_trim = to.trim(); - if to_trim.is_empty() { - return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("Type a name") }; - } - if to_trim.chars().count() > 200 || to_trim.chars().any(char::is_control) { - return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("A name is at most 200 characters, without control characters") }; - } - let from_key = from.trim().to_lowercase(); - let to_key = to_trim.to_lowercase(); - let mut matched = 0usize; - let mut faces = 0usize; - let mut needs_merge = false; - if !from_key.is_empty() { - for photo in cat.photos().filter(|p| p.in_library()) { - for region in photo.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { - let Some(name) = region.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; - let key = name.to_lowercase(); - if key == from_key { - matched += 1; - if region.name.as_deref() != Some(to_trim) { - faces += 1; - } - } else if key == to_key { - needs_merge = true; - } - } - } - } - let block = if matched == 0 { - Some("No person by that name") - } else if faces == 0 { - Some("That is already their name") - } else { - None - }; - // a case-only change is the same person, not a merge - PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block } -} - -/// A dialog's one text field: it takes the focus when the dialog opens, and opens with its text -/// selected, so typing replaces it. It takes the focus back only while nothing else has it: taking -/// it back every frame would keep Tab from ever reaching the dialog's buttons and check boxes. -/// True when Return confirms the dialog; Esc cancels the dialog (`show`). -fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) -> bool { - let r = crate::text_field::TextField::singleline(widget, text).hint(crate::i18n::tr(hint)).width(f32::INFINITY).select_on_focus(true).show(ui); - // (and takes it back when nothing has it: after a click on the dialog's background or a check box, - // or when Return gave it up on a refusal; a control Tab reached keeps it). A disabled dialog (a - // prompt or the blocking library window owns input) must not request the focus at all: egui's - // text edit would otherwise keep editing the draft from host text behind that window. - if ui.is_enabled() && (just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none()))) { - r.response.request_focus(); - } - r.ending == Some(crate::text_field::Ending::Return) -} - -/// The dialog part `key` is drawn this frame but wasn't the frame before: the dialog has just -/// opened (to give its first field the focus once). -fn just_opened(ui: &egui::Ui, key: &str) -> bool { - let id = egui::Id::unique(("dialog-shown", key)); - let pass = ui.ctx().cumulative_pass_nr(); - let last: Option = ui.data_mut(|d| d.get_temp(id)); - ui.data_mut(|d| d.insert_temp(id, pass)); - last.and_then(|l| l.checked_add(1)) != Some(pass) -} - -#[cfg(test)] -mod watermark_position_tests { - use super::WM_POSITIONS; - use lightcraft_engine::export::Anchor; - - #[test] - fn export_dialog_offers_every_rendered_anchor_exactly_once() { - let anchors = [ - Anchor::TopLeft, - Anchor::Top, - Anchor::TopRight, - Anchor::Left, - Anchor::Center, - Anchor::Right, - Anchor::BottomLeft, - Anchor::Bottom, - Anchor::BottomRight, - ]; - assert_eq!(WM_POSITIONS.len(), anchors.len()); - for anchor in anchors { - assert_eq!(WM_POSITIONS.iter().filter(|(position, _)| *position == anchor).count(), 1); - } - } -} - -/// How a dialog's confirm button looks: [`ConfirmStyle::Primary`] (the accent's fill) when it -/// makes or brings in something, plain when it deletes or removes (never the eye-catching -/// default) or when the dialog only shows something (its Close). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ConfirmStyle { - Primary, - Plain, -} - -pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { - match dlg { - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } | Dialog::RemoveFolder { .. } => { - ConfirmStyle::Plain - } - _ if only_shows(dlg) => ConfirmStyle::Plain, - _ => ConfirmStyle::Primary, - } -} - -/// A dialog that only shows something: one Close, nothing to confirm or cancel. (Synchronize -/// Folder is one when its folder turned out up to date.) -fn only_shows(dlg: &Dialog) -> bool { - matches!( - dlg, - Dialog::About - | Dialog::Shortcuts - | Dialog::Settings { .. } - | Dialog::SystemInfo { .. } - | Dialog::AllMetadata { .. } - | Dialog::WhatsNew - | Dialog::ManageEffects { .. } - | Dialog::ViewOptions - ) || matches!(dlg, Dialog::SynchronizeFolder { counts: Some(c), .. } if c.up_to_date()) -} - -#[cfg(test)] -mod gap_tests { - use super::{fmt_gap, parse_gap}; - - #[test] - fn gap_text_reads_back_what_it_shows() { - for v in [0.0, 1.0, 12.0, 59.0, 60.0, 90.0, 600.0, 3599.0, 3600.0, 3601.0, 3661.0, 3900.0, 7325.0, 86340.0, 86399.0] { - assert_eq!(parse_gap(&fmt_gap(v)), Some(v), "{}", fmt_gap(v)); - } - assert_eq!(parse_gap("1 day"), Some(86400.0)); - assert_eq!(parse_gap("45"), Some(45.0)); - assert_eq!(parse_gap(" 45s "), Some(45.0)); - assert_eq!(parse_gap("2min 5s"), Some(125.0)); - for bad in ["", "abc", "5 parsecs", "NaN", "1 min x"] { - assert_eq!(parse_gap(bad), None, "{bad:?}"); - } - // a day reads back in the UI language too ("1 Tag") - crate::i18n::set_language(crate::i18n::Locale::De); - let shown = fmt_gap(86400.0); - let back = parse_gap(&shown); - crate::i18n::set_language(crate::i18n::Locale::En); - assert_eq!((shown.as_str(), back), ("1 Tag", Some(86400.0))); - } - - #[test] - fn gaps_past_the_slider_read_back_too() { - // the Assisted Culling slider drags to 10 min; its value field takes up to a day - for (v, text) in [(1800.0, "30 min"), (5400.0, "1 h 30 min"), (3601.0, "1 h 0 min 1 s"), (86400.0, "1 day")] { - assert_eq!(fmt_gap(v), text); - assert_eq!(parse_gap(text), Some(v), "{text}"); - } - assert_eq!(parse_gap("1800"), Some(1800.0)); - assert_eq!(parse_gap("0"), Some(0.0)); - } -} - -#[cfg(test)] -mod cull_dialog_tests { - use std::time::Duration; - - use serde_json::json; - - use super::{clamp_cull_burst, cull_params}; - use crate::headless::Headless; - use crate::state::Dialog; - use crate::{LightcraftApp, Services}; - - const T: Duration = Duration::from_secs(20); - - #[test] - fn typed_values_are_kept_within_what_photo_analyze_accepts() { - for (gap, sim) in [(0.0, 0.0), (1800.0, 1.0), (86400.0, 0.5), (0.5, 0.995)] { - let (mut g, mut s) = (gap, sim); - clamp_cull_burst(&mut g, &mut s); - assert_eq!((g, s), (gap, sim), "in range: kept as typed"); - } - for ((gap, sim), want) in [((-5.0, -0.1), (0.0, 0.0)), ((90000.0, 1.5), (86400.0, 1.0)), ((f32::NAN, f32::INFINITY), (10.0, 0.93))] { - let (mut g, mut s) = (gap, sim); - clamp_cull_burst(&mut g, &mut s); - assert_eq!((g, s), want); - } - assert_eq!(cull_params(0.0, true, 1800.0, 1.0), json!({"pickBest": true, "burstGap": 1800.0, "similarity": 1.0})); - assert_eq!(cull_params(20.0, false, 0.0, 0.7)["rejectBelow"], 20.0); - } - - /// Click the value field right of a slider registered as `id`, type `text`, Enter. - fn type_value(h: &mut Headless, id: &str, text: &str) { - let r = h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")); - let spacing = h.view.ctx.global_style().spacing.clone(); - let x = r.left() + spacing.slider_width + spacing.item_spacing.x + 6.0; - assert_eq!(h.request("ui.click", json!({"x": x, "y": r.center().y}), T)["ok"], true); - h.step(); - for key in ["a", "Backspace"] { - assert_eq!(h.request("ui.key", json!({"key": key, "cmd": key == "a"}), T)["ok"], true); - } - assert_eq!(h.request("ui.text", json!({"text": text}), T)["ok"], true); - assert_eq!(h.request("ui.key", json!({"key": "Enter"}), T)["ok"], true); - h.step(); - h.step(); - } - - fn burst(h: &Headless) -> (f32, f32) { - match &h.app.ui.dialog { - Some(Dialog::Cull { burst_gap, similarity, .. }) => (*burst_gap, *similarity), - d => panic!("{d:?}"), - } - } - - #[test] - fn the_culling_value_fields_take_values_past_the_sliders() { - let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); - let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); - h.step(); - h.app.run("dialog.cull", json!({})).unwrap(); - for _ in 0..4 { - h.step(); - } - assert_eq!(burst(&h), (10.0, 0.93)); - // half an hour and "only identical shots": past the sliders' 10 min and 0.99 - type_value(&mut h, "field:cullGap", "30 min"); - type_value(&mut h, "field:cullSimilar", "1.0"); - assert_eq!(burst(&h), (1800.0, 1.0)); - // a day is as far as photo.analyze goes; more is held there - type_value(&mut h, "field:cullGap", "2 days"); - assert_eq!(burst(&h).0, 86400.0); - type_value(&mut h, "field:cullGap", "0"); - assert_eq!(burst(&h), (0.0, 1.0)); - type_value(&mut h, "field:cullGap", "1800"); - assert_eq!(burst(&h).0, 1800.0); - // a similarity between the slider's hundredths is kept to the thousandth - type_value(&mut h, "field:cullSimilar", "0.995"); - assert_eq!(burst(&h).1, 0.995); - type_value(&mut h, "field:cullSimilar", "0.9951"); - assert_eq!(burst(&h).1, 0.995); - type_value(&mut h, "field:cullSimilar", "1.0"); - assert_eq!(burst(&h).1, 1.0); - // OK hands them to photo.analyze as typed (it refuses values out of its range) - let r = h.request("ui.dialog.confirm", json!({}), T); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); - assert_eq!(h.app.ui.cull_burst, Some((1800.0, 1.0))); - // and the next opening starts from them, not from the sliders' range - h.app.run("dialog.cull", json!({})).unwrap(); - h.step(); - h.step(); - assert_eq!(burst(&h), (1800.0, 1.0)); - } -} diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs new file mode 100644 index 000000000..196018e55 --- /dev/null +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -0,0 +1,235 @@ +use super::cull::cull_params; +use super::export::export_dialog_params; +use super::*; + +/// Runs a command that creates an album and asks the Albums tree to open the folders down to it. +fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) -> Result { + let r = app.run(command, params)?; + app.ui.reveal_album = r.get("id").and_then(serde_json::Value::as_u64); + Ok(r) +} + +/// The albums an Album rule in smart album `editing` picks from, in the sidebar's order: the album +/// itself and any smart album that tests it (testing it back would loop) are blocked, with why. +pub(crate) fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option) -> Vec { + let editing = editing.map(lightcraft_catalog::AlbumId); + crate::album_picker::entries_from(cat, |a| match editing { + Some(e) if a.id == e => Some(crate::i18n::tr("This is the album you're editing").to_string()), + Some(e) if cat.album_reaches(a.id, e) => Some(crate::i18n::tr("It tests this album, so testing it back would loop").to_string()), + _ => None, + }) +} + +pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { + match dlg { + Dialog::LightroomResult { first, .. } => { + if let Some(first) = first { + app.run("library.source", json!({"kind": "all"}))?; + let mut filter = serde_json::to_value(lightcraft_catalog::Filter::default()).map_err(|e| e.to_string())?; + // The filter command merges a patch; these defaults are omitted by serde. + filter["only"] = json!([]); + filter["labels"] = json!([]); + filter["ruleSet"] = serde_json::Value::Null; + app.run("library.filter", filter)?; + app.run("library.select", json!({"ids": [first]}))?; + app.ui.view = crate::state::ViewMode::PhotoGrid; + } + Ok(serde_json::Value::Null) + } + Dialog::SamModel { then, .. } => { + if app.session.segmenter.installed() { + // installed: start what the user was doing + if let Some((kind, op)) = then { + crate::panels::masking::begin_ai(app, kind, op)?; + } + return Ok(serde_json::Value::Null); + } + let r = app.run("segment.model.download", json!({"acknowledged": true})); + if r.is_ok() { + app.ui.sam_downloading = true; + } + r + } + Dialog::NewAlbum { name, folder, parent, include_selected } => { + created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder && *include_selected, "parent": parent})) + } + Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), + Dialog::TextPrompt { value, command, params, key, .. } => { + let mut p = params.clone(); + p[key.as_str()] = json!(value.trim()); + app.run(command, p) + } + Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => app.run( + "photo.setCaptureTime", + match mode.as_str() { + "set" => json!({"time": time}), + "shift" => json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}), + _ => json!({"hours": zone}), + }, + ), + Dialog::LabelNames { names, save_as } => { + let mut m = serde_json::Map::new(); + for (l, n) in lightcraft_catalog::ColorLabel::ALL.iter().zip(names) { + m.insert(format!("{l:?}").to_lowercase(), if n.trim().is_empty() { serde_json::Value::Null } else { json!(n.trim()) }); + } + let r = app.run("label.setNames", json!({"names": m})); + if r.is_ok() && !save_as.trim().is_empty() { + return app.run("label.saveSet", json!({"name": save_as.trim()})); + } + r + } + Dialog::Rename { template, start } => app.run("photo.rename", json!({"template": template, "start": start})), + Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), + Dialog::RenamePerson { from, to, merge } => app.run("faces.renamePerson", json!({"from": from, "to": to.trim(), "merge": merge})), + Dialog::KeywordTag { + editing, + name, + parent, + inside, + synonyms, + include_on_export, + export_containing, + export_synonyms, + person, + add_to_selected, + reveal, + } => { + let mut params = json!({ + "synonyms": synonyms.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::>(), + "includeOnExport": include_on_export, + "exportContaining": export_containing, + "exportSynonyms": export_synonyms, + "person": person, + }); + match editing { + Some(keyword) => { + params["keyword"] = json!(keyword); + params["name"] = json!(name); + let r = app.run("keyword.edit", params); + if r.is_ok() { + let to = match keyword.rsplit_once('|') { + Some((parent, _)) => format!("{parent}|{}", name.trim()), + None => name.trim().to_string(), + }; + crate::panels::keyword_list::follow(app, keyword, &to); + } + r + } + None => { + params["name"] = json!(name); + params["parent"] = if *inside { json!(parent) } else { serde_json::Value::Null }; + params["addToSelected"] = json!(add_to_selected); + let r = app.run("keyword.create", params); + if *reveal && let Ok(made) = &r { + app.ui.reveal_keyword = made["keyword"].as_str().map(str::to_string); + } + r + } + } + } + Dialog::DeleteKeyword { keyword, .. } => app.run("keyword.delete", json!({"keyword": keyword})), + Dialog::KeywordSet { replaces, name, slots, as_new } => { + use lightcraft_catalog::keywords::same; + let name = name.trim(); + if name.is_empty() { + return Err(crate::i18n::tr("A keyword set needs a name").to_string()); + } + if same(name, lightcraft_engine::cmd::keywords::RECENT) { + return Err(crate::i18n::tr("Recent Keywords is built in: choose another name").to_string()); + } + // the set it renames: none when saving as new, or when it was deleted meanwhile (then it is + // saved anew rather than refused) + let sets = &app.session.keyword_sets; + let renames = replaces.as_deref().filter(|_| !as_new).filter(|old| sets.iter().any(|x| same(&x.name, old))); + if sets.iter().any(|x| same(&x.name, name) && !renames.is_some_and(|old| same(&x.name, old))) { + return Err(crate::i18n::tr_format!("There is a keyword set “{name}” already", name = name)); + } + let mut params = json!({"name": name, "keywords": slots}); + match renames { + Some(old) => params["replace"] = json!(old), + None => params["new"] = json!(true), + } + app.run("keyword.saveSet", params) + } + Dialog::MoveKeyword { keyword, parent } => { + let r = app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true})); + if r.is_ok() { + let leaf = keyword.rsplit('|').next().unwrap_or(keyword); + let to = parent.as_deref().map_or_else(|| leaf.to_string(), |p| format!("{p}|{leaf}")); + crate::panels::keyword_list::follow(app, keyword, &to); + } + r + } + Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), + Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), + Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted), + Dialog::FaceModel { path, info, accepted } => crate::panels::faces::install(app, path, info, *accepted), + Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null), + Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { + app.ui.cull_burst = Some((*burst_gap, *similarity)); + app.run("photo.analyze", cull_params(*reject_below, *pick_best, *burst_gap, *similarity)) + } + Dialog::SmartRules { id, name, rules, parent } => { + // refused before anything changes (not even the name) + if let Some(problem) = rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).first() { + return Err(problem.to_string()); + } + let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() }; + match id { + // the name and the rules together: one undo step, both or neither + Some(id) => app.run("album.setRules", json!({"id": id, "name": name, "replace": true, "rules": {"ruleSet": rules}})), + None => created_in(app, "album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}, "parent": parent})), + } + } + Dialog::NewSmartAlbum { name, parent } => { + created_in(app, "album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }, "parent": parent})) + } + Dialog::CreatePreset { name, group, groups } => app.run( + "preset.create", + json!({ + "name": if name.trim().is_empty() { "My Preset" } else { name }, + "group": if group.trim().is_empty() { "User Presets" } else { group }, + "groups": groups, + }), + ), + Dialog::ContactSheet { options } => app.run("app.contactSheet", json!(options)), + Dialog::SlideshowExport { settings } => app.run("app.slideshowExport", json!(settings)), + Dialog::Print => app.run("app.print", json!({})), + Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), + Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), + Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { + let mut p = export_dialog_params(opts, *full_size, resize, *limit_kb); + p["dir"] = json!(dir); + p["background"] = json!(true); + app.run("app.export", p) + } + Dialog::Merge { opts } => crate::merge::start_final(app, opts), + Dialog::Import { opts } => crate::import::start(app, opts), + Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), + Dialog::MovePhotos { ids, folder, dont_ask, library, .. } => { + // another library opened meanwhile (from the menu bar, an agent): these numbers are + // other photos there + if *library != app.session.library_generation() { + return Err(crate::i18n::tr("Another library was opened since the photos were dropped: nothing was moved").to_string()); + } + if *dont_ask { + app.ui.settings.ask_before_move = false; + } + crate::panels::left::move_photos(app, Some(ids), folder).map(|n| json!({"moved": n})) + } + // the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it + Dialog::LightroomCatalog { catalog, .. } => { + app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) + } + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::confirm(app, d), + Dialog::SaveEffect { name, group, fields, amount, brush } => { + crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush) + } + Dialog::DeleteEffects { names } => app.run("mask.deleteEffect", json!({"names": names})), + Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), + d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } | Dialog::ViewOptions => { + Ok(serde_json::Value::Null) + } + } +} diff --git a/crates/ui-egui/src/dialogs/cull.rs b/crates/ui-egui/src/dialogs/cull.rs new file mode 100644 index 000000000..2c3d6497a --- /dev/null +++ b/crates/ui-egui/src/dialogs/cull.rs @@ -0,0 +1,201 @@ +use super::*; + +/// Apply a dialog's action (also used by `ui.dialog.confirm`). +/// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day` +/// (units `s`, `min`, `h`, `day`, each optional after a plain number of seconds). +pub fn parse_gap(text: &str) -> Option { + // the one translated text `fmt_gap` writes ("1 Tag") + if text.trim() == crate::i18n::tr("1 day") { + return Some(86400.0); + } + let mut total = 0.0; + let mut seen = false; + let mut words = text.split_whitespace().peekable(); + while let Some(word) = words.next() { + // "90s" as well as "90 s" + let split = word.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(word.len()); + let (num, unit) = word.split_at(split); + let n: f64 = num.parse().ok().filter(|n: &f64| n.is_finite())?; + let unit = if unit.is_empty() { + match words.peek() { + Some(u) if u.parse::().is_err() => words.next().unwrap_or_default(), + _ => "s", + } + } else { + unit + }; + let scale = match unit.trim_end_matches('.').to_lowercase().as_str() { + "s" | "sec" | "secs" | "second" | "seconds" => 1.0, + "m" | "min" | "mins" | "minute" | "minutes" => 60.0, + "h" | "hr" | "hrs" | "hour" | "hours" => 3600.0, + "d" | "day" | "days" => 86400.0, + _ => return None, + }; + total += n * scale; + seen = true; + } + seen.then_some(total) +} + +/// The longest burst gap `photo.analyze` accepts: a day. +pub const MAX_BURST_GAP: f32 = 86_400.0; + +/// Keep what was typed in the Assisted Culling value fields within what `photo.analyze` accepts +/// (a gap of 0 s to a day, a similarity of 0 to 1), not within the sliders' drag range. +pub fn clamp_cull_burst(gap: &mut f32, similarity: &mut f32) { + *gap = if gap.is_finite() { gap.clamp(0.0, MAX_BURST_GAP) } else { 10.0 }; + *similarity = if similarity.is_finite() { similarity.clamp(0.0, 1.0) } else { 0.93 }; +} + +/// The `photo.analyze` parameters of the Assisted Culling dialog, its values as they are. +pub fn cull_params(reject_below: f32, pick_best: bool, burst_gap: f32, similarity: f32) -> serde_json::Value { + let mut p = json!({"pickBest": pick_best, "burstGap": burst_gap, "similarity": similarity}); + if reject_below > 0.0 { + p["rejectBelow"] = json!(reject_below); + } + p +} + +/// `90` → `1 min 30 s`. +pub fn fmt_gap(v: f64) -> String { + let v = v.round() as u64; + match v { + 0..60 => format!("{v} s"), + 60..3600 if v.is_multiple_of(60) => format!("{} min", v / 60), + 60..3600 => format!("{} min {} s", v / 60, v % 60), + 3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600), + 3600..86400 if v.is_multiple_of(60) => format!("{} h {} min", v / 3600, v % 3600 / 60), + // the seconds too, so editing the text reads back the same gap + 3600..86400 => format!("{} h {} min {} s", v / 3600, v % 3600 / 60, v % 60), + _ => crate::i18n::tr("1 day").into(), + } +} + +#[cfg(test)] +mod gap_tests { + use super::{fmt_gap, parse_gap}; + + #[test] + fn gap_text_reads_back_what_it_shows() { + for v in [0.0, 1.0, 12.0, 59.0, 60.0, 90.0, 600.0, 3599.0, 3600.0, 3601.0, 3661.0, 3900.0, 7325.0, 86340.0, 86399.0] { + assert_eq!(parse_gap(&fmt_gap(v)), Some(v), "{}", fmt_gap(v)); + } + assert_eq!(parse_gap("1 day"), Some(86400.0)); + assert_eq!(parse_gap("45"), Some(45.0)); + assert_eq!(parse_gap(" 45s "), Some(45.0)); + assert_eq!(parse_gap("2min 5s"), Some(125.0)); + for bad in ["", "abc", "5 parsecs", "NaN", "1 min x"] { + assert_eq!(parse_gap(bad), None, "{bad:?}"); + } + // a day reads back in the UI language too ("1 Tag") + crate::i18n::set_language(crate::i18n::Locale::De); + let shown = fmt_gap(86400.0); + let back = parse_gap(&shown); + crate::i18n::set_language(crate::i18n::Locale::En); + assert_eq!((shown.as_str(), back), ("1 Tag", Some(86400.0))); + } + + #[test] + fn gaps_past_the_slider_read_back_too() { + // the Assisted Culling slider drags to 10 min; its value field takes up to a day + for (v, text) in [(1800.0, "30 min"), (5400.0, "1 h 30 min"), (3601.0, "1 h 0 min 1 s"), (86400.0, "1 day")] { + assert_eq!(fmt_gap(v), text); + assert_eq!(parse_gap(text), Some(v), "{text}"); + } + assert_eq!(parse_gap("1800"), Some(1800.0)); + assert_eq!(parse_gap("0"), Some(0.0)); + } +} + +#[cfg(test)] +mod cull_dialog_tests { + use std::time::Duration; + + use serde_json::json; + + use super::{clamp_cull_burst, cull_params}; + use crate::headless::Headless; + use crate::state::Dialog; + use crate::{LightcraftApp, Services}; + + const T: Duration = Duration::from_secs(20); + + #[test] + fn typed_values_are_kept_within_what_photo_analyze_accepts() { + for (gap, sim) in [(0.0, 0.0), (1800.0, 1.0), (86400.0, 0.5), (0.5, 0.995)] { + let (mut g, mut s) = (gap, sim); + clamp_cull_burst(&mut g, &mut s); + assert_eq!((g, s), (gap, sim), "in range: kept as typed"); + } + for ((gap, sim), want) in [((-5.0, -0.1), (0.0, 0.0)), ((90000.0, 1.5), (86400.0, 1.0)), ((f32::NAN, f32::INFINITY), (10.0, 0.93))] { + let (mut g, mut s) = (gap, sim); + clamp_cull_burst(&mut g, &mut s); + assert_eq!((g, s), want); + } + assert_eq!(cull_params(0.0, true, 1800.0, 1.0), json!({"pickBest": true, "burstGap": 1800.0, "similarity": 1.0})); + assert_eq!(cull_params(20.0, false, 0.0, 0.7)["rejectBelow"], 20.0); + } + + /// Click the value field right of a slider registered as `id`, type `text`, Enter. + fn type_value(h: &mut Headless, id: &str, text: &str) { + let r = h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")); + let spacing = h.view.ctx.global_style().spacing.clone(); + let x = r.left() + spacing.slider_width + spacing.item_spacing.x + 6.0; + assert_eq!(h.request("ui.click", json!({"x": x, "y": r.center().y}), T)["ok"], true); + h.step(); + for key in ["a", "Backspace"] { + assert_eq!(h.request("ui.key", json!({"key": key, "cmd": key == "a"}), T)["ok"], true); + } + assert_eq!(h.request("ui.text", json!({"text": text}), T)["ok"], true); + assert_eq!(h.request("ui.key", json!({"key": "Enter"}), T)["ok"], true); + h.step(); + h.step(); + } + + fn burst(h: &Headless) -> (f32, f32) { + match &h.app.ui.dialog { + Some(Dialog::Cull { burst_gap, similarity, .. }) => (*burst_gap, *similarity), + d => panic!("{d:?}"), + } + } + + #[test] + fn the_culling_value_fields_take_values_past_the_sliders() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("dialog.cull", json!({})).unwrap(); + for _ in 0..4 { + h.step(); + } + assert_eq!(burst(&h), (10.0, 0.93)); + // half an hour and "only identical shots": past the sliders' 10 min and 0.99 + type_value(&mut h, "field:cullGap", "30 min"); + type_value(&mut h, "field:cullSimilar", "1.0"); + assert_eq!(burst(&h), (1800.0, 1.0)); + // a day is as far as photo.analyze goes; more is held there + type_value(&mut h, "field:cullGap", "2 days"); + assert_eq!(burst(&h).0, 86400.0); + type_value(&mut h, "field:cullGap", "0"); + assert_eq!(burst(&h), (0.0, 1.0)); + type_value(&mut h, "field:cullGap", "1800"); + assert_eq!(burst(&h).0, 1800.0); + // a similarity between the slider's hundredths is kept to the thousandth + type_value(&mut h, "field:cullSimilar", "0.995"); + assert_eq!(burst(&h).1, 0.995); + type_value(&mut h, "field:cullSimilar", "0.9951"); + assert_eq!(burst(&h).1, 0.995); + type_value(&mut h, "field:cullSimilar", "1.0"); + assert_eq!(burst(&h).1, 1.0); + // OK hands them to photo.analyze as typed (it refuses values out of its range) + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); + assert_eq!(h.app.ui.cull_burst, Some((1800.0, 1.0))); + // and the next opening starts from them, not from the sliders' range + h.app.run("dialog.cull", json!({})).unwrap(); + h.step(); + h.step(); + assert_eq!(burst(&h), (1800.0, 1.0)); + } +} diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs new file mode 100644 index 000000000..56292605c --- /dev/null +++ b/crates/ui-egui/src/dialogs/export.rs @@ -0,0 +1,302 @@ +use super::frame::{field, num}; +use super::*; + +/// The Export dialog's watermark preview: the first exported photo, as wide as the dialog whatever +/// its shape, with the watermark drawn on it as the file will have it at size `resize` (`None` = +/// full size). On a worker thread, once per photo, catalog change and size step, the cached +/// thumbnail comes first (at once, a little soft) and then a render at the preview's own pixel size +/// replaces it. The watermark is redrawn on a copy only when it, the output size or the picture +/// changes, which is quick at this size, so the preview follows the sliders. +pub(super) fn watermark_preview( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + wm: &lightcraft_engine::export::Watermark, + resize: Option, +) { + use std::hash::{Hash, Hasher}; + // (pictures received so far: 1 the thumbnail, 2 the sharp render; the latest, or why none came) + type Base = std::sync::Arc>)>>; + type Shown = (u64, u8, lightcraft_engine::export::Watermark, usize, egui::TextureHandle); + let t = Tokens::get(ui.ctx()); + let Some(&id) = app.session.selected().first() else { return }; + let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; + let (out_w, out_h) = lightcraft_engine::export::output_size(&photo, &lightcraft_engine::export::ExportOptions { resize, ..Default::default() }); + let export_short = out_w.min(out_h); + // pixels across the dialog, in steps so a scroll bar or a point of layout doesn't re-render + let step = 128.0; + let px = ((ui.available_width() * ui.ctx().pixels_per_point() / step).ceil() * step).clamp(step, 2048.0) as usize; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (id, app.session.catalog.revision, px).hash(&mut h); + let key = h.finish(); + let ctx = ui.ctx().clone(); + let (base_id, shown_id) = (egui::Id::unique("export-wm-preview-base"), egui::Id::unique("export-wm-preview-shown")); + let base = match ctx.data(|d| d.get_temp::<(u64, Base)>(base_id)) { + Some((k, base)) if k == key => base, + _ => { + let base: Base = Default::default(); + // the thumbnail, then as wide as the dialog (a tall photo up to three times that high) + let jobs: Vec<_> = [app.session.thumb_job(id, 512), app.session.render_job(id, px, px * 3, false, true)].into_iter().flatten().collect(); + // the web build has no worker thread: a full render there would hold up the page + #[cfg(target_arch = "wasm32")] + let jobs: Vec<_> = jobs.into_iter().take(1).collect(); + if jobs.is_empty() { + *base.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = (1, Some(Err("no such photo".into()))); + } else { + let (out, repaint) = (base.clone(), ctx.clone()); + let work = move || { + for job in jobs { + // (a panic in the render is its failure: `RenderJob::run`) + let r = job.run().rendered.map(|r| r.image); + let mut got = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // a failed sharp render keeps the thumbnail + if r.is_ok() || !matches!(got.1, Some(Ok(_))) { + *got = (got.0.saturating_add(1), Some(r)); + repaint.request_repaint(); + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + if let Err(e) = std::thread::Builder::new().name("lc-watermark-preview".into()).spawn(work) { + log::warn!("watermark preview: {e}"); + } + #[cfg(target_arch = "wasm32")] + work(); + } + ctx.data_mut(|d| d.insert_temp(base_id, (key, base.clone()))); + base + } + }; + let shown = ctx.data(|d| d.get_temp::(shown_id)); + let stage = base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).0; + let tex = match shown { + Some((k, g, w, s, tex)) if k == key && g == stage && &w == wm && s == export_short => tex, + _ => { + let photo_px = match &base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).1 { + Some(Ok(img)) => Ok(img.clone()), + Some(Err(e)) => Err(Some(e.clone())), + None => Err(None), + }; + let mut img = match photo_px { + Ok(img) => img, + Err(e) => { + let note = if e.is_some() { "Preview unavailable" } else { "Loading preview…" }; + let r = ui + .horizontal(|ui| { + if e.is_none() { + crate::progress::spinner(ui, 12.0); + } + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)) + }) + .inner; + if let Some(e) = e { + r.on_hover_text(e); + } + return; + } + }; + lightcraft_engine::export::draw_watermark_preview(&mut img, wm, export_short); + // shown through the monitor profile like every other preview + if let Some(d) = app.renderer.display() + && let Err(e) = d.profile.from_srgb(&mut img) + { + log::warn!("display profile: {e}"); + } + let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); + let tex = match shown { + Some((.., mut tex)) => { + tex.set(color, egui::TextureOptions::LINEAR); + tex + } + None => ctx.load_texture("export-wm-preview", color, egui::TextureOptions::LINEAR), + }; + ctx.data_mut(|d| d.insert_temp(shown_id, (key, stage, wm.clone(), export_short, tex.clone()))); + tex + } + }; + let size = tex.size_vec2(); + let scale = ui.available_width() / size.x.max(1.0); + let r = ui.vertical_centered(|ui| ui.add(egui::Image::new((tex.id(), size * scale)))).inner; + crate::widgets::register(ui, "image:exportWatermarkPreview", r.rect); +} + +/// The Export dialog's choices as `app.export` params (without the folder). +pub(super) fn export_dialog_params( + opts: &lightcraft_engine::export::ExportOptions, + full_size: bool, + resize: &lightcraft_engine::export::Resize, + limit_kb: u32, +) -> serde_json::Value { + let o = lightcraft_engine::export::ExportOptions { + resize: (!full_size).then_some(*resize), + limit_kb: (limit_kb > 0).then_some(limit_kb), + ..opts.clone() + }; + o.to_json() +} + +// ------------------------------------------------------------------------------------------ dialog widgets + +/// Two columns of settings-group checkboxes (Copy Settings, Create Preset) plus All / None. +pub(super) fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec) { + let key_of = |g: &SettingsGroup| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + ui.columns(2, |cols| { + for (i, g) in SettingsGroup::ALL.iter().enumerate() { + let key = key_of(g); + let mut on = groups.contains(&key); + let r = cols[i % 2].checkbox(&mut on, crate::i18n::tr(g.label())); + crate::widgets::register_response(format!("{tag}:{key}"), r.rect, &r); + if r.changed() { + if on { + groups.push(key); + } else { + groups.retain(|x| *x != key); + } + } + } + }); + ui.horizontal(|ui| { + let all = ui.small_button(crate::i18n::tr("All")); + crate::widgets::register(ui, format!("{tag}:all"), all.rect); + if all.clicked() { + *groups = SettingsGroup::ALL.iter().map(key_of).collect(); + } + let none = ui.small_button(crate::i18n::tr("None")); + crate::widgets::register(ui, format!("{tag}:none"), none.rect); + if none.clicked() { + groups.clear(); + } + }); +} + +/// Width of the label column in dialogs. +pub(super) const LABEL_W: f32 = 78.0; + +const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, default: f64, step: f64) -> ControlSpec { + ControlSpec { id, label, section: Section::Light, min, max, default, step, decimals: 0, track: Track::Plain } +} + +pub(super) const QUALITY: ControlSpec = spec("export.quality", "Quality", 1.0, 100.0, 90.0, 1.0); + +pub(super) const LIMIT_KB: ControlSpec = spec("export.limitKb", "Limit file size (KB, 0 = off)", 0.0, 20_000.0, 0.0, 10.0); + +const SIZE_PX: ControlSpec = spec("export.sizePx", "Pixels", 16.0, 20_000.0, 2048.0, 16.0); + +const SIZE_W: ControlSpec = spec("export.sizeW", "Width (px)", 16.0, 20_000.0, 2048.0, 16.0); + +const SIZE_H: ControlSpec = spec("export.sizeH", "Height (px)", 16.0, 20_000.0, 2048.0, 16.0); + +const SIZE_MP: ControlSpec = ControlSpec { decimals: 1, ..spec("export.sizeMp", "Megapixels", 0.1, 100.0, 12.0, 0.1) }; + +const SIZE_PCT: ControlSpec = spec("export.sizePercent", "Percent", 1.0, 400.0, 50.0, 1.0); + +pub(super) const START_NUMBER: ControlSpec = spec("export.startNumber", "Start number", 1.0, 9999.0, 1.0, 1.0); + +const PPI: ControlSpec = spec("export.ppi", "Resolution (ppi)", 1.0, 1200.0, 240.0, 1.0); + +/// Image Sizing: full size, or a resize mode and its value(s), don't enlarge, ppi. +pub(super) fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::export::Resize, ppi: &mut u16) { + use lightcraft_engine::export::ResizeMode as R; + const MODES: [(R, &str); 8] = [ + (R::LongEdge, "Long Edge"), + (R::ShortEdge, "Short Edge"), + (R::Width, "Width"), + (R::Height, "Height"), + (R::WidthHeight, "Width & Height"), + (R::Dimensions, "Dimensions"), + (R::Megapixels, "Megapixels"), + (R::Percent, "Percentage"), + ]; + let r_full = ui.checkbox(full, crate::i18n::tr("Full size")); + crate::widgets::register(ui, "check:exportFullSize", r_full.rect); + if !*full { + field(ui, "Resize to", |ui| { + let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1); + let before = r.mode; + let c = crate::widgets::combo_box("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + for (m, l) in MODES { + ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l)); + } + }); + crate::widgets::name_combo(&c.response, crate::i18n::tr("Resize")); + crate::widgets::register(ui, "combo:exportResizeMode", c.response.rect); + if r.mode != before { + // a sensible value for the new unit + r.value = match r.mode { + R::Megapixels => 12.0, + R::Percent => 50.0, + _ if matches!(before, R::Megapixels | R::Percent) => 2048.0, + _ => r.value, + }; + if matches!(r.mode, R::Dimensions | R::WidthHeight) && r.height == 0 { + r.height = r.value as u32; + } + } + }); + let mut v = r.value as f64; + let spec = match r.mode { + R::Megapixels => &SIZE_MP, + R::Percent => &SIZE_PCT, + R::Dimensions | R::WidthHeight => &SIZE_W, + _ => &SIZE_PX, + }; + if num(ui, spec, &mut v) { + r.value = v as f32; + } + if matches!(r.mode, R::Dimensions | R::WidthHeight) { + let mut h = r.height as f64; + if num(ui, &SIZE_H, &mut h) { + r.height = h as u32; + } + } + let c = ui.checkbox(&mut r.dont_enlarge, crate::i18n::tr("Don't enlarge")); + crate::widgets::register(ui, "check:exportDontEnlarge", c.rect); + } + let mut p = *ppi as f64; + if num(ui, &PPI, &mut p) { + *ppi = p as u16; + } +} + +/// All nine positions supported by both the text and graphic watermark renderer. +/// Keep them in grid order so bottom-centre and edge-centre anchors are available in Export. +pub(super) const WM_POSITIONS: [(lightcraft_engine::export::Anchor, &str); 9] = { + use lightcraft_engine::export::Anchor::*; + [(TopLeft, "↖"), (Top, "↑"), (TopRight, "↗"), (Left, "←"), (Center, "•"), (Right, "→"), (BottomLeft, "↙"), (Bottom, "↓"), (BottomRight, "↘")] +}; + +pub(super) const WM_IMAGE_WIDTH: ControlSpec = spec("export.watermarkImageWidth", "Width (% of photo)", 2.0, 100.0, 20.0, 1.0); + +pub(super) const WM_SIZE: ControlSpec = spec("export.watermarkSize", "Size (% of short edge)", 1.0, 15.0, 3.5, 0.5); + +pub(super) const WM_OPACITY: ControlSpec = spec("export.watermarkOpacity", "Opacity (%)", 5.0, 100.0, 70.0, 1.0); + +pub(super) const WM_INSET_X: ControlSpec = + ControlSpec { decimals: 1, ..spec("export.watermarkInsetX", "Horizontal inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; + +pub(super) const WM_INSET_Y: ControlSpec = + ControlSpec { decimals: 1, ..spec("export.watermarkInsetY", "Vertical inset (% of short edge)", 0.0, 40.0, 2.5, 0.5) }; + +#[cfg(test)] +mod watermark_position_tests { + use super::WM_POSITIONS; + use lightcraft_engine::export::Anchor; + + #[test] + fn export_dialog_offers_every_rendered_anchor_exactly_once() { + let anchors = [ + Anchor::TopLeft, + Anchor::Top, + Anchor::TopRight, + Anchor::Left, + Anchor::Center, + Anchor::Right, + Anchor::BottomLeft, + Anchor::Bottom, + Anchor::BottomRight, + ]; + assert_eq!(WM_POSITIONS.len(), anchors.len()); + for anchor in anchors { + assert_eq!(WM_POSITIONS.iter().filter(|(position, _)| *position == anchor).count(), 1); + } + } +} diff --git a/crates/ui-egui/src/dialogs/frame.rs b/crates/ui-egui/src/dialogs/frame.rs new file mode 100644 index 000000000..3d8eb4ef8 --- /dev/null +++ b/crates/ui-egui/src/dialogs/frame.rs @@ -0,0 +1,214 @@ +use super::export::LABEL_W; +use super::*; + +/// A click on the dimmed backdrop around the dialog: it closes the dialog like Esc. Pressed and +/// released outside the window, so a slider dragged past its edge doesn't close it; a press while a +/// popup is open (a dropdown, a date picker's calendar) only closes the popup: `popup_was_open` is +/// read before the window is drawn, since drawing it already closes the popup. Only a press made +/// while the dialog is shown counts: in the frame a dialog appears, its input is the click that +/// opened it (a quick click on a slow frame presses and releases in that one frame), so it never +/// closes it. (Read from the raw events: egui forgets the press origin on release.) +pub(super) fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, popup_was_open: bool) -> bool { + let press_id = egui::Id::unique("dialog-backdrop-press"); + let frame = ctx.cumulative_frame_nr(); + // the same frame too: a second pass of it gets no input + let shown = ctx.data(|d| d.get_temp::(egui::Id::unique(BACKDROP_SHOWN))).is_some_and(|f| f + 1 >= frame); + if !shown { + ctx.data_mut(|d| d.remove_temp::(press_id)); + return false; + } + let (press, release, clicked) = ctx.input(|i| { + let at = |down: bool| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } if *pressed == down => Some(*pos), + _ => None, + }) + }; + (at(true), at(false), i.pointer.primary_clicked()) + }); + if let Some(p) = press { + let outside = !window.contains(p) && !popup_was_open; + ctx.data_mut(|d| d.insert_temp(press_id, outside)); + } + let Some(release) = release else { return false }; + let pressed_outside = ctx.data_mut(|d| d.remove_temp::(press_id)).unwrap_or(false); + clicked && pressed_outside && !window.contains(release) +} + +/// Room kept free above and below a dialog (#781). +pub(super) const DIALOG_SCREEN_MARGIN: f32 = 12.0; + +/// A dialog's title bar, frame and button row before the first frame measures them. +pub(super) const DIALOG_CHROME_GUESS: f32 = 100.0; + +/// A dialog's options never get less room than this, however short the window. +pub(super) const DIALOG_MIN_BODY: f32 = 80.0; + +/// A list that scrolls on its own never gets shorter than this to make its dialog fit. +const DIALOG_MIN_LIST: f32 = 120.0; + +fn list_shrink_id() -> egui::Id { + egui::Id::unique("dialog-list-shrink") +} + +fn list_applied_id() -> egui::Id { + egui::Id::unique("dialog-list-applied") +} + +/// The height for a list in a dialog that scrolls on its own (What's New, All Metadata, the +/// shortcuts, smart-album rules): `preferred`, less what the dialog needs to fit the window, so +/// on a short window the list gets shorter rather than scrolling inside a scrolling dialog. +pub fn list_height(ui: &egui::Ui, preferred: f32) -> f32 { + let shrink = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); + let height = (preferred - shrink).max(preferred.min(DIALOG_MIN_LIST)); + ui.data_mut(|d| *d.get_temp_mut_or_default::(list_applied_id()) += preferred - height); + height +} + +/// A dialog's options: in a vertical scroll area at most `max_height` tall when `scroll` names +/// the dialog (it shrinks to its content, so a dialog that fits looks as it would without it; +/// each dialog keeps a scroll position of its own), drawn directly otherwise. Returns the height +/// they took when scrolled. +pub(super) fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: impl FnOnce(&mut egui::Ui)) -> Option { + let Some(name) = scroll else { + ui.data_mut(|d| d.remove_temp::(list_shrink_id())); + body(ui); + return None; + }; + ui.data_mut(|d| d.insert_temp(list_applied_id(), 0.0_f32)); + let top = ui.cursor().top(); + // (an auto-sized window offers its content last frame's height; the minimum lets a dialog + // that grows take its full height at once, and the area still shrinks to fit its content) + let out = egui::ScrollArea::vertical() + .id_salt(("dialog-body", name)) + .max_height(max_height) + .min_scrolled_height(max_height) + .auto_shrink([true, true]) + .show(ui, body); + // what the lists that scroll on their own gave up this frame, and what they should next frame + let applied = ui.data(|d| d.get_temp::(list_applied_id())).unwrap_or(0.0); + let was = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); + let shrink = (out.content_size.y + applied - max_height).max(0.0); + if (shrink - was).abs() > 0.5 { + ui.data_mut(|d| d.insert_temp(list_shrink_id(), shrink)); + ui.ctx().request_repaint(); + } + // (the room the scroll area took: its `inner_rect` can be taller than what it allocated) + Some(ui.min_rect().bottom() - top) +} + +/// Open a one-field dialog that runs `command` with `params` + `{key: typed value}`. +pub fn prompt(app: &mut LightcraftApp, title: &str, hint: &str, value: &str, command: &str, params: serde_json::Value, key: &str) { + app.ui.dialog = + Some(Dialog::TextPrompt { title: title.into(), hint: hint.into(), value: value.into(), command: command.into(), params, key: key.into() }); +} + +/// A themed slider row editing `v`; true when it changed. +pub(super) fn num(ui: &mut egui::Ui, spec: &ControlSpec, v: &mut f64) -> bool { + match crate::widgets::slider(ui, spec, *v, true, None).value { + Some(n) => { + *v = n; + true + } + None => false, + } +} + +pub(super) fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(LABEL_W); + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); + }); + add(ui) + }) + .inner +} + +/// The rest of a [`field`] row, laid out right to left: `add` places its trailing button(s) first, +/// then a text field taking exactly the width that's left. (Subtracting a guessed button width +/// instead made the auto-sized dialog grow every frame when the real button was wider, #8.) +pub(super) fn trailing_button_row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { + let h = ui.spacing().interact_size.y.max(24.0); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), h), egui::Layout::right_to_left(egui::Align::Center), add); +} + +/// A labelled row of mutually exclusive choice buttons (ids `button:{id}-{index}`). +pub(super) fn choices(ui: &mut egui::Ui, label: &str, id: &str, options: &[(V, &str)], value: &mut V) { + field(ui, label, |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (v, l)) in options.iter().enumerate() { + if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() { + *value = *v; + } + } + }); +} + +/// Dialogs that Return confirms with nothing focused (those with text fields confirm from the +/// field: `dialog_field`). +pub(super) fn return_confirms(dlg: &Dialog) -> bool { + matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. }) +} + +/// A dialog's one text field: it takes the focus when the dialog opens, and opens with its text +/// selected, so typing replaces it. It takes the focus back only while nothing else has it: taking +/// it back every frame would keep Tab from ever reaching the dialog's buttons and check boxes. +/// True when Return confirms the dialog; Esc cancels the dialog (`show`). +pub(super) fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) -> bool { + let r = crate::text_field::TextField::singleline(widget, text).hint(crate::i18n::tr(hint)).width(f32::INFINITY).select_on_focus(true).show(ui); + // (and takes it back when nothing has it: after a click on the dialog's background or a check box, + // or when Return gave it up on a refusal; a control Tab reached keeps it). A disabled dialog (a + // prompt or the blocking library window owns input) must not request the focus at all: egui's + // text edit would otherwise keep editing the draft from host text behind that window. + if ui.is_enabled() && (just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none()))) { + r.response.request_focus(); + } + r.ending == Some(crate::text_field::Ending::Return) +} + +/// The dialog part `key` is drawn this frame but wasn't the frame before: the dialog has just +/// opened (to give its first field the focus once). +pub(super) fn just_opened(ui: &egui::Ui, key: &str) -> bool { + let id = egui::Id::unique(("dialog-shown", key)); + let pass = ui.ctx().cumulative_pass_nr(); + let last: Option = ui.data_mut(|d| d.get_temp(id)); + ui.data_mut(|d| d.insert_temp(id, pass)); + last.and_then(|l| l.checked_add(1)) != Some(pass) +} + +/// How a dialog's confirm button looks: [`ConfirmStyle::Primary`] (the accent's fill) when it +/// makes or brings in something, plain when it deletes or removes (never the eye-catching +/// default) or when the dialog only shows something (its Close). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfirmStyle { + Primary, + Plain, +} + +pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { + match dlg { + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } | Dialog::RemoveFolder { .. } => { + ConfirmStyle::Plain + } + _ if only_shows(dlg) => ConfirmStyle::Plain, + _ => ConfirmStyle::Primary, + } +} + +/// A dialog that only shows something: one Close, nothing to confirm or cancel. (Synchronize +/// Folder is one when its folder turned out up to date.) +pub(super) fn only_shows(dlg: &Dialog) -> bool { + matches!( + dlg, + Dialog::About + | Dialog::Shortcuts + | Dialog::Settings { .. } + | Dialog::SystemInfo { .. } + | Dialog::AllMetadata { .. } + | Dialog::WhatsNew + | Dialog::ManageEffects { .. } + | Dialog::ViewOptions + ) || matches!(dlg, Dialog::SynchronizeFolder { counts: Some(c), .. } if c.up_to_date()) +} diff --git a/crates/ui-egui/src/dialogs/rename.rs b/crates/ui-egui/src/dialogs/rename.rs new file mode 100644 index 000000000..f178ba6eb --- /dev/null +++ b/crates/ui-egui/src/dialogs/rename.rs @@ -0,0 +1,96 @@ +use super::*; + +/// Rows the Rename dialog previews. +pub(super) const RENAME_PREVIEW_ROWS: usize = 6; + +/// The Rename dialog's preview: its first rows (`None` while being planned) and how many files +/// are renamed. Which names are taken is checked on disk, which can block on a slow drive, so the +/// rows are planned on a worker thread, and only when the template, start number, photos or +/// catalog change — never once per frame. +pub(crate) fn rename_preview( + app: &mut LightcraftApp, + ctx: &egui::Context, + template: &str, + start: usize, +) -> (Option>, usize) { + use std::hash::{Hash, Hasher}; + type Rows = std::sync::Arc>>>; + let ids = app.session.selected(); + let photos = lightcraft_engine::rename::rename_photos(&app.session.catalog, &ids); + let total = photos.len(); + let mut h = std::collections::hash_map::DefaultHasher::new(); + (template, start, app.session.catalog.revision, &ids).hash(&mut h); + let key = h.finish(); + let id = egui::Id::unique("rename-preview-rows"); + let read = |rows: &Rows| rows.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone(); + if let Some((k, rows)) = ctx.data(|d| d.get_temp::<(u64, Rows)>(id)) + && k == key + { + return (read(&rows), total); + } + let rows: Rows = Default::default(); + let first: Vec<_> = photos.into_iter().take(RENAME_PREVIEW_ROWS).collect(); + let (out, template, repaint) = (rows.clone(), template.to_string(), ctx.clone()); + let exists = app.session.media.availability.probe(); + let work = move || { + let plans = lightcraft_engine::rename::plan_rename_photos(&first, &template, start, &|f| exists(f)); + *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(plans); + repaint.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if let Err(e) = std::thread::Builder::new().name("lc-rename-preview".into()).spawn(work) { + log::warn!("rename preview: {e}"); + } + #[cfg(target_arch = "wasm32")] + work(); + ctx.data_mut(|d| d.insert_temp(id, (key, rows.clone()))); + (read(&rows), total) +} + +pub(super) struct PersonRenamePlan { + pub(super) faces: usize, + pub(super) needs_merge: bool, + /// Why Rename stays disabled. `None` when the typed name can be applied. + pub(super) block: Option<&'static str>, +} + +/// What renaming `from` to `to` would do, using the same rules as `faces.renamePerson`. +pub(super) fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) -> PersonRenamePlan { + let to_trim = to.trim(); + if to_trim.is_empty() { + return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("Type a name") }; + } + if to_trim.chars().count() > 200 || to_trim.chars().any(char::is_control) { + return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("A name is at most 200 characters, without control characters") }; + } + let from_key = from.trim().to_lowercase(); + let to_key = to_trim.to_lowercase(); + let mut matched = 0usize; + let mut faces = 0usize; + let mut needs_merge = false; + if !from_key.is_empty() { + for photo in cat.photos().filter(|p| p.in_library()) { + for region in photo.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { + let Some(name) = region.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = name.to_lowercase(); + if key == from_key { + matched += 1; + if region.name.as_deref() != Some(to_trim) { + faces += 1; + } + } else if key == to_key { + needs_merge = true; + } + } + } + } + let block = if matched == 0 { + Some("No person by that name") + } else if faces == 0 { + Some("That is already their name") + } else { + None + }; + // a case-only change is the same person, not a merge + PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block } +} diff --git a/crates/ui-egui/src/dialogs/sam_model.rs b/crates/ui-egui/src/dialogs/sam_model.rs new file mode 100644 index 000000000..fa578440b --- /dev/null +++ b/crates/ui-egui/src/dialogs/sam_model.rs @@ -0,0 +1,114 @@ +use super::*; + +/// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is +/// all the dialog can offer. +pub(super) fn sam_by_hand(sam: &lightcraft_engine::segment::Segmenter) -> bool { + !sam.installed() && !sam.download_status().running && sam.mirrors().is_empty() +} + +/// The model installation guide. +const SAM_HELP: &str = "https://github.com/storytold/lightcraft/blob/main/docs/ai-masks.md#getting-the-model"; + +/// Open the configured SAM 3 folder itself; revealing a file opens its parent instead. +pub(crate) fn show_model_folder(app: &mut LightcraftApp) -> Result { + let dir = app.session.segmenter.dir.as_ref().ok_or("no folder is set for the SAM 3 model")?; + let path = dir.to_str().ok_or("the model folder path is not valid UTF-8")?; + let open = app.services.open_with.as_mut().ok_or("opening model folders is not available here")?; + // Create only after the host action is available. Existing model files are left alone. + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + #[cfg(target_os = "linux")] + if let Some(open_folder) = app.services.open_model_folder.clone() { + let path = path.to_string(); + let folder = path.clone(); + crate::tasks::spawn( + app, + "Show in File Manager", + Some("openModelFolder"), + move || open_folder(&folder), + |app, ctx, result| { + if let Err(e) = result { + app.toast_error(ctx, e); + } + }, + )?; + return Ok(json!({"path": path, "background": true})); + } + open(path, "")?; + Ok(json!({"path": path})) +} + +/// The SAM 3 dialog: what the model is, its size and licence, and the download's progress. +pub(super) fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str>) { + use lightcraft_engine::segment::{LICENSE_NAME, LICENSE_URL, MODEL_BYTES}; + let t = Tokens::get(ui.ctx()); + let seg = &app.session.segmenter; + let d = seg.download_status(); + if seg.installed() { + ui.label(crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready.")); + return; + } + let gb = |b: u64| b as f64 / 1e9; + ui.label(crate::i18n::tr( + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.", + )); + let dir = seg.dir.as_ref().map(|d| d.display().to_string()).unwrap_or_default(); + ui.label(format!("{} {:.1} GB, {} {dir}", crate::i18n::tr("A one-time download of about"), gb(MODEL_BYTES), crate::i18n::tr("saved in"))); + ui.label( + egui::RichText::new(format!( + "{} {LICENSE_NAME} — {}", + crate::i18n::tr("Licence:"), + crate::i18n::tr("Meta's terms, not LightCraft's. Downloading it means accepting them.") + )) + .color(t.text_label), + ); + let r = ui.link(crate::i18n::tr("Read the SAM License")).on_hover_text(LICENSE_URL); + crate::widgets::register(ui, "link:samLicense", r.rect); + if r.clicked() { + let _ = crate::links::open(app, LICENSE_URL); + } + let seg = &app.session.segmenter; + if d.running { + ui.add_space(4.0); + let text = format!("{:.2} / {:.2} GB · {}", gb(d.done), gb(d.total), d.file); + crate::progress::Progress::new(crate::progress::fraction_of(d.done, d.total)).label(text).widget("progress:samDownload").show(ui); + let r = ui.button(crate::i18n::tr("Cancel Download")); + crate::widgets::register(ui, "button:samCancel", r.rect); + if r.clicked() { + app.session.segmenter.cancel_download(); + } + ui.label(egui::RichText::new(crate::i18n::tr("You can close this: the download continues, and resumes if interrupted.")).color(t.text_dim)); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); + return; + } + if seg.mirrors().is_empty() { + // nothing to download from: say how to install it by hand, with the folder and the guide a click away + let dir = seg.dir.clone(); + ui.label( + egui::RichText::new(crate::i18n::tr( + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.", + )) + .color(t.text_dim), + ); + ui.horizontal_wrapped(|ui| { + if dir.is_some() && crate::menus::ui_enabled(app, "app.openSamModelFolder") { + let r = ui.button(crate::i18n::tr(crate::menus::reveal_label())); + crate::widgets::register(ui, "button:samFolder", r.rect); + if r.clicked() + && let Err(e) = app.run("app.openSamModelFolder", json!({})) + { + app.toast_error(ui.ctx(), e); + } + } + let r = ui.link(crate::i18n::tr("How to install the model")).on_hover_text(SAM_HELP); + crate::widgets::register(ui, "link:samHelp", r.rect); + if r.clicked() + && let Err(e) = crate::links::open(app, SAM_HELP) + { + app.toast(ui.ctx(), e); + } + }); + } + if let Some(e) = error.map(str::to_string).or(d.error) { + ui.label(egui::RichText::new(format!("{} {e}", crate::i18n::tr("The download didn't work:"))).color(egui::Color32::from_rgb(230, 90, 80))); + } +} diff --git a/crates/ui-egui/src/dialogs/slideshow_export.rs b/crates/ui-egui/src/dialogs/slideshow_export.rs index 62996e421..ed33d0341 100644 --- a/crates/ui-egui/src/dialogs/slideshow_export.rs +++ b/crates/ui-egui/src/dialogs/slideshow_export.rs @@ -5,6 +5,8 @@ use serde_json::{Value, json}; +use super::frame::{choices, field}; +use super::*; use crate::LightcraftApp; use crate::pick::{PickKind, PickRequest, Picked}; @@ -71,6 +73,61 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast(ctx, msg); } +/// A labelled row (fixed label column). +/// View ▸ Export Slideshow…: what to write (PDF, JPEG sequence, video) and how big; where comes +/// after (a save dialog). +pub(super) fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, settings: &mut lightcraft_engine::slideshow_export::ExportSettings) { + use lightcraft_engine::slideshow_export::{ExportSettings, Format}; + let t = Tokens::get(ui.ctx()); + let photos = app.session.visible_cloned().len(); + let slide = app.session.slideshow.slide_duration; + let (intro, ending) = app.session.slide_titles.durations(slide); + let titles = usize::from(intro.is_some()) + usize::from(ending.is_some()); + let seconds = photos as f64 * slide + intro.unwrap_or(0.0) + ending.unwrap_or(0.0); + let summary = crate::i18n::tr_format!( + "{n} photos in view · {slides} slides · {time}", + n = photos, + slides = photos + titles, + time = format!("{}:{:02}", (seconds / 60.0).floor() as u64, (seconds % 60.0).round() as u64) + ); + ui.label(egui::RichText::new(summary).color(t.text_dim)); + let before = settings.format; + choices(ui, "Format", "slideshowExportFormat", &[(Format::Pdf, "PDF"), (Format::Jpeg, "JPEG"), (Format::Video, "Video")], &mut settings.format); + if settings.format != before { + *settings = ExportSettings { full_screen: settings.full_screen, ..ExportSettings::for_format(settings.format) }; + } + match settings.format { + Format::Video => { + choices(ui, "Size", "slideshowExportSize", &[(720, "720p"), (1080, "1080p")], &mut settings.size); + choices(ui, "Frame rate", "slideshowExportFps", &[(24, "24 fps"), (30, "30 fps")], &mut settings.fps); + let mut webm = settings.container == "webm"; + choices(ui, "Container", "slideshowExportContainer", &[(false, "MP4"), (true, "WebM")], &mut webm); + settings.container = if webm { "webm".into() } else { "mp4".into() }; + } + _ => { + choices(ui, "Size", "slideshowExportSize", &[(1024, "1024 px"), (1920, "1920 px"), (3840, "3840 px")], &mut settings.size); + } + } + field(ui, "Quality", |ui| { + let r = ui.add(egui::Slider::new(&mut settings.quality, 1..=100)); + crate::widgets::register(ui, "slider:slideshowExportQuality", r.rect); + }); + let note = match settings.format { + Format::Pdf => { + let r = ui.checkbox(&mut settings.full_screen, crate::i18n::tr("Automatically show full screen")); + crate::widgets::register(ui, "check:slideshowExportFullScreen", r.rect); + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode." + } + Format::Jpeg => "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.", + Format::Video => "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.", + }; + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("Slides use the slideshow's layout, overlays and intro and ending screens, in view order.")) + .color(t.text_dim), + ); +} + #[cfg(test)] mod tests { use super::*; From b0356787e32bd90edfe9b3baa9ee701a2372e64d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:13:11 +0000 Subject: [PATCH 126/191] refactor(lightcraft-ui-egui): split dialogs/settings.rs into dialogs/settings/ --- crates/ui-egui/src/dialogs/settings.rs | 1558 +---------------- .../src/dialogs/settings/appearance.rs | 220 +++ .../ui-egui/src/dialogs/settings/display.rs | 221 +++ .../ui-egui/src/dialogs/settings/general.rs | 175 ++ crates/ui-egui/src/dialogs/settings/import.rs | 187 ++ .../ui-egui/src/dialogs/settings/libraries.rs | 313 ++++ .../src/dialogs/settings/performance.rs | 185 ++ .../ui-egui/src/dialogs/settings/slideshow.rs | 120 ++ .../ui-egui/src/dialogs/settings/widgets.rs | 143 ++ 9 files changed, 1584 insertions(+), 1538 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/settings/appearance.rs create mode 100644 crates/ui-egui/src/dialogs/settings/display.rs create mode 100644 crates/ui-egui/src/dialogs/settings/general.rs create mode 100644 crates/ui-egui/src/dialogs/settings/import.rs create mode 100644 crates/ui-egui/src/dialogs/settings/libraries.rs create mode 100644 crates/ui-egui/src/dialogs/settings/performance.rs create mode 100644 crates/ui-egui/src/dialogs/settings/slideshow.rs create mode 100644 crates/ui-egui/src/dialogs/settings/widgets.rs diff --git a/crates/ui-egui/src/dialogs/settings.rs b/crates/ui-egui/src/dialogs/settings.rs index 03573a213..7b264c811 100644 --- a/crates/ui-egui/src/dialogs/settings.rs +++ b/crates/ui-egui/src/dialogs/settings.rs @@ -20,6 +20,26 @@ use crate::state::{GridBadges, PREVIEW_LIMITS, StartupView}; use crate::theme::Tokens; use crate::widgets::register; +mod appearance; +mod display; +mod general; +mod import; +mod libraries; +mod performance; +mod slideshow; +mod widgets; + +pub use display::display_profile; +pub(super) use display::view_options; +use display::{display_tab, interface_tab}; +use general::general_tab; +use import::import_tab; +pub(crate) use libraries::{can_switch_library, free_name}; +pub use libraries::{forget_library, library_name, new_library, open_library, open_library_name, recent_libraries, remember_library}; +use performance::performance_tab; +use slideshow::slideshow_tab; +pub(crate) use widgets::{check, choices, heading, hint, row}; + pub mod slide_layout; pub mod slide_overlays; pub mod slide_templates; @@ -140,1541 +160,3 @@ pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_ }); register(ui, "settings:pageRoom", page.inner_rect); } - -/// `path` shortened to fit `max` (as `width` measures it) by dropping leading folders, after -/// either kind of separator, so that its end stays (`…/Pictures/Library`, -/// `…\Pictures\Library`): the end says which folder it is. A last folder too long by itself -/// loses its end ([`crate::widgets::elide_head`]). -fn elide_path(path: &str, max: f32, width: impl Fn(&str) -> f32) -> String { - if width(path) <= max { - return path.to_string(); - } - // (a separator at the very end is not where a name starts: `…/` alone would say nothing) - let end = path.trim_end_matches(['/', '\\']).len(); - let mut rest = path; - while let Some(i) = rest.find(['/', '\\']).filter(|i| path.len() - rest.len() + i + 1 < end) { - let separator = if rest.get(i..).is_some_and(|r| r.starts_with('\\')) { '\\' } else { '/' }; - rest = rest.get(i + 1..).unwrap_or(""); - let shorter = format!("…{separator}{rest}"); - if width(&shorter) <= max { - return shorter; - } - } - let name = rest.trim_end_matches(['/', '\\']); - // (nothing but separators: never an empty label) - if name.is_empty() { "…".to_string() } else { crate::widgets::elide_head(name, max, width) } -} - -/// A folder or file path on one line in the room left in its row (`text:{id}`): shortened from -/// the front when it is too long ([`elide_path`]), with all of it in a tooltip then. `after` is -/// the width of what follows it on the line (a button, a note) and its spacing: the path leaves -/// that room, so those never wrap onto a second line. -fn path_label(ui: &mut egui::Ui, id: &str, path: &str, font: egui::FontId, color: egui::Color32, after: f32) { - let shown = - elide_path(path, (ui.available_width() - after).max(0.0), |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x); - // (`truncate` is the last resort, for a room narrower than "…") - let r = ui.add(egui::Label::new(RichText::new(&shown).font(font).color(color)).truncate().show_tooltip_when_elided(false)); - register(ui, format!("text:{id}"), r.rect); - if shown != path { - r.on_hover_text(path); - } -} - -/// The width of `text` in the style `style`, and the gap before it: what a label takes in a row. -fn label_width(ui: &egui::Ui, text: &str, style: egui::TextStyle) -> f32 { - ui.painter().layout_no_wrap(text.to_string(), style.resolve(ui.style()), egui::Color32::WHITE).size().x + ui.spacing().item_spacing.x -} - -/// The same for a button, which has padding either side of its text. -fn button_width(ui: &egui::Ui, text: &str) -> f32 { - label_width(ui, text, egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.x -} - -pub(crate) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { - ui.add_space(4.0); - ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text)); -} - -pub(crate) fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { - ui.horizontal(|ui| { - ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(LABEL_W); - // (a label longer than its column wraps in it; it does not push the controls along) - ui.add(egui::Label::new(RichText::new(crate::i18n::tr(label)).color(t.text_label)).wrap()); - }); - // The controls wrap in the room beside the label, each further line starting under the - // first control, so a row with many choices grows down and never widens the window. - let wrapped = egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true); - ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 24.0), wrapped, |ui| { - ui.spacing_mut().item_spacing.y = 4.0; - add(ui) - }) - .inner - }) - .inner -} - -pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) { - ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim)); -} - -/// A checkbox addressable as `check:{id}`; true when toggled. -pub(crate) fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool { - let r = ui.checkbox(value, crate::i18n::tr(label)); - register(ui, format!("check:{id}"), r.rect); - r.changed() -} - -/// Mutually exclusive buttons (`button:{id}-{index}`). -pub(crate) fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool { - let mut changed = false; - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (v, l)) in options.iter().enumerate() { - if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() && *value != *v { - *value = *v; - changed = true; - } - } - changed -} - -// ------------------------------------------------------------------------------------- General - -fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - row(ui, t, crate::i18n::tr("Language"), |ui| { - let languages: Vec<_> = crate::i18n::Locale::for_picker().into_iter().map(|language| (language, language.name())).collect(); - // Thirteen language names do not fit one line in a narrow window: wrap them. - ui.horizontal_wrapped(|ui| { - choices(ui, "settingsLanguage", &languages, &mut app.ui.language); - }); - crate::i18n::set_language(app.ui.language); - }); - heading(ui, t, crate::i18n::tr("Library")); - let location = match &app.session.library { - Some(l) => l.dir.display().to_string(), - None => crate::i18n::tr("In memory — nothing is saved").to_string(), - }; - row(ui, t, crate::i18n::tr("Location"), |ui| { - path_label(ui, "settingsLocation", &location, egui::TextStyle::Body.resolve(ui.style()), t.text, 0.0); - }); - row(ui, t, "", |ui| { - let can = app.services.pick_folder.is_some(); - let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Open Library…"))); - register(ui, "button:settingsOpenLibrary", r.rect); - if r.clicked() { - let _ = app.run("app.openLibrary", json!({})); - } - let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("New Library…"))); - register(ui, "button:settingsNewLibrary", r.rect); - if r.clicked() { - let _ = app.run("app.newLibrary", json!({})); - } - if !can { - hint(ui, t, crate::i18n::tr("not available here")); - } - }); - // where libraries are folders to choose from (not in the browser, which keeps one) - if app.services.pick_folder.is_some() { - use crate::state::LaunchLibrary; - row(ui, t, crate::i18n::tr("At launch"), |ui| { - choices( - ui, - "settingsAtLaunch", - &[(LaunchLibrary::Last, "Open the last library"), (LaunchLibrary::Ask, "Ask me")], - &mut app.ui.settings.at_launch, - ); - }); - hint(ui, t, crate::i18n::tr("Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.")); - } - // File ▸ Import Lightroom Catalog… (#2672): the window that asks where a catalog goes, or not - ui.label(RichText::new(crate::i18n::tr("When importing a Lightroom catalog")).color(t.text_label)); - row(ui, t, "", |ui| { - use crate::state::LightroomImport; - choices( - ui, - "settingsLightroomImport", - &[(LightroomImport::Ask, "Ask where to import it"), (LightroomImport::AlwaysMerge, "Always merge into the open library")], - &mut app.ui.settings.lightroom_import, - ); - }); - heading(ui, t, crate::i18n::tr("Startup")); - row(ui, t, crate::i18n::tr("Open in"), |ui| { - choices( - ui, - "settingsStartup", - &[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail View")], - &mut app.ui.settings.startup_view, - ); - }); - heading(ui, t, crate::i18n::tr("Culling")); - check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); - check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); - check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk"); - heading(ui, t, crate::i18n::tr("Dialogs")); - check(ui, "settings.closeDialogsOutside", &mut app.ui.settings.close_dialogs_outside, "Close a dialog by clicking outside it"); - heading(ui, t, crate::i18n::tr("External Editing")); - row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { - crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) - .hint(crate::i18n::tr("Found automatically")) - .width(220.0) - .show(ui); - }); - edit_file_rows(ui, t, "photocraftFile", &mut app.ui.settings.photocraft_file); - hint( - ui, - t, - crate::i18n::tr( - "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.", - ), - ); - row(ui, t, crate::i18n::tr("Other editor"), |ui| { - crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor) - .hint(crate::i18n::tr("System default")) - .width(220.0) - .show(ui); - }); - edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); - hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); - hint( - ui, - t, - crate::i18n::tr( - "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).", - ), - ); -} - -/// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`). -fn edit_file_rows(ui: &mut egui::Ui, t: &Tokens, id: &str, file: &mut crate::state::EditFile) { - use crate::state::{EditFormat, EditSpace}; - row(ui, t, "", |ui| { - choices(ui, &format!("{id}Format"), &[(EditFormat::Psd, "PSD"), (EditFormat::Tiff, "TIFF")], &mut file.format); - ui.separator(); - choices(ui, &format!("{id}Depth"), &[(16u8, "16-bit"), (8, "8-bit")], &mut file.bit_depth); - }); - row(ui, t, "", |ui| { - choices( - ui, - &format!("{id}Space"), - &[(EditSpace::AdobeRgb, "Adobe RGB"), (EditSpace::ProPhoto, "ProPhoto"), (EditSpace::DisplayP3, "P3"), (EditSpace::Srgb, "sRGB")], - &mut file.color_space, - ); - }); -} - -// -------------------------------------------------------------------------------------- Import - -/// A preset picker: `None` = `none_label`. Returns the new choice when it changed. -fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, current: Option<&str>, none_label: &str) -> Option> { - let name = |pid: &str| { - app.session - .presets - .iter() - .find(|p| p.id == pid) - .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) - .unwrap_or_else(|| crate::i18n::tr_format!("{pid} (missing)", pid = pid)) - }; - let text = current.map(name).unwrap_or_else(|| crate::i18n::tr(none_label).to_string()); - let mut out = None; - let r = crate::widgets::combo_box(id).width(240.0).selected_text(text).show_ui(ui, |ui| { - if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() { - out = Some(None); - } - if let Some(pid) = crate::panels::presets::picker_rows(ui, id, &app.session.presets, current) { - out = Some(Some(pid)); - } - }); - crate::widgets::name_combo(&r.response, label); - register(ui, format!("combo:{id}"), r.response.rect); - out.filter(|v| v.as_deref() != current) -} - -/// Cameras of the raws in the library plus those with a stored default, sorted. -fn cameras(app: &LightcraftApp) -> Vec { - let mut v: Vec = app - .session - .catalog - .photos() - .filter(|p| p.kind == lightcraft_catalog::MediaKind::Raw && !p.meta.camera.is_empty()) - .map(|p| p.meta.camera.clone()) - .chain(app.session.import_defaults.cameras.iter().map(|c| c.camera.clone())) - .collect(); - v.sort_by_key(|c| c.to_lowercase()); - v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - v -} - -fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - let d = app.session.import_defaults.clone(); - heading(ui, t, crate::i18n::tr("Raw defaults")); - hint(ui, t, crate::i18n::tr("Settings new raw photos start from. Changing them doesn't touch photos already in the library.")); - row(ui, t, crate::i18n::tr("Raw photos"), |ui| { - if let Some(v) = preset_combo(app, ui, "settingsRawPreset", crate::i18n::tr("Raw default"), d.raw_preset.as_deref(), "LightCraft Default") { - let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}})); - } - }); - let mut per = d.per_camera; - row(ui, t, "", |ui| { - if check(ui, "settings.perCamera", &mut per, "Use camera-specific defaults") { - let _ = app.run("library.preferences", json!({"import": {"perCamera": per}})); - } - }); - if per { - let cams = cameras(app); - if cams.is_empty() { - hint(ui, t, crate::i18n::tr("No raw photos yet: cameras appear here once their photos are in the library.")); - } - egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| { - for (i, cam) in cams.iter().enumerate() { - let entry = d.cameras.iter().find(|c| c.camera.eq_ignore_ascii_case(cam)); - row(ui, t, cam, |ui| { - // "Raw default" = no entry; otherwise the entry's preset (None = LightCraft Default) - const RAW_DEFAULT: &str = "\u{1}raw"; - let current = match entry { - None => Some(RAW_DEFAULT), - Some(e) => e.preset.as_deref(), - }; - let mut pick = None; - let label = match current { - Some(RAW_DEFAULT) => crate::i18n::tr("Same as raw default").to_string(), - None => crate::i18n::tr("LightCraft Default").to_string(), - Some(pid) => app - .session - .presets - .iter() - .find(|p| p.id == pid) - .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) - .unwrap_or_else(|| pid.to_string()), - }; - let id = format!("settingsCamera-{i}"); - let r = crate::widgets::combo_box(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { - if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() { - pick = Some(json!({"camera": cam, "remove": true})); - } - if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() { - pick = Some(json!({"camera": cam, "preset": null})); - } - if let Some(pid) = crate::panels::presets::picker_rows(ui, &id, &app.session.presets, current) { - pick = Some(json!({"camera": cam, "preset": pid})); - } - }); - crate::widgets::name_combo(&r.response, crate::i18n::tr("Camera")); - register(ui, format!("combo:{id}"), r.response.rect); - if let Some(c) = pick { - let _ = app.run("library.preferences", json!({"camera": c})); - } - }); - } - }); - } - heading(ui, t, crate::i18n::tr("Other images (JPEG, PNG, TIFF, HEIC…)")); - row(ui, t, crate::i18n::tr("Non-raw photos"), |ui| { - if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", crate::i18n::tr("Other preset"), d.other_preset.as_deref(), "None") { - let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}})); - } - }); - heading(ui, t, crate::i18n::tr("Metadata")); - hint(ui, t, crate::i18n::tr("Added to photos you import that don't already have it.")); - for (key, label, hint_text, value) in - [("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())] - { - row(ui, t, label, |ui| { - let id = egui::Id::unique(("settingsMeta", key)); - let mut text: String = ui.data(|m| m.get_temp(id)).unwrap_or(value.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(hint_text).desired_width(240.0)); - register(ui, format!("field:settings.{key}"), r.rect); - if r.lost_focus() && text.trim() != value { - let _ = app.run("library.preferences", json!({"import": {key: text.trim()}})); - } - if r.has_focus() { - ui.data_mut(|m| m.insert_temp(id, text)); - } else { - ui.data_mut(|m| m.remove::(id)); - } - }); - } - if !app.session.metadata_presets.is_empty() { - row(ui, t, crate::i18n::tr("Metadata preset"), |ui| { - let cur = d.metadata_preset.clone(); - let mut pick = None; - let r = crate::widgets::combo_box("settingsMetaPreset") - .width(240.0) - .selected_text(cur.clone().unwrap_or_else(|| crate::i18n::tr("None").into())) - .show_ui(ui, |ui| { - if ui.selectable_label(cur.is_none(), crate::i18n::tr("None")).clicked() { - pick = Some(String::new()); - } - for m in &app.session.metadata_presets { - if ui.selectable_label(cur.as_deref() == Some(m.name.as_str()), &m.name).clicked() { - pick = Some(m.name.clone()); - } - } - }); - crate::widgets::name_combo(&r.response, crate::i18n::tr("Metadata preset")); - register(ui, "combo:settingsMetaPreset", r.response.rect); - if let Some(n) = pick { - let _ = app.run("library.preferences", json!({"import": {"metadataPreset": n}})); - } - }); - } - heading(ui, t, crate::i18n::tr("XMP sidecars")); - let mut xmp = app.session.xmp; - if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") { - let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write})); - } - row(ui, t, crate::i18n::tr("Sidecar names"), |ui| { - use lightcraft_engine::sidecar::SidecarNaming as N; - let mut n = xmp.naming; - if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) { - let naming = if n == N::Full { "full" } else { "stem" }; - let _ = app.run("library.xmpPreferences", json!({"naming": naming})); - } - }); - if !cfg!(target_arch = "wasm32") { - heading(ui, t, crate::i18n::tr("Dropped Files")); - check(ui, "settings.importDropped", &mut app.ui.settings.import_dropped_directly, "Import dropped files without review"); - heading(ui, t, crate::i18n::tr("Cameras and Cards")); - let mut on_card = d.show_on_card; - if check(ui, "settings.importOnCard", &mut on_card, "Show the import review when a camera or card is connected") { - let _ = app.run("library.preferences", json!({"import": {"showOnCard": on_card}})); - } - heading(ui, t, crate::i18n::tr("Auto Import")); - hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.")); - row(ui, t, crate::i18n::tr("Watched folder"), |ui| { - match &d.auto_folder { - Some(folder) => path_label( - ui, - "settingsAutoFolder", - folder, - egui::TextStyle::Body.resolve(ui.style()), - t.text, - button_width(ui, crate::i18n::tr("Choose…")) + button_width(ui, crate::i18n::tr("Turn Off")), - ), - None => drop(ui.label(RichText::new(crate::i18n::tr("Off")).color(t.text))), - } - let can = app.services.pick_folder.is_some(); - let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…"))); - register(ui, "button:settingsAutoFolder", r.rect); - if r.clicked() - && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) - && let Err(e) = app.run("library.autoImport", json!({"folder": f})) - { - app.toast(ui.ctx(), e); - } - if d.auto_folder.is_some() && ui.button(crate::i18n::tr("Turn Off")).clicked() { - let _ = app.run("library.autoImport", json!({"folder": null})); - } - }); - if d.auto_folder.is_some() { - row(ui, t, "", |ui| { - let mut copy = d.auto_copy; - if ui.checkbox(&mut copy, crate::i18n::tr("Copy into the library (else use the files where they are)")).changed() { - let _ = app.run("library.autoImport", json!({"copy": copy})); - } - }); - row(ui, t, crate::i18n::tr("Album"), |ui| { - let id = egui::Id::unique("auto-album"); - let mut name = ui.data(|m| m.get_temp::(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0)); - if r.lost_focus() { - let _ = app.run("library.autoImport", json!({"album": name.trim()})); - } - ui.data_mut(|m| m.insert_temp(id, name)); - }); - } - } - if app.session.library.is_none() { - hint(ui, t, crate::i18n::tr("In-memory session: these settings last until LightCraft quits.")); - } -} - -// --------------------------------------------------------------------------------- Performance - -fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - use lightcraft_engine::gpu; - heading(ui, t, crate::i18n::tr("Rendering")); - check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); - let status = if !gpu::available() { - match gpu::unavailable_reason() { - Some(why) => crate::i18n::tr_format!("Rendering on the CPU: {why}", why = why), - None => "No usable GPU found: rendering on the CPU".to_string(), - } - } else { - format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) - }; - // One line, the whole text on hover: the reason can be a long driver message (no adapter - // among the backends …), and the GPU state is process-wide, so it may change between frames; - // a hint that wrapped would move every control below it under the pointer. - ui.add(egui::Label::new(RichText::new(crate::i18n::tr(&status)).size(11.0).color(t.text_dim)).truncate()).on_hover_text(&status); - row(ui, t, crate::i18n::tr("Preview size"), |ui| { - let opts: Vec<(u32, String)> = - PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect(); - let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect(); - choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_limit); - }); - hint( - ui, - t, - crate::i18n::tr( - "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.", - ), - ); - row(ui, t, crate::i18n::tr("Memory for caches"), |ui| { - let auto = crate::i18n::tr_format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20); - let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")]; - choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb); - }); - heading(ui, t, crate::i18n::tr("Thumbnail cache")); - let cur = (app.session.cache_bytes() >> 20) as u32; - row(ui, t, crate::i18n::tr("Size limit"), |ui| { - let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")]; - let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 }; - if choices(ui, "settingsCache", &opts, &mut v) { - let _ = app.run("library.preferences", json!({"cacheMb": v})); - } - }); - let used = app.session.media.rendered.disk().map(|d| d.size()); - row(ui, t, crate::i18n::tr("In use"), |ui| { - ui.label( - RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| crate::i18n::tr("memory only").to_string())) - .color(t.text), - ); - let r = ui.button(crate::i18n::tr("Clear Cache")); - register(ui, "button:settingsClearCache", r.rect); - if r.clicked() { - let _ = app.run("library.clearPreviews", json!({})); - } - }); - heading(ui, t, crate::i18n::tr("Local folders")); - row(ui, t, crate::i18n::tr("Forget unchanged photos"), |ui| { - let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")]; - let mut v = app.session.forget_local_days; - if choices(ui, "settingsForgetLocal", &opts, &mut v) { - let _ = app.run("library.preferences", json!({"forgetLocalDays": v})); - } - }); - hint( - ui, - t, - crate::i18n::tr( - "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.", - ), - ); - #[cfg(not(target_arch = "wasm32"))] - smart_previews(app, ui, t); -} - -/// Where this library keeps its smart previews (the offline-editing proxies, which can be large): -/// the effective folder, what is in it, and choosing another one. -#[cfg(not(target_arch = "wasm32"))] -fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - // listing a big folder every frame would be slow: refresh every 2 s and after a change - let cache = egui::Id::unique("smart-location"); - let now = ui.input(|i| i.time); - let loc: Value = match ui.data(|d| d.get_temp::<(f64, Value)>(cache)) { - Some((at, v)) if now - at < 2.0 => v, - _ => { - let v = app.run("library.smartPreviewsLocation", json!({})).unwrap_or(Value::Null); - ui.data_mut(|d| d.insert_temp(cache, (now, v.clone()))); - v - } - }; - if loc.is_null() { - return; - } - heading(ui, t, crate::i18n::tr("Smart previews")); - let path = loc["path"].as_str().unwrap_or_default().to_string(); - let available = loc["available"].as_bool().unwrap_or(true); - let custom = loc["custom"].as_bool().unwrap_or(false); - row(ui, t, crate::i18n::tr("Folder"), |ui| { - path_label( - ui, - "settingsSmartFolder", - &path, - egui::TextStyle::Body.resolve(ui.style()), - if available { t.text } else { t.reject }, - if custom { 0.0 } else { label_width(ui, "(library default)", egui::TextStyle::Body) }, - ); - if !custom { - let r = ui.label(RichText::new("(library default)").color(t.text)); - register(ui, "text:settingsSmartDefault", r.rect); - } - }); - let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0)); - if available { - hint( - ui, - t, - &crate::i18n::tr_format!( - "{count} smart preview{} · {:.1} MB", - if count == 1 { "" } else { "s" }, - bytes as f64 / 1048576.0, - count = count - ), - ); - } else { - hint( - ui, - t, - crate::i18n::tr( - "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.", - ), - ); - } - // what happens to the previews already built when the folder changes - let mode_id = egui::Id::unique("smart-existing"); - let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0); - if count > 0 { - row(ui, t, crate::i18n::tr("Existing previews"), |ui| { - if choices(ui, "settingsSmartExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) { - ui.data_mut(|d| d.insert_temp(mode_id, mode)); - } - }); - } - let existing = ["move", "leave", "discard"][mode.min(2) as usize]; - let mut result = None; - ui.horizontal(|ui| { - ui.add_space(LABEL_W); - if app.services.pick_folder.is_some() { - let r = ui.button(crate::i18n::tr("Choose Folder…")); - register(ui, "button:settingsSmartChoose", r.rect); - if r.clicked() - && let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f()) - { - result = Some(app.run("library.smartPreviewsLocation", json!({"path": dir, "existing": existing}))); - } - } - let r = ui.add_enabled(custom, egui::Button::new(crate::i18n::tr("Use Library Folder"))); - register(ui, "button:settingsSmartReset", r.rect); - if r.clicked() { - result = Some(app.run("library.smartPreviewsLocation", json!({"reset": true, "existing": existing}))); - } - }); - if let Some(r) = result { - ui.data_mut(|d| d.remove::<(f64, Value)>(cache)); - match r { - Ok(v) => { - let failed = v["failed"].as_array().map_or(0, Vec::len); - let msg = match (existing, v["handled"].as_u64().unwrap_or(0)) { - (_, 0) => "Smart previews folder changed".to_string(), - ("move", n) => format!("Smart previews folder changed; moved {n}"), - ("discard", n) => format!("Smart previews folder changed; deleted {n}"), - _ => "Smart previews folder changed".to_string(), - }; - app.toast(ui.ctx(), if failed > 0 { crate::i18n::tr_format!("{msg} ({failed} failed)", failed = failed, msg = msg) } else { msg }); - } - Err(e) => app.toast(ui.ctx(), e), - } - } - hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.")); -} - -// ---------------------------------------------------------------------------------- Interface - -/// The monitor profile (`app.displayProfile`): previews are shown through the display's ICC -/// profile, so wide-gamut and calibrated displays show colours as they are. -fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - heading(ui, t, crate::i18n::tr("Monitor profile")); - let current = app.renderer.display().cloned(); - let screen = current.as_ref().is_some_and(|d| d.path == crate::SCREEN_PROFILE); - row(ui, t, crate::i18n::tr("Profile"), |ui| { - let name = match ¤t { - Some(d) => d.profile.description.clone(), - None => crate::i18n::tr("None (the display is treated as sRGB)").to_string(), - }; - ui.label(RichText::new(name).color(t.text)); - if screen { - hint(ui, t, crate::i18n::tr("this screen's profile")); - } - }); - row(ui, t, "", |ui| { - let can = app.services.pick_display_profile.is_some(); - let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose Profile…"))); - register(ui, "button:settingsDisplayProfile", r.rect); - if r.clicked() - && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) - { - app.ui.settings.display_profile = path; - } - // with no profile chosen, previews follow the screen's own profile where the host knows it - let none = if app.services.screen_profile.is_some() { crate::i18n::tr("Use Screen Profile") } else { crate::i18n::tr("Use sRGB") }; - let r = ui.add_enabled(!app.ui.settings.display_profile.is_empty(), egui::Button::new(none)); - register(ui, "button:settingsDisplayProfileNone", r.rect); - if r.clicked() { - app.ui.settings.display_profile.clear(); - } - if !can { - hint(ui, t, crate::i18n::tr("not available here")); - } - }); - if let Some(e) = &app.display_error { - ui.label(RichText::new(e).size(11.0).color(t.reject)); - } - if let Some(d) = ¤t { - let info = d.describe(); - if !screen { - row(ui, t, crate::i18n::tr("File"), |ui| { - path_label(ui, "settingsDisplayFile", &d.path, egui::FontId::proportional(11.0), t.text_dim, 0.0); - }); - } - row(ui, t, crate::i18n::tr("Type"), |ui| { - let kind = match d.profile.kind { - lightcraft_engine::display::DisplayKind::MatrixTrc => crate::i18n::tr("Matrix/TRC"), - lightcraft_engine::display::DisplayKind::Lut => crate::i18n::tr("LUT-based"), - }; - ui.label(RichText::new(kind).color(t.text)); - }); - row(ui, t, crate::i18n::tr("Primaries (x, y)"), |ui| { - let p = &info["primaries"]; - let xy = |(k, l): (&str, &str)| format!("{l} {:.3}, {:.3}", p[k][0].as_f64().unwrap_or(0.0), p[k][1].as_f64().unwrap_or(0.0)); - let text = [("red", "R"), ("green", "G"), ("blue", "B")].map(xy).join(" "); - ui.label(RichText::new(text).size(11.0).color(t.text_dim)); - }); - } - hint( - ui, - t, - crate::i18n::tr( - "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.", - ), - ); - #[cfg(target_os = "linux")] - hint(ui, t, crate::i18n::tr("Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.")); -} - -fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - let system = app.system_theme(ui.ctx()); - appearance(ui, t, &mut app.ui.settings, system); - heading(ui, t, crate::i18n::tr("Filmstrip")); - check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); - check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); - heading(ui, t, crate::i18n::tr("Grid")); - row(ui, t, crate::i18n::tr("Ratings & flags"), |ui| { - choices( - ui, - "settingsGridBadges", - &[(GridBadges::Auto, "When rated or hovered"), (GridBadges::Always, "Always"), (GridBadges::Never, "Never")], - &mut app.ui.settings.grid_badges, - ); - }); - check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats"); - heading(ui, t, crate::i18n::tr("Keywords")); - row(ui, t, crate::i18n::tr("Keywords of the selection"), |ui| { - use crate::state::KeywordChips as K; - choices(ui, "settingsKeywordChips", &[(K::Paths, "Whole names"), (K::Grouped, "Grouped by parent")], &mut app.ui.settings.keyword_chips); - }); - hint( - ui, - t, - crate::i18n::tr("Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened."), - ); - heading(ui, t, crate::i18n::tr("Detail View")); - check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); - row(ui, t, crate::i18n::tr("Info overlay"), |ui| { - use crate::state::InfoOverlay as I; - choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); - }); - hint(ui, t, crate::i18n::tr("View → View Options chooses the lines Info 1 and Info 2 show.")); -} - -/// View ▸ View Options (⌘J), Lightroom Classic's Loupe View tab: whether the info overlay shows and -/// which, the three lines each of Info 1 and Info 2 shows, which of them shows briefly when the -/// photo changes, and whether the loupe shows its loading and rendering message -/// (`view.viewOptions`). Changes apply as they are made. -pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - use crate::state::{InfoOverlay as I, LoupeInfo, LoupeInfoField as F}; - heading(ui, t, crate::i18n::tr("Loupe View")); - let mut show = app.ui.info_overlay != I::Off; - if check(ui, "viewOptions.showInfo", &mut show, "Show Info Overlay") { - app.ui.info_overlay = if show { I::Basic } else { I::Off }; - } - ui.add_enabled_ui(show, |ui| { - ui.horizontal(|ui| { - choices(ui, "viewOptionsInfo", &[(I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); - }); - }); - for (n, title, defaults) in [(1, "Loupe Info 1", LoupeInfo::INFO1), (2, "Loupe Info 2", LoupeInfo::INFO2)] { - heading(ui, t, crate::i18n::tr(title)); - let lines = if n == 1 { &mut app.ui.settings.loupe_info.info1 } else { &mut app.ui.settings.loupe_info.info2 }; - for (i, line) in lines.iter_mut().enumerate() { - let id = format!("viewOptionsInfo{n}Line{}", i + 1); - let r = crate::widgets::combo_box(&id).width(220.0).selected_text(crate::i18n::tr(line.label())).show_ui(ui, |ui| { - for f in F::ALL { - let key = serde_json::to_value(f).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); - let r = ui.selectable_label(line == f, crate::i18n::tr(f.label())); - register(ui, format!("button:{id}-{key}"), r.rect); - if r.clicked() { - *line = *f; - } - } - }); - crate::widgets::name_combo( - &r.response, - &crate::i18n::tr_format!("{title}: {line}", title = crate::i18n::tr(title), line = crate::i18n::tr(line.label())), - ); - register(ui, format!("combo:{id}"), r.response.rect); - } - let r = ui.add_enabled(*lines != defaults, egui::Button::new(crate::i18n::tr("Use Defaults"))); - register(ui, format!("button:viewOptionsDefaults{n}"), r.rect); - if r.clicked() { - *lines = defaults; - } - // one Info at most shows briefly: ticking one unticks the other - let info = if n == 1 { I::Basic } else { I::Exposure }; - let mut briefly = app.ui.settings.loupe_info.briefly == info; - if check(ui, &format!("viewOptions.briefly{n}"), &mut briefly, "Show briefly when photo changes") { - app.ui.settings.loupe_info.briefly = if briefly { info } else { I::Off }; - } - } - heading(ui, t, crate::i18n::tr("General")); - check(ui, "viewOptions.loupeMessages", &mut app.ui.settings.loupe_messages, "Show message when loading or rendering photos"); -} - -// ----------------------------------------------------------------------------------- Slideshow - -/// A playback duration slider (`slider:{id}`). While it is dragged the value changes in memory -/// (the slideshow reads it); letting go saves it through `slideshow.playback`. -fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, range: std::ops::RangeInclusive) { - let mut v = value; - let r = ui.add(egui::Slider::new(&mut v, range).logarithmic(key == "slideDuration").suffix(" s").max_decimals(1)); - register(ui, format!("slider:{id}"), r.rect); - if r.changed() - && let Ok(next) = app.session.slideshow.merged(&json!({ key: v })) - { - app.session.slideshow = next; - } - if r.drag_stopped() || (r.changed() && !r.dragged()) { - let _ = app.run("slideshow.playback", json!({ key: v })); - } -} - -/// The Slideshow tab: the slide preview, then the settings, in one column the width of the page -/// (the page scrolls; a column beside the preview would be wider than the window). Without -/// `with_preview` the preview is left out: [`window`] has pinned it above the page. -fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, with_preview: bool) { - if with_preview { - crate::panels::slide_layout::preview(app, ui, t, f32::INFINITY); - ui.add_space(6.0); - } - crate::panels::slide_templates::section(app, ui, t); - ui.add_space(6.0); - playback_section(app, ui, t); - ui.add_space(6.0); - crate::panels::slide_layout::sections(app, ui, t); - ui.add_space(6.0); - crate::panels::slide_overlays::sections(app, ui, t); - ui.add_space(6.0); - crate::panels::slide_titles::sections(app, ui, t); - ui.add_space(6.0); - export_section(app, ui, t); -} - -/// Export: PDF, JPEG sequence or video of the slideshow (View ▸ Export Slideshow…). -fn export_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - heading(ui, t, crate::i18n::tr("Export")); - let running = app.session.slideshow_export.as_ref().is_some_and(|r| r.running()); - ui.horizontal_wrapped(|ui| { - for (id, label, format) in [ - ("slideshowExportPdf", "Export PDF…", "pdf"), - ("slideshowExportJpeg", "Export JPEG…", "jpeg"), - ("slideshowExportVideo", "Export Video…", "video"), - ] { - let r = ui.add_enabled(!running, egui::Button::new(crate::i18n::tr(label))); - register(ui, format!("button:{id}"), r.rect); - if r.clicked() { - let _ = app.run("dialog.slideshowExport", json!({"format": format})); - } - } - }); - hint(ui, t, crate::i18n::tr("The photos in view as slides, with the settings above. Progress shows in the activity stack.")); -} - -fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; - heading(ui, t, crate::i18n::tr("Playback")); - let s = app.session.slideshow.clone(); - let mut v = s.manual; - if check(ui, "settings.slideshowManual", &mut v, "Manual slideshow") { - let _ = app.run("slideshow.playback", json!({"manual": v})); - } - hint(ui, t, crate::i18n::tr("A manual slideshow shows each slide until → or ← changes it. Exports still use the slide duration.")); - row(ui, t, crate::i18n::tr("Slide duration"), |ui| { - duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX) - }); - row(ui, t, crate::i18n::tr("Fade duration"), |ui| { - duration_slider(app, ui, "slideshowFade", "fadeDuration", s.fade_duration, 0.0..=FADE_MAX.min(s.slide_duration)) - }); - hint(ui, t, crate::i18n::tr("A slide stays up for its duration, its fade in included. A fade of 0 cuts.")); - row(ui, t, crate::i18n::tr("Fade"), |ui| { - #[derive(Clone, Copy, PartialEq)] - enum Fade { - Cross, - Black, - White, - Color, - } - let mut f = match s.fade_color { - None => Fade::Cross, - Some([0, 0, 0]) => Fade::Black, - Some([255, 255, 255]) => Fade::White, - Some(_) => Fade::Color, - }; - let opts = [(Fade::Cross, "Crossfade"), (Fade::Black, "Through black"), (Fade::White, "Through white"), (Fade::Color, "Through a colour")]; - if choices(ui, "settingsSlideshowFade", &opts, &mut f) { - let c = match f { - Fade::Cross => Value::Null, - Fade::Black => json!("#000000"), - Fade::White => json!("#ffffff"), - Fade::Color => json!([64, 64, 64]), - }; - let _ = app.run("slideshow.playback", json!({"fadeColor": c})); - } - if f == Fade::Color - && let Some(mut c) = s.fade_color - { - let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); - register(ui, "button:slideshowFadeColor", r.rect); - if r.changed() { - let _ = app.run("slideshow.playback", json!({"fadeColor": c})); - } - } - }); - let mut v = s.random_order; - if check(ui, "settings.slideshowRandom", &mut v, "Random order") { - let _ = app.run("slideshow.playback", json!({"randomOrder": v})); - } - let mut v = s.repeat; - if check(ui, "settings.slideshowRepeat", &mut v, "Repeat (off: the slideshow ends after the last photo)") { - let _ = app.run("slideshow.playback", json!({"repeat": v})); - } - let mut v = s.prepare_previews; - if check(ui, "settings.slideshowPrepare", &mut v, "Prepare previews in advance") { - let _ = app.run("slideshow.playback", json!({"preparePreviews": v})); - } - hint( - ui, - t, - crate::i18n::tr( - "Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).", - ), - ); - hint( - ui, - t, - crate::i18n::tr("Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library."), - ); -} - -/// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and -/// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in -/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]). -fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::AppSettings, system: Option) { - use crate::state::{AppearanceMode as M, DarkTheme, LightTheme}; - heading(ui, t, crate::i18n::tr("Appearance")); - ui.label(RichText::new(crate::i18n::tr("Appearance Mode")).color(t.text_label)); - ui.horizontal_wrapped(|ui| { - choices(ui, "settingsAppearance", &[(M::Auto, "Sync with System"), (M::Dark, "Dark Mode"), (M::Light, "Light Mode")], &mut s.appearance_mode); - }); - ui.add_space(4.0); - let light_active = s.theme(system) == s.light_theme.kind(); - let available = ui.available_width(); - let mut cards = |ui: &mut egui::Ui, width: f32| { - ui.spacing_mut().item_spacing.x = 12.0; - let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); - theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, &mut s.favorite_themes, width); - let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); - theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, &mut s.favorite_themes, width); - }; - if available < 440.0 { - ui.vertical(|ui| cards(ui, available)); - } else { - ui.horizontal_top(|ui| cards(ui, ((available - 12.0) / 2.0).floor())); - } - ui.add_space(2.0); -} - -/// One family's card: its title (and "Active" when it is the one shown), a preview of the -/// selected theme and a radio button per theme. -fn theme_card( - ui: &mut egui::Ui, - t: &Tokens, - title: &str, - active: bool, - options: &[(V, crate::theme::ThemeKind)], - value: &mut V, - favorites: &mut Vec, - width: f32, -) { - egui::Frame::new() - .fill(t.inset) - .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) - .corner_radius(6) - .inner_margin(8) - .show(ui, |ui| { - ui.vertical(|ui| { - let inner = (width - 18.0).clamp(120.0, 240.0); - ui.set_width(inner); - ui.horizontal(|ui| { - ui.label(RichText::new(crate::i18n::tr(title)).font(t.semibold(12.0)).color(t.text)); - if active { - ui.label(RichText::new(crate::i18n::tr("In Use")).size(11.0).color(t.accent)); - } - }); - let shown = options.iter().find(|(v, _)| v == value).or(options.first()).map(|(_, k)| *k).unwrap_or_default(); - theme_preview(ui, shown, inner); - for (v, kind) in options { - let row = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(ui.available_width(), 24.0)); - let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); - ui.horizontal(|ui| { - let r = ui.radio_value(value, *v, crate::i18n::tr(kind.label())); - register(ui, format!("radio:theme-{}", kind.id()), r.rect); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let favorite = favorites.iter().any(|id| id == kind.id()); - let r = ui - .add_visible(favorite || hovered, egui::Button::new("").min_size(egui::vec2(24.0, 24.0)).frame(false)) - .on_hover_text(crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" })); - r.widget_info(|| { - egui::WidgetInfo::labeled( - egui::Role::Button, - true, - format!( - "{}: {}", - crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" }), - crate::i18n::tr(kind.label()) - ), - ) - }); - register(ui, format!("button:themeFavorite-{}", kind.id()), r.rect); - if favorite || hovered { - crate::rating::paint_star(ui.painter(), r.rect.shrink(4.0), favorite, if favorite { t.accent } else { t.text_dim }); - } - if r.clicked() { - if favorite { - favorites.retain(|id| id != kind.id()); - } else { - favorites.push(kind.id().into()); - } - } - }); - }); - } - }); - }); -} - -/// A small LightCraft window painted with `kind`'s tokens: the top bar with its search field, the -/// photo with the filmstrip under it, the Edit panel (histogram and sliders), the tool strip and -/// the bottom bar. -fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) { - use egui::{Color32, Rect, Stroke, StrokeKind, pos2, vec2}; - let p = Tokens::for_kind(kind); - let (rect, _) = ui.allocate_exact_size(vec2(width, (width * 0.56).round().min(130.0)), egui::Sense::hover()); - let painter = ui.painter_at(rect); - let bar = |y: f32, x: f32, w: f32, c: Color32| painter.rect_filled(Rect::from_min_size(pos2(x, y), vec2(w, 2.0)), 1.0, c); - painter.rect_filled(rect, 3.0, p.canvas); - // top bar: sidebar and back icons, the centred search field, icons on the right - let top = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); - painter.rect_filled(top, 0.0, p.chrome); - for x in [6.0, 14.0, 20.0] { - painter.rect_filled(Rect::from_min_size(top.min + vec2(x, 4.5), vec2(4.0, 4.0)), 1.0, p.icon); - } - let search = Rect::from_center_size(top.center(), vec2(rect.width() * 0.34, 8.0)); - painter.rect(search, 2.0, p.field, Stroke::new(1.0, p.field_border), StrokeKind::Inside); - bar(search.center().y - 1.0, search.center().x - 9.0, 18.0, p.text_dim); - for i in 0..4 { - painter.rect_filled(Rect::from_min_size(pos2(top.right() - 10.0 - i as f32 * 9.0, top.top() + 4.5), vec2(4.0, 4.0)), 2.0, p.icon); - } - // bottom bar: view buttons, rating stars, a button - let bottom = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 12.0), rect.max); - painter.rect_filled(bottom, 0.0, p.chrome); - for x in [6.0, 13.0, 20.0] { - painter.rect_filled(Rect::from_min_size(bottom.min + vec2(x, 4.0), vec2(4.0, 4.0)), 1.0, p.icon); - } - let stars_x = bottom.center().x - 30.0; - for i in 0..5 { - painter.circle_filled(pos2(stars_x + i as f32 * 5.0, bottom.center().y), 1.4, p.star); - } - let copy = Rect::from_min_size(pos2(stars_x + 28.0, bottom.top() + 2.5), vec2(30.0, 7.0)); - painter.rect(copy, 3.0, p.button, Stroke::new(1.0, p.button_border), StrokeKind::Inside); - // tool strip on the right edge, the first tool (Edit) active - let strip = Rect::from_min_max(pos2(rect.right() - 12.0, top.bottom()), pos2(rect.right(), bottom.top())); - painter.rect_filled(strip, 0.0, p.chrome); - painter.line_segment([strip.left_top(), strip.left_bottom()], Stroke::new(1.0, p.divider)); - for i in 0..5 { - let r = Rect::from_min_size(pos2(strip.left() + 3.0, strip.top() + 4.0 + i as f32 * 9.0), vec2(6.0, 6.0)); - if i == 0 { - painter.rect_filled(r.expand(1.5), 1.5, p.tool_active); - } - painter.rect_stroke(r, 1.0, Stroke::new(1.0, if i == 0 { p.text } else { p.icon }), StrokeKind::Inside); - } - // Edit panel: histogram, then labelled sliders - let panel = Rect::from_min_max(pos2(strip.left() - (rect.width() * 0.27).round(), top.bottom()), pos2(strip.left(), bottom.top())); - painter.rect_filled(panel, 0.0, p.chrome); - painter.line_segment([panel.left_top(), panel.left_bottom()], Stroke::new(1.0, p.divider)); - let hist = Rect::from_min_size(panel.min + vec2(4.0, 4.0), vec2(panel.width() - 8.0, 14.0)); - painter.rect_filled(hist, 1.0, p.inset); - let curve = [0.2, 0.55, 0.9, 0.7, 0.5, 0.62, 0.4, 0.3, 0.18, 0.1]; - let mut pts = vec![hist.left_bottom()]; - for (i, h) in curve.iter().enumerate() { - pts.push(pos2(hist.left() + hist.width() * i as f32 / (curve.len() - 1) as f32, hist.bottom() - hist.height() * h)); - } - pts.push(hist.right_bottom()); - // the area under the curve, a column per stretch between two points - for pair in pts.windows(2) { - if let [a, b] = pair { - let r = Rect::from_min_max(pos2(a.x, a.y.max(b.y)), pos2(b.x, hist.bottom())); - painter.rect_filled(r, 0.0, p.track.gamma_multiply(0.5)); - } - } - painter.add(egui::Shape::line(pts, Stroke::new(1.0, p.text_dim))); - let sliders = [0.55, 0.62, 0.35, 0.7, 0.6, 0.45]; - let mut y = hist.bottom() + 7.0; - bar(y, panel.left() + 5.0, 14.0, p.text); - y += 6.0; - for v in sliders { - if y + 7.0 > panel.bottom() { - break; - } - bar(y, panel.left() + 5.0, 12.0, p.text_label); - bar(y, panel.right() - 10.0, 5.0, p.text_dim); - let track_y = y + 5.0; - painter.line_segment([pos2(panel.left() + 5.0, track_y), pos2(panel.right() - 5.0, track_y)], Stroke::new(1.0, p.track)); - painter.circle_filled(pos2(panel.left() + 5.0 + (panel.width() - 10.0) * v, track_y), 1.8, p.thumb); - y += 10.0; - } - // the photo (its own colours: a photo looks the same in every theme) above the filmstrip - let film = Rect::from_min_max(pos2(rect.left(), bottom.top() - 18.0), pos2(panel.left(), bottom.top())); - let stage = Rect::from_min_max(pos2(rect.left(), top.bottom()), pos2(panel.left(), film.top())); - let photo = Rect::from_center_size(stage.center(), vec2(stage.width() * 0.62, stage.height() - 8.0)); - painter.rect_filled(photo, 0.0, Color32::from_rgb(0x6f, 0x8f, 0xb4)); - let horizon = photo.top() + photo.height() * 0.62; - painter.add(egui::Shape::convex_polygon( - vec![ - pos2(photo.left(), horizon), - pos2(photo.left() + photo.width() * 0.3, photo.top() + photo.height() * 0.3), - pos2(photo.left() + photo.width() * 0.55, horizon), - ], - Color32::from_rgb(0x4a, 0x55, 0x66), - Stroke::NONE, - )); - painter.add(egui::Shape::convex_polygon( - vec![ - pos2(photo.left() + photo.width() * 0.35, horizon), - pos2(photo.left() + photo.width() * 0.68, photo.top() + photo.height() * 0.2), - pos2(photo.right(), horizon), - ], - Color32::from_rgb(0x5a, 0x63, 0x72), - Stroke::NONE, - )); - painter.rect_filled(Rect::from_min_max(pos2(photo.left(), horizon), photo.max), 0.0, Color32::from_rgb(0x3d, 0x5a, 0x3a)); - painter.line_segment([film.left_top(), film.right_top()], Stroke::new(1.0, p.divider)); - for i in 0..5 { - let cell = Rect::from_min_size(pos2(film.left() + 4.0 + i as f32 * 17.0, film.top() + 3.0), vec2(15.0, 12.0)); - if cell.right() > film.right() - 2.0 { - break; - } - let selected = i == 1; - painter.rect_filled(cell, 0.0, if selected { p.cell_selected } else { p.cell }); - let thumb = cell.shrink(2.5); - painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f)); - if selected { - painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside); - } - } -} - -// ------------------------------------------------------------------------------- Open Library… - -/// Switching libraries is refused while an import (or the folder scan before one) is running: its -/// remaining batches would be committed into the new library (issue #1509). -pub(crate) fn can_switch_library(app: &LightcraftApp) -> Result<(), String> { - if crate::lightroom_import::is_running(app) { - return Err("wait for Lightroom catalog import to finish before switching libraries".into()); - } - if app.import.is_some() || app.scan.is_some() { - return Err("wait for the import to finish before switching libraries".into()); - } - Ok(()) -} - -/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, -/// or a folder chosen in a dialog. Remembered as the library to open at launch. -/// -/// A Lightroom Classic catalog (its folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` -/// itself, see [`lightcraft_engine::lightroom_folder`]) is not opened: the current library stays -/// and a dialog offers Import Lightroom Catalog… instead; returns `{opened: false, -/// lightroomCatalog: <.lrcat path or null>, path}`. -pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result { - can_switch_library(app)?; - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let req = crate::pick::PickRequest::folder(crate::i18n::tr("Open Library")); - match crate::pick::ask(app, "app.openLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { - crate::pick::Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Ok(Value::Null), - }, - crate::pick::Picked::Later => return Ok(Value::Null), - crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), - } - } - }; - // A Lightroom catalog folder (or part of one) is not a library: before closing the library - // open now, offer to import it instead (issue #891). The engine refuses it too. - if let Some(place) = lightcraft_engine::lightroom_folder::lightroom_catalog_at(std::path::Path::new(&path)) { - let catalog = place.catalog.map(|c| c.to_string_lossy().into_owned()); - let err = lightcraft_engine::EngineError::LightroomCatalog { path: path.clone().into(), catalog: catalog.clone().map(Into::into) }; - // the library problem window (no library could be opened) shows the error instead - if app.library_problem.is_some() { - return Err(err.to_string()); - } - app.ui.dialog = Some(crate::state::Dialog::LightroomCatalog { folder: path.clone(), catalog: catalog.clone() }); - return Ok(json!({"opened": false, "lightroomCatalog": catalog, "path": path})); - } - // a remembered library that is gone: say so (and forget it) instead of creating an empty one there - if app.ui.settings.recent_libraries.contains(&path) && !std::path::Path::new(&path).exists() { - app.ui.settings.recent_libraries.retain(|r| r != &path); - return Err(format!("{path}: the library is no longer there (moved, renamed or on a disconnected drive); removed from Open Recent Library")); - } - switch_library(app, &path) -} - -/// Close the current library and open the one at `path`. If it can't be opened (locked by -/// another program, unreadable, a file), the error is returned and the current library stays open: -/// the target is checked before anything is closed, and if opening it still fails the previous -/// library is opened again (both errors are reported if that fails too). -fn switch_library(app: &mut LightcraftApp, path: &str) -> Result { - let dir = app.session.check_library(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; - let path = dir.to_string_lossy().into_owned(); - // the library being left goes in the list too (also when it was opened at launch) - let previous_dir = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - let previous = previous_dir.as_ref().map(|d| d.to_string_lossy().into_owned()); - app.session.close_library().map_err(|e| e.to_string())?; - let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}")); - app.lightroom_last = None; - app.lightroom_result = None; - if matches!(app.ui.dialog.as_ref(), Some(crate::state::Dialog::LightroomResult { .. })) { - app.ui.dialog = None; - } - app.renderer.forget_all(); - if let Err(err) = opened { - let Some(prev) = previous_dir else { return Err(err) }; - return match app.session.open_library(&prev, false) { - Ok(_) => Err(err), - Err(e) => Err(format!("{err}; reopening the previous library {} failed too: {e}", prev.display())), - }; - } - // Photo IDs are local to a catalog: pins cannot follow them into another library (a refused - // or failed switch reopens the same catalog, so its pins stay). - app.ui.reference = None; - app.ui.compare = None; - app.ui.brief_info = crate::state::BriefInfo::default(); - if app.ui.view == crate::state::ViewMode::Reference { - app.ui.view = crate::state::ViewMode::PhotoGrid; - } - app.ui.settings.library_path = path.clone(); - if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) { - remember_library(&mut app.ui.settings.recent_libraries, &prev); - } - remember_library(&mut app.ui.settings.recent_libraries, &path); - Ok(json!({"path": path, "name": library_name(&dir), "photos": app.session.catalog.len()})) -} - -/// Put `path` first in the recent list, without duplicates, capped at [`RECENT_LIBRARIES`]. -pub fn remember_library(recent: &mut Vec, path: &str) { - recent.retain(|r| r != path); - recent.insert(0, path.to_string()); - recent.truncate(crate::state::RECENT_LIBRARIES); -} - -/// A library's name as people see it: its folder's name. -pub fn library_name(dir: &std::path::Path) -> String { - dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string()) -} - -/// The open library's name (`None`: in memory, nothing is saved). -pub fn open_library_name(app: &LightcraftApp) -> Option { - app.session.library.as_ref().filter(|l| l.on_disk).map(|l| library_name(&l.dir)) -} - -/// `app.newLibrary {path?, name?}`: create a library and switch to it. With `name`, the library -/// is the folder `name` inside `path` (or inside a location chosen in a dialog); without it, -/// `path` itself, or a new folder named after the default library. The folder must not hold a -/// library already, nor anything else. -pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result { - can_switch_library(app)?; - let name = match p.get("name") { - None | Some(Value::Null) => None, - Some(Value::String(s)) => Some(valid_library_name(s)?), - Some(_) => return Err("app.newLibrary: name must be a string".into()), - }; - // from the menu: ask for the name first (default prefilled), then for the location - if name.is_none() && p.get("path").is_none() && app.services.pick_folder.is_some() { - crate::panels::dialogs::prompt( - app, - "New Library", - "Library name", - lightcraft_engine::library::DEFAULT_NAME, - "app.newLibrary", - json!({}), - "name", - ); - return Ok(Value::Null); - } - let (location, picked) = match p.get("path").and_then(Value::as_str) { - Some(x) => (std::path::PathBuf::from(x), false), - None => { - let req = crate::pick::PickRequest::folder(crate::i18n::tr("New Library: Choose Where to Put It")); - match crate::pick::ask(app, "app.newLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { - crate::pick::Picked::Now(v) => match v.into_iter().next() { - Some(x) => (std::path::PathBuf::from(x), true), - None => return Ok(Value::Null), - }, - crate::pick::Picked::Later => return Ok(Value::Null), - crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), - } - } - }; - let dir = match name { - Some(n) => location.join(n), - // a chosen location gets a new folder in it, named after the default library - None if picked => free_name(&location, lightcraft_engine::library::DEFAULT_NAME), - None => location, - }; - if lightcraft_engine::library::is_library(&dir) { - return Err(format!("{}: there is a library here already; open it with File ▸ Open Library…", dir.display())); - } - if dir.is_file() { - return Err(format!("{}: is a file; a library is a folder", dir.display())); - } - if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) { - return Err(format!("{}: the folder isn't empty; choose an empty folder or a new name", dir.display())); - } - std::fs::create_dir_all(&dir).map_err(|e| format!("{}: can't create the library folder: {e}", dir.display()))?; - switch_library(app, &dir.to_string_lossy()) -} - -/// Characters a folder name can't hold on Windows (and `/`, which no system allows). -const RESERVED_NAME_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; - -fn valid_library_name(s: &str) -> Result<&str, String> { - let n = s.trim(); - // Windows drops trailing dots and spaces from a folder name: refuse them rather than - // creating a folder whose name differs from the one asked for (spaces around the name - // are trimmed first) - let bad = n.is_empty() || n == "." || n == ".." || n.ends_with(['.', ' ']) || n.contains(RESERVED_NAME_CHARS) || n.chars().any(char::is_control); - if bad { - return Err(format!( - "app.newLibrary: {s:?} can't be a library name (a folder name without < > : \" / \\ | ? * and not ending in a dot or space)" - )); - } - Ok(n) -} - -/// `base` inside `location`, or `base 2`, `base 3`… if that is taken. -pub(crate) fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { - let first = location.join(base); - if !first.exists() { - return first; - } - (2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first) -} - -/// `app.recentLibraries {remove?: path, clear?: bool}`: the recently opened libraries, newest -/// first, as `[{path, name, exists, checking, open, lastOpened}]`. `exists` is what the last -/// background check found (`false`: moved, on a disconnected drive, not a folder or no longer a -/// library; `null`: not checked yet, or the check couldn't tell — a folder that can't be listed, an -/// unreadable catalog, a lookup or check that failed; `checking` says whether a check is running): the command never -/// looks at a folder itself, as one on an offline network share can take seconds to answer. -/// `lastOpened`: ISO 8601 time, `null` when not known. `remove` forgets that -/// one entry (an error if the list doesn't hold it) and `clear` forgets them all; neither touches -/// anything on disk (issue #1290). -pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result { - match p.get("remove") { - None | Some(Value::Null) => {} - Some(Value::String(path)) => { - if !forget_library(&mut app.ui.settings, path) { - return Err(format!("app.recentLibraries: {path:?} is not in the recent libraries")); - } - app.recent_checks.forget(path); - } - Some(_) => return Err("app.recentLibraries: remove must be a library path (a string)".into()), - } - if p.get("clear").and_then(Value::as_bool) == Some(true) { - for path in std::mem::take(&mut app.ui.settings.recent_libraries) { - app.recent_checks.forget(&path); - } - app.ui.settings.recent_opened.clear(); - } - let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - let (settings, checks) = (&app.ui.settings, &app.recent_checks); - let list: Vec = settings - .recent_libraries - .iter() - .map(|r| { - let d = std::path::Path::new(r); - let exists = checks.exists(r); - json!({"path": r, "name": library_name(d), "exists": exists, "checking": checks.is_checking(r), "open": open.as_deref() == Some(d), "lastOpened": settings.recent_opened.get(r)}) - }) - .collect(); - Ok(Value::Array(list)) -} - -/// Forget `path` in File ▸ Open Recent Library (and when it was opened); the library itself is -/// left where it is. False if the list didn't hold it. -pub fn forget_library(settings: &mut crate::state::AppSettings, path: &str) -> bool { - let before = settings.recent_libraries.len(); - settings.recent_libraries.retain(|r| r != path); - settings.recent_opened.remove(path); - settings.recent_libraries.len() != before -} - -// ---------------------------------------------------------------------------- Display profile - -/// `app.displayProfile {path?: string | null}`: show previews through this monitor ICC profile -/// (`""` or `null`: none, the profile of the screen showing the window where the host knows it -/// (macOS), else the display is treated as sRGB) and keep it as the setting; no `path`: report -/// only. A file that can't be used is an error and changes nothing. Returns the profile in use: -/// `{path, description, kind (matrix|lut), primaries {red, green, blue: [x, y]}}`, with -/// `path: null, screen: true` for the screen's profile. -pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result { - let path = match p.get("path") { - None => None, - Some(Value::Null) => Some(String::new()), - Some(Value::String(s)) => Some(s.trim().to_string()), - Some(_) => return Err("app.displayProfile: path must be a string or null".into()), - }; - if let Some(path) = path { - let d = lightcraft_engine::display::Display::load_opt(&path).map_err(|e| format!("app.displayProfile: {e}"))?; - app.renderer.set_display(d); - app.ui.settings.display_profile = path.clone(); - app.display_applied = Some(path); - app.display_error = None; - app.screen_profile = None; - app.screen_profile_read = None; - if app.ui.settings.display_profile.is_empty() { - app.apply_screen_profile(); - } - } - Ok(match app.renderer.display() { - Some(d) if d.path == crate::SCREEN_PROFILE => { - let mut v = d.describe(); - v["path"] = Value::Null; - v["screen"] = json!(true); - v - } - Some(d) => d.describe(), - None => json!({"path": null, "description": "sRGB (no display profile)"}), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn app_with_import() -> LightcraftApp { - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); - app.run("photo.setReference", json!({})).unwrap(); - app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false)); - app - } - - #[test] - fn open_library_is_refused_while_an_import_runs() { - let mut app = app_with_import(); - let pin = app.ui.reference; - let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err(); - assert!(err.contains("import to finish"), "{err}"); - assert!(app.import.is_some()); - assert_eq!(app.ui.reference, pin, "a refused Open Library keeps the reference pin"); - } - - #[test] - fn new_library_is_refused_while_an_import_runs() { - let mut app = app_with_import(); - let pin = app.ui.reference; - let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err(); - assert!(err.contains("import to finish"), "{err}"); - assert!(app.import.is_some()); - assert_eq!(app.ui.reference, pin, "a refused New Library keeps the reference pin"); - } - - /// The menu items that switch libraries are greyed out meanwhile, like during a Lightroom import. - #[test] - fn library_switching_menu_items_are_disabled_while_an_import_runs() { - let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); - app.services.pick_folder = Some(Box::new(|| None)); - assert!(crate::menus::ui_enabled(&app, "app.openLibrary")); - assert!(crate::menus::ui_enabled(&app, "app.newLibrary")); - app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false)); - assert!(!crate::menus::ui_enabled(&app, "app.openLibrary")); - assert!(!crate::menus::ui_enabled(&app, "app.newLibrary")); - } - - /// The folder scan before an import's review counts too: it ends in an import into the open library. - #[test] - fn open_library_is_refused_while_an_import_scan_runs() { - let dir = std::env::temp_dir().join(format!("lc-ui-switchscan-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); - app.run("photo.setReference", json!({})).unwrap(); - let pin = app.ui.reference; - crate::import::open(&mut app, vec![dir.to_string_lossy().into_owned()]).unwrap(); - assert!(app.scan.is_some()); - let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err(); - assert!(err.contains("import to finish"), "{err}"); - assert_eq!(app.ui.reference, pin, "a refused Open Library during a scan keeps the reference pin"); - let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err(); - assert!(err.contains("import to finish"), "{err}"); - assert_eq!(app.ui.reference, pin, "a refused New Library during a scan keeps the reference pin"); - assert!(app.scan.is_some()); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(test)] -mod path_tests { - use super::elide_path; - - /// Width = characters, so a limit of 20 is "20 characters". - fn fit(path: &str, max: usize) -> String { - elide_path(path, max as f32, |s| s.chars().count() as f32) - } - - #[test] - fn a_path_that_fits_is_left_alone() { - assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures"); - assert_eq!(fit("", 5), ""); - } - - #[test] - fn leading_folders_go_first_and_the_end_stays() { - assert_eq!(fit("/Users/me/Pictures/Library", 20), "…/Pictures/Library"); - assert_eq!(fit("/Users/me/Pictures/Library", 10), "…/Library"); - } - - /// Windows paths: `Path::display` gives backslashes there. - #[test] - fn a_path_with_backslashes_keeps_its_end_too() { - assert_eq!(fit(r"C:\Users\me\Pictures\Library", 20), r"…\Pictures\Library"); - assert_eq!(fit(r"C:\Users\me/Pictures\Library", 10), r"…\Library"); - } - - #[test] - fn a_last_folder_too_long_by_itself_loses_its_end() { - assert_eq!(fit("/Users/me/a-very-long-folder-name", 8), "a-very-…"); - assert_eq!(fit("no-separator-at-all", 8), "no-sepa…"); - assert_eq!(fit("/Users/Фотографии-из-отпуска", 8), "Фотогра…"); - } - - #[test] - fn a_separator_at_the_end_is_not_where_the_name_starts() { - assert_eq!(fit("/Users/me/Pictures/Library/", 11), "…/Library/"); - assert_eq!(fit("/Users/me/a-very-long-folder-name/", 8), "a-very-…"); - assert_eq!(fit(r"C:\Users\me\Library\", 11), r"…\Library\"); - assert_eq!(fit("///", 2), "…"); - assert_eq!(fit(r"\\server\share\folder", 15), r"…\share\folder"); - } -} diff --git a/crates/ui-egui/src/dialogs/settings/appearance.rs b/crates/ui-egui/src/dialogs/settings/appearance.rs new file mode 100644 index 000000000..51159df6b --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/appearance.rs @@ -0,0 +1,220 @@ +use super::widgets::{choices, heading}; +use super::*; + +/// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and +/// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in +/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]). +pub(super) fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::AppSettings, system: Option) { + use crate::state::{AppearanceMode as M, DarkTheme, LightTheme}; + heading(ui, t, crate::i18n::tr("Appearance")); + ui.label(RichText::new(crate::i18n::tr("Appearance Mode")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsAppearance", &[(M::Auto, "Sync with System"), (M::Dark, "Dark Mode"), (M::Light, "Light Mode")], &mut s.appearance_mode); + }); + ui.add_space(4.0); + let light_active = s.theme(system) == s.light_theme.kind(); + let available = ui.available_width(); + let mut cards = |ui: &mut egui::Ui, width: f32| { + ui.spacing_mut().item_spacing.x = 12.0; + let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); + theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, &mut s.favorite_themes, width); + let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); + theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, &mut s.favorite_themes, width); + }; + if available < 440.0 { + ui.vertical(|ui| cards(ui, available)); + } else { + ui.horizontal_top(|ui| cards(ui, ((available - 12.0) / 2.0).floor())); + } + ui.add_space(2.0); +} + +/// One family's card: its title (and "Active" when it is the one shown), a preview of the +/// selected theme and a radio button per theme. +fn theme_card( + ui: &mut egui::Ui, + t: &Tokens, + title: &str, + active: bool, + options: &[(V, crate::theme::ThemeKind)], + value: &mut V, + favorites: &mut Vec, + width: f32, +) { + egui::Frame::new() + .fill(t.inset) + .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(6) + .inner_margin(8) + .show(ui, |ui| { + ui.vertical(|ui| { + let inner = (width - 18.0).clamp(120.0, 240.0); + ui.set_width(inner); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr(title)).font(t.semibold(12.0)).color(t.text)); + if active { + ui.label(RichText::new(crate::i18n::tr("In Use")).size(11.0).color(t.accent)); + } + }); + let shown = options.iter().find(|(v, _)| v == value).or(options.first()).map(|(_, k)| *k).unwrap_or_default(); + theme_preview(ui, shown, inner); + for (v, kind) in options { + let row = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(ui.available_width(), 24.0)); + let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); + ui.horizontal(|ui| { + let r = ui.radio_value(value, *v, crate::i18n::tr(kind.label())); + register(ui, format!("radio:theme-{}", kind.id()), r.rect); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let favorite = favorites.iter().any(|id| id == kind.id()); + let r = ui + .add_visible(favorite || hovered, egui::Button::new("").min_size(egui::vec2(24.0, 24.0)).frame(false)) + .on_hover_text(crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" })); + r.widget_info(|| { + egui::WidgetInfo::labeled( + egui::Role::Button, + true, + format!( + "{}: {}", + crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" }), + crate::i18n::tr(kind.label()) + ), + ) + }); + register(ui, format!("button:themeFavorite-{}", kind.id()), r.rect); + if favorite || hovered { + crate::rating::paint_star(ui.painter(), r.rect.shrink(4.0), favorite, if favorite { t.accent } else { t.text_dim }); + } + if r.clicked() { + if favorite { + favorites.retain(|id| id != kind.id()); + } else { + favorites.push(kind.id().into()); + } + } + }); + }); + } + }); + }); +} + +/// A small LightCraft window painted with `kind`'s tokens: the top bar with its search field, the +/// photo with the filmstrip under it, the Edit panel (histogram and sliders), the tool strip and +/// the bottom bar. +fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) { + use egui::{Color32, Rect, Stroke, StrokeKind, pos2, vec2}; + let p = Tokens::for_kind(kind); + let (rect, _) = ui.allocate_exact_size(vec2(width, (width * 0.56).round().min(130.0)), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let bar = |y: f32, x: f32, w: f32, c: Color32| painter.rect_filled(Rect::from_min_size(pos2(x, y), vec2(w, 2.0)), 1.0, c); + painter.rect_filled(rect, 3.0, p.canvas); + // top bar: sidebar and back icons, the centred search field, icons on the right + let top = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); + painter.rect_filled(top, 0.0, p.chrome); + for x in [6.0, 14.0, 20.0] { + painter.rect_filled(Rect::from_min_size(top.min + vec2(x, 4.5), vec2(4.0, 4.0)), 1.0, p.icon); + } + let search = Rect::from_center_size(top.center(), vec2(rect.width() * 0.34, 8.0)); + painter.rect(search, 2.0, p.field, Stroke::new(1.0, p.field_border), StrokeKind::Inside); + bar(search.center().y - 1.0, search.center().x - 9.0, 18.0, p.text_dim); + for i in 0..4 { + painter.rect_filled(Rect::from_min_size(pos2(top.right() - 10.0 - i as f32 * 9.0, top.top() + 4.5), vec2(4.0, 4.0)), 2.0, p.icon); + } + // bottom bar: view buttons, rating stars, a button + let bottom = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 12.0), rect.max); + painter.rect_filled(bottom, 0.0, p.chrome); + for x in [6.0, 13.0, 20.0] { + painter.rect_filled(Rect::from_min_size(bottom.min + vec2(x, 4.0), vec2(4.0, 4.0)), 1.0, p.icon); + } + let stars_x = bottom.center().x - 30.0; + for i in 0..5 { + painter.circle_filled(pos2(stars_x + i as f32 * 5.0, bottom.center().y), 1.4, p.star); + } + let copy = Rect::from_min_size(pos2(stars_x + 28.0, bottom.top() + 2.5), vec2(30.0, 7.0)); + painter.rect(copy, 3.0, p.button, Stroke::new(1.0, p.button_border), StrokeKind::Inside); + // tool strip on the right edge, the first tool (Edit) active + let strip = Rect::from_min_max(pos2(rect.right() - 12.0, top.bottom()), pos2(rect.right(), bottom.top())); + painter.rect_filled(strip, 0.0, p.chrome); + painter.line_segment([strip.left_top(), strip.left_bottom()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let r = Rect::from_min_size(pos2(strip.left() + 3.0, strip.top() + 4.0 + i as f32 * 9.0), vec2(6.0, 6.0)); + if i == 0 { + painter.rect_filled(r.expand(1.5), 1.5, p.tool_active); + } + painter.rect_stroke(r, 1.0, Stroke::new(1.0, if i == 0 { p.text } else { p.icon }), StrokeKind::Inside); + } + // Edit panel: histogram, then labelled sliders + let panel = Rect::from_min_max(pos2(strip.left() - (rect.width() * 0.27).round(), top.bottom()), pos2(strip.left(), bottom.top())); + painter.rect_filled(panel, 0.0, p.chrome); + painter.line_segment([panel.left_top(), panel.left_bottom()], Stroke::new(1.0, p.divider)); + let hist = Rect::from_min_size(panel.min + vec2(4.0, 4.0), vec2(panel.width() - 8.0, 14.0)); + painter.rect_filled(hist, 1.0, p.inset); + let curve = [0.2, 0.55, 0.9, 0.7, 0.5, 0.62, 0.4, 0.3, 0.18, 0.1]; + let mut pts = vec![hist.left_bottom()]; + for (i, h) in curve.iter().enumerate() { + pts.push(pos2(hist.left() + hist.width() * i as f32 / (curve.len() - 1) as f32, hist.bottom() - hist.height() * h)); + } + pts.push(hist.right_bottom()); + // the area under the curve, a column per stretch between two points + for pair in pts.windows(2) { + if let [a, b] = pair { + let r = Rect::from_min_max(pos2(a.x, a.y.max(b.y)), pos2(b.x, hist.bottom())); + painter.rect_filled(r, 0.0, p.track.gamma_multiply(0.5)); + } + } + painter.add(egui::Shape::line(pts, Stroke::new(1.0, p.text_dim))); + let sliders = [0.55, 0.62, 0.35, 0.7, 0.6, 0.45]; + let mut y = hist.bottom() + 7.0; + bar(y, panel.left() + 5.0, 14.0, p.text); + y += 6.0; + for v in sliders { + if y + 7.0 > panel.bottom() { + break; + } + bar(y, panel.left() + 5.0, 12.0, p.text_label); + bar(y, panel.right() - 10.0, 5.0, p.text_dim); + let track_y = y + 5.0; + painter.line_segment([pos2(panel.left() + 5.0, track_y), pos2(panel.right() - 5.0, track_y)], Stroke::new(1.0, p.track)); + painter.circle_filled(pos2(panel.left() + 5.0 + (panel.width() - 10.0) * v, track_y), 1.8, p.thumb); + y += 10.0; + } + // the photo (its own colours: a photo looks the same in every theme) above the filmstrip + let film = Rect::from_min_max(pos2(rect.left(), bottom.top() - 18.0), pos2(panel.left(), bottom.top())); + let stage = Rect::from_min_max(pos2(rect.left(), top.bottom()), pos2(panel.left(), film.top())); + let photo = Rect::from_center_size(stage.center(), vec2(stage.width() * 0.62, stage.height() - 8.0)); + painter.rect_filled(photo, 0.0, Color32::from_rgb(0x6f, 0x8f, 0xb4)); + let horizon = photo.top() + photo.height() * 0.62; + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left(), horizon), + pos2(photo.left() + photo.width() * 0.3, photo.top() + photo.height() * 0.3), + pos2(photo.left() + photo.width() * 0.55, horizon), + ], + Color32::from_rgb(0x4a, 0x55, 0x66), + Stroke::NONE, + )); + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left() + photo.width() * 0.35, horizon), + pos2(photo.left() + photo.width() * 0.68, photo.top() + photo.height() * 0.2), + pos2(photo.right(), horizon), + ], + Color32::from_rgb(0x5a, 0x63, 0x72), + Stroke::NONE, + )); + painter.rect_filled(Rect::from_min_max(pos2(photo.left(), horizon), photo.max), 0.0, Color32::from_rgb(0x3d, 0x5a, 0x3a)); + painter.line_segment([film.left_top(), film.right_top()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let cell = Rect::from_min_size(pos2(film.left() + 4.0 + i as f32 * 17.0, film.top() + 3.0), vec2(15.0, 12.0)); + if cell.right() > film.right() - 2.0 { + break; + } + let selected = i == 1; + painter.rect_filled(cell, 0.0, if selected { p.cell_selected } else { p.cell }); + let thumb = cell.shrink(2.5); + painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f)); + if selected { + painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside); + } + } +} diff --git a/crates/ui-egui/src/dialogs/settings/display.rs b/crates/ui-egui/src/dialogs/settings/display.rs new file mode 100644 index 000000000..a54b05ced --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/display.rs @@ -0,0 +1,221 @@ +use super::appearance::appearance; +use super::widgets::{check, choices, heading, hint, path_label, row}; +use super::*; + +// ---------------------------------------------------------------------------------- Interface + +/// The monitor profile (`app.displayProfile`): previews are shown through the display's ICC +/// profile, so wide-gamut and calibrated displays show colours as they are. +pub(super) fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, crate::i18n::tr("Monitor profile")); + let current = app.renderer.display().cloned(); + let screen = current.as_ref().is_some_and(|d| d.path == crate::SCREEN_PROFILE); + row(ui, t, crate::i18n::tr("Profile"), |ui| { + let name = match ¤t { + Some(d) => d.profile.description.clone(), + None => crate::i18n::tr("None (the display is treated as sRGB)").to_string(), + }; + ui.label(RichText::new(name).color(t.text)); + if screen { + hint(ui, t, crate::i18n::tr("this screen's profile")); + } + }); + row(ui, t, "", |ui| { + let can = app.services.pick_display_profile.is_some(); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose Profile…"))); + register(ui, "button:settingsDisplayProfile", r.rect); + if r.clicked() + && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) + { + app.ui.settings.display_profile = path; + } + // with no profile chosen, previews follow the screen's own profile where the host knows it + let none = if app.services.screen_profile.is_some() { crate::i18n::tr("Use Screen Profile") } else { crate::i18n::tr("Use sRGB") }; + let r = ui.add_enabled(!app.ui.settings.display_profile.is_empty(), egui::Button::new(none)); + register(ui, "button:settingsDisplayProfileNone", r.rect); + if r.clicked() { + app.ui.settings.display_profile.clear(); + } + if !can { + hint(ui, t, crate::i18n::tr("not available here")); + } + }); + if let Some(e) = &app.display_error { + ui.label(RichText::new(e).size(11.0).color(t.reject)); + } + if let Some(d) = ¤t { + let info = d.describe(); + if !screen { + row(ui, t, crate::i18n::tr("File"), |ui| { + path_label(ui, "settingsDisplayFile", &d.path, egui::FontId::proportional(11.0), t.text_dim, 0.0); + }); + } + row(ui, t, crate::i18n::tr("Type"), |ui| { + let kind = match d.profile.kind { + lightcraft_engine::display::DisplayKind::MatrixTrc => crate::i18n::tr("Matrix/TRC"), + lightcraft_engine::display::DisplayKind::Lut => crate::i18n::tr("LUT-based"), + }; + ui.label(RichText::new(kind).color(t.text)); + }); + row(ui, t, crate::i18n::tr("Primaries (x, y)"), |ui| { + let p = &info["primaries"]; + let xy = |(k, l): (&str, &str)| format!("{l} {:.3}, {:.3}", p[k][0].as_f64().unwrap_or(0.0), p[k][1].as_f64().unwrap_or(0.0)); + let text = [("red", "R"), ("green", "G"), ("blue", "B")].map(xy).join(" "); + ui.label(RichText::new(text).size(11.0).color(t.text_dim)); + }); + } + hint( + ui, + t, + crate::i18n::tr( + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.", + ), + ); + #[cfg(target_os = "linux")] + hint(ui, t, crate::i18n::tr("Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.")); +} + +pub(super) fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let system = app.system_theme(ui.ctx()); + appearance(ui, t, &mut app.ui.settings, system); + heading(ui, t, crate::i18n::tr("Filmstrip")); + check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); + check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); + heading(ui, t, crate::i18n::tr("Grid")); + row(ui, t, crate::i18n::tr("Ratings & flags"), |ui| { + choices( + ui, + "settingsGridBadges", + &[(GridBadges::Auto, "When rated or hovered"), (GridBadges::Always, "Always"), (GridBadges::Never, "Never")], + &mut app.ui.settings.grid_badges, + ); + }); + check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats"); + heading(ui, t, crate::i18n::tr("Keywords")); + row(ui, t, crate::i18n::tr("Keywords of the selection"), |ui| { + use crate::state::KeywordChips as K; + choices(ui, "settingsKeywordChips", &[(K::Paths, "Whole names"), (K::Grouped, "Grouped by parent")], &mut app.ui.settings.keyword_chips); + }); + hint( + ui, + t, + crate::i18n::tr("Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened."), + ); + heading(ui, t, crate::i18n::tr("Detail View")); + check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); + row(ui, t, crate::i18n::tr("Info overlay"), |ui| { + use crate::state::InfoOverlay as I; + choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); + }); + hint(ui, t, crate::i18n::tr("View → View Options chooses the lines Info 1 and Info 2 show.")); +} + +/// View ▸ View Options (⌘J), Lightroom Classic's Loupe View tab: whether the info overlay shows and +/// which, the three lines each of Info 1 and Info 2 shows, which of them shows briefly when the +/// photo changes, and whether the loupe shows its loading and rendering message +/// (`view.viewOptions`). Changes apply as they are made. +pub(in super::super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use crate::state::{InfoOverlay as I, LoupeInfo, LoupeInfoField as F}; + heading(ui, t, crate::i18n::tr("Loupe View")); + let mut show = app.ui.info_overlay != I::Off; + if check(ui, "viewOptions.showInfo", &mut show, "Show Info Overlay") { + app.ui.info_overlay = if show { I::Basic } else { I::Off }; + } + ui.add_enabled_ui(show, |ui| { + ui.horizontal(|ui| { + choices(ui, "viewOptionsInfo", &[(I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); + }); + }); + for (n, title, defaults) in [(1, "Loupe Info 1", LoupeInfo::INFO1), (2, "Loupe Info 2", LoupeInfo::INFO2)] { + heading(ui, t, crate::i18n::tr(title)); + let lines = if n == 1 { &mut app.ui.settings.loupe_info.info1 } else { &mut app.ui.settings.loupe_info.info2 }; + for (i, line) in lines.iter_mut().enumerate() { + let id = format!("viewOptionsInfo{n}Line{}", i + 1); + let r = crate::widgets::combo_box(&id).width(220.0).selected_text(crate::i18n::tr(line.label())).show_ui(ui, |ui| { + for f in F::ALL { + let key = serde_json::to_value(f).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let r = ui.selectable_label(line == f, crate::i18n::tr(f.label())); + register(ui, format!("button:{id}-{key}"), r.rect); + if r.clicked() { + *line = *f; + } + } + }); + crate::widgets::name_combo( + &r.response, + &crate::i18n::tr_format!("{title}: {line}", title = crate::i18n::tr(title), line = crate::i18n::tr(line.label())), + ); + register(ui, format!("combo:{id}"), r.response.rect); + } + let r = ui.add_enabled(*lines != defaults, egui::Button::new(crate::i18n::tr("Use Defaults"))); + register(ui, format!("button:viewOptionsDefaults{n}"), r.rect); + if r.clicked() { + *lines = defaults; + } + // one Info at most shows briefly: ticking one unticks the other + let info = if n == 1 { I::Basic } else { I::Exposure }; + let mut briefly = app.ui.settings.loupe_info.briefly == info; + if check(ui, &format!("viewOptions.briefly{n}"), &mut briefly, "Show briefly when photo changes") { + app.ui.settings.loupe_info.briefly = if briefly { info } else { I::Off }; + } + } + heading(ui, t, crate::i18n::tr("General")); + check(ui, "viewOptions.loupeMessages", &mut app.ui.settings.loupe_messages, "Show message when loading or rendering photos"); +} + +// ----------------------------------------------------------------------------------- Slideshow + +/// A playback duration slider (`slider:{id}`). While it is dragged the value changes in memory +/// (the slideshow reads it); letting go saves it through `slideshow.playback`. +pub(super) fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, range: std::ops::RangeInclusive) { + let mut v = value; + let r = ui.add(egui::Slider::new(&mut v, range).logarithmic(key == "slideDuration").suffix(" s").max_decimals(1)); + register(ui, format!("slider:{id}"), r.rect); + if r.changed() + && let Ok(next) = app.session.slideshow.merged(&json!({ key: v })) + { + app.session.slideshow = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + let _ = app.run("slideshow.playback", json!({ key: v })); + } +} + +// ---------------------------------------------------------------------------- Display profile + +/// `app.displayProfile {path?: string | null}`: show previews through this monitor ICC profile +/// (`""` or `null`: none, the profile of the screen showing the window where the host knows it +/// (macOS), else the display is treated as sRGB) and keep it as the setting; no `path`: report +/// only. A file that can't be used is an error and changes nothing. Returns the profile in use: +/// `{path, description, kind (matrix|lut), primaries {red, green, blue: [x, y]}}`, with +/// `path: null, screen: true` for the screen's profile. +pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result { + let path = match p.get("path") { + None => None, + Some(Value::Null) => Some(String::new()), + Some(Value::String(s)) => Some(s.trim().to_string()), + Some(_) => return Err("app.displayProfile: path must be a string or null".into()), + }; + if let Some(path) = path { + let d = lightcraft_engine::display::Display::load_opt(&path).map_err(|e| format!("app.displayProfile: {e}"))?; + app.renderer.set_display(d); + app.ui.settings.display_profile = path.clone(); + app.display_applied = Some(path); + app.display_error = None; + app.screen_profile = None; + app.screen_profile_read = None; + if app.ui.settings.display_profile.is_empty() { + app.apply_screen_profile(); + } + } + Ok(match app.renderer.display() { + Some(d) if d.path == crate::SCREEN_PROFILE => { + let mut v = d.describe(); + v["path"] = Value::Null; + v["screen"] = json!(true); + v + } + Some(d) => d.describe(), + None => json!({"path": null, "description": "sRGB (no display profile)"}), + }) +} diff --git a/crates/ui-egui/src/dialogs/settings/general.rs b/crates/ui-egui/src/dialogs/settings/general.rs new file mode 100644 index 000000000..21b94f150 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/general.rs @@ -0,0 +1,175 @@ +use super::widgets::{check, choices, heading, hint, path_label, row}; +use super::*; + +// ------------------------------------------------------------------------------------- General + +pub(super) fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + row(ui, t, crate::i18n::tr("Language"), |ui| { + let languages: Vec<_> = crate::i18n::Locale::for_picker().into_iter().map(|language| (language, language.name())).collect(); + // Thirteen language names do not fit one line in a narrow window: wrap them. + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + }); + crate::i18n::set_language(app.ui.language); + }); + heading(ui, t, crate::i18n::tr("Library")); + let location = match &app.session.library { + Some(l) => l.dir.display().to_string(), + None => crate::i18n::tr("In memory — nothing is saved").to_string(), + }; + row(ui, t, crate::i18n::tr("Location"), |ui| { + path_label(ui, "settingsLocation", &location, egui::TextStyle::Body.resolve(ui.style()), t.text, 0.0); + }); + row(ui, t, "", |ui| { + let can = app.services.pick_folder.is_some(); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Open Library…"))); + register(ui, "button:settingsOpenLibrary", r.rect); + if r.clicked() { + let _ = app.run("app.openLibrary", json!({})); + } + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("New Library…"))); + register(ui, "button:settingsNewLibrary", r.rect); + if r.clicked() { + let _ = app.run("app.newLibrary", json!({})); + } + if !can { + hint(ui, t, crate::i18n::tr("not available here")); + } + }); + // where libraries are folders to choose from (not in the browser, which keeps one) + if app.services.pick_folder.is_some() { + use crate::state::LaunchLibrary; + row(ui, t, crate::i18n::tr("At launch"), |ui| { + choices( + ui, + "settingsAtLaunch", + &[(LaunchLibrary::Last, "Open the last library"), (LaunchLibrary::Ask, "Ask me")], + &mut app.ui.settings.at_launch, + ); + }); + hint(ui, t, crate::i18n::tr("Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.")); + } + // File ▸ Import Lightroom Catalog… (#2672): the window that asks where a catalog goes, or not + ui.label(RichText::new(crate::i18n::tr("When importing a Lightroom catalog")).color(t.text_label)); + row(ui, t, "", |ui| { + use crate::state::LightroomImport; + choices( + ui, + "settingsLightroomImport", + &[(LightroomImport::Ask, "Ask where to import it"), (LightroomImport::AlwaysMerge, "Always merge into the open library")], + &mut app.ui.settings.lightroom_import, + ); + }); + heading(ui, t, crate::i18n::tr("Startup")); + row(ui, t, crate::i18n::tr("Open in"), |ui| { + choices( + ui, + "settingsStartup", + &[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail View")], + &mut app.ui.settings.startup_view, + ); + }); + heading(ui, t, crate::i18n::tr("Culling")); + check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); + check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); + check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk"); + heading(ui, t, crate::i18n::tr("Dialogs")); + check(ui, "settings.closeDialogsOutside", &mut app.ui.settings.close_dialogs_outside, "Close a dialog by clicking outside it"); + heading(ui, t, crate::i18n::tr("External Editing")); + row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { + crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) + .hint(crate::i18n::tr("Found automatically")) + .width(220.0) + .show(ui); + }); + edit_file_rows(ui, t, "photocraftFile", &mut app.ui.settings.photocraft_file); + hint( + ui, + t, + crate::i18n::tr( + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.", + ), + ); + row(ui, t, crate::i18n::tr("Other editor"), |ui| { + crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor) + .hint(crate::i18n::tr("System default")) + .width(220.0) + .show(ui); + }); + edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); + hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); + hint( + ui, + t, + crate::i18n::tr( + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).", + ), + ); +} + +/// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`). +fn edit_file_rows(ui: &mut egui::Ui, t: &Tokens, id: &str, file: &mut crate::state::EditFile) { + use crate::state::{EditFormat, EditSpace}; + row(ui, t, "", |ui| { + choices(ui, &format!("{id}Format"), &[(EditFormat::Psd, "PSD"), (EditFormat::Tiff, "TIFF")], &mut file.format); + ui.separator(); + choices(ui, &format!("{id}Depth"), &[(16u8, "16-bit"), (8, "8-bit")], &mut file.bit_depth); + }); + row(ui, t, "", |ui| { + choices( + ui, + &format!("{id}Space"), + &[(EditSpace::AdobeRgb, "Adobe RGB"), (EditSpace::ProPhoto, "ProPhoto"), (EditSpace::DisplayP3, "P3"), (EditSpace::Srgb, "sRGB")], + &mut file.color_space, + ); + }); +} + +// -------------------------------------------------------------------------------------- Import + +/// A preset picker: `None` = `none_label`. Returns the new choice when it changed. +pub(super) fn preset_combo( + app: &LightcraftApp, + ui: &mut egui::Ui, + id: &str, + label: &str, + current: Option<&str>, + none_label: &str, +) -> Option> { + let name = |pid: &str| { + app.session + .presets + .iter() + .find(|p| p.id == pid) + .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) + .unwrap_or_else(|| crate::i18n::tr_format!("{pid} (missing)", pid = pid)) + }; + let text = current.map(name).unwrap_or_else(|| crate::i18n::tr(none_label).to_string()); + let mut out = None; + let r = crate::widgets::combo_box(id).width(240.0).selected_text(text).show_ui(ui, |ui| { + if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() { + out = Some(None); + } + if let Some(pid) = crate::panels::presets::picker_rows(ui, id, &app.session.presets, current) { + out = Some(Some(pid)); + } + }); + crate::widgets::name_combo(&r.response, label); + register(ui, format!("combo:{id}"), r.response.rect); + out.filter(|v| v.as_deref() != current) +} + +/// Cameras of the raws in the library plus those with a stored default, sorted. +pub(super) fn cameras(app: &LightcraftApp) -> Vec { + let mut v: Vec = app + .session + .catalog + .photos() + .filter(|p| p.kind == lightcraft_catalog::MediaKind::Raw && !p.meta.camera.is_empty()) + .map(|p| p.meta.camera.clone()) + .chain(app.session.import_defaults.cameras.iter().map(|c| c.camera.clone())) + .collect(); + v.sort_by_key(|c| c.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v +} diff --git a/crates/ui-egui/src/dialogs/settings/import.rs b/crates/ui-egui/src/dialogs/settings/import.rs new file mode 100644 index 000000000..c7272d366 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/import.rs @@ -0,0 +1,187 @@ +use super::general::{cameras, preset_combo}; +use super::widgets::{button_width, check, choices, heading, hint, path_label, row}; +use super::*; + +pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let d = app.session.import_defaults.clone(); + heading(ui, t, crate::i18n::tr("Raw defaults")); + hint(ui, t, crate::i18n::tr("Settings new raw photos start from. Changing them doesn't touch photos already in the library.")); + row(ui, t, crate::i18n::tr("Raw photos"), |ui| { + if let Some(v) = preset_combo(app, ui, "settingsRawPreset", crate::i18n::tr("Raw default"), d.raw_preset.as_deref(), "LightCraft Default") { + let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}})); + } + }); + let mut per = d.per_camera; + row(ui, t, "", |ui| { + if check(ui, "settings.perCamera", &mut per, "Use camera-specific defaults") { + let _ = app.run("library.preferences", json!({"import": {"perCamera": per}})); + } + }); + if per { + let cams = cameras(app); + if cams.is_empty() { + hint(ui, t, crate::i18n::tr("No raw photos yet: cameras appear here once their photos are in the library.")); + } + egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| { + for (i, cam) in cams.iter().enumerate() { + let entry = d.cameras.iter().find(|c| c.camera.eq_ignore_ascii_case(cam)); + row(ui, t, cam, |ui| { + // "Raw default" = no entry; otherwise the entry's preset (None = LightCraft Default) + const RAW_DEFAULT: &str = "\u{1}raw"; + let current = match entry { + None => Some(RAW_DEFAULT), + Some(e) => e.preset.as_deref(), + }; + let mut pick = None; + let label = match current { + Some(RAW_DEFAULT) => crate::i18n::tr("Same as raw default").to_string(), + None => crate::i18n::tr("LightCraft Default").to_string(), + Some(pid) => app + .session + .presets + .iter() + .find(|p| p.id == pid) + .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) + .unwrap_or_else(|| pid.to_string()), + }; + let id = format!("settingsCamera-{i}"); + let r = crate::widgets::combo_box(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { + if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() { + pick = Some(json!({"camera": cam, "remove": true})); + } + if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() { + pick = Some(json!({"camera": cam, "preset": null})); + } + if let Some(pid) = crate::panels::presets::picker_rows(ui, &id, &app.session.presets, current) { + pick = Some(json!({"camera": cam, "preset": pid})); + } + }); + crate::widgets::name_combo(&r.response, crate::i18n::tr("Camera")); + register(ui, format!("combo:{id}"), r.response.rect); + if let Some(c) = pick { + let _ = app.run("library.preferences", json!({"camera": c})); + } + }); + } + }); + } + heading(ui, t, crate::i18n::tr("Other images (JPEG, PNG, TIFF, HEIC…)")); + row(ui, t, crate::i18n::tr("Non-raw photos"), |ui| { + if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", crate::i18n::tr("Other preset"), d.other_preset.as_deref(), "None") { + let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}})); + } + }); + heading(ui, t, crate::i18n::tr("Metadata")); + hint(ui, t, crate::i18n::tr("Added to photos you import that don't already have it.")); + for (key, label, hint_text, value) in + [("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())] + { + row(ui, t, label, |ui| { + let id = egui::Id::unique(("settingsMeta", key)); + let mut text: String = ui.data(|m| m.get_temp(id)).unwrap_or(value.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(hint_text).desired_width(240.0)); + register(ui, format!("field:settings.{key}"), r.rect); + if r.lost_focus() && text.trim() != value { + let _ = app.run("library.preferences", json!({"import": {key: text.trim()}})); + } + if r.has_focus() { + ui.data_mut(|m| m.insert_temp(id, text)); + } else { + ui.data_mut(|m| m.remove::(id)); + } + }); + } + if !app.session.metadata_presets.is_empty() { + row(ui, t, crate::i18n::tr("Metadata preset"), |ui| { + let cur = d.metadata_preset.clone(); + let mut pick = None; + let r = crate::widgets::combo_box("settingsMetaPreset") + .width(240.0) + .selected_text(cur.clone().unwrap_or_else(|| crate::i18n::tr("None").into())) + .show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), crate::i18n::tr("None")).clicked() { + pick = Some(String::new()); + } + for m in &app.session.metadata_presets { + if ui.selectable_label(cur.as_deref() == Some(m.name.as_str()), &m.name).clicked() { + pick = Some(m.name.clone()); + } + } + }); + crate::widgets::name_combo(&r.response, crate::i18n::tr("Metadata preset")); + register(ui, "combo:settingsMetaPreset", r.response.rect); + if let Some(n) = pick { + let _ = app.run("library.preferences", json!({"import": {"metadataPreset": n}})); + } + }); + } + heading(ui, t, crate::i18n::tr("XMP sidecars")); + let mut xmp = app.session.xmp; + if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") { + let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write})); + } + row(ui, t, crate::i18n::tr("Sidecar names"), |ui| { + use lightcraft_engine::sidecar::SidecarNaming as N; + let mut n = xmp.naming; + if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) { + let naming = if n == N::Full { "full" } else { "stem" }; + let _ = app.run("library.xmpPreferences", json!({"naming": naming})); + } + }); + if !cfg!(target_arch = "wasm32") { + heading(ui, t, crate::i18n::tr("Dropped Files")); + check(ui, "settings.importDropped", &mut app.ui.settings.import_dropped_directly, "Import dropped files without review"); + heading(ui, t, crate::i18n::tr("Cameras and Cards")); + let mut on_card = d.show_on_card; + if check(ui, "settings.importOnCard", &mut on_card, "Show the import review when a camera or card is connected") { + let _ = app.run("library.preferences", json!({"import": {"showOnCard": on_card}})); + } + heading(ui, t, crate::i18n::tr("Auto Import")); + hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.")); + row(ui, t, crate::i18n::tr("Watched folder"), |ui| { + match &d.auto_folder { + Some(folder) => path_label( + ui, + "settingsAutoFolder", + folder, + egui::TextStyle::Body.resolve(ui.style()), + t.text, + button_width(ui, crate::i18n::tr("Choose…")) + button_width(ui, crate::i18n::tr("Turn Off")), + ), + None => drop(ui.label(RichText::new(crate::i18n::tr("Off")).color(t.text))), + } + let can = app.services.pick_folder.is_some(); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…"))); + register(ui, "button:settingsAutoFolder", r.rect); + if r.clicked() + && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) + && let Err(e) = app.run("library.autoImport", json!({"folder": f})) + { + app.toast(ui.ctx(), e); + } + if d.auto_folder.is_some() && ui.button(crate::i18n::tr("Turn Off")).clicked() { + let _ = app.run("library.autoImport", json!({"folder": null})); + } + }); + if d.auto_folder.is_some() { + row(ui, t, "", |ui| { + let mut copy = d.auto_copy; + if ui.checkbox(&mut copy, crate::i18n::tr("Copy into the library (else use the files where they are)")).changed() { + let _ = app.run("library.autoImport", json!({"copy": copy})); + } + }); + row(ui, t, crate::i18n::tr("Album"), |ui| { + let id = egui::Id::unique("auto-album"); + let mut name = ui.data(|m| m.get_temp::(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default()); + let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0)); + if r.lost_focus() { + let _ = app.run("library.autoImport", json!({"album": name.trim()})); + } + ui.data_mut(|m| m.insert_temp(id, name)); + }); + } + } + if app.session.library.is_none() { + hint(ui, t, crate::i18n::tr("In-memory session: these settings last until LightCraft quits.")); + } +} diff --git a/crates/ui-egui/src/dialogs/settings/libraries.rs b/crates/ui-egui/src/dialogs/settings/libraries.rs new file mode 100644 index 000000000..389f79f11 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/libraries.rs @@ -0,0 +1,313 @@ +use super::*; + +// ------------------------------------------------------------------------------- Open Library… + +/// Switching libraries is refused while an import (or the folder scan before one) is running: its +/// remaining batches would be committed into the new library (issue #1509). +pub(crate) fn can_switch_library(app: &LightcraftApp) -> Result<(), String> { + if crate::lightroom_import::is_running(app) { + return Err("wait for Lightroom catalog import to finish before switching libraries".into()); + } + if app.import.is_some() || app.scan.is_some() { + return Err("wait for the import to finish before switching libraries".into()); + } + Ok(()) +} + +/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, +/// or a folder chosen in a dialog. Remembered as the library to open at launch. +/// +/// A Lightroom Classic catalog (its folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` +/// itself, see [`lightcraft_engine::lightroom_folder`]) is not opened: the current library stays +/// and a dialog offers Import Lightroom Catalog… instead; returns `{opened: false, +/// lightroomCatalog: <.lrcat path or null>, path}`. +pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result { + can_switch_library(app)?; + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let req = crate::pick::PickRequest::folder(crate::i18n::tr("Open Library")); + match crate::pick::ask(app, "app.openLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { + crate::pick::Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Ok(Value::Null), + }, + crate::pick::Picked::Later => return Ok(Value::Null), + crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), + } + } + }; + // A Lightroom catalog folder (or part of one) is not a library: before closing the library + // open now, offer to import it instead (issue #891). The engine refuses it too. + if let Some(place) = lightcraft_engine::lightroom_folder::lightroom_catalog_at(std::path::Path::new(&path)) { + let catalog = place.catalog.map(|c| c.to_string_lossy().into_owned()); + let err = lightcraft_engine::EngineError::LightroomCatalog { path: path.clone().into(), catalog: catalog.clone().map(Into::into) }; + // the library problem window (no library could be opened) shows the error instead + if app.library_problem.is_some() { + return Err(err.to_string()); + } + app.ui.dialog = Some(crate::state::Dialog::LightroomCatalog { folder: path.clone(), catalog: catalog.clone() }); + return Ok(json!({"opened": false, "lightroomCatalog": catalog, "path": path})); + } + // a remembered library that is gone: say so (and forget it) instead of creating an empty one there + if app.ui.settings.recent_libraries.contains(&path) && !std::path::Path::new(&path).exists() { + app.ui.settings.recent_libraries.retain(|r| r != &path); + return Err(format!("{path}: the library is no longer there (moved, renamed or on a disconnected drive); removed from Open Recent Library")); + } + switch_library(app, &path) +} + +/// Close the current library and open the one at `path`. If it can't be opened (locked by +/// another program, unreadable, a file), the error is returned and the current library stays open: +/// the target is checked before anything is closed, and if opening it still fails the previous +/// library is opened again (both errors are reported if that fails too). +fn switch_library(app: &mut LightcraftApp, path: &str) -> Result { + let dir = app.session.check_library(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; + let path = dir.to_string_lossy().into_owned(); + // the library being left goes in the list too (also when it was opened at launch) + let previous_dir = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let previous = previous_dir.as_ref().map(|d| d.to_string_lossy().into_owned()); + app.session.close_library().map_err(|e| e.to_string())?; + let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}")); + app.lightroom_last = None; + app.lightroom_result = None; + if matches!(app.ui.dialog.as_ref(), Some(crate::state::Dialog::LightroomResult { .. })) { + app.ui.dialog = None; + } + app.renderer.forget_all(); + if let Err(err) = opened { + let Some(prev) = previous_dir else { return Err(err) }; + return match app.session.open_library(&prev, false) { + Ok(_) => Err(err), + Err(e) => Err(format!("{err}; reopening the previous library {} failed too: {e}", prev.display())), + }; + } + // Photo IDs are local to a catalog: pins cannot follow them into another library (a refused + // or failed switch reopens the same catalog, so its pins stay). + app.ui.reference = None; + app.ui.compare = None; + app.ui.brief_info = crate::state::BriefInfo::default(); + if app.ui.view == crate::state::ViewMode::Reference { + app.ui.view = crate::state::ViewMode::PhotoGrid; + } + app.ui.settings.library_path = path.clone(); + if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) { + remember_library(&mut app.ui.settings.recent_libraries, &prev); + } + remember_library(&mut app.ui.settings.recent_libraries, &path); + Ok(json!({"path": path, "name": library_name(&dir), "photos": app.session.catalog.len()})) +} + +/// Put `path` first in the recent list, without duplicates, capped at [`RECENT_LIBRARIES`]. +pub fn remember_library(recent: &mut Vec, path: &str) { + recent.retain(|r| r != path); + recent.insert(0, path.to_string()); + recent.truncate(crate::state::RECENT_LIBRARIES); +} + +/// A library's name as people see it: its folder's name. +pub fn library_name(dir: &std::path::Path) -> String { + dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string()) +} + +/// The open library's name (`None`: in memory, nothing is saved). +pub fn open_library_name(app: &LightcraftApp) -> Option { + app.session.library.as_ref().filter(|l| l.on_disk).map(|l| library_name(&l.dir)) +} + +/// `app.newLibrary {path?, name?}`: create a library and switch to it. With `name`, the library +/// is the folder `name` inside `path` (or inside a location chosen in a dialog); without it, +/// `path` itself, or a new folder named after the default library. The folder must not hold a +/// library already, nor anything else. +pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result { + can_switch_library(app)?; + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(valid_library_name(s)?), + Some(_) => return Err("app.newLibrary: name must be a string".into()), + }; + // from the menu: ask for the name first (default prefilled), then for the location + if name.is_none() && p.get("path").is_none() && app.services.pick_folder.is_some() { + crate::panels::dialogs::prompt( + app, + "New Library", + "Library name", + lightcraft_engine::library::DEFAULT_NAME, + "app.newLibrary", + json!({}), + "name", + ); + return Ok(Value::Null); + } + let (location, picked) = match p.get("path").and_then(Value::as_str) { + Some(x) => (std::path::PathBuf::from(x), false), + None => { + let req = crate::pick::PickRequest::folder(crate::i18n::tr("New Library: Choose Where to Put It")); + match crate::pick::ask(app, "app.newLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { + crate::pick::Picked::Now(v) => match v.into_iter().next() { + Some(x) => (std::path::PathBuf::from(x), true), + None => return Ok(Value::Null), + }, + crate::pick::Picked::Later => return Ok(Value::Null), + crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), + } + } + }; + let dir = match name { + Some(n) => location.join(n), + // a chosen location gets a new folder in it, named after the default library + None if picked => free_name(&location, lightcraft_engine::library::DEFAULT_NAME), + None => location, + }; + if lightcraft_engine::library::is_library(&dir) { + return Err(format!("{}: there is a library here already; open it with File ▸ Open Library…", dir.display())); + } + if dir.is_file() { + return Err(format!("{}: is a file; a library is a folder", dir.display())); + } + if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) { + return Err(format!("{}: the folder isn't empty; choose an empty folder or a new name", dir.display())); + } + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: can't create the library folder: {e}", dir.display()))?; + switch_library(app, &dir.to_string_lossy()) +} + +/// Characters a folder name can't hold on Windows (and `/`, which no system allows). +const RESERVED_NAME_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +fn valid_library_name(s: &str) -> Result<&str, String> { + let n = s.trim(); + // Windows drops trailing dots and spaces from a folder name: refuse them rather than + // creating a folder whose name differs from the one asked for (spaces around the name + // are trimmed first) + let bad = n.is_empty() || n == "." || n == ".." || n.ends_with(['.', ' ']) || n.contains(RESERVED_NAME_CHARS) || n.chars().any(char::is_control); + if bad { + return Err(format!( + "app.newLibrary: {s:?} can't be a library name (a folder name without < > : \" / \\ | ? * and not ending in a dot or space)" + )); + } + Ok(n) +} + +/// `base` inside `location`, or `base 2`, `base 3`… if that is taken. +pub(crate) fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { + let first = location.join(base); + if !first.exists() { + return first; + } + (2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first) +} + +/// `app.recentLibraries {remove?: path, clear?: bool}`: the recently opened libraries, newest +/// first, as `[{path, name, exists, checking, open, lastOpened}]`. `exists` is what the last +/// background check found (`false`: moved, on a disconnected drive, not a folder or no longer a +/// library; `null`: not checked yet, or the check couldn't tell — a folder that can't be listed, an +/// unreadable catalog, a lookup or check that failed; `checking` says whether a check is running): the command never +/// looks at a folder itself, as one on an offline network share can take seconds to answer. +/// `lastOpened`: ISO 8601 time, `null` when not known. `remove` forgets that +/// one entry (an error if the list doesn't hold it) and `clear` forgets them all; neither touches +/// anything on disk (issue #1290). +pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result { + match p.get("remove") { + None | Some(Value::Null) => {} + Some(Value::String(path)) => { + if !forget_library(&mut app.ui.settings, path) { + return Err(format!("app.recentLibraries: {path:?} is not in the recent libraries")); + } + app.recent_checks.forget(path); + } + Some(_) => return Err("app.recentLibraries: remove must be a library path (a string)".into()), + } + if p.get("clear").and_then(Value::as_bool) == Some(true) { + for path in std::mem::take(&mut app.ui.settings.recent_libraries) { + app.recent_checks.forget(&path); + } + app.ui.settings.recent_opened.clear(); + } + let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let (settings, checks) = (&app.ui.settings, &app.recent_checks); + let list: Vec = settings + .recent_libraries + .iter() + .map(|r| { + let d = std::path::Path::new(r); + let exists = checks.exists(r); + json!({"path": r, "name": library_name(d), "exists": exists, "checking": checks.is_checking(r), "open": open.as_deref() == Some(d), "lastOpened": settings.recent_opened.get(r)}) + }) + .collect(); + Ok(Value::Array(list)) +} + +/// Forget `path` in File ▸ Open Recent Library (and when it was opened); the library itself is +/// left where it is. False if the list didn't hold it. +pub fn forget_library(settings: &mut crate::state::AppSettings, path: &str) -> bool { + let before = settings.recent_libraries.len(); + settings.recent_libraries.retain(|r| r != path); + settings.recent_opened.remove(path); + settings.recent_libraries.len() != before +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn app_with_import() -> LightcraftApp { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); + app.run("photo.setReference", json!({})).unwrap(); + app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false)); + app + } + + #[test] + fn open_library_is_refused_while_an_import_runs() { + let mut app = app_with_import(); + let pin = app.ui.reference; + let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err(); + assert!(err.contains("import to finish"), "{err}"); + assert!(app.import.is_some()); + assert_eq!(app.ui.reference, pin, "a refused Open Library keeps the reference pin"); + } + + #[test] + fn new_library_is_refused_while_an_import_runs() { + let mut app = app_with_import(); + let pin = app.ui.reference; + let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err(); + assert!(err.contains("import to finish"), "{err}"); + assert!(app.import.is_some()); + assert_eq!(app.ui.reference, pin, "a refused New Library keeps the reference pin"); + } + + /// The menu items that switch libraries are greyed out meanwhile, like during a Lightroom import. + #[test] + fn library_switching_menu_items_are_disabled_while_an_import_runs() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.services.pick_folder = Some(Box::new(|| None)); + assert!(crate::menus::ui_enabled(&app, "app.openLibrary")); + assert!(crate::menus::ui_enabled(&app, "app.newLibrary")); + app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false)); + assert!(!crate::menus::ui_enabled(&app, "app.openLibrary")); + assert!(!crate::menus::ui_enabled(&app, "app.newLibrary")); + } + + /// The folder scan before an import's review counts too: it ends in an import into the open library. + #[test] + fn open_library_is_refused_while_an_import_scan_runs() { + let dir = std::env::temp_dir().join(format!("lc-ui-switchscan-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default()); + app.run("photo.setReference", json!({})).unwrap(); + let pin = app.ui.reference; + crate::import::open(&mut app, vec![dir.to_string_lossy().into_owned()]).unwrap(); + assert!(app.scan.is_some()); + let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err(); + assert!(err.contains("import to finish"), "{err}"); + assert_eq!(app.ui.reference, pin, "a refused Open Library during a scan keeps the reference pin"); + let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err(); + assert!(err.contains("import to finish"), "{err}"); + assert_eq!(app.ui.reference, pin, "a refused New Library during a scan keeps the reference pin"); + assert!(app.scan.is_some()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/dialogs/settings/performance.rs b/crates/ui-egui/src/dialogs/settings/performance.rs new file mode 100644 index 000000000..daf2fa435 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/performance.rs @@ -0,0 +1,185 @@ +use super::widgets::{check, choices, heading, hint, row}; +#[cfg(not(target_arch = "wasm32"))] +use super::widgets::{label_width, path_label}; +use super::*; + +// --------------------------------------------------------------------------------- Performance + +pub(super) fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use lightcraft_engine::gpu; + heading(ui, t, crate::i18n::tr("Rendering")); + check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); + let status = if !gpu::available() { + match gpu::unavailable_reason() { + Some(why) => crate::i18n::tr_format!("Rendering on the CPU: {why}", why = why), + None => "No usable GPU found: rendering on the CPU".to_string(), + } + } else { + format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) + }; + // One line, the whole text on hover: the reason can be a long driver message (no adapter + // among the backends …), and the GPU state is process-wide, so it may change between frames; + // a hint that wrapped would move every control below it under the pointer. + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(&status)).size(11.0).color(t.text_dim)).truncate()).on_hover_text(&status); + row(ui, t, crate::i18n::tr("Preview size"), |ui| { + let opts: Vec<(u32, String)> = + PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect(); + let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect(); + choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_limit); + }); + hint( + ui, + t, + crate::i18n::tr( + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.", + ), + ); + row(ui, t, crate::i18n::tr("Memory for caches"), |ui| { + let auto = crate::i18n::tr_format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20); + let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")]; + choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb); + }); + heading(ui, t, crate::i18n::tr("Thumbnail cache")); + let cur = (app.session.cache_bytes() >> 20) as u32; + row(ui, t, crate::i18n::tr("Size limit"), |ui| { + let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")]; + let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 }; + if choices(ui, "settingsCache", &opts, &mut v) { + let _ = app.run("library.preferences", json!({"cacheMb": v})); + } + }); + let used = app.session.media.rendered.disk().map(|d| d.size()); + row(ui, t, crate::i18n::tr("In use"), |ui| { + ui.label( + RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| crate::i18n::tr("memory only").to_string())) + .color(t.text), + ); + let r = ui.button(crate::i18n::tr("Clear Cache")); + register(ui, "button:settingsClearCache", r.rect); + if r.clicked() { + let _ = app.run("library.clearPreviews", json!({})); + } + }); + heading(ui, t, crate::i18n::tr("Local folders")); + row(ui, t, crate::i18n::tr("Forget unchanged photos"), |ui| { + let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")]; + let mut v = app.session.forget_local_days; + if choices(ui, "settingsForgetLocal", &opts, &mut v) { + let _ = app.run("library.preferences", json!({"forgetLocalDays": v})); + } + }); + hint( + ui, + t, + crate::i18n::tr( + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.", + ), + ); + #[cfg(not(target_arch = "wasm32"))] + smart_previews(app, ui, t); +} + +/// Where this library keeps its smart previews (the offline-editing proxies, which can be large): +/// the effective folder, what is in it, and choosing another one. +#[cfg(not(target_arch = "wasm32"))] +fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + // listing a big folder every frame would be slow: refresh every 2 s and after a change + let cache = egui::Id::unique("smart-location"); + let now = ui.input(|i| i.time); + let loc: Value = match ui.data(|d| d.get_temp::<(f64, Value)>(cache)) { + Some((at, v)) if now - at < 2.0 => v, + _ => { + let v = app.run("library.smartPreviewsLocation", json!({})).unwrap_or(Value::Null); + ui.data_mut(|d| d.insert_temp(cache, (now, v.clone()))); + v + } + }; + if loc.is_null() { + return; + } + heading(ui, t, crate::i18n::tr("Smart previews")); + let path = loc["path"].as_str().unwrap_or_default().to_string(); + let available = loc["available"].as_bool().unwrap_or(true); + let custom = loc["custom"].as_bool().unwrap_or(false); + row(ui, t, crate::i18n::tr("Folder"), |ui| { + path_label( + ui, + "settingsSmartFolder", + &path, + egui::TextStyle::Body.resolve(ui.style()), + if available { t.text } else { t.reject }, + if custom { 0.0 } else { label_width(ui, "(library default)", egui::TextStyle::Body) }, + ); + if !custom { + let r = ui.label(RichText::new("(library default)").color(t.text)); + register(ui, "text:settingsSmartDefault", r.rect); + } + }); + let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0)); + if available { + hint( + ui, + t, + &crate::i18n::tr_format!( + "{count} smart preview{} · {:.1} MB", + if count == 1 { "" } else { "s" }, + bytes as f64 / 1048576.0, + count = count + ), + ); + } else { + hint( + ui, + t, + crate::i18n::tr( + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.", + ), + ); + } + // what happens to the previews already built when the folder changes + let mode_id = egui::Id::unique("smart-existing"); + let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0); + if count > 0 { + row(ui, t, crate::i18n::tr("Existing previews"), |ui| { + if choices(ui, "settingsSmartExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) { + ui.data_mut(|d| d.insert_temp(mode_id, mode)); + } + }); + } + let existing = ["move", "leave", "discard"][mode.min(2) as usize]; + let mut result = None; + ui.horizontal(|ui| { + ui.add_space(LABEL_W); + if app.services.pick_folder.is_some() { + let r = ui.button(crate::i18n::tr("Choose Folder…")); + register(ui, "button:settingsSmartChoose", r.rect); + if r.clicked() + && let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f()) + { + result = Some(app.run("library.smartPreviewsLocation", json!({"path": dir, "existing": existing}))); + } + } + let r = ui.add_enabled(custom, egui::Button::new(crate::i18n::tr("Use Library Folder"))); + register(ui, "button:settingsSmartReset", r.rect); + if r.clicked() { + result = Some(app.run("library.smartPreviewsLocation", json!({"reset": true, "existing": existing}))); + } + }); + if let Some(r) = result { + ui.data_mut(|d| d.remove::<(f64, Value)>(cache)); + match r { + Ok(v) => { + let failed = v["failed"].as_array().map_or(0, Vec::len); + let msg = match (existing, v["handled"].as_u64().unwrap_or(0)) { + (_, 0) => "Smart previews folder changed".to_string(), + ("move", n) => format!("Smart previews folder changed; moved {n}"), + ("discard", n) => format!("Smart previews folder changed; deleted {n}"), + _ => "Smart previews folder changed".to_string(), + }; + app.toast(ui.ctx(), if failed > 0 { crate::i18n::tr_format!("{msg} ({failed} failed)", failed = failed, msg = msg) } else { msg }); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.")); +} diff --git a/crates/ui-egui/src/dialogs/settings/slideshow.rs b/crates/ui-egui/src/dialogs/settings/slideshow.rs new file mode 100644 index 000000000..b67427c40 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/slideshow.rs @@ -0,0 +1,120 @@ +use super::display::duration_slider; +use super::widgets::{check, choices, heading, hint, row}; +use super::*; + +/// The Slideshow tab: the slide preview, then the settings, in one column the width of the page +/// (the page scrolls; a column beside the preview would be wider than the window). Without +/// `with_preview` the preview is left out: [`window`] has pinned it above the page. +pub(super) fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, with_preview: bool) { + if with_preview { + crate::panels::slide_layout::preview(app, ui, t, f32::INFINITY); + ui.add_space(6.0); + } + crate::panels::slide_templates::section(app, ui, t); + ui.add_space(6.0); + playback_section(app, ui, t); + ui.add_space(6.0); + crate::panels::slide_layout::sections(app, ui, t); + ui.add_space(6.0); + crate::panels::slide_overlays::sections(app, ui, t); + ui.add_space(6.0); + crate::panels::slide_titles::sections(app, ui, t); + ui.add_space(6.0); + export_section(app, ui, t); +} + +/// Export: PDF, JPEG sequence or video of the slideshow (View ▸ Export Slideshow…). +fn export_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, crate::i18n::tr("Export")); + let running = app.session.slideshow_export.as_ref().is_some_and(|r| r.running()); + ui.horizontal_wrapped(|ui| { + for (id, label, format) in [ + ("slideshowExportPdf", "Export PDF…", "pdf"), + ("slideshowExportJpeg", "Export JPEG…", "jpeg"), + ("slideshowExportVideo", "Export Video…", "video"), + ] { + let r = ui.add_enabled(!running, egui::Button::new(crate::i18n::tr(label))); + register(ui, format!("button:{id}"), r.rect); + if r.clicked() { + let _ = app.run("dialog.slideshowExport", json!({"format": format})); + } + } + }); + hint(ui, t, crate::i18n::tr("The photos in view as slides, with the settings above. Progress shows in the activity stack.")); +} + +fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; + heading(ui, t, crate::i18n::tr("Playback")); + let s = app.session.slideshow.clone(); + let mut v = s.manual; + if check(ui, "settings.slideshowManual", &mut v, "Manual slideshow") { + let _ = app.run("slideshow.playback", json!({"manual": v})); + } + hint(ui, t, crate::i18n::tr("A manual slideshow shows each slide until → or ← changes it. Exports still use the slide duration.")); + row(ui, t, crate::i18n::tr("Slide duration"), |ui| { + duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX) + }); + row(ui, t, crate::i18n::tr("Fade duration"), |ui| { + duration_slider(app, ui, "slideshowFade", "fadeDuration", s.fade_duration, 0.0..=FADE_MAX.min(s.slide_duration)) + }); + hint(ui, t, crate::i18n::tr("A slide stays up for its duration, its fade in included. A fade of 0 cuts.")); + row(ui, t, crate::i18n::tr("Fade"), |ui| { + #[derive(Clone, Copy, PartialEq)] + enum Fade { + Cross, + Black, + White, + Color, + } + let mut f = match s.fade_color { + None => Fade::Cross, + Some([0, 0, 0]) => Fade::Black, + Some([255, 255, 255]) => Fade::White, + Some(_) => Fade::Color, + }; + let opts = [(Fade::Cross, "Crossfade"), (Fade::Black, "Through black"), (Fade::White, "Through white"), (Fade::Color, "Through a colour")]; + if choices(ui, "settingsSlideshowFade", &opts, &mut f) { + let c = match f { + Fade::Cross => Value::Null, + Fade::Black => json!("#000000"), + Fade::White => json!("#ffffff"), + Fade::Color => json!([64, 64, 64]), + }; + let _ = app.run("slideshow.playback", json!({"fadeColor": c})); + } + if f == Fade::Color + && let Some(mut c) = s.fade_color + { + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui, "button:slideshowFadeColor", r.rect); + if r.changed() { + let _ = app.run("slideshow.playback", json!({"fadeColor": c})); + } + } + }); + let mut v = s.random_order; + if check(ui, "settings.slideshowRandom", &mut v, "Random order") { + let _ = app.run("slideshow.playback", json!({"randomOrder": v})); + } + let mut v = s.repeat; + if check(ui, "settings.slideshowRepeat", &mut v, "Repeat (off: the slideshow ends after the last photo)") { + let _ = app.run("slideshow.playback", json!({"repeat": v})); + } + let mut v = s.prepare_previews; + if check(ui, "settings.slideshowPrepare", &mut v, "Prepare previews in advance") { + let _ = app.run("slideshow.playback", json!({"preparePreviews": v})); + } + hint( + ui, + t, + crate::i18n::tr( + "Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).", + ), + ); + hint( + ui, + t, + crate::i18n::tr("Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library."), + ); +} diff --git a/crates/ui-egui/src/dialogs/settings/widgets.rs b/crates/ui-egui/src/dialogs/settings/widgets.rs new file mode 100644 index 000000000..3aea00b44 --- /dev/null +++ b/crates/ui-egui/src/dialogs/settings/widgets.rs @@ -0,0 +1,143 @@ +use super::*; + +/// `path` shortened to fit `max` (as `width` measures it) by dropping leading folders, after +/// either kind of separator, so that its end stays (`…/Pictures/Library`, +/// `…\Pictures\Library`): the end says which folder it is. A last folder too long by itself +/// loses its end ([`crate::widgets::elide_head`]). +pub(super) fn elide_path(path: &str, max: f32, width: impl Fn(&str) -> f32) -> String { + if width(path) <= max { + return path.to_string(); + } + // (a separator at the very end is not where a name starts: `…/` alone would say nothing) + let end = path.trim_end_matches(['/', '\\']).len(); + let mut rest = path; + while let Some(i) = rest.find(['/', '\\']).filter(|i| path.len() - rest.len() + i + 1 < end) { + let separator = if rest.get(i..).is_some_and(|r| r.starts_with('\\')) { '\\' } else { '/' }; + rest = rest.get(i + 1..).unwrap_or(""); + let shorter = format!("…{separator}{rest}"); + if width(&shorter) <= max { + return shorter; + } + } + let name = rest.trim_end_matches(['/', '\\']); + // (nothing but separators: never an empty label) + if name.is_empty() { "…".to_string() } else { crate::widgets::elide_head(name, max, width) } +} + +/// A folder or file path on one line in the room left in its row (`text:{id}`): shortened from +/// the front when it is too long ([`elide_path`]), with all of it in a tooltip then. `after` is +/// the width of what follows it on the line (a button, a note) and its spacing: the path leaves +/// that room, so those never wrap onto a second line. +pub(super) fn path_label(ui: &mut egui::Ui, id: &str, path: &str, font: egui::FontId, color: egui::Color32, after: f32) { + let shown = + elide_path(path, (ui.available_width() - after).max(0.0), |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x); + // (`truncate` is the last resort, for a room narrower than "…") + let r = ui.add(egui::Label::new(RichText::new(&shown).font(font).color(color)).truncate().show_tooltip_when_elided(false)); + register(ui, format!("text:{id}"), r.rect); + if shown != path { + r.on_hover_text(path); + } +} + +/// The width of `text` in the style `style`, and the gap before it: what a label takes in a row. +pub(super) fn label_width(ui: &egui::Ui, text: &str, style: egui::TextStyle) -> f32 { + ui.painter().layout_no_wrap(text.to_string(), style.resolve(ui.style()), egui::Color32::WHITE).size().x + ui.spacing().item_spacing.x +} + +/// The same for a button, which has padding either side of its text. +pub(super) fn button_width(ui: &egui::Ui, text: &str) -> f32 { + label_width(ui, text, egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.x +} + +pub(crate) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { + ui.add_space(4.0); + ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text)); +} + +pub(crate) fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(LABEL_W); + // (a label longer than its column wraps in it; it does not push the controls along) + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(label)).color(t.text_label)).wrap()); + }); + // The controls wrap in the room beside the label, each further line starting under the + // first control, so a row with many choices grows down and never widens the window. + let wrapped = egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 24.0), wrapped, |ui| { + ui.spacing_mut().item_spacing.y = 4.0; + add(ui) + }) + .inner + }) + .inner +} + +pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) { + ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim)); +} + +/// A checkbox addressable as `check:{id}`; true when toggled. +pub(crate) fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool { + let r = ui.checkbox(value, crate::i18n::tr(label)); + register(ui, format!("check:{id}"), r.rect); + r.changed() +} + +/// Mutually exclusive buttons (`button:{id}-{index}`). +pub(crate) fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool { + let mut changed = false; + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (v, l)) in options.iter().enumerate() { + if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() && *value != *v { + *value = *v; + changed = true; + } + } + changed +} + +#[cfg(test)] +mod path_tests { + use super::elide_path; + + /// Width = characters, so a limit of 20 is "20 characters". + fn fit(path: &str, max: usize) -> String { + elide_path(path, max as f32, |s| s.chars().count() as f32) + } + + #[test] + fn a_path_that_fits_is_left_alone() { + assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures"); + assert_eq!(fit("", 5), ""); + } + + #[test] + fn leading_folders_go_first_and_the_end_stays() { + assert_eq!(fit("/Users/me/Pictures/Library", 20), "…/Pictures/Library"); + assert_eq!(fit("/Users/me/Pictures/Library", 10), "…/Library"); + } + + /// Windows paths: `Path::display` gives backslashes there. + #[test] + fn a_path_with_backslashes_keeps_its_end_too() { + assert_eq!(fit(r"C:\Users\me\Pictures\Library", 20), r"…\Pictures\Library"); + assert_eq!(fit(r"C:\Users\me/Pictures\Library", 10), r"…\Library"); + } + + #[test] + fn a_last_folder_too_long_by_itself_loses_its_end() { + assert_eq!(fit("/Users/me/a-very-long-folder-name", 8), "a-very-…"); + assert_eq!(fit("no-separator-at-all", 8), "no-sepa…"); + assert_eq!(fit("/Users/Фотографии-из-отпуска", 8), "Фотогра…"); + } + + #[test] + fn a_separator_at_the_end_is_not_where_the_name_starts() { + assert_eq!(fit("/Users/me/Pictures/Library/", 11), "…/Library/"); + assert_eq!(fit("/Users/me/a-very-long-folder-name/", 8), "a-very-…"); + assert_eq!(fit(r"C:\Users\me\Library\", 11), r"…\Library\"); + assert_eq!(fit("///", 2), "…"); + assert_eq!(fit(r"\\server\share\folder", 15), r"…\share\folder"); + } +} From 20c26b83c849c9eb57bcb7895ecea2cb503ce3ce Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:13:11 +0000 Subject: [PATCH 127/191] refactor(lightcraft-ui-egui): split dialogs/import.rs into dialogs/import/ --- crates/ui-egui/src/dialogs/import.rs | 1474 +-------------------- crates/ui-egui/src/dialogs/import/body.rs | 558 ++++++++ crates/ui-egui/src/dialogs/import/scan.rs | 272 ++++ crates/ui-egui/src/dialogs/import/tags.rs | 95 ++ crates/ui-egui/src/dialogs/import/task.rs | 544 ++++++++ 5 files changed, 1486 insertions(+), 1457 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/import/body.rs create mode 100644 crates/ui-egui/src/dialogs/import/scan.rs create mode 100644 crates/ui-egui/src/dialogs/import/tags.rs create mode 100644 crates/ui-egui/src/dialogs/import/task.rs diff --git a/crates/ui-egui/src/dialogs/import.rs b/crates/ui-egui/src/dialogs/import.rs index 74dcc6c40..8425c3ec9 100644 --- a/crates/ui-egui/src/dialogs/import.rs +++ b/crates/ui-egui/src/dialogs/import.rs @@ -21,35 +21,24 @@ use crate::render::Slot; use crate::theme::Tokens; use crate::widgets::register; -/// Files per batch in the browser build (each batch joins the catalog as it is ready, so the -/// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`]. -#[cfg(target_arch = "wasm32")] -const BATCH: usize = 8; +mod body; +mod scan; +mod tags; +mod task; -/// The review dialog's first size (points). It can be resized; the photo grid takes the height. -pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0]; -/// Memory keys: where the photo grid ended and where the dialog's content (its buttons) ended, last -/// frame. Their difference is the room the grid leaves below it. -const GRID_BOTTOM: &str = "import-grid-bottom"; -const CONTENT_BOTTOM: &str = "import-content-bottom"; -/// Room below the grid before it has been measured (more than the options and buttons take). -const DEFAULT_BELOW_GRID: f32 = 360.0; - -/// Height of the photo grid: what is `available` once `below` (options and buttons) is left free, -/// in whole points so the window never grows by a fraction each frame, and at least one `row`. -pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 { - let h = (available - below.max(0.0)).floor(); - if h.is_finite() { h.max(row) } else { (DIALOG_SIZE[1] - DEFAULT_BELOW_GRID).max(row) } -} - -/// Remember where the review dialog's content ends this frame (after its buttons), for -/// [`grid_height`] on the next. -pub(crate) fn note_dialog_bottom(ui: &egui::Ui) { - if !ui.is_sizing_pass() { - let y = ui.min_rect().bottom(); - ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(CONTENT_BOTTOM), y)); - } -} +#[cfg(test)] +pub(crate) use body::grid_height; +#[cfg(test)] +use body::{DEFAULT_BELOW_GRID, badge_rect, dup_counts}; +pub use body::{DEFAULT_FOLDER_TEMPLATE, body, example_destination, source_summary}; +pub(crate) use body::{DIALOG_SIZE, note_dialog_bottom}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use scan::cards_seen; +pub use scan::{ScanTask, browse, open, poll_scan, running}; +#[cfg(test)] +pub(crate) use scan::{stall_scan, stall_scan_of}; +pub(crate) use tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; +pub use task::{ImportTask, start, start_paths, tick}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -237,1435 +226,6 @@ impl ImportDialog { } } -/// A running import (see the module docs). The file-system work — expanding folders, probing, -/// reading sidecars, copying or placing files — runs on a worker thread -/// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the -/// UI thread between frames, so a slow drive never stalls the window. -#[derive(Debug, Default)] -pub struct ImportTask { - /// Files and folders to import (handed to the worker when the task starts). - queue: Vec, - pub total: usize, - pub done: usize, - params: Value, - pub imported: usize, - /// Brought back from Recently Deleted (`onDeleted: restore`). - pub restored: usize, - pub duplicates: usize, - /// The library photos the skipped duplicates match (shown when nothing new was added). - existing: Vec, - pub failed: usize, - /// Move: originals moved, and sources left in place (reported by the engine with a reason). - pub moved: usize, - pub kept: usize, - /// Structured diagnostics from foreign XMP develop settings. - pub warnings: Vec, - undo0: usize, - first: Option, - /// Reading a folder for the Local view: the photos stay out of the library, and nothing is - /// selected or announced as added. - browse: bool, - /// Cancel was pressed: no further files are started; batches already readied are added. - pub cancelled: bool, - /// Auto Import (the watched folder): the selection stays as it is, a short toast when done. - auto: bool, - /// Auto Import: the selection to keep. - keep_selection: Option, - /// The selection and source as the import last left them: a change between frames is the - /// user choosing another photo or view, which the end of the import doesn't take away. - left: Option<(lightcraft_engine::Selection, lightcraft_engine::LibrarySource)>, - /// The user chose another photo or view while the import ran. - moved_on: bool, - run: Option, - /// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag). - guard: Option, -} - -impl ImportTask { - /// An import of `paths` (files or folders) with `library.import` params (without `paths`). - pub fn new(paths: Vec, params: Value, undo0: usize, browse: bool) -> Self { - ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() } - } - - /// An Auto Import (see [`ImportTask::auto`]). - pub fn auto(mut self) -> Self { - self.auto = true; - self - } - - /// `{done, total, imported, cancelled}` for `ui.inspect`. - pub fn status(&self) -> Value { - json!({"done": self.done, "total": self.total, "imported": self.imported, "cancelled": self.cancelled}) - } -} - -/// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no -/// slow drives) inline, one batch per frame. -enum Runner { - #[cfg(not(target_arch = "wasm32"))] - Thread(std::sync::mpsc::Receiver), - #[cfg(target_arch = "wasm32")] - Inline { job: Box, queue: Vec, files: Option> }, -} - -struct ImportRun { - runner: Runner, - cancel: std::sync::Arc, - total: std::sync::Arc, - done: std::sync::Arc, - opts: lightcraft_engine::import::ImportOptions, - now: String, - album: Option, - album_name: Option, -} - -impl std::fmt::Debug for ImportRun { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ImportRun").field("cancel", &self.cancel.load(Ordering::Relaxed)).finish() - } -} - -impl Drop for ImportRun { - /// A dropped import (replaced, or the app closing) stops its worker at the next file. - fn drop(&mut self) { - self.cancel.store(true, Ordering::Relaxed); - } -} - -impl ImportRun { - fn start( - job: lightcraft_engine::import::ImportJob, - queue: Vec, - album: Option, - album_name: Option, - ctx: &egui::Context, - ) -> Result { - let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let (total, done) = (job.total.clone(), job.done.clone()); - let (opts, now) = (job.opts.clone(), job.now().to_string()); - let runner = Self::spawn(job, queue, cancel.clone(), ctx)?; - Ok(ImportRun { runner, cancel, total, done, opts, now, album, album_name }) - } - - #[cfg(not(target_arch = "wasm32"))] - fn spawn( - mut job: lightcraft_engine::import::ImportJob, - queue: Vec, - cancel: std::sync::Arc, - ctx: &egui::Context, - ) -> Result { - let (tx, rx) = std::sync::mpsc::channel(); - let ctx = ctx.clone(); - let work = move || { - let files = job.expand(&queue); - for chunk in files.chunks(lightcraft_engine::import::batch_size()) { - if cancel.load(Ordering::Relaxed) { - break; - } - let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) { - Ok(p) => p, - Err(_) => break, // logged; the files readied so far are added - }; - if let Err(unsent) = tx.send(prepared) { - // the import was dropped: nothing will add these, so take back what a Move placed - unsent.0.rollback(); - break; - } - ctx.request_repaint(); - } - ctx.request_repaint(); - }; - std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}")) - } - - #[cfg(target_arch = "wasm32")] - fn spawn( - job: lightcraft_engine::import::ImportJob, - queue: Vec, - _cancel: std::sync::Arc, - _ctx: &egui::Context, - ) -> Result { - Ok(Runner::Inline { job: Box::new(job), queue, files: None }) - } - - /// Readied batches (at most a few per frame). `None` once the worker is done. - fn next_batches(&mut self) -> Option> { - match &mut self.runner { - #[cfg(not(target_arch = "wasm32"))] - Runner::Thread(rx) => { - let mut out = Vec::new(); - while out.len() < 4 { - match rx.try_recv() { - Ok(p) => out.push(p), - Err(std::sync::mpsc::TryRecvError::Empty) => return Some(out), - Err(std::sync::mpsc::TryRecvError::Disconnected) => return if out.is_empty() { None } else { Some(out) }, - } - } - Some(out) - } - #[cfg(target_arch = "wasm32")] - Runner::Inline { job, queue, files } => { - if self.cancel.load(Ordering::Relaxed) { - return None; - } - if files.is_none() { - *files = Some(job.expand(queue).into()); - return Some(Vec::new()); - } - let Some(files) = files.as_mut() else { return None }; - if files.is_empty() { - return None; - } - let n = files.len().min(BATCH); - let batch: Vec = files.drain(..n).collect(); - Some(vec![job.prepare_files(batch, &self.cancel)]) - } - } - } -} - -/// A folder scan running on a worker thread (a network share can take minutes to read; the -/// window must keep answering meanwhile). -pub struct ScanTask { - progress: std::sync::Arc, - rx: std::sync::mpsc::Receiver, - /// Open the review with "copy into the library" checked (a camera / card). - pub copy: bool, - /// Open the review with this album chosen (an album's context menu ▸ Import…). - pub album: Option, - /// Browsing a folder (Local): the photos are read in place instead of opening the review. - browse: bool, - /// What is being scanned (the review's source). - sources: Vec, - /// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`). - guard: lightcraft_engine::activity::TaskGuard, -} - -/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder"). -fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard { - let label = if browse { "Reading folder" } else { "Scanning folder" }; - app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone())) -} - -/// An import and a Synchronize Folder never run at once: each readies its files against the -/// library as it was when it started, so both could add the same file. -pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> { - if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) } -} - -/// Settings → Import → "Show the import review when a camera or card is connected", called with -/// each device scan while it's on. The first look only notes the cards already in. A card that -/// shows up after that opens Import from Device once nothing else is open, and a card that is -/// taken out and put back in asks again. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn cards_seen(app: &mut LightcraftApp, now: Vec) { - let Some(known) = app.ui.known_cards.as_mut() else { - app.ui.known_cards = Some(now.into_iter().map(|d| d.root).collect()); - return; - }; - known.retain(|root| now.iter().any(|d| d.root == *root)); - let Some(card) = now.into_iter().find(|d| !known.contains(&d.root)) else { return }; - // busy: ask when the window is free (the card isn't noted until then) - if app.import.is_some() || app.scan.is_some() || app.sync_run.is_some() || app.lightroom.is_some() || app.ui.dialog.is_some() { - return; - } - known.push(card.root); - if let Err(e) = app.run("file.addFromDevice", json!({"path": card.path})) { - log::warn!("import from {}: {e}", card.name); - } -} - -/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). -pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result { - if app.scan.is_some() { - return Err("a scan is already running".into()); - } - if app.review_pending.is_some() { - return Err("an import review is waiting for the open dialog to close".into()); - } - let sources = paths.clone(); - let (input, paths) = ScanInput::new(&mut app.session, &paths); - let progress = std::sync::Arc::new(ScanProgress::default()); - let (tx, rx) = std::sync::mpsc::channel(); - let p = progress.clone(); - let job = move || { - let _ = tx.send(scan_with(input, &paths, &p)); - }; - #[cfg(not(target_arch = "wasm32"))] - std::thread::spawn(job); - #[cfg(target_arch = "wasm32")] - job(); - let guard = scan_guard(app, &progress, false); - app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: false, sources, guard }); - Ok(json!({"scanning": true})) -} - -/// Show a folder's photos in the Local view (`library.browse` from the UI): the view switches at -/// once, the folder is listed and read on a worker thread (a network share can take minutes), -/// and the photos then join the view in small batches under a progress window. A browse already -/// running is replaced; the import review's scan is not. -pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option) -> Result { - let dir = std::path::absolute(std::path::Path::new(path)).map_err(|e| e.to_string())?; - if !dir.is_dir() { - return Err(format!("{path}: not a folder")); - } - if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) { - return Err("an import is running".into()); - } - let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); - let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders)); - let running = app.scan.as_ref().is_some_and(|t| t.browse) || app.import.as_ref().is_some_and(|t| t.browse); - if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) { - // already reading this folder: clicking it again must not restart the progress - app.session.source = lightcraft_engine::LibrarySource::Folder; - app.session.keep_active_in_view(); - return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})); - } - if let Some(t) = app.scan.take() { - t.progress.cancel.store(true, Ordering::Relaxed); - } - app.import = None; - app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders }); - app.session.source = lightcraft_engine::LibrarySource::Folder; - // the photos the library already knows from this folder show at once: keep or pick the active one - app.session.keep_active_in_view(); - let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s)); - let progress = std::sync::Arc::new(ScanProgress::default()); - let (tx, rx) = std::sync::mpsc::channel(); - let p = progress.clone(); - let root = dir_s.clone(); - let job = move || { - let files: Vec = if subfolders { - lightcraft_engine::import::expand(&[root], None) - } else { - let mut v: Vec = std::fs::read_dir(&root) - .map(|rd| { - rd.flatten() - .map(|e| e.path()) - .filter(|f| { - f.is_file() - && lightcraft_engine::import::is_supported(f) - && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) - }) - .map(|f| f.to_string_lossy().to_string()) - .collect() - }) - .unwrap_or_default(); - v.sort(); - v - }; - let _ = tx.send(scan_with(input, &files, &p)); - }; - #[cfg(not(target_arch = "wasm32"))] - std::thread::spawn(job); - #[cfg(target_arch = "wasm32")] - job(); - let guard = scan_guard(app, &progress, true); - app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: true, sources: Vec::new(), guard }); - app.renderer.forget_imports(); - Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) -} - -/// Collect a finished scan and open the review (called every frame). -pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { - show_pending_review(app, ctx); - let Some(task) = app.scan.as_ref() else { return }; - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - let out = match task.rx.try_recv() { - Ok(o) => o, - Err(std::sync::mpsc::TryRecvError::Empty) => { - if task.guard.is_cancelled() { - // ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while) - app.scan = None; - return; - } - let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed)); - task.guard.progress(done as u64, total as u64); - // while the folders are listed the total isn't known: say what is happening instead - task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" }); - return; - } - Err(_) => { - app.scan = None; - app.toast(ctx, crate::i18n::tr("Scan failed")); - return; - } - }; - let Some(task) = app.scan.take() else { return }; - if task.progress.cancel.load(Ordering::Relaxed) { - return; - } - app.session.import_probes = out.probes; - if task.browse { - // the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so - // their untouched Local photos aren't forgotten while the folder is in use - if let Some(root) = app.session.browse.as_ref().map(|b| b.path.clone()) { - let shown: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); - app.session.note_browsed(&root, &shown); - } - // records of files gone from the folder (deleted or moved by another app) that nothing was changed on go, - // as `library.browse` forgets them (issue #1392) - if let Some(b) = app.session.browse.clone() { - let listed: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); - app.session.forget_gone_from(&b.path, b.subfolders, &listed); - } - // what the library doesn't know yet joins the Local view - let queue: Vec = - out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect(); - if !queue.is_empty() { - let undo0 = app.session.undo.len(); - let total = queue.len(); - let _ = total; - app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true)); - } - return; - } - if out.candidates.is_empty() { - app.toast(ctx, crate::i18n::tr("No photos found")); - return; - } - app.renderer.forget_imports(); - let mut d = ImportDialog::new(out.candidates); - let trashed = d - .candidates - .iter() - .map(|c| { - c.duplicate.is_some() - && c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)) - }) - .collect(); - d.set_trashed(trashed); - d.copy = task.copy; - // only an album the review offers (it may have been deleted while the scan ran) - d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); - d.sources = task.sources; - d.library = Some(app.session.library_generation()); - app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) }); - show_pending_review(app, ctx); -} - -/// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings… -/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was -/// closed meanwhile (Settings can open another) is dropped. -fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) { - if app.review_pending.is_none() { - return; - } - if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending - && !opts.in_library(app) - { - app.review_pending = None; - return; - } - if app.ui.dialog.is_none() { - app.ui.dialog = app.review_pending.take(); - } else { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } -} - -/// Tests: a review scan that never ends while the returned value lives, so a drop meets it running. -#[cfg(test)] -pub(crate) fn stall_scan(app: &mut LightcraftApp) -> Box { - stall_scan_of(app, false) -} - -/// Tests: the same, for a Local folder browse scan (`browse`) instead of the review's scan. -#[cfg(test)] -pub(crate) fn stall_scan_of(app: &mut LightcraftApp, browse: bool) -> Box { - let progress = std::sync::Arc::new(ScanProgress::default()); - let (tx, rx) = std::sync::mpsc::channel::(); - let guard = scan_guard(app, &progress, false); - app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse, sources: Vec::new(), guard }); - Box::new(tx) -} - -/// Whether an import or any scan (the review's, or a Local folder browse) is running, or a finished scan's -/// review waits for a dialog to close, which a drop of photos must not replace: `open` refuses while a scan -/// runs, and a browse that finishes replaces `app.import`. -pub fn running(app: &LightcraftApp) -> bool { - app.import.is_some() || app.scan.is_some() || app.review_pending.is_some() -} - -impl ScanTask { - /// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed). - pub fn status(&self) -> Value { - json!({"done": self.progress.done.load(Ordering::Relaxed), "total": self.progress.total.load(Ordering::Relaxed)}) - } -} - -/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). Refused while another -/// import runs: replacing `app.import` would stop its worker. -pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result { - busy_synchronizing(app)?; - if !d.in_library(app) { - return Err("another library was opened since the photos were scanned: nothing was imported".into()); - } - // a browse scan that finishes starts its own import, replacing this one - if app.import.is_some() || app.scan.as_ref().is_some_and(|t| t.browse) { - return Err(crate::i18n::tr("An import is running").to_string()); - } - let queue = d.selected_paths(); - if queue.is_empty() { - return Err("no photos selected".into()); - } - let undo0 = app.session.undo.len(); - let mut album = d.album; - if album.is_none() && !d.new_album.trim().is_empty() { - let r = app.session.execute("album.create", &json!({"name": d.new_album.trim()})).map_err(|e| e.to_string())?; - album = r["id"].as_u64(); - } - let mode = match (d.copy, d.move_files) { - (false, _) => "add", - (true, false) => "copy", - (true, true) => "move", - }; - let mut params = json!({"mode": mode, "keywords": d.keywords()}); - if !d.on_deleted.is_empty() { - params["onDeleted"] = json!(d.on_deleted); - } - if let Some(a) = album { - params["album"] = json!(a); - } - if !d.preset.is_empty() { - params["preset"] = json!(d.preset); - } - if !d.metadata_preset.is_empty() { - params["metadataPreset"] = json!(d.metadata_preset); - } - if d.copy { - if !d.destination.trim().is_empty() { - params["destination"] = json!(d.destination.trim()); - } - if let Some(o) = d.organize_param()? { - params["organize"] = json!(o); - } - if !d.rename.trim().is_empty() { - params["rename"] = json!(d.rename.trim()); - params["renameStart"] = json!(1); - } - if d.dng && !d.move_files { - params["dng"] = json!(true); - } - } - let total = queue.len(); - app.import = Some(ImportTask::new(queue, params, undo0, false)); - app.renderer.forget_imports(); - Ok(json!({"importing": total})) -} - -/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window. -pub fn start_paths(app: &mut LightcraftApp, paths: Vec) -> Result { - busy_synchronizing(app)?; - if running(app) { - return Err(crate::i18n::tr("An import is running").to_string()); - } - let undo0 = app.session.undo.len(); - let total = paths.len(); - app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false)); - Ok(json!({"importing": total})) -} - -/// Advance the import in progress (called every frame): start its worker, then add the batches it -/// has readied to the catalog. -pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(mut task) = app.import.take() else { return }; - if task.run.is_none() { - let mut p = task.params.clone(); - p["paths"] = json!(task.queue); - let started = lightcraft_engine::cmd::library::import_params(&app.session, &p) - .and_then(|req| Ok((lightcraft_engine::import::ImportJob::new(&mut app.session, req.opts)?, req.album, req.album_name))) - .map_err(|e| e.to_string()) - .and_then(|(job, album, name)| ImportRun::start(job, std::mem::take(&mut task.queue), album, name, ctx)); - if task.auto { - task.keep_selection = Some(app.session.selection.clone()); - } - match started { - Ok(run) => { - let label = if task.browse { "Reading folder" } else { "Importing" }; - task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone()))); - task.run = Some(run); - } - Err(e) => { - log::warn!("import: {e}"); - app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e)); - return; - } - } - } - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - let Some(run) = task.run.as_mut() else { return }; - let batches = run.next_batches(); - let finished = batches.is_none(); - if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) { - task.moved_on = true; - } - for prepared in batches.into_iter().flatten() { - commit_batch(app, &mut task, prepared); - // arriving photos don't take over the selection - if let Some(sel) = &task.keep_selection { - app.session.selection = sel.clone(); - } - // a Local folder still on screen: its first photo becomes active once there is one (#1447) - if task.browse && app.session.source == lightcraft_engine::LibrarySource::Folder { - app.session.keep_active_in_view(); - } - } - task.left = Some((app.session.selection.clone(), app.session.source)); - if let Some(run) = &task.run { - task.total = run.total.load(Ordering::Relaxed).max(task.total); - task.done = run.done.load(Ordering::Relaxed); - } - if let Some(guard) = &task.guard { - guard.progress(task.done as u64, task.total as u64); - // ✕ in the stack (or `activity.cancel`): no further files are started - if guard.is_cancelled() { - task.cancelled = true; - } - } - if !finished { - app.import = Some(task); - return; - } - finish(app, ctx, task); -} - -/// Add one readied batch to the catalog (an undo step merged into the import's at the end). -fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightcraft_engine::import::Prepared) { - let Some(run) = task.run.as_ref() else { return }; - let n = prepared.len(); - let (opts, now, album, album_name) = (&run.opts, run.now.as_str(), run.album, run.album_name.clone()); - let r = app.session.execute_fn("library.import", |s| { - let report = lightcraft_engine::import::commit_prepared(s, opts, now, prepared)?; - lightcraft_engine::cmd::library::import_batch_done(s, report, album, album_name.as_deref()) - }); - match r { - Ok(v) => { - // a new album (named in the params) is created with the first photos; later batches join it - if let (Some(a), Some(run)) = (v["album"].as_u64(), task.run.as_mut()) { - run.album = Some(a); - } - let len = |k: &str| v[k].as_array().map_or(0, Vec::len); - task.imported += len("imported"); - task.restored += len("restored"); - task.duplicates += len("duplicates"); - task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64())); - task.failed += len("failed"); - task.moved += len("moved"); - task.kept += len("kept"); - task.warnings.extend(v["warnings"].as_array().into_iter().flatten().filter_map(|warning| serde_json::from_value(warning.clone()).ok())); - for k in v["kept"].as_array().into_iter().flatten() { - log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or("")); - } - if task.first.is_none() { - task.first = ["imported", "restored"].iter().find_map(|k| v[*k].as_array().and_then(|a| a.first()).and_then(Value::as_u64)); - } - } - Err(e) => { - log::warn!("import: {e}"); - task.failed += n; - } - } -} - -fn xmp_warning_text(warning: &ImportWarning) -> String { - let path = &warning.path; - match warning.code { - ImportWarningCode::CameraProfile => crate::i18n::tr_format!( - "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.", - profile = warning.profile.as_deref().unwrap_or_default(), - path = path, - ), - ImportWarningCode::UprightGeometry => match warning.mode.unwrap_or(ImportUprightMode::Unknown) { - ImportUprightMode::Off => crate::i18n::tr_format!( - "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.", - path = path, - ), - ImportUprightMode::Guided => crate::i18n::tr_format!( - "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.", - path = path, - ), - ImportUprightMode::Auto => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Auto") - ), - ImportUprightMode::Level => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Level") - ), - ImportUprightMode::Vertical => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Vertical") - ), - ImportUprightMode::Full => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Full") - ), - ImportUprightMode::Unknown => { - crate::i18n::tr_format!("Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.", path = path,) - } - }, - ImportWarningCode::UnmappedXmp => { - crate::i18n::tr_format!("XMP adjustments were not imported for {path}: {fields}.", path = path, fields = warning.fields.join(", "),) - } - } -} - -fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { - crate::i18n::tr_format!( - "Import warnings ({n}):\n{warnings}", - n = warnings.len(), - warnings = warnings.iter().map(xmp_warning_text).collect::>().join("\n"), - ) -} - -/// The import is done (or cancelled): one undo step, show and select the first photo (unless the -/// user chose another meanwhile), say what happened. -fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { - let steps = app.session.undo.len().saturating_sub(task.undo0); - let plural = |n: usize| if n == 1 { "" } else { "s" }; - let label = if task.imported == 0 && task.restored > 0 { - crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored)) - } else if task.restored > 0 { - crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored) - } else { - crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }) - }; - app.session.merge_undo(steps, &label); - if task.auto { - if !task.warnings.is_empty() { - app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); - } else if task.imported > 0 { - app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" })); - } - return; - } - if task.browse { - if !task.warnings.is_empty() { - app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); - } else if task.failed > 0 { - app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" })); - } - return; - } - if let Some(f) = task.first.filter(|_| !task.moved_on) { - // a view the new photos aren't in (an album, Picks, a Local folder) would leave the active - // photo off screen: show All Photos, as Lightroom shows the import's own collection (#1446) - if !app.session.visible().contains(&lightcraft_catalog::PhotoId(f)) { - let _ = app.run("library.source", json!({"kind": "all"})); - } - let _ = app.run("library.select", json!({"ids": [f]})); - } - let plural = |n: usize| if n == 1 { "" } else { "s" }; - // nothing new, only files the library already has: show where they are (Recently Deleted is - // easy to miss, and the side panel that lists it starts collapsed) - if task.imported == 0 - && task.restored == 0 - && task.failed == 0 - && task.warnings.is_empty() - && !task.cancelled - && show_existing(app, ctx, &task.existing) - { - return; - } - let mut msg = if task.cancelled { - crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) - } else if task.imported == 0 && task.restored > 0 { - crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored)) - } else { - crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) - }; - if task.restored > 0 && (task.imported > 0 || task.cancelled) { - msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); - } - if task.params["mode"] == "move" { - msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved)); - if task.kept > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept))); - } - } - if task.duplicates > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); - let deleted = deleted_of(app, &task.existing).len(); - if deleted > 0 { - msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); - } - } - if task.failed > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); - } - if task.warnings.is_empty() { - app.toast(ctx, msg); - } else { - msg.push_str("\n\n"); - msg.push_str(&xmp_warnings_text(&task.warnings)); - app.toast_for(ctx, msg, 8.0); - } -} - -/// The photos among `ids` that are in Recently Deleted (each once). -fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec { - let mut v: Vec = - ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect(); - v.sort_unstable(); - v.dedup(); - v -} - -/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel -/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none. -fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool { - let deleted = deleted_of(app, existing); - let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) }; - ids.sort_unstable(); - ids.dedup(); - ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some()); - if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() { - return false; - } - let _ = app.run("library.select", json!({"ids": ids})); - app.ui.left_panel = true; - let n = ids.len(); - let plural = if n == 1 { "" } else { "s" }; - let msg = if kind == "all" { - crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural) - } else { - crate::i18n::tr_format!( - "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh", - n, - plural - ) - }; - app.toast_for(ctx, msg, 6.0); - true -} - -/// Skipped candidates: (already in the library, identical copies within the source, in Recently -/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with -/// the same bytes as one earlier in the list (no library photo) is a copy in the source. -fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) { - let trashed = d.trashed.iter().filter(|t| **t).count(); - let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count(); - let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source); - (dups, in_source, trashed) -} - -/// The dialog body: options, then the candidate grid. -pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { - let t = Tokens::get(ui.ctx()); - let n = d.candidates.len(); - let (dups, in_source, trashed) = dup_counts(d); - let sel = d.selected_paths().len(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); - if dups > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); - } - if in_source > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim)) - .on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported.")); - } - if trashed > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() { - d.checked.iter_mut().for_each(|c| *c = false); - } - if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() { - for i in 0..n { - d.checked[i] = d.checked_by_default(i); - } - } - }); - }); - // with more than one file type: a toggle per type checks or unchecks all of its files - let types = d.file_types(); - if types.len() > 1 { - ui.horizontal_wrapped(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim)); - for (ty, count) in &types { - let on = d.type_checked(ty); - let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on) - .on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" })); - if r.clicked() { - d.check_type(ty, !on); - } - } - }); - } - // candidate grid - let cell = 116.0; - let avail = ui.available_width(); - let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize; - let rows = n.div_ceil(cols); - // as tall as the window leaves after the options and buttons below it (measured last frame), - // so resizing the dialog resizes the grid - let below = ui.ctx().data(|m| { - let y = |key: &str| m.get_temp::(egui::Id::unique(key)); - y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid) - }); - let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0); - let grid = - egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { - let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover()); - for i in 0..n { - let (c, r) = (i % cols, i / cols); - let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0)); - if !local.intersects(viewport.expand(cell)) { - continue; - } - let rect = local.translate(area.min.to_vec2()); - candidate_cell(app, ui, d, i, rect); - } - }); - if !ui.is_sizing_pass() { - ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(GRID_BOTTOM), grid.inner_rect.bottom())); - } - ui.add_space(4.0); - // options - if !d.sources.is_empty() { - field(ui, "Source", |ui| { - // (whether each source is a folder is checked off the UI thread, once) - let joined = d.sources.join("\n"); - let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| { - inspect_sources(&s.split('\n').map(str::to_string).collect::>()) - }) - .map(|summary| summary.display()) - .unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" })); - let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined); - register(ui, "label:importSource", r.rect); - }); - ui.label( - egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).")) - .color(t.text_dim) - .small(), - ); - } - field(ui, "Transfer", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - if crate::widgets::text_button(ui, "importAdd", crate::i18n::tr("Add in place"), !d.copy) - .on_hover_text(crate::i18n::tr("Reference the files where they are")) - .clicked() - { - d.copy = false; - d.move_files = false; - } - let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some(); - let r = - ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", crate::i18n::tr("Copy"), d.copy && !d.move_files)).inner; - if r.on_hover_text(crate::i18n::tr("Copy the files (into the library's Originals/, or a folder you choose)")).clicked() { - d.copy = true; - d.move_files = false; - } - let r = - ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", crate::i18n::tr("Move"), d.copy && d.move_files)).inner; - if r.on_hover_text(crate::i18n::tr("Move the files (into the library's Originals/, or a folder you choose), removing them from the source")) - .clicked() - { - d.copy = true; - d.move_files = true; - } - }); - let r = ui.label( - egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) { - (true, false) => { - "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are." - } - (true, true) => { - "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are." - } - _ => "Add in place: the library references the files where they are; nothing is copied or moved.", - })) - .color(if d.copy && d.move_files { t.caution } else { t.text_dim }) - .small(), - ); - register(ui, "label:importModeHelp", r.rect); - if d.copy { - field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() }; - ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label)); - if app.services.pick_folder.is_some() - && crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked() - && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) - { - d.destination = f; - } - if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() { - d.destination.clear(); - } - }); - field(ui, "Folders", |ui| { - let opts = [ - ("date", "By day (YYYY/YYYY-MM-DD)"), - ("month", "By month (YYYY/YYYY-MM)"), - ("flat", "Into one folder"), - ("custom", "Custom template…"), - ]; - let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() }; - let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1); - let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { - for (k, label) in opts { - let r = ui.selectable_label(key == k, crate::i18n::tr(label)); - register(ui, format!("button:importOrganize-{k}"), r.rect); - if r.clicked() { - d.organize = k.to_string(); - if k == "custom" && d.folder_template.trim().is_empty() { - d.folder_template = DEFAULT_FOLDER_TEMPLATE.into(); - } - } - } - }); - crate::widgets::name_combo(&combo.response, crate::i18n::tr("Organize into")); - register(ui, "combo:importOrganize", combo.response.rect); - }); - if d.organize == "custom" { - let folders_id = egui::Id::unique("import-folder-template"); - let tags_open = field(ui, "Template", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = tag_field_width(ui); - let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w)); - register(ui, "field:importFolderTemplate", r.rect); - tag_toggle(ui, "importFolders") - }); - if tags_open { - tag_help(ui, "importFolders", &mut d.folder_template, folders_id); - } - let t = Tokens::get(ui.ctx()); - match lightcraft_engine::rename::folder_template_error(&d.folder_template) { - Some(e) => { - ui.label(egui::RichText::new(e).color(t.caution)); - } - None => unknown_tags_warning(ui, &d.folder_template), - } - ui.label( - egui::RichText::new(crate::i18n::tr( - "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").", - )) - .color(t.text_dim) - .small(), - ); - } - if !d.move_files { - field(ui, "Raw files", |ui| { - let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG")); - register(ui, "check:importDng", r.rect); - }); - } - let rename_id = egui::Id::unique("import-rename"); - let tags_open = field(ui, "Rename", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = tag_field_width(ui); - let r = ui.add( - egui::TextEdit::singleline(&mut d.rename) - .id(rename_id) - .hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}")) - .desired_width(w), - ); - register(ui, "field:importRename", r.rect); - tag_toggle(ui, "importRename") - }); - if tags_open { - tag_help(ui, "importRename", &mut d.rename, rename_id); - } - unknown_tags_warning(ui, &d.rename); - if let Some(example) = example_destination(app, d) { - let t = Tokens::get(ui.ctx()); - let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim)); - register(ui, "label:importExample", r.rect); - ui.label( - egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date.")) - .color(t.text_dim) - .small(), - ); - } - } - if d.trashed.iter().any(|t| *t) { - field(ui, "In Recently Deleted", |ui| { - let cur = match d.on_deleted.as_str() { - "restore" => "Restore them", - "fresh" => "Import as new", - _ => "Leave them", - }; - let mut pick = None; - let combo = crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { - for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { - if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { - pick = Some(v); - } - } - }); - crate::widgets::name_combo(&combo.response, crate::i18n::tr("Recently deleted files")); - if let Some(v) = pick { - d.set_on_deleted(v); - } - let t = Tokens::get(ui.ctx()); - ui.label( - egui::RichText::new(crate::i18n::tr("Restore keeps their edits; Import as new deletes the old record first.")) - .color(t.text_dim) - .small(), - ); - }); - } - let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None); - albums.retain(|a| a.folder || !a.smart); - // Catalog changes while the review is open must not leave a hidden, unselectable destination. - d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder)); - field(ui, "Album", |ui| { - ui.vertical(|ui| { - let mut chosen = d.album; - let mut extra = None; - let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" }); - let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums) - .width(ui.available_width()) - .empty_text(empty) - .show_with_choices(ui, |ui| { - let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")); - register(ui, "importAlbum:none", none.rect); - if none.clicked() { - extra = Some(false); - } - let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")); - register(ui, "importAlbum:new", new.rect); - if new.clicked() { - extra = Some(true); - } - ui.separator(); - extra.is_some() - }); - if response.changed() { - d.album = chosen; - d.new_album.clear(); - } - if let Some(new) = extra { - d.album = None; - if !new { - d.new_album.clear(); - } else if d.new_album.is_empty() { - d.new_album = crate::i18n::tr("Imported Photos").into(); - } - } - if d.album.is_none() && !d.new_album.is_empty() { - let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY)); - register(ui, "field:importAlbumName", r.rect); - } - }); - }); - field(ui, "Preset", |ui| { - let cur = app - .session - .presets - .iter() - .find(|p| p.id == d.preset) - .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) - .unwrap_or_else(|| crate::i18n::tr("None").into()); - let combo = crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { - if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { - d.preset.clear(); - } - let selected = (!d.preset.is_empty()).then_some(d.preset.as_str()); - if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) { - d.preset = pid; - } - }); - crate::widgets::name_combo(&combo.response, crate::i18n::tr("Preset")); - }); - if !app.session.metadata_presets.is_empty() { - field(ui, "Metadata", |ui| { - let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() }; - let combo = crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| { - if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() { - d.metadata_preset.clear(); - } - for m in &app.session.metadata_presets { - if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() { - d.metadata_preset = m.name.clone(); - } - } - }); - crate::widgets::name_combo(&combo.response, crate::i18n::tr("Metadata preset")); - }); - } - field(ui, "Keywords", |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY)); - register(ui, "field:importKeywords", r.rect); - }); -} - -/// Draw one import-dialog candidate cell: register `import:{i}`, request the cell's thumbnail, -/// paint the selection checkbox, and label duplicates, unreadable or trashed files. A click on an -/// importable cell toggles it (Shift extends the range); `rect` is the full cell and the thumbnail -/// uses its width as height. -fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) { - let t = Tokens::get(ui.ctx()); - let c = d.candidates[i].clone(); - let ok = d.importable(i); - let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width())); - let resp = ui.interact(img, egui::Id::unique(("import-cell", i)), Sense::click()); - crate::widgets::announce(&resp, c.name.clone()); - register(ui, format!("import:{i}"), img); - let p = ui.painter(); - p.rect_filled(img, 3.0, t.skeleton); - // thumbnail (background job; the grid only asks for the cells in view) - let slot = Slot::Import(i as u32); - if !app.renderer.textures.contains_key(&slot) - && let Some(job) = app.session.candidate_thumb_job(&c, 192, i as u64) - { - app.renderer.request_quick(slot, job, 4); - } - if let Some(tex) = app.renderer.textures.get(&slot) { - let [tw, th] = tex.size; - let s = ((img.width() - 8.0) / tw as f32).min((img.height() - 8.0) / th as f32); - let fit = Rect::from_center_size(img.center(), vec2(tw as f32 * s, th as f32 * s)); - let tint = if ok { Color32::WHITE } else { Color32::from_gray(110) }; - p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); - } - let on = d.checked[i] && ok; - if on { - p.rect_stroke(img, 3.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); - } - // checkbox - let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0)); - p.rect(cb, 3.0, if on { t.accent } else { Color32::from_black_alpha(150) }, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); - if on { - p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE)); - p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE)); - } - let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)); - let badge = match (&c.duplicate, &c.error) { - // checked with "Leave them", the file goes without `onDeleted`: the engine restores it - (Some(_), _) if in_trash && on => Some(if d.on_deleted == "fresh" { "Will be replaced" } else { "Will be restored" }), - (Some(_), _) if in_trash => Some("In Recently Deleted"), - (Some(r), _) if r == "path" => Some("In library"), - (Some(_), _) => Some("Duplicate"), - (None, Some(_)) => Some("Unreadable"), - _ => None, - }; - if let Some(b) = badge { - let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); - let br = badge_rect(img, cb, g.size()); - p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); - p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); - } - let name = if c.name.chars().count() > 18 { format!("{}…", c.name.chars().take(17).collect::()) } else { c.name.clone() }; - p.text(pos2(rect.left() + 2.0, img.bottom() + 9.0), Align2::LEFT_CENTER, name, t.font(10.5), if ok { t.text_label } else { t.text_dim }); - let tip = format!( - "{}\n{} × {} · {} · {:.1} MB{}", - c.path, - c.width, - c.height, - c.format, - c.file_size as f64 / 1e6, - c.captured.as_deref().map(|d| format!("\n{}", d.replace('T', " "))).unwrap_or_default() - ); - let resp = resp.on_hover_text(tip); - if resp.clicked() && ok { - let shift = ui.input(|input| input.modifiers.shift); - d.click(i, shift); - } -} - -/// Where a cell's badge goes: top right, or under the checkbox when the text is too wide to sit -/// beside it — drawn over the checkbox it hid it (issue #1285). -fn badge_rect(img: Rect, cb: Rect, text: Vec2) -> Rect { - let size = text + vec2(8.0, 4.0); - let left = img.right() - size.x - 4.0; - let top = if left < cb.right() + 4.0 { cb.bottom() + 4.0 } else { img.top() + 6.0 }; - Rect::from_min_size(pos2(left, top), size) -} - -/// Reserve the actual translated tag button width beside a template field. -pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text); - (ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0) -} - -/// The "Tags" toggle beside a template field (`button:Tags`); returns whether the tag help -/// ([`tag_help`]) is open. -pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool { - let id = egui::Id::unique((key, "tags-open")); - let mut open = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or(false); - let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open) - .on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it")); - if r.clicked() { - open = !open; - ui.ctx().data_mut(|d| d.insert_temp(id, open)); - } - open -} - -/// "Unknown tag {x} stays as typed" under a template field, when it has one. -pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) { - let unknown = lightcraft_engine::rename::unknown_tokens(template); - if !unknown.is_empty() { - let s = if unknown.len() == 1 { "" } else { "s" }; - let text = crate::i18n::tr_format!( - "Unknown tag{s} {} stay{} as typed (see Tags)", - unknown.join(" "), - if unknown.len() == 1 { "s" } else { "" }, - s = s - ); - ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution)); - } -} - -/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection); -/// appended when the field never had a cursor. The cursor ends up after the tag. -pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) { - use egui::text::{CCursor, CCursorRange}; - let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default(); - let n = text.chars().count(); - let (a, b) = state.cursor.char_range().map_or((n, n), |r| { - let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n)); - (x.min(y), x.max(y)) - }); - let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); - let (ba, bb) = (byte(a), byte(b)); - text.replace_range(ba..bb, tag); - state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count())))); - state.store(ctx, edit_id); - ctx.memory_mut(|m| m.request_focus(edit_id)); -} - -/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its -/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag -/// (`button:Tag-`) inserts it at the cursor of the field `edit_id`. -pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) { - use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example}; - let t = Tokens::get(ui.ctx()); - egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| { - ui.label( - egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.")) - .color(t.text_dim) - .small(), - ); - egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| { - egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| { - for (i, tok) in TOKENS.iter().enumerate() { - let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( - if tok.aliases.is_empty() { - crate::i18n::tr("Insert at the cursor").to_string() - } else { - crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) - }, - ); - register(ui, format!("button:{key}Tag-{i}"), r.rect); - if r.clicked() { - insert_at_cursor(ui.ctx(), edit_id, text, tok.tag); - } - ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label)); - ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim)); - ui.end_row(); - } - }); - let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect(); - ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small()); - for n in TEMPLATE_NOTES { - ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small()); - } - }); - }); -} - -/// The folder template the Custom choice starts with (`2026/20260114/`). -pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}"; - -/// Where the first selected photo would be copied to (destination, folders, name), for the -/// dialog's example line; `None` without a photo or with an unusable folder template. -pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option { - let c = d.candidates.iter().zip(&d.checked).enumerate().find(|(i, (_, on))| **on && d.importable(*i)).map(|(_, (c, _))| c)?; - let organize = match d.organize_param().ok()? { - Some(o) => lightcraft_engine::import::Organize::parse(&o)?, - None => Default::default(), - }; - let mut q = lightcraft_catalog::Photo::new( - lightcraft_catalog::PhotoId(0), - lightcraft_catalog::Source::File { path: c.path.clone() }, - &c.name, - &c.format, - 0, - 0, - // a photo without a capture time is dated by the import (now) - &(app.session.clock)(), - ); - q.captured = c.captured.clone(); - // the probe's metadata, so {camera}, {title}… preview as they will import - if let Some(info) = app.session.import_probes.get(&c.path) { - q.meta = info.meta.clone(); - } - let name = if d.rename.trim().is_empty() { c.name.clone() } else { lightcraft_engine::rename::expand(d.rename.trim(), &q, 1) }; - let root = if d.destination.trim().is_empty() { - app.session.library.as_ref().map_or_else(|| "Originals".to_string(), |l| l.dir.join("Originals").to_string_lossy().to_string()) - } else { - d.destination.trim().trim_end_matches(['/', '\\']).to_string() - }; - let sep = std::path::MAIN_SEPARATOR_STR; - let mut parts = vec![root]; - parts.extend(organize.folders(&q)); - parts.push(name); - Some(parts.join(sep)) -} - -/// The import source in a few words: a folder's name, a file's name, or "N files and folders". -pub fn source_summary(sources: &[String]) -> String { - inspect_sources(sources).display() -} - -/// Facts cached by the filesystem worker, formatted in the UI thread's active language. -/// Caching a translated String on the worker would always use its default language (English) -/// and also retain that language after the user changes the UI language. -#[derive(Clone)] -enum SourceSummary { - Folder(String), - File(String), - Many { files: usize, folders: usize }, -} - -impl SourceSummary { - fn display(&self) -> String { - match self { - Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name), - Self::File(name) => name.clone(), - Self::Many { files, folders } => match (files, folders) { - (_, 0) => crate::i18n::tr_format!("{} files", files), - (0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f), - (_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f), - }, - } - } -} - -fn inspect_sources(sources: &[String]) -> SourceSummary { - let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string()); - match sources { - [one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)), - [one] => SourceSummary::File(name(one)), - many => { - let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count(); - SourceSummary::Many { files: many.len().saturating_sub(folders), folders } - } - } -} - -/// A labelled row (fixed label column). -fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(78.0); - ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); - }); - add(ui) - }) - .inner -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/dialogs/import/body.rs b/crates/ui-egui/src/dialogs/import/body.rs new file mode 100644 index 000000000..9ba470660 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/body.rs @@ -0,0 +1,558 @@ +use super::tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; +use super::*; + +/// The review dialog's first size (points). It can be resized; the photo grid takes the height. +pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0]; + +/// Memory keys: where the photo grid ended and where the dialog's content (its buttons) ended, last +/// frame. Their difference is the room the grid leaves below it. +const GRID_BOTTOM: &str = "import-grid-bottom"; + +const CONTENT_BOTTOM: &str = "import-content-bottom"; + +/// Room below the grid before it has been measured (more than the options and buttons take). +pub(super) const DEFAULT_BELOW_GRID: f32 = 360.0; + +/// Height of the photo grid: what is `available` once `below` (options and buttons) is left free, +/// in whole points so the window never grows by a fraction each frame, and at least one `row`. +pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 { + let h = (available - below.max(0.0)).floor(); + if h.is_finite() { h.max(row) } else { (DIALOG_SIZE[1] - DEFAULT_BELOW_GRID).max(row) } +} + +/// Remember where the review dialog's content ends this frame (after its buttons), for +/// [`grid_height`] on the next. +pub(crate) fn note_dialog_bottom(ui: &egui::Ui) { + if !ui.is_sizing_pass() { + let y = ui.min_rect().bottom(); + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(CONTENT_BOTTOM), y)); + } +} + +/// Skipped candidates: (already in the library, identical copies within the source, in Recently +/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with +/// the same bytes as one earlier in the list (no library photo) is a copy in the source. +pub(super) fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) { + let trashed = d.trashed.iter().filter(|t| **t).count(); + let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count(); + let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source); + (dups, in_source, trashed) +} + +/// The dialog body: options, then the candidate grid. +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { + let t = Tokens::get(ui.ctx()); + let n = d.candidates.len(); + let (dups, in_source, trashed) = dup_counts(d); + let sel = d.selected_paths().len(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); + if dups > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); + } + if in_source > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported.")); + } + if trashed > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() { + d.checked.iter_mut().for_each(|c| *c = false); + } + if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() { + for i in 0..n { + d.checked[i] = d.checked_by_default(i); + } + } + }); + }); + // with more than one file type: a toggle per type checks or unchecks all of its files + let types = d.file_types(); + if types.len() > 1 { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim)); + for (ty, count) in &types { + let on = d.type_checked(ty); + let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on) + .on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" })); + if r.clicked() { + d.check_type(ty, !on); + } + } + }); + } + // candidate grid + let cell = 116.0; + let avail = ui.available_width(); + let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize; + let rows = n.div_ceil(cols); + // as tall as the window leaves after the options and buttons below it (measured last frame), + // so resizing the dialog resizes the grid + let below = ui.ctx().data(|m| { + let y = |key: &str| m.get_temp::(egui::Id::unique(key)); + y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid) + }); + let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0); + let grid = + egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover()); + for i in 0..n { + let (c, r) = (i % cols, i / cols); + let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0)); + if !local.intersects(viewport.expand(cell)) { + continue; + } + let rect = local.translate(area.min.to_vec2()); + candidate_cell(app, ui, d, i, rect); + } + }); + if !ui.is_sizing_pass() { + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(GRID_BOTTOM), grid.inner_rect.bottom())); + } + ui.add_space(4.0); + // options + if !d.sources.is_empty() { + field(ui, "Source", |ui| { + // (whether each source is a folder is checked off the UI thread, once) + let joined = d.sources.join("\n"); + let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| { + inspect_sources(&s.split('\n').map(str::to_string).collect::>()) + }) + .map(|summary| summary.display()) + .unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" })); + let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined); + register(ui, "label:importSource", r.rect); + }); + ui.label( + egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).")) + .color(t.text_dim) + .small(), + ); + } + field(ui, "Transfer", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if crate::widgets::text_button(ui, "importAdd", crate::i18n::tr("Add in place"), !d.copy) + .on_hover_text(crate::i18n::tr("Reference the files where they are")) + .clicked() + { + d.copy = false; + d.move_files = false; + } + let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some(); + let r = + ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", crate::i18n::tr("Copy"), d.copy && !d.move_files)).inner; + if r.on_hover_text(crate::i18n::tr("Copy the files (into the library's Originals/, or a folder you choose)")).clicked() { + d.copy = true; + d.move_files = false; + } + let r = + ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", crate::i18n::tr("Move"), d.copy && d.move_files)).inner; + if r.on_hover_text(crate::i18n::tr("Move the files (into the library's Originals/, or a folder you choose), removing them from the source")) + .clicked() + { + d.copy = true; + d.move_files = true; + } + }); + let r = ui.label( + egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) { + (true, false) => { + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are." + } + (true, true) => { + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are." + } + _ => "Add in place: the library references the files where they are; nothing is copied or moved.", + })) + .color(if d.copy && d.move_files { t.caution } else { t.text_dim }) + .small(), + ); + register(ui, "label:importModeHelp", r.rect); + if d.copy { + field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() }; + ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label)); + if app.services.pick_folder.is_some() + && crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked() + && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) + { + d.destination = f; + } + if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() { + d.destination.clear(); + } + }); + field(ui, "Folders", |ui| { + let opts = [ + ("date", "By day (YYYY/YYYY-MM-DD)"), + ("month", "By month (YYYY/YYYY-MM)"), + ("flat", "Into one folder"), + ("custom", "Custom template…"), + ]; + let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() }; + let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1); + let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + for (k, label) in opts { + let r = ui.selectable_label(key == k, crate::i18n::tr(label)); + register(ui, format!("button:importOrganize-{k}"), r.rect); + if r.clicked() { + d.organize = k.to_string(); + if k == "custom" && d.folder_template.trim().is_empty() { + d.folder_template = DEFAULT_FOLDER_TEMPLATE.into(); + } + } + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Organize into")); + register(ui, "combo:importOrganize", combo.response.rect); + }); + if d.organize == "custom" { + let folders_id = egui::Id::unique("import-folder-template"); + let tags_open = field(ui, "Template", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = tag_field_width(ui); + let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w)); + register(ui, "field:importFolderTemplate", r.rect); + tag_toggle(ui, "importFolders") + }); + if tags_open { + tag_help(ui, "importFolders", &mut d.folder_template, folders_id); + } + let t = Tokens::get(ui.ctx()); + match lightcraft_engine::rename::folder_template_error(&d.folder_template) { + Some(e) => { + ui.label(egui::RichText::new(e).color(t.caution)); + } + None => unknown_tags_warning(ui, &d.folder_template), + } + ui.label( + egui::RichText::new(crate::i18n::tr( + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").", + )) + .color(t.text_dim) + .small(), + ); + } + if !d.move_files { + field(ui, "Raw files", |ui| { + let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG")); + register(ui, "check:importDng", r.rect); + }); + } + let rename_id = egui::Id::unique("import-rename"); + let tags_open = field(ui, "Rename", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = tag_field_width(ui); + let r = ui.add( + egui::TextEdit::singleline(&mut d.rename) + .id(rename_id) + .hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}")) + .desired_width(w), + ); + register(ui, "field:importRename", r.rect); + tag_toggle(ui, "importRename") + }); + if tags_open { + tag_help(ui, "importRename", &mut d.rename, rename_id); + } + unknown_tags_warning(ui, &d.rename); + if let Some(example) = example_destination(app, d) { + let t = Tokens::get(ui.ctx()); + let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim)); + register(ui, "label:importExample", r.rect); + ui.label( + egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date.")) + .color(t.text_dim) + .small(), + ); + } + } + if d.trashed.iter().any(|t| *t) { + field(ui, "In Recently Deleted", |ui| { + let cur = match d.on_deleted.as_str() { + "restore" => "Restore them", + "fresh" => "Import as new", + _ => "Leave them", + }; + let mut pick = None; + let combo = crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { + if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { + pick = Some(v); + } + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Recently deleted files")); + if let Some(v) = pick { + d.set_on_deleted(v); + } + let t = Tokens::get(ui.ctx()); + ui.label( + egui::RichText::new(crate::i18n::tr("Restore keeps their edits; Import as new deletes the old record first.")) + .color(t.text_dim) + .small(), + ); + }); + } + let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None); + albums.retain(|a| a.folder || !a.smart); + // Catalog changes while the review is open must not leave a hidden, unselectable destination. + d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder)); + field(ui, "Album", |ui| { + ui.vertical(|ui| { + let mut chosen = d.album; + let mut extra = None; + let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" }); + let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums) + .width(ui.available_width()) + .empty_text(empty) + .show_with_choices(ui, |ui| { + let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")); + register(ui, "importAlbum:none", none.rect); + if none.clicked() { + extra = Some(false); + } + let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")); + register(ui, "importAlbum:new", new.rect); + if new.clicked() { + extra = Some(true); + } + ui.separator(); + extra.is_some() + }); + if response.changed() { + d.album = chosen; + d.new_album.clear(); + } + if let Some(new) = extra { + d.album = None; + if !new { + d.new_album.clear(); + } else if d.new_album.is_empty() { + d.new_album = crate::i18n::tr("Imported Photos").into(); + } + } + if d.album.is_none() && !d.new_album.is_empty() { + let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY)); + register(ui, "field:importAlbumName", r.rect); + } + }); + }); + field(ui, "Preset", |ui| { + let cur = app + .session + .presets + .iter() + .find(|p| p.id == d.preset) + .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) + .unwrap_or_else(|| crate::i18n::tr("None").into()); + let combo = crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { + if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { + d.preset.clear(); + } + let selected = (!d.preset.is_empty()).then_some(d.preset.as_str()); + if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) { + d.preset = pid; + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Preset")); + }); + if !app.session.metadata_presets.is_empty() { + field(ui, "Metadata", |ui| { + let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() }; + let combo = crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| { + if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() { + d.metadata_preset.clear(); + } + for m in &app.session.metadata_presets { + if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() { + d.metadata_preset = m.name.clone(); + } + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Metadata preset")); + }); + } + field(ui, "Keywords", |ui| { + let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY)); + register(ui, "field:importKeywords", r.rect); + }); +} + +/// Draw one import-dialog candidate cell: register `import:{i}`, request the cell's thumbnail, +/// paint the selection checkbox, and label duplicates, unreadable or trashed files. A click on an +/// importable cell toggles it (Shift extends the range); `rect` is the full cell and the thumbnail +/// uses its width as height. +fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) { + let t = Tokens::get(ui.ctx()); + let c = d.candidates[i].clone(); + let ok = d.importable(i); + let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width())); + let resp = ui.interact(img, egui::Id::unique(("import-cell", i)), Sense::click()); + crate::widgets::announce(&resp, c.name.clone()); + register(ui, format!("import:{i}"), img); + let p = ui.painter(); + p.rect_filled(img, 3.0, t.skeleton); + // thumbnail (background job; the grid only asks for the cells in view) + let slot = Slot::Import(i as u32); + if !app.renderer.textures.contains_key(&slot) + && let Some(job) = app.session.candidate_thumb_job(&c, 192, i as u64) + { + app.renderer.request_quick(slot, job, 4); + } + if let Some(tex) = app.renderer.textures.get(&slot) { + let [tw, th] = tex.size; + let s = ((img.width() - 8.0) / tw as f32).min((img.height() - 8.0) / th as f32); + let fit = Rect::from_center_size(img.center(), vec2(tw as f32 * s, th as f32 * s)); + let tint = if ok { Color32::WHITE } else { Color32::from_gray(110) }; + p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); + } + let on = d.checked[i] && ok; + if on { + p.rect_stroke(img, 3.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + } + // checkbox + let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0)); + p.rect(cb, 3.0, if on { t.accent } else { Color32::from_black_alpha(150) }, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); + if on { + p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE)); + p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE)); + } + let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)); + let badge = match (&c.duplicate, &c.error) { + // checked with "Leave them", the file goes without `onDeleted`: the engine restores it + (Some(_), _) if in_trash && on => Some(if d.on_deleted == "fresh" { "Will be replaced" } else { "Will be restored" }), + (Some(_), _) if in_trash => Some("In Recently Deleted"), + (Some(r), _) if r == "path" => Some("In library"), + (Some(_), _) => Some("Duplicate"), + (None, Some(_)) => Some("Unreadable"), + _ => None, + }; + if let Some(b) = badge { + let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); + let br = badge_rect(img, cb, g.size()); + p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); + p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); + } + let name = if c.name.chars().count() > 18 { format!("{}…", c.name.chars().take(17).collect::()) } else { c.name.clone() }; + p.text(pos2(rect.left() + 2.0, img.bottom() + 9.0), Align2::LEFT_CENTER, name, t.font(10.5), if ok { t.text_label } else { t.text_dim }); + let tip = format!( + "{}\n{} × {} · {} · {:.1} MB{}", + c.path, + c.width, + c.height, + c.format, + c.file_size as f64 / 1e6, + c.captured.as_deref().map(|d| format!("\n{}", d.replace('T', " "))).unwrap_or_default() + ); + let resp = resp.on_hover_text(tip); + if resp.clicked() && ok { + let shift = ui.input(|input| input.modifiers.shift); + d.click(i, shift); + } +} + +/// Where a cell's badge goes: top right, or under the checkbox when the text is too wide to sit +/// beside it — drawn over the checkbox it hid it (issue #1285). +pub(super) fn badge_rect(img: Rect, cb: Rect, text: Vec2) -> Rect { + let size = text + vec2(8.0, 4.0); + let left = img.right() - size.x - 4.0; + let top = if left < cb.right() + 4.0 { cb.bottom() + 4.0 } else { img.top() + 6.0 }; + Rect::from_min_size(pos2(left, top), size) +} + +/// The folder template the Custom choice starts with (`2026/20260114/`). +pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}"; + +/// Where the first selected photo would be copied to (destination, folders, name), for the +/// dialog's example line; `None` without a photo or with an unusable folder template. +pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option { + let c = d.candidates.iter().zip(&d.checked).enumerate().find(|(i, (_, on))| **on && d.importable(*i)).map(|(_, (c, _))| c)?; + let organize = match d.organize_param().ok()? { + Some(o) => lightcraft_engine::import::Organize::parse(&o)?, + None => Default::default(), + }; + let mut q = lightcraft_catalog::Photo::new( + lightcraft_catalog::PhotoId(0), + lightcraft_catalog::Source::File { path: c.path.clone() }, + &c.name, + &c.format, + 0, + 0, + // a photo without a capture time is dated by the import (now) + &(app.session.clock)(), + ); + q.captured = c.captured.clone(); + // the probe's metadata, so {camera}, {title}… preview as they will import + if let Some(info) = app.session.import_probes.get(&c.path) { + q.meta = info.meta.clone(); + } + let name = if d.rename.trim().is_empty() { c.name.clone() } else { lightcraft_engine::rename::expand(d.rename.trim(), &q, 1) }; + let root = if d.destination.trim().is_empty() { + app.session.library.as_ref().map_or_else(|| "Originals".to_string(), |l| l.dir.join("Originals").to_string_lossy().to_string()) + } else { + d.destination.trim().trim_end_matches(['/', '\\']).to_string() + }; + let sep = std::path::MAIN_SEPARATOR_STR; + let mut parts = vec![root]; + parts.extend(organize.folders(&q)); + parts.push(name); + Some(parts.join(sep)) +} + +/// The import source in a few words: a folder's name, a file's name, or "N files and folders". +pub fn source_summary(sources: &[String]) -> String { + inspect_sources(sources).display() +} + +/// Facts cached by the filesystem worker, formatted in the UI thread's active language. +/// Caching a translated String on the worker would always use its default language (English) +/// and also retain that language after the user changes the UI language. +#[derive(Clone)] +enum SourceSummary { + Folder(String), + File(String), + Many { files: usize, folders: usize }, +} + +impl SourceSummary { + fn display(&self) -> String { + match self { + Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name), + Self::File(name) => name.clone(), + Self::Many { files, folders } => match (files, folders) { + (_, 0) => crate::i18n::tr_format!("{} files", files), + (0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f), + (_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f), + }, + } + } +} + +fn inspect_sources(sources: &[String]) -> SourceSummary { + let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string()); + match sources { + [one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)), + [one] => SourceSummary::File(name(one)), + many => { + let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count(); + SourceSummary::Many { files: many.len().saturating_sub(folders), folders } + } + } +} + +/// A labelled row (fixed label column). +fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(78.0); + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); + }); + add(ui) + }) + .inner +} diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs new file mode 100644 index 000000000..ee237fd5a --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/scan.rs @@ -0,0 +1,272 @@ +use super::task::ImportTask; +use super::*; + +/// A folder scan running on a worker thread (a network share can take minutes to read; the +/// window must keep answering meanwhile). +pub struct ScanTask { + progress: std::sync::Arc, + rx: std::sync::mpsc::Receiver, + /// Open the review with "copy into the library" checked (a camera / card). + pub copy: bool, + /// Open the review with this album chosen (an album's context menu ▸ Import…). + pub album: Option, + /// Browsing a folder (Local): the photos are read in place instead of opening the review. + pub(super) browse: bool, + /// What is being scanned (the review's source). + sources: Vec, + /// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`). + guard: lightcraft_engine::activity::TaskGuard, +} + +/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder"). +fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard { + let label = if browse { "Reading folder" } else { "Scanning folder" }; + app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone())) +} + +/// An import and a Synchronize Folder never run at once: each readies its files against the +/// library as it was when it started, so both could add the same file. +pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> { + if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) } +} + +/// Settings → Import → "Show the import review when a camera or card is connected", called with +/// each device scan while it's on. The first look only notes the cards already in. A card that +/// shows up after that opens Import from Device once nothing else is open, and a card that is +/// taken out and put back in asks again. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn cards_seen(app: &mut LightcraftApp, now: Vec) { + let Some(known) = app.ui.known_cards.as_mut() else { + app.ui.known_cards = Some(now.into_iter().map(|d| d.root).collect()); + return; + }; + known.retain(|root| now.iter().any(|d| d.root == *root)); + let Some(card) = now.into_iter().find(|d| !known.contains(&d.root)) else { return }; + // busy: ask when the window is free (the card isn't noted until then) + if app.import.is_some() || app.scan.is_some() || app.sync_run.is_some() || app.lightroom.is_some() || app.ui.dialog.is_some() { + return; + } + known.push(card.root); + if let Err(e) = app.run("file.addFromDevice", json!({"path": card.path})) { + log::warn!("import from {}: {e}", card.name); + } +} + +/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). +pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result { + if app.scan.is_some() { + return Err("a scan is already running".into()); + } + if app.review_pending.is_some() { + return Err("an import review is waiting for the open dialog to close".into()); + } + let sources = paths.clone(); + let (input, paths) = ScanInput::new(&mut app.session, &paths); + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel(); + let p = progress.clone(); + let job = move || { + let _ = tx.send(scan_with(input, &paths, &p)); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(job); + #[cfg(target_arch = "wasm32")] + job(); + let guard = scan_guard(app, &progress, false); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: false, sources, guard }); + Ok(json!({"scanning": true})) +} + +/// Show a folder's photos in the Local view (`library.browse` from the UI): the view switches at +/// once, the folder is listed and read on a worker thread (a network share can take minutes), +/// and the photos then join the view in small batches under a progress window. A browse already +/// running is replaced; the import review's scan is not. +pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option) -> Result { + let dir = std::path::absolute(std::path::Path::new(path)).map_err(|e| e.to_string())?; + if !dir.is_dir() { + return Err(format!("{path}: not a folder")); + } + if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) { + return Err("an import is running".into()); + } + let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders)); + let running = app.scan.as_ref().is_some_and(|t| t.browse) || app.import.as_ref().is_some_and(|t| t.browse); + if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) { + // already reading this folder: clicking it again must not restart the progress + app.session.source = lightcraft_engine::LibrarySource::Folder; + app.session.keep_active_in_view(); + return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})); + } + if let Some(t) = app.scan.take() { + t.progress.cancel.store(true, Ordering::Relaxed); + } + app.import = None; + app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders }); + app.session.source = lightcraft_engine::LibrarySource::Folder; + // the photos the library already knows from this folder show at once: keep or pick the active one + app.session.keep_active_in_view(); + let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s)); + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel(); + let p = progress.clone(); + let root = dir_s.clone(); + let job = move || { + let files: Vec = if subfolders { + lightcraft_engine::import::expand(&[root], None) + } else { + let mut v: Vec = std::fs::read_dir(&root) + .map(|rd| { + rd.flatten() + .map(|e| e.path()) + .filter(|f| { + f.is_file() + && lightcraft_engine::import::is_supported(f) + && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) + }) + .map(|f| f.to_string_lossy().to_string()) + .collect() + }) + .unwrap_or_default(); + v.sort(); + v + }; + let _ = tx.send(scan_with(input, &files, &p)); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(job); + #[cfg(target_arch = "wasm32")] + job(); + let guard = scan_guard(app, &progress, true); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: true, sources: Vec::new(), guard }); + app.renderer.forget_imports(); + Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) +} + +/// Collect a finished scan and open the review (called every frame). +pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { + show_pending_review(app, ctx); + let Some(task) = app.scan.as_ref() else { return }; + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + let out = match task.rx.try_recv() { + Ok(o) => o, + Err(std::sync::mpsc::TryRecvError::Empty) => { + if task.guard.is_cancelled() { + // ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while) + app.scan = None; + return; + } + let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed)); + task.guard.progress(done as u64, total as u64); + // while the folders are listed the total isn't known: say what is happening instead + task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" }); + return; + } + Err(_) => { + app.scan = None; + app.toast(ctx, crate::i18n::tr("Scan failed")); + return; + } + }; + let Some(task) = app.scan.take() else { return }; + if task.progress.cancel.load(Ordering::Relaxed) { + return; + } + app.session.import_probes = out.probes; + if task.browse { + // the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so + // their untouched Local photos aren't forgotten while the folder is in use + if let Some(root) = app.session.browse.as_ref().map(|b| b.path.clone()) { + let shown: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); + app.session.note_browsed(&root, &shown); + } + // records of files gone from the folder (deleted or moved by another app) that nothing was changed on go, + // as `library.browse` forgets them (issue #1392) + if let Some(b) = app.session.browse.clone() { + let listed: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); + app.session.forget_gone_from(&b.path, b.subfolders, &listed); + } + // what the library doesn't know yet joins the Local view + let queue: Vec = + out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect(); + if !queue.is_empty() { + let undo0 = app.session.undo.len(); + let total = queue.len(); + let _ = total; + app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true)); + } + return; + } + if out.candidates.is_empty() { + app.toast(ctx, crate::i18n::tr("No photos found")); + return; + } + app.renderer.forget_imports(); + let mut d = ImportDialog::new(out.candidates); + let trashed = d + .candidates + .iter() + .map(|c| { + c.duplicate.is_some() + && c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)) + }) + .collect(); + d.set_trashed(trashed); + d.copy = task.copy; + // only an album the review offers (it may have been deleted while the scan ran) + d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); + d.sources = task.sources; + d.library = Some(app.session.library_generation()); + app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) }); + show_pending_review(app, ctx); +} + +/// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings… +/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was +/// closed meanwhile (Settings can open another) is dropped. +fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) { + if app.review_pending.is_none() { + return; + } + if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending + && !opts.in_library(app) + { + app.review_pending = None; + return; + } + if app.ui.dialog.is_none() { + app.ui.dialog = app.review_pending.take(); + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Tests: a review scan that never ends while the returned value lives, so a drop meets it running. +#[cfg(test)] +pub(crate) fn stall_scan(app: &mut LightcraftApp) -> Box { + stall_scan_of(app, false) +} + +/// Tests: the same, for a Local folder browse scan (`browse`) instead of the review's scan. +#[cfg(test)] +pub(crate) fn stall_scan_of(app: &mut LightcraftApp, browse: bool) -> Box { + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel::(); + let guard = scan_guard(app, &progress, false); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse, sources: Vec::new(), guard }); + Box::new(tx) +} + +/// Whether an import or any scan (the review's, or a Local folder browse) is running, or a finished scan's +/// review waits for a dialog to close, which a drop of photos must not replace: `open` refuses while a scan +/// runs, and a browse that finishes replaces `app.import`. +pub fn running(app: &LightcraftApp) -> bool { + app.import.is_some() || app.scan.is_some() || app.review_pending.is_some() +} + +impl ScanTask { + /// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed). + pub fn status(&self) -> Value { + json!({"done": self.progress.done.load(Ordering::Relaxed), "total": self.progress.total.load(Ordering::Relaxed)}) + } +} diff --git a/crates/ui-egui/src/dialogs/import/tags.rs b/crates/ui-egui/src/dialogs/import/tags.rs new file mode 100644 index 000000000..770bb5b30 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/tags.rs @@ -0,0 +1,95 @@ +use super::*; + +/// Reserve the actual translated tag button width beside a template field. +pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text); + (ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0) +} + +/// The "Tags" toggle beside a template field (`button:Tags`); returns whether the tag help +/// ([`tag_help`]) is open. +pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool { + let id = egui::Id::unique((key, "tags-open")); + let mut open = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or(false); + let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open) + .on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it")); + if r.clicked() { + open = !open; + ui.ctx().data_mut(|d| d.insert_temp(id, open)); + } + open +} + +/// "Unknown tag {x} stays as typed" under a template field, when it has one. +pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) { + let unknown = lightcraft_engine::rename::unknown_tokens(template); + if !unknown.is_empty() { + let s = if unknown.len() == 1 { "" } else { "s" }; + let text = crate::i18n::tr_format!( + "Unknown tag{s} {} stay{} as typed (see Tags)", + unknown.join(" "), + if unknown.len() == 1 { "s" } else { "" }, + s = s + ); + ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution)); + } +} + +/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection); +/// appended when the field never had a cursor. The cursor ends up after the tag. +pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) { + use egui::text::{CCursor, CCursorRange}; + let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default(); + let n = text.chars().count(); + let (a, b) = state.cursor.char_range().map_or((n, n), |r| { + let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n)); + (x.min(y), x.max(y)) + }); + let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); + let (ba, bb) = (byte(a), byte(b)); + text.replace_range(ba..bb, tag); + state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count())))); + state.store(ctx, edit_id); + ctx.memory_mut(|m| m.request_focus(edit_id)); +} + +/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its +/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag +/// (`button:Tag-`) inserts it at the cursor of the field `edit_id`. +pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) { + use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example}; + let t = Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| { + ui.label( + egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.")) + .color(t.text_dim) + .small(), + ); + egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| { + egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| { + for (i, tok) in TOKENS.iter().enumerate() { + let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( + if tok.aliases.is_empty() { + crate::i18n::tr("Insert at the cursor").to_string() + } else { + crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) + }, + ); + register(ui, format!("button:{key}Tag-{i}"), r.rect); + if r.clicked() { + insert_at_cursor(ui.ctx(), edit_id, text, tok.tag); + } + ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label)); + ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim)); + ui.end_row(); + } + }); + let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect(); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small()); + for n in TEMPLATE_NOTES { + ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small()); + } + }); + }); +} diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs new file mode 100644 index 000000000..bcef55b11 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/task.rs @@ -0,0 +1,544 @@ +use super::scan::{busy_synchronizing, running}; +use super::*; + +/// Files per batch in the browser build (each batch joins the catalog as it is ready, so the +/// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`]. +#[cfg(target_arch = "wasm32")] +const BATCH: usize = 8; + +/// A running import (see the module docs). The file-system work — expanding folders, probing, +/// reading sidecars, copying or placing files — runs on a worker thread +/// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the +/// UI thread between frames, so a slow drive never stalls the window. +#[derive(Debug, Default)] +pub struct ImportTask { + /// Files and folders to import (handed to the worker when the task starts). + queue: Vec, + pub total: usize, + pub done: usize, + params: Value, + pub imported: usize, + /// Brought back from Recently Deleted (`onDeleted: restore`). + pub restored: usize, + pub duplicates: usize, + /// The library photos the skipped duplicates match (shown when nothing new was added). + existing: Vec, + pub failed: usize, + /// Move: originals moved, and sources left in place (reported by the engine with a reason). + pub moved: usize, + pub kept: usize, + /// Structured diagnostics from foreign XMP develop settings. + pub warnings: Vec, + undo0: usize, + first: Option, + /// Reading a folder for the Local view: the photos stay out of the library, and nothing is + /// selected or announced as added. + pub(super) browse: bool, + /// Cancel was pressed: no further files are started; batches already readied are added. + pub cancelled: bool, + /// Auto Import (the watched folder): the selection stays as it is, a short toast when done. + auto: bool, + /// Auto Import: the selection to keep. + keep_selection: Option, + /// The selection and source as the import last left them: a change between frames is the + /// user choosing another photo or view, which the end of the import doesn't take away. + left: Option<(lightcraft_engine::Selection, lightcraft_engine::LibrarySource)>, + /// The user chose another photo or view while the import ran. + moved_on: bool, + run: Option, + /// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag). + guard: Option, +} + +impl ImportTask { + /// An import of `paths` (files or folders) with `library.import` params (without `paths`). + pub fn new(paths: Vec, params: Value, undo0: usize, browse: bool) -> Self { + ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() } + } + + /// An Auto Import (see [`ImportTask::auto`]). + pub fn auto(mut self) -> Self { + self.auto = true; + self + } + + /// `{done, total, imported, cancelled}` for `ui.inspect`. + pub fn status(&self) -> Value { + json!({"done": self.done, "total": self.total, "imported": self.imported, "cancelled": self.cancelled}) + } +} + +/// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no +/// slow drives) inline, one batch per frame. +enum Runner { + #[cfg(not(target_arch = "wasm32"))] + Thread(std::sync::mpsc::Receiver), + #[cfg(target_arch = "wasm32")] + Inline { job: Box, queue: Vec, files: Option> }, +} + +struct ImportRun { + runner: Runner, + cancel: std::sync::Arc, + total: std::sync::Arc, + done: std::sync::Arc, + opts: lightcraft_engine::import::ImportOptions, + now: String, + album: Option, + album_name: Option, +} + +impl std::fmt::Debug for ImportRun { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImportRun").field("cancel", &self.cancel.load(Ordering::Relaxed)).finish() + } +} + +impl Drop for ImportRun { + /// A dropped import (replaced, or the app closing) stops its worker at the next file. + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +impl ImportRun { + fn start( + job: lightcraft_engine::import::ImportJob, + queue: Vec, + album: Option, + album_name: Option, + ctx: &egui::Context, + ) -> Result { + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (total, done) = (job.total.clone(), job.done.clone()); + let (opts, now) = (job.opts.clone(), job.now().to_string()); + let runner = Self::spawn(job, queue, cancel.clone(), ctx)?; + Ok(ImportRun { runner, cancel, total, done, opts, now, album, album_name }) + } + + #[cfg(not(target_arch = "wasm32"))] + fn spawn( + mut job: lightcraft_engine::import::ImportJob, + queue: Vec, + cancel: std::sync::Arc, + ctx: &egui::Context, + ) -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + let ctx = ctx.clone(); + let work = move || { + let files = job.expand(&queue); + for chunk in files.chunks(lightcraft_engine::import::batch_size()) { + if cancel.load(Ordering::Relaxed) { + break; + } + let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) { + Ok(p) => p, + Err(_) => break, // logged; the files readied so far are added + }; + if let Err(unsent) = tx.send(prepared) { + // the import was dropped: nothing will add these, so take back what a Move placed + unsent.0.rollback(); + break; + } + ctx.request_repaint(); + } + ctx.request_repaint(); + }; + std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}")) + } + + #[cfg(target_arch = "wasm32")] + fn spawn( + job: lightcraft_engine::import::ImportJob, + queue: Vec, + _cancel: std::sync::Arc, + _ctx: &egui::Context, + ) -> Result { + Ok(Runner::Inline { job: Box::new(job), queue, files: None }) + } + + /// Readied batches (at most a few per frame). `None` once the worker is done. + fn next_batches(&mut self) -> Option> { + match &mut self.runner { + #[cfg(not(target_arch = "wasm32"))] + Runner::Thread(rx) => { + let mut out = Vec::new(); + while out.len() < 4 { + match rx.try_recv() { + Ok(p) => out.push(p), + Err(std::sync::mpsc::TryRecvError::Empty) => return Some(out), + Err(std::sync::mpsc::TryRecvError::Disconnected) => return if out.is_empty() { None } else { Some(out) }, + } + } + Some(out) + } + #[cfg(target_arch = "wasm32")] + Runner::Inline { job, queue, files } => { + if self.cancel.load(Ordering::Relaxed) { + return None; + } + if files.is_none() { + *files = Some(job.expand(queue).into()); + return Some(Vec::new()); + } + let Some(files) = files.as_mut() else { return None }; + if files.is_empty() { + return None; + } + let n = files.len().min(BATCH); + let batch: Vec = files.drain(..n).collect(); + Some(vec![job.prepare_files(batch, &self.cancel)]) + } + } + } +} + +/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). Refused while another +/// import runs: replacing `app.import` would stop its worker. +pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result { + busy_synchronizing(app)?; + if !d.in_library(app) { + return Err("another library was opened since the photos were scanned: nothing was imported".into()); + } + // a browse scan that finishes starts its own import, replacing this one + if app.import.is_some() || app.scan.as_ref().is_some_and(|t| t.browse) { + return Err(crate::i18n::tr("An import is running").to_string()); + } + let queue = d.selected_paths(); + if queue.is_empty() { + return Err("no photos selected".into()); + } + let undo0 = app.session.undo.len(); + let mut album = d.album; + if album.is_none() && !d.new_album.trim().is_empty() { + let r = app.session.execute("album.create", &json!({"name": d.new_album.trim()})).map_err(|e| e.to_string())?; + album = r["id"].as_u64(); + } + let mode = match (d.copy, d.move_files) { + (false, _) => "add", + (true, false) => "copy", + (true, true) => "move", + }; + let mut params = json!({"mode": mode, "keywords": d.keywords()}); + if !d.on_deleted.is_empty() { + params["onDeleted"] = json!(d.on_deleted); + } + if let Some(a) = album { + params["album"] = json!(a); + } + if !d.preset.is_empty() { + params["preset"] = json!(d.preset); + } + if !d.metadata_preset.is_empty() { + params["metadataPreset"] = json!(d.metadata_preset); + } + if d.copy { + if !d.destination.trim().is_empty() { + params["destination"] = json!(d.destination.trim()); + } + if let Some(o) = d.organize_param()? { + params["organize"] = json!(o); + } + if !d.rename.trim().is_empty() { + params["rename"] = json!(d.rename.trim()); + params["renameStart"] = json!(1); + } + if d.dng && !d.move_files { + params["dng"] = json!(true); + } + } + let total = queue.len(); + app.import = Some(ImportTask::new(queue, params, undo0, false)); + app.renderer.forget_imports(); + Ok(json!({"importing": total})) +} + +/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window. +pub fn start_paths(app: &mut LightcraftApp, paths: Vec) -> Result { + busy_synchronizing(app)?; + if running(app) { + return Err(crate::i18n::tr("An import is running").to_string()); + } + let undo0 = app.session.undo.len(); + let total = paths.len(); + app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false)); + Ok(json!({"importing": total})) +} + +/// Advance the import in progress (called every frame): start its worker, then add the batches it +/// has readied to the catalog. +pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(mut task) = app.import.take() else { return }; + if task.run.is_none() { + let mut p = task.params.clone(); + p["paths"] = json!(task.queue); + let started = lightcraft_engine::cmd::library::import_params(&app.session, &p) + .and_then(|req| Ok((lightcraft_engine::import::ImportJob::new(&mut app.session, req.opts)?, req.album, req.album_name))) + .map_err(|e| e.to_string()) + .and_then(|(job, album, name)| ImportRun::start(job, std::mem::take(&mut task.queue), album, name, ctx)); + if task.auto { + task.keep_selection = Some(app.session.selection.clone()); + } + match started { + Ok(run) => { + let label = if task.browse { "Reading folder" } else { "Importing" }; + task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone()))); + task.run = Some(run); + } + Err(e) => { + log::warn!("import: {e}"); + app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e)); + return; + } + } + } + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + let Some(run) = task.run.as_mut() else { return }; + let batches = run.next_batches(); + let finished = batches.is_none(); + if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) { + task.moved_on = true; + } + for prepared in batches.into_iter().flatten() { + commit_batch(app, &mut task, prepared); + // arriving photos don't take over the selection + if let Some(sel) = &task.keep_selection { + app.session.selection = sel.clone(); + } + // a Local folder still on screen: its first photo becomes active once there is one (#1447) + if task.browse && app.session.source == lightcraft_engine::LibrarySource::Folder { + app.session.keep_active_in_view(); + } + } + task.left = Some((app.session.selection.clone(), app.session.source)); + if let Some(run) = &task.run { + task.total = run.total.load(Ordering::Relaxed).max(task.total); + task.done = run.done.load(Ordering::Relaxed); + } + if let Some(guard) = &task.guard { + guard.progress(task.done as u64, task.total as u64); + // ✕ in the stack (or `activity.cancel`): no further files are started + if guard.is_cancelled() { + task.cancelled = true; + } + } + if !finished { + app.import = Some(task); + return; + } + finish(app, ctx, task); +} + +/// Add one readied batch to the catalog (an undo step merged into the import's at the end). +fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightcraft_engine::import::Prepared) { + let Some(run) = task.run.as_ref() else { return }; + let n = prepared.len(); + let (opts, now, album, album_name) = (&run.opts, run.now.as_str(), run.album, run.album_name.clone()); + let r = app.session.execute_fn("library.import", |s| { + let report = lightcraft_engine::import::commit_prepared(s, opts, now, prepared)?; + lightcraft_engine::cmd::library::import_batch_done(s, report, album, album_name.as_deref()) + }); + match r { + Ok(v) => { + // a new album (named in the params) is created with the first photos; later batches join it + if let (Some(a), Some(run)) = (v["album"].as_u64(), task.run.as_mut()) { + run.album = Some(a); + } + let len = |k: &str| v[k].as_array().map_or(0, Vec::len); + task.imported += len("imported"); + task.restored += len("restored"); + task.duplicates += len("duplicates"); + task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64())); + task.failed += len("failed"); + task.moved += len("moved"); + task.kept += len("kept"); + task.warnings.extend(v["warnings"].as_array().into_iter().flatten().filter_map(|warning| serde_json::from_value(warning.clone()).ok())); + for k in v["kept"].as_array().into_iter().flatten() { + log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or("")); + } + if task.first.is_none() { + task.first = ["imported", "restored"].iter().find_map(|k| v[*k].as_array().and_then(|a| a.first()).and_then(Value::as_u64)); + } + } + Err(e) => { + log::warn!("import: {e}"); + task.failed += n; + } + } +} + +fn xmp_warning_text(warning: &ImportWarning) -> String { + let path = &warning.path; + match warning.code { + ImportWarningCode::CameraProfile => crate::i18n::tr_format!( + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.", + profile = warning.profile.as_deref().unwrap_or_default(), + path = path, + ), + ImportWarningCode::UprightGeometry => match warning.mode.unwrap_or(ImportUprightMode::Unknown) { + ImportUprightMode::Off => crate::i18n::tr_format!( + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.", + path = path, + ), + ImportUprightMode::Guided => crate::i18n::tr_format!( + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.", + path = path, + ), + ImportUprightMode::Auto => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Auto") + ), + ImportUprightMode::Level => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Level") + ), + ImportUprightMode::Vertical => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Vertical") + ), + ImportUprightMode::Full => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Full") + ), + ImportUprightMode::Unknown => { + crate::i18n::tr_format!("Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.", path = path,) + } + }, + ImportWarningCode::UnmappedXmp => { + crate::i18n::tr_format!("XMP adjustments were not imported for {path}: {fields}.", path = path, fields = warning.fields.join(", "),) + } + } +} + +fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { + crate::i18n::tr_format!( + "Import warnings ({n}):\n{warnings}", + n = warnings.len(), + warnings = warnings.iter().map(xmp_warning_text).collect::>().join("\n"), + ) +} + +/// The import is done (or cancelled): one undo step, show and select the first photo (unless the +/// user chose another meanwhile), say what happened. +fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { + let steps = app.session.undo.len().saturating_sub(task.undo0); + let plural = |n: usize| if n == 1 { "" } else { "s" }; + let label = if task.imported == 0 && task.restored > 0 { + crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored)) + } else if task.restored > 0 { + crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored) + } else { + crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }) + }; + app.session.merge_undo(steps, &label); + if task.auto { + if !task.warnings.is_empty() { + app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); + } else if task.imported > 0 { + app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" })); + } + return; + } + if task.browse { + if !task.warnings.is_empty() { + app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); + } else if task.failed > 0 { + app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" })); + } + return; + } + if let Some(f) = task.first.filter(|_| !task.moved_on) { + // a view the new photos aren't in (an album, Picks, a Local folder) would leave the active + // photo off screen: show All Photos, as Lightroom shows the import's own collection (#1446) + if !app.session.visible().contains(&lightcraft_catalog::PhotoId(f)) { + let _ = app.run("library.source", json!({"kind": "all"})); + } + let _ = app.run("library.select", json!({"ids": [f]})); + } + let plural = |n: usize| if n == 1 { "" } else { "s" }; + // nothing new, only files the library already has: show where they are (Recently Deleted is + // easy to miss, and the side panel that lists it starts collapsed) + if task.imported == 0 + && task.restored == 0 + && task.failed == 0 + && task.warnings.is_empty() + && !task.cancelled + && show_existing(app, ctx, &task.existing) + { + return; + } + let mut msg = if task.cancelled { + crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) + } else if task.imported == 0 && task.restored > 0 { + crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored)) + } else { + crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) + }; + if task.restored > 0 && (task.imported > 0 || task.cancelled) { + msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); + } + if task.params["mode"] == "move" { + msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved)); + if task.kept > 0 { + msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept))); + } + } + if task.duplicates > 0 { + msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); + let deleted = deleted_of(app, &task.existing).len(); + if deleted > 0 { + msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); + } + } + if task.failed > 0 { + msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); + } + if task.warnings.is_empty() { + app.toast(ctx, msg); + } else { + msg.push_str("\n\n"); + msg.push_str(&xmp_warnings_text(&task.warnings)); + app.toast_for(ctx, msg, 8.0); + } +} + +/// The photos among `ids` that are in Recently Deleted (each once). +fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec { + let mut v: Vec = + ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect(); + v.sort_unstable(); + v.dedup(); + v +} + +/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel +/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none. +fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool { + let deleted = deleted_of(app, existing); + let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) }; + ids.sort_unstable(); + ids.dedup(); + ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some()); + if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() { + return false; + } + let _ = app.run("library.select", json!({"ids": ids})); + app.ui.left_panel = true; + let n = ids.len(); + let plural = if n == 1 { "" } else { "s" }; + let msg = if kind == "all" { + crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural) + } else { + crate::i18n::tr_format!( + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh", + n, + plural + ) + }; + app.toast_for(ctx, msg, 6.0); + true +} From cb2a9223831d25d5f6c9abfa826cfdb71ef0e1a5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:14:13 +0000 Subject: [PATCH 128/191] refactor(lightcraft-ui-egui): split lib.rs into crate-root modules --- crates/ui-egui/src/app.rs | 2182 ++++++++++++++++++++++++ crates/ui-egui/src/caches.rs | 469 ++++++ crates/ui-egui/src/clock.rs | 35 + crates/ui-egui/src/lib.rs | 2854 +------------------------------- crates/ui-egui/src/services.rs | 130 ++ crates/ui-egui/src/state.rs | 41 + 6 files changed, 2876 insertions(+), 2835 deletions(-) create mode 100644 crates/ui-egui/src/app.rs create mode 100644 crates/ui-egui/src/caches.rs create mode 100644 crates/ui-egui/src/clock.rs create mode 100644 crates/ui-egui/src/services.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs new file mode 100644 index 000000000..413927fd1 --- /dev/null +++ b/crates/ui-egui/src/app.rs @@ -0,0 +1,2182 @@ +use super::clock::{AppClock, monotonic_ms}; +use super::services::Services; +use super::state::{GestureFrame, InjectedDrop, InjectedPress}; +use super::*; + +impl LightcraftApp { + /// Build the app with default UI, renderer, cache and control state, and move AI segmentation + /// to a worker so frames apply results instead of waiting. + pub fn new(mut session: Session, services: Services) -> Self { + // AI mask requests run on the model's worker; frames apply their results (never wait) + session.segmenter.background = true; + Self { + session, + ui: UiState::default(), + services, + renderer: render::Renderer::default(), + perf: Perf::default(), + caches: Caches::default(), + integrated_titlebar: false, + window_movable: None, + native_menu: false, + native_shortcuts: Default::default(), + recording_shortcut: None, + text_focus: false, + headless_host: false, + notices: vec![], + updates_rt: Default::default(), + quit_prompt: None, + notice_input: Default::default(), + quit_confirmed: false, + control_rx: None, + pending_screenshots: vec![], + pending_gestures: vec![], + layout_epoch: 0, + screenshot_token: 0, + shot_bytes: 0, + shot_geometry_epoch: 0, + last_shot_geometry: None, + max_texture_side: MAX_TEXTURE_SIDE, + clock: AppClock::new(), + accesskit_language: None, + slideshow_hidden_at: None, + slideshow_auto_paused: false, + dropped_files: vec![], + synthetic_in_flight: false, + ui_passes: 0, + #[cfg(test)] + library_passes: vec![], + shadow: None, + synthetic: vec![], + pointer_cleanup: false, + drop_hover: vec![], + drop_pending: vec![], + copied: None, + pending_picks: vec![], + synthetic_mods: egui::Modifiers::NONE, + synthetic_mods_release: false, + host_mods: egui::Modifiers::NONE, + styled: false, + font_language: i18n::Locale::En, + chinese_font: None, + fonts_ready: false, + last_time: 0.0, + canvas_rect: None, + image_rect: None, + grid_scroll: None, + film_scroll: None, + widgets: vec![], + widget_hits: vec![], + next_widgets: vec![], + next_widget_hits: vec![], + gesture: None, + brush_live: None, + loupe_shown: None, + region_view: None, + region_before_view: None, + size_hold: Default::default(), + drag_scale: Default::default(), + window_refused: None, + region_tiles: Default::default(), + compare_windows: [None; 2], + compare_tiles: Default::default(), + compare_hold_until: f64::NEG_INFINITY, + merge: merge::MergeState::default(), + import: None, + scan: None, + sync: None, + sync_owns_changes: false, + sync_run: None, + lightroom: None, + lightroom_last: None, + lightroom_result: None, + review_pending: None, + shown_library: None, + export: None, + tasks: Default::default(), + last_export_result: None, + slideshow_export_reported: true, + hover_preview: None, + window_is_fullscreen: false, + gpu_applied: None, + gpu_notice: false, + memory_applied: None, + display_applied: None, + display_error: None, + screen_profile: None, + screen_profile_read: None, + library_problem: None, + library_window_input: Default::default(), + alt_asks_for_library: false, + recent_checks: Default::default(), + activity_expanded: false, + model_setup: Default::default(), + } + } + + /// Supply the browser's separately downloaded Simplified Chinese font before the first frame. + pub fn with_chinese_font(mut self, bytes: Vec) -> Self { + if !bytes.is_empty() { + self.chinese_font = Some(std::sync::Arc::new(egui::FontData::from_owned(bytes))); + } + self + } + + /// Attach the control-channel receiver and keep CPU copies of photo textures, which headless + /// screenshots need; returns the app for chaining. + pub fn with_control(mut self, rx: Receiver) -> Self { + self.control_rx = Some(rx); + // keep CPU copies of photo textures so `ui.screenshot {"headless": true}` can draw them + self.renderer.keep_pixels = true; + self + } + + /// Seconds on the app's monotonic clock. Every timer that must advance while the window is + /// hidden (imports, autosave, playback, toasts, captures) uses this, never `i.time`. + pub fn tick_now(&self) -> f64 { + self.clock.now() + } + + /// The app clock in milliseconds, for control deadlines and timers. + fn tick_now_ms(&self) -> f64 { + self.clock.now() * 1000.0 + } + + /// The immutable identity a capture is bound to: the library generation. It changes when a + /// different library is opened, not on every edit, import batch or keyword op (`catalog.revision` + /// bumps on all of those), so a screenshot during interactive editing still belongs to the + /// document the user asked for. + pub(crate) fn shot_document_generation(&self) -> u64 { + self.session.library_generation() + } + + /// Give a headless driver its simulated time (`None` returns to the monotonic host clock). + pub fn set_simulated_clock(&mut self, seconds: Option) { + self.clock.simulated = seconds.filter(|s| s.is_finite() && *s >= 0.0); + } + + /// The window's current surface geometry as native points plus native pixels per point. + /// `run_logic` refreshes `input.viewports`, so this is fresh even while the UI is not laid out; + /// without one, the last laid-out content rect is used. + fn shot_geometry(&self, ctx: &egui::Context) -> ShotGeometry { + // Both `viewport.inner_rect` and the content rect are in egui points, and `pixels_per_point` + // already includes the UI zoom (egui-winit divides by the zoomed ppp). Multiplying by the + // zoom again would reserve a surface that is too small, so the zoom is only tracked as a + // change signal. + let zoom = ctx.zoom_factor(); + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + let pixels_per_point = ctx.input(|i| i.pixels_per_point()); + let viewport = ctx.input(|i| { + i.viewport().inner_rect.filter(|r| r.width() > 0.0 && r.height() > 0.0).map_or_else(|| i.content_rect().size(), |inner| inner.size()) + }); + ShotGeometry { viewport, pixels_per_point, zoom, epoch: self.shot_geometry_epoch } + } + + /// Track geometry changes as a generation. Called once per logic tick from the refreshed + /// viewport information, so a hidden window still notices a resize. + fn note_shot_geometry(&mut self, ctx: &egui::Context) { + let mut now = self.shot_geometry(ctx); + if let Some(last) = self.last_shot_geometry + && (last.viewport != now.viewport || last.pixels_per_point != now.pixels_per_point || last.zoom != now.zoom) + { + self.shot_geometry_epoch = self.shot_geometry_epoch.wrapping_add(1); + now.epoch = self.shot_geometry_epoch; + } + self.last_shot_geometry = Some(now); + } + + /// The texture limit the host reported (16 384 unless it said otherwise). + fn max_texture_side(&self) -> usize { + self.max_texture_side + } + + /// Retain the host's `RawInput::max_texture_side`. Hosts (egui-winit, `Headless`) report it + /// only when it changes, so keep the last value instead of assuming 16 384. + fn note_max_texture_side(&mut self, ctx: &egui::Context) { + if let Some(limit) = ctx.input(|i| i.raw.max_texture_side) + && limit > 0 + { + self.max_texture_side = limit; + } + } + + /// Reserve a new image's `bytes` against the aggregate screenshot budget, or report why not. + fn reserve_shot_bytes(&mut self, bytes: usize) -> Result<(), String> { + if bytes > MAX_SHOT_BYTES { + return Err(format!("ui.screenshot: {bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit")); + } + let total = self.shot_bytes.checked_add(bytes).ok_or("ui.screenshot: byte reservation overflow")?; + if total > MAX_SHOT_TOTAL_BYTES { + return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit")); + } + self.shot_bytes = total; + Ok(()) + } + + /// A surface that grew after admission (a stale `content_rect` on Wayland, a resize between + /// issue and completion): bound the *new image* against the per-image limit, then reserve only + /// the growth against the aggregate. The old per-image check would compare the delta to the + /// per-image cap and name the delta as if it were the image. + fn grow_shot_reservation(&mut self, current_reserved: usize, image_bytes: usize) -> Result<(), String> { + if image_bytes > MAX_SHOT_BYTES { + return Err(format!("ui.screenshot: {image_bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit")); + } + let extra = image_bytes.saturating_sub(current_reserved); + let total = self.shot_bytes.checked_add(extra).ok_or("ui.screenshot: byte reservation overflow")?; + if total > MAX_SHOT_TOTAL_BYTES { + return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit")); + } + self.shot_bytes = total; + Ok(()) + } + + /// Release a reservation; never let an accounting bug underflow `usize`. + fn release_shot_bytes(&mut self, bytes: usize) { + self.shot_bytes = self.shot_bytes.saturating_sub(bytes); + } + + /// The open person page, the way back to it, and the photo it was opened from follow a rename. + fn follow_renamed_person(&mut self, from: &str, to: &str) { + let same = |n: &str| n.trim().to_lowercase() == from.trim().to_lowercase(); + if self.ui.person_page.as_deref().is_some_and(same) { + self.ui.person_page = Some(to.to_string()); + } + if self.ui.last_person.as_deref().is_some_and(same) { + self.ui.last_person = Some(to.to_string()); + } + if let Some((Some(name), photo)) = self.ui.person_from.clone() + && same(&name) + { + self.ui.person_from = Some((Some(to.to_string()), photo)); + } + } + + /// Run a UI or engine command by id. The single entry point for every frontend path. + pub fn run(&mut self, id: &str, params: Value) -> Result { + self.run_typed(id, params).map_err(|e| e.message) + } + + /// [`LightcraftApp::run`], keeping what kind of error a refusal is: for code that decides on it + /// (offer a model's download, treat a cancel as one) instead of reading the message. + pub fn run_typed(&mut self, id: &str, params: Value) -> Result { + if let Some(result) = model_setup::intercept(self, id, ¶ms) { + return result.map_err(CommandError::from); + } + if let Some(r) = menus::run_ui_command(self, id, ¶ms) { + if r.is_ok() { + docking::reveal_command(self, id)?; + } + return r.map_err(CommandError::from); + } + let params = if id == "crop.aspect" + && params.get("aspect").and_then(Value::as_str) == Some("toggle") + && self.crop_tool_aspect().is_some() + && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) + { + serde_json::json!({"aspect": "free"}) + } else { + params + }; + let r = self.session.execute(id, ¶ms).map_err(CommandError::from); + if r.is_ok() && matches!(id, "crop.aspect" | "crop.rotateAspect") { + self.ui.crop_default_aspect = if id == "crop.aspect" && params.get("aspect").and_then(Value::as_str) == Some("original") { + Some(serde_json::json!("original")) + } else { + self.session + .active() + .and_then(|photo| self.session.develop_of(photo)) + .map(|d| d.crop.aspect.map_or(serde_json::json!("free"), |(w, h)| serde_json::json!([w as f64 / 100.0, h as f64 / 100.0]))) + }; + } + if r.is_ok() && id == "library.toggleFilters" { + // the search field shows the text filter: empty while the filters are off, back with them + self.ui.search = self.session.filter.text.clone(); + } + if r.is_ok() && id == "library.clearFilter" { + // the search field shows the text filter: cleared from anywhere (the loupe's pill, the + // menu), it must not keep a search that no longer applies (issue #1648) + self.ui.search.clear(); + } + if r.is_ok() && id == "mask.adjust" { + // Judge local adjustments on the photo, without the selection overlay obscuring them. + // Keep it hidden after release; O / the overlay eye can show it again. + self.ui.mask_overlay = false; + } + if r.is_ok() && matches!(id, "mask.add" | "mask.addComponent" | "mask.duplicate" | "mask.select") { + // A new or newly picked mask shows its overlay again, as in Lightroom: hidden by a slider + // drag above, it stayed hidden while the Overlay menu still named a mode (issue #1800). + self.ui.mask_overlay = true; + } + if r.is_ok() { + // These commands finish synchronously, so their completion would otherwise + // be invisible once the activity stack is gone. Never show success on error. + let completed = match id { + "library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))), + "library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Discard Preview Cache"))), + "develop.autoSync" => Some(format!("{}: {}", i18n::tr("Auto Sync"), i18n::tr(if self.session.auto_sync { "On" } else { "Off" }))), + _ => None, + }; + if let Some(message) = completed { + let now = self.tick_now(); + self.ui.toast = Some((message, now + TOAST_SECONDS, None)); + } + } + if r.is_ok() && id == "photo.label" { + let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse); + let text = match label { + Some(label) => { + let name = + self.session.catalog.custom_label_name(label).map(str::to_owned).unwrap_or_else(|| i18n::tr(&format!("{label:?}")).into()); + i18n::tr_format!("{name} Label", name = name) + } + None => i18n::tr("Color label cleared").into(), + }; + let now = self.tick_now(); + self.ui.toast = Some((text, now + TOAST_SECONDS, label)); + } + match &r { + Err(e) => { + log::warn!("{id}: {e}"); + self.ui.status = e.message.clone(); + // no detector yet: offer it (its terms first) instead of only saying so + if id == "faces.detect" && e.kind == ErrorKind::ModelMissing(AiModel::FaceDetector) { + panels::faces::offer_detector(self); + } + } + Ok(v) if id == "faces.detect" => self.ui.status = detect_summary(v), + Ok(v) if id == "faces.renamePerson" => { + let to = v["to"].as_str().unwrap_or("").to_string(); + let from = params.get("from").and_then(Value::as_str).unwrap_or(""); + if !to.is_empty() { + self.follow_renamed_person(from, &to); + } + } + Ok(_) => {} + } + r + } + + /// The remembered ratio belongs to the tool until the user starts editing the crop. + pub(crate) fn crop_tool_aspect(&self) -> Option<(u32, u32)> { + let photo = self.session.active()?; + let d = self.session.develop_of(photo)?; + if d.crop != lightcraft_develop::Crop::default() { + return d.crop.aspect; + } + // nothing chosen yet: locked to the photo's own shape, as Lightroom starts (#991); one + // Lock toggle (remembered as "free") unlocks it for good + let remembered = self.ui.crop_default_aspect.clone().unwrap_or_else(|| Value::String("original".into())); + match &remembered { + Value::String(s) if s == "original" => { + let p = self.session.catalog.photo(photo)?; + let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100)); + Some(if d.orientation.swaps_axes() { (h, w) } else { (w, h) }) + } + Value::Array(a) => { + let number = |i: usize| { + let v = a.get(i)?.as_f64()? * 100.0; + (v.is_finite() && v >= 1.0 && v <= f64::from(u32::MAX)).then_some(v.round() as u32) + }; + Some((number(0)?, number(1)?)) + } + _ => None, + } + } + + /// Called only after a crop gesture begins, inside its undo transaction. + pub(crate) fn begin_crop(&mut self) -> Result<(), String> { + // nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it + let aspect = self.ui.crop_default_aspect.clone().unwrap_or_else(|| serde_json::json!("original")); + if self.session.interaction.is_some() + && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) + && aspect != serde_json::json!("free") + // the remembered ratio, turned like the new photo's crop (as a named ratio is) + && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"})) + { + // A remembered value the command refuses (a hand-edited settings file) would refuse + // every crop drag on an untouched photo: forget it and let this drag go ahead. + self.ui.crop_default_aspect = None; + self.ui.status = e.to_string(); + } + Ok(()) + } + + /// Advance a running slideshow, but not while the window is hidden: in 0.36 `i.time` froze + /// there, and a minimized show must not keep waking the event loop or requesting full-size + /// slide renders overnight. Playback is auto-paused on hide and resumed on unhide with the + /// slide's remaining time preserved. + fn slideshow_tick(&mut self, ctx: &egui::Context, now: f64) { + let hidden = ctx.input(|i| i.viewport().visible() == Some(false)); + let Some(run) = self.ui.slideshow.as_mut() else { + self.slideshow_hidden_at = None; + self.slideshow_auto_paused = false; + return; + }; + if hidden { + if self.slideshow_hidden_at.is_none() { + self.slideshow_hidden_at = Some(now); + if !run.play.is_paused() { + run.play.toggle_pause(now); + self.slideshow_auto_paused = true; + } + } + return; + } + if self.slideshow_hidden_at.take().is_some() && self.slideshow_auto_paused { + self.slideshow_auto_paused = false; + if run.play.is_paused() { + run.play.toggle_pause(now); + } + } + panels::slideshow::tick(self, ctx, now); + } + + /// Announce the end of a Build Previews run. + fn preview_build_status(&mut self, ctx: &egui::Context) { + use std::sync::atomic::Ordering; + let Some(b) = self.session.preview_build.clone() else { return }; + let key = std::sync::Arc::as_ptr(&b) as usize; + if b.finished.load(Ordering::Relaxed) { + if self.ui.preview_build_seen != Some((key, true)) { + self.ui.preview_build_seen = Some((key, true)); + let (done, failed) = (b.done.load(Ordering::Relaxed), b.failed.load(Ordering::Relaxed)); + let plural = if done == 1 { "" } else { "s" }; + let mut msg = match (b.error(), b.what) { + (Some(e), what) => format!("{}: {e}", if what.is_empty() { "previews" } else { what }), + (None, "") => crate::i18n::tr_format!("Previews ready for {done} photo{plural}", done = done, plural = plural), + (None, what) => { + crate::i18n::tr_format!("Done ({what}): {done} photo{plural}", done = done, plural = plural, what = crate::i18n::tr(what)) + } + }; + if failed > 0 { + msg.push_str(&crate::i18n::tr_format!(" · {failed} couldn't be rendered", failed = failed)); + } + self.toast(ctx, msg); + } + } else { + // The activity stack shows the run while it works; poll for the end. + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + } + + /// Announce when saving the library starts failing (changes then live only in memory and are + /// retried) and when it works again; the top bar's cloud icon shows the state meanwhile. + fn save_status(&mut self, ctx: &egui::Context) { + let unsaved = self.session.unsaved().map(|(n, e)| (n, e.to_string())); + match (unsaved, self.ui.unsaved_seen) { + (Some((n, e)), false) => { + self.ui.unsaved_seen = true; + let t = self.tick_now(); + self.ui.toast = Some(( + crate::i18n::tr_format!( + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry", + if n == 1 { "" } else { "s" }, + n = n, + e = e + ), + t + 6.0, + None, + )); + } + (None, true) => { + self.ui.unsaved_seen = false; + self.toast(ctx, crate::i18n::tr("Library saved")); + } + (Some(_), true) => ctx.request_repaint_after(std::time::Duration::from_secs(2)), // retry + (None, false) => {} + } + } + + pub fn toast(&mut self, ctx: &egui::Context, text: impl Into) { + self.toast_for(ctx, text, TOAST_SECONDS); + } + + /// Auto straighten (the crop panel's Auto, a double-click with the Straighten tool), saying so + /// when it finds nothing to level by instead of leaving the photo as it is without a word. + pub fn auto_straighten(&mut self, ctx: &egui::Context) { + match self.run("crop.autoStraighten", serde_json::json!({})) { + Ok(r) if r.get("changed").and_then(Value::as_bool) == Some(false) => { + self.toast(ctx, crate::i18n::tr("No horizon or vertical lines to level by")); + } + Ok(_) => {} + Err(e) => self.toast(ctx, e), + } + } + + /// A toast that stays `secs` seconds (messages that say where to look or what to do next). + pub fn toast_for(&mut self, _ctx: &egui::Context, text: impl Into, secs: f64) { + let t = self.tick_now(); + self.ui.toast = Some((text.into(), t + secs, None)); + } + + /// Reverse geocoding: say how a place-names download ended (a failure stays readable, with what to do). + fn place_names_outcome(&mut self, ctx: &egui::Context) { + use lightcraft_engine::cmd::geocode::DownloadOutcome; + match self.session.place_names.take_outcome() { + Some(DownloadOutcome::Done(_)) => self.toast(ctx, crate::i18n::tr("Place names downloaded")), + Some(DownloadOutcome::Cancelled) => self.toast(ctx, crate::i18n::tr("Place names download cancelled")), + Some(DownloadOutcome::Failed(e)) => self.toast_error(ctx, e), + None if self.session.place_names.downloading() => ctx.request_repaint_after(std::time::Duration::from_millis(500)), + None => {} + } + } + + /// AI masks: apply finished background requests (clicks, descriptions, detail passes) and + /// show their errors; start a zoomed-in detail pass once the clicking stops + /// (`ui.detail_due`); watch the model download. Never waits for the model. + fn ai_mask_detail(&mut self, ctx: &egui::Context, now: f64) { + let polled = self.session.segment_poll(); + if polled.changed { + ctx.request_repaint(); + } + if let Some(mask) = polled.refine { + // a new selection from a click or a description: show it, as `run` does for new masks + self.ui.mask_overlay = true; + self.ui.detail_due = Some((now + 0.3, mask)); + } + if let Some(e) = polled.messages.into_iter().last() { + self.ai_error(ctx, e, None); + } + let seg = &self.session.segmenter; + if seg.busy() || seg.pending_clicks().is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + let download = seg.download_status(); + if download.running { + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + // a download finishing while its dialog is closed: say so once + if self.ui.sam_downloading && !download.running { + self.ui.sam_downloading = false; + let open = matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. })); + match sam_download_ended(&download) { + _ if open => {} + Some((text, true)) => self.toast_error(ctx, text), + Some((text, false)) => self.toast(ctx, text), + None => {} + } + } + if let Some((due, mask)) = self.ui.detail_due { + if now >= due && !self.session.segmenter.busy() && !self.session.segmenter.detail_busy() { + self.ui.detail_due = None; + if let Err(e) = self.run("mask.refineDetail", serde_json::json!({"id": mask})) { + log::warn!("detail pass: {e}"); + } + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(150)); + } + } + if self.session.segmenter.detail_busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } + } + + /// An AI mask error: when the model isn't installed, the dialog that offers to download it + /// (`then`: the AI mask to start afterwards); otherwise a toast. Decided on the error's kind + /// ([`ErrorKind::ModelMissing`]), not its words. + pub fn ai_error(&mut self, ctx: &egui::Context, e: impl Into, then: Option<(&str, &str)>) { + let e = e.into(); + if lightcraft_engine::segment::Segmenter::AVAILABLE && e.kind == ErrorKind::ModelMissing(AiModel::Sam3) { + self.offer_sam_download(then); + } else { + self.toast_error(ctx, e.message); + } + } + + /// Open the dialog offering the SAM 3 download (never downloads by itself). + pub fn offer_sam_download(&mut self, then: Option<(&str, &str)>) { + if self.ui.dialog.is_none() || matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. })) { + self.ui.dialog = Some(state::Dialog::SamModel { then: then.map(|(k, o)| (k.to_string(), o.to_string())), error: None }); + } + } + + /// A toast for an error the user has to read and act on (stays 6 s). + pub fn toast_error(&mut self, _ctx: &egui::Context, text: impl Into) { + let t = self.tick_now(); + self.ui.toast = Some((text.into(), t + 6.0, None)); + } + + /// Take the control receiver for this frame, run every queued request whose sender still + /// wants it, and reply directly or admit its screenshot/gesture to a later completed pass. + /// Requests abandoned by their sender are skipped rather than executed late. + fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + // a request its sender gave up on (the window drew nothing for a while) is not run + // late: it would happen twice once the sender retries (issue #718) + if !req.take() { + continue; + } + let reply = req.reply.clone(); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { target, headless } => { + if let Err(error) = self.admit_shot(ctx, target, headless, reply.clone()) { + let _ = reply.send(serde_json::json!({"ok": false, "error": error})); + } + } + control::Outcome::Gesture { method, params } => { + if let Err(error) = self.admit_gesture(ctx, method, params, reply.clone()) { + let _ = reply.send(serde_json::json!({"ok": false, "error": error})); + } + } + } + } + self.control_rx = Some(rx); + } + + /// The navigation/document/modal/panel state that decides what an automation id means. + fn gesture_frame(&self) -> GestureFrame { + GestureFrame { + view: self.ui.view, + active: self.session.active(), + library: self.session.library_generation(), + dialog: self.ui.dialog.as_ref().map(std::mem::discriminant), + left_panel: self.ui.left_panel, + right: self.ui.right, + presets: self.ui.presets, + filmstrip: self.ui.filmstrip, + } + } + + /// Queue a synthetic-input request. Nothing is answered and no event is queued until a + /// completed layout newer than this admission exists (see [`Self::resolve_pending_gestures`]). + pub(crate) fn admit_gesture(&mut self, ctx: &egui::Context, method: String, params: Value, reply: Sender) -> Result<(), String> { + if self.pending_gestures.len() >= MAX_PENDING_GESTURES { + return Err(format!("{}: {MAX_PENDING_GESTURES} control gestures are already pending", method)); + } + let now = self.tick_now_ms(); + self.pending_gestures.push(PendingGesture { + method, + params, + reply, + admitted_epoch: self.layout_epoch, + frame: self.gesture_frame(), + deadline_ms: now + GESTURE_TIMEOUT_MS, + resolved_pass: None, + remaining_events: 0, + started: false, + target: None, + pressed: None, + result: Value::Null, + }); + ctx.request_repaint(); + Ok(()) + } + + /// Resolve one queued gesture against the fresh widgets/image rect of the pass that just + /// completed. A changed view/document/dialog/panel state cancels instead of retargeting. + pub(crate) fn resolve_pending_gestures(&mut self, ctx: &egui::Context) { + if self.pending_gestures.is_empty() { + return; + } + let now = self.tick_now_ms(); + let frame = self.gesture_frame(); + let epoch = self.layout_epoch; + let mut gestures = std::mem::take(&mut self.pending_gestures); + let mut keep = Vec::with_capacity(gestures.len()); + for mut gesture in gestures.drain(..) { + if gesture.resolved_pass.is_some() { + // Already handed to the delivery queue; `reply_delivered_gestures` (or + // `expire_gestures`) owns its deadline and must remove its unsent events too. + keep.push(gesture); + continue; + } + if now > gesture.deadline_ms { + // Only this request failed to get a fresh layout. A later gesture has its own + // deadline and must not be canceled with it. + let _ = + gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)})); + continue; + } + if epoch <= gesture.admitted_epoch { + keep.push(gesture); + continue; + } + if gesture.frame != frame { + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: the view, document, dialog or panel layout changed before a fresh layout", gesture.method), + })); + continue; + } + let before = self.synthetic.len(); + match control::gesture_events(self, ctx, &gesture.method, &gesture.params) { + Ok(outcome) => { + gesture.target = outcome.target; + gesture.result = outcome.result; + gesture.resolved_pass = Some(self.ui_passes); + gesture.remaining_events = self.synthetic.len().saturating_sub(before); + gesture.started = gesture.remaining_events == 0; + keep.push(gesture); + } + Err(error) => { + let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": error})); + } + } + } + self.pending_gestures = keep; + } + + /// Answer gestures whose last synthetic event a completed, nondiscarded UI pass consumed. + /// A zero-event action waits for one completed pass after resolution. + /// + /// Delivery does not compare the frame again: the gesture's own event may legitimately change + /// the view, document, dialog or panel (a click that opens a dialog, a key that starts Compare), + /// so a second snapshot would cancel exactly the input that did its job. Freshness is checked + /// before resolution ([`Self::resolve_pending_gestures`]) and again immediately before the + /// batch is injected ([`Self::drop_gestures_stale_before_injection`]). + pub(crate) fn reply_delivered_gestures(&mut self) { + if self.pending_gestures.is_empty() { + return; + } + let now = self.tick_now_ms(); + // Cancel only the gestures that actually stalled: their unsent events are at the front of + // the queue, and gestures queued behind them are independent input that must survive. + let mut i = 0; + while i < self.pending_gestures.len() { + if self.pending_gestures[i].resolved_pass.is_none() || now <= self.pending_gestures[i].deadline_ms { + i += 1; + continue; + } + let at_head = self.head_gesture() == Some(i); + let gesture = self.pending_gestures.remove(i); + self.cancel_gesture_events(&gesture, at_head); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} timed out before the UI consumed its last event", gesture.method), + })); + } + if !self.synthetic.is_empty() || self.synthetic_in_flight { + return; + } + let mut gestures = std::mem::take(&mut self.pending_gestures); + let mut keep = Vec::with_capacity(gestures.len()); + for mut gesture in gestures.drain(..) { + let Some(resolved) = gesture.resolved_pass else { + keep.push(gesture); + continue; + }; + if self.ui_passes <= resolved { + keep.push(gesture); + continue; + } + if gesture.remaining_events > 0 { + // A defensive queue reset (a non-head gesture owned unsent events) dropped this + // batch. It must not become an `ok: true` for input no panel ever saw. + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: its queued input was dropped before delivery", gesture.method), + })); + continue; + } + let result = std::mem::take(&mut gesture.result); + let _ = gesture.reply.send(serde_json::json!({"ok": true, "result": result})); + } + self.pending_gestures = keep; + } + + /// The gesture whose events sit at the head of the synthetic queue. + fn head_gesture(&self) -> Option { + self.pending_gestures.iter().position(|gesture| gesture.resolved_pass.is_some() && gesture.remaining_events > 0) + } + + /// Fail resolved gestures whose view/document/dialog/panel snapshot changed before their + /// first synthetic batch reached the next completed pass. A hidden logic tick runs between + /// passes and may change what an automation id or a photo click means; fail closed instead of + /// delivering it against the new state. + /// + /// A widget gesture also has to keep aiming at the requested widget. egui attributes a click + /// to the widget under the press point in the *previous completed pass*, while the control + /// batch spans several host frames (a move, then press, then release). Recheck the target + /// before the first event and before every press; if it moved or disappeared, cancel this + /// gesture with an error instead of clicking whatever is now at those coordinates. + /// + /// Only the gesture at the head of the queue is checked, and later batches of the same gesture + /// may see its own partial effect (the first click of a double click selects the photo the + /// second one opens). A second, concurrently queued gesture is checked when its events reach + /// the head. + fn drop_gestures_stale_before_injection(&mut self, ctx: &egui::Context) -> bool { + let Some(head) = self.head_gesture() else { return false }; + let first_batch = !self.pending_gestures[head].started; + if first_batch && self.pending_gestures[head].frame != self.gesture_frame() { + let gestures = std::mem::take(&mut self.pending_gestures); + for gesture in gestures { + if gesture.resolved_pass.is_none() { + self.pending_gestures.push(gesture); + } else { + if gesture.pressed.is_some() { + self.queue_pointer_cleanup(); + } + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: the view, document, dialog or panel layout changed before delivery", gesture.method), + })); + } + } + self.synthetic.clear(); + return true; + } + let Some(target) = self.pending_gestures[head].target.clone() else { return false }; + let press_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: true, .. })); + let release_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: false, .. })); + // A drag legitimately ends outside the widget it started on; only a click must still find + // its target at the release that egui attributes the click to. A hover must still find a + // visible widget for its single pointer move. + let check_release = release_next && target.check_release; + let check_visible = first_batch && target.check_visible; + if !first_batch && !press_next && !check_release && !check_visible { + return false; + } + let latest = control::widget_rect(self, &target.id); + let latest_hit = control::widget_hit(self, &target.id); + // A press must go to the widget that owns the point. A click's release must too (a drag + // may legitimately end outside the widget it started on), so an overlay that appears + // between press and release cancels the click before the release rather than activating it. + // A hover's single move must also still find the widget visible: the documented contract + // rejects a clipped or newly covered target instead of moving the pointer at stale coordinates. + let must_own = press_next || (release_next && target.check_release) || check_visible; + // A quit prompt or settings notice owns input even before its Foreground areas have + // settled (egui's first layout pass is non-interactable), and even when a control request + // is resolved against the registry of that first pass. Only the prompt's own buttons pass. + let notice_ok = !must_own || crate::panels::notices::allows_target(self, latest_hit); + let still_aimed = latest.is_some_and(|rect| { + // A changed widget cancels any gesture before its first event. Owned presses, click + // releases and hovers must also be inside egui's clipped, visible part and be the + // topmost widget hit: a target scrolled out of its panel, or one under a non-modal + // overlay, must not be pressed or hovered at stale coordinates. + let geometry_ok = rect == target.rect || rect.contains(target.at); + geometry_ok && notice_ok && (!must_own || control::widget_target_visible(ctx, latest_hit, target.at)) + }); + if still_aimed { + return false; + } + let outside_registered = !target.rect.contains(target.at); + let reason = match latest { + None => "disappeared", + Some(rect) if !(rect == target.rect || rect.contains(target.at)) => "moved", + Some(_) if outside_registered => "has no visible interaction area at the requested point", + Some(_) => "is no longer visible", + }; + let phase = if first_batch { + "its first event" + } else if check_release { + "its release" + } else { + "its press" + }; + // Only this gesture's unsent events are removed: they are at the front of the queue, and + // gestures resolved after it are checked in turn when they reach the head. Failing the + // whole click would also discard unrelated input. + let gesture = self.pending_gestures.remove(head); + self.cancel_gesture_events(&gesture, true); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: widget `{}` {reason} before {phase}; nothing was retargeted", gesture.method, target.id), + })); + true + } + + /// Remove a canceled gesture's unsent events from the front of the queue and release a pointer + /// button it left down. Callers cancel in queue order, so the events removed are exactly this + /// gesture's unsent prefix; a later gesture's events stay queued. + fn cancel_gesture_events(&mut self, gesture: &PendingGesture, at_head: bool) { + if at_head { + let unsent = gesture.remaining_events.min(self.synthetic.len()); + self.synthetic.drain(..unsent); + } else if gesture.remaining_events > 0 { + // The queue order makes only the head own unsent events. If a caller ever breaks that + // invariant, fail safe: stale synthetic input must not reach an unrelated widget, even + // at the cost of dropping the rest of this batch instead of panicking or guessing a + // prefix. `reply_delivered_gestures` fails any gesture whose events disappeared this + // way instead of reporting a false success. + log::warn!("canceled non-head gesture still owned {} unsent synthetic events; dropping the queue", gesture.remaining_events); + self.synthetic.clear(); + self.synthetic_in_flight = false; + } + if gesture.pressed.is_some() { + self.queue_pointer_cleanup(); + } + } + + /// Cancel every synthetic gesture that a newly shown prompt or blocking library window makes + /// unsafe. + /// + /// A gesture whose input is still queued must not be delivered under the barrier or later when + /// it is gone. A gesture whose press already reached egui must be released without a click. A + /// gesture that already delivered all its events may still complete its reply: the barrier may + /// well be its own result (e.g. a key that opened the quit prompt). `all` is for a batch already + /// injected this host frame before the barrier was discovered after `raw_input_hook`: the + /// barrier filter drops it before any panel sees it, so even a fully injected gesture must fail + /// rather than report a successful delivery. + pub(crate) fn cancel_gestures_for_barrier(&mut self, all: bool, reason: &str) { + // All not-yet-injected events belong to the queue's head, one gesture at a time. Cancel + // them in order so only that gesture's unsent prefix is removed. + while let Some(head) = self.head_gesture() { + let gesture = self.pending_gestures.remove(head); + self.cancel_gesture_events(&gesture, true); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: {reason} before its input was delivered", gesture.method), + })); + } + let mut i = 0; + while i < self.pending_gestures.len() { + if all || self.pending_gestures[i].resolved_pass.is_none() || self.pending_gestures[i].pressed.is_some() { + let gesture = self.pending_gestures.remove(i); + self.cancel_gesture_events(&gesture, false); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: {reason} before its input was delivered", gesture.method), + })); + } else { + i += 1; + } + } + } + + /// Cancel a press that reached egui without making it a click or starting a drag. The reset + /// itself happens before egui's next input pass in [`Self::raw_input_hook`], where replacing + /// the pointer state and stopping the drag is safe (the same approach as the sibling + /// SoundCraft/VectorCraft apps). No pointer movement is injected: moving beyond + /// `max_click_dist` suppresses a click but can also start a drag on a slider or curve. + fn queue_pointer_cleanup(&mut self) { + self.pointer_cleanup = true; + } + + /// Apply a queued pointer cancellation before egui processes the next input pass. + fn apply_pointer_cleanup(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + if !std::mem::take(&mut self.pointer_cleanup) { + return; + } + ctx.input_mut(|input| input.pointer = egui::PointerState::default()); + ctx.stop_dragging(); + if self.synthetic_mods != egui::Modifiers::NONE { + // The cleanup removes only the app's synthetic modifiers, never a real host change. + // A host `ModifiersChanged` in this same raw batch is already authoritative and was + // recorded before this call; otherwise restore the host's last known state (which may + // be a physically held Shift), rather than clearing it unconditionally with NONE. + let host_changed = raw.events.iter().any(|event| matches!(event, egui::Event::ModifiersChanged(_))); + self.synthetic_mods = egui::Modifiers::NONE; + if !host_changed { + raw.events.push(egui::Event::ModifiersChanged(self.host_mods)); + } + } + self.synthetic_mods_release = false; + } + + /// Fail pending gestures at the monotonic deadline even when no UI pass runs (a hidden window + /// whose paint request the host ignored). `logic` calls this every tick. + fn expire_gestures(&mut self) { + if self.pending_gestures.is_empty() { + return; + } + let now = self.tick_now_ms(); + if !self.pending_gestures.iter().any(|gesture| now > gesture.deadline_ms) { + return; + } + // Cancel exactly the stalled requests, in queue order, so a later gesture's input survives. + let mut i = 0; + while i < self.pending_gestures.len() { + if now <= self.pending_gestures[i].deadline_ms { + i += 1; + continue; + } + let at_head = self.head_gesture() == Some(i); + let gesture = self.pending_gestures.remove(i); + self.cancel_gesture_events(&gesture, at_head); + let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)})); + } + } + + /// Admit a `ui.screenshot` request against the bounded capture budget. Nothing is written and + /// no capture is issued here: [`Self::issue_screenshots`] waits for a settled, nondiscarded UI + /// pass and a fresh geometry generation. + pub(crate) fn admit_shot( + &mut self, + ctx: &egui::Context, + target: Option, + headless: bool, + reply: Sender, + ) -> Result<(), String> { + if self.pending_screenshots.len() >= MAX_PENDING_SHOTS { + return Err(format!("ui.screenshot: {MAX_PENDING_SHOTS} requests are already pending; wait for a reply first")); + } + let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx)); + let bytes = geometry.bytes(self.max_texture_side()).ok_or_else(|| geometry.rejection(self.max_texture_side()))?; + self.reserve_shot_bytes(bytes)?; + self.screenshot_token = self.screenshot_token.wrapping_add(1); + let now = self.tick_now_ms(); + self.pending_screenshots.push(PendingShot { + token: self.screenshot_token, + target, + reply, + headless: headless && !self.headless_host, + admitted_passes: self.ui_passes, + not_before_ms: now + SHOT_SETTLE_MS, + // Capture the current settled frame at this instant even if renders are still in flight + // (0.36 did this at its three-second deadline, so automation never got an error instead + // of an image while a slider/import kept the renderer busy). + force_ms: now + SHOT_FORCE_BUSY_MS, + // Settle + forced capture + compositor fallback + the headless settle, all from admission. + deadline_ms: now + SHOT_SETTLE_MS + SCREENSHOT_SETTLE_MS + SCREENSHOT_FALLBACK_MS + SCREENSHOT_SETTLE_MS, + sent_at_ms: None, + reserved: bytes, + slot: None, + issued: None, + issued_library: self.shot_document_generation(), + }); + ctx.request_repaint(); + Ok(()) + } + + /// Advance pending screenshots: wait (≥ 3 completed UI frames, ≥ 120 ms) until no renders are + /// in flight — or until the forced-capture instant, so sustained rendering cannot turn a + /// request into a timeout — then capture through the compositor's one-shot callback, or + /// headlessly ([`Self::headless_screenshot`]) when asked or when the compositor delivers nothing. + pub(crate) fn issue_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_screenshots.is_empty() { + return; + } + let now = self.tick_now_ms(); + let busy = self.renderer.in_flight() > 0 || self.merge.busy(); + let max_side = self.max_texture_side(); + let library = self.shot_document_generation(); + let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx)); + let mut shots = std::mem::take(&mut self.pending_screenshots); + let mut shadow_ticked = false; + let mut keep = Vec::with_capacity(shots.len()); + for mut shot in shots.drain(..) { + // Hard deadline from admission: a compositor fallback does not extend it. + if now > shot.deadline_ms { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)})); + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + self.release_shot_bytes(shot.reserved); + continue; + } + if let Some(sent) = shot.sent_at_ms { + if now - sent < SCREENSHOT_FALLBACK_MS || self.headless_host { + keep.push(shot); + continue; + } + // The compositor delivered nothing (display asleep, window occluded): go headless. + log::warn!("ui.screenshot: no frame from the compositor after {SCREENSHOT_FALLBACK_MS} ms; rendering headlessly"); + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + shot.sent_at_ms = None; + shot.issued = None; + shot.headless = true; + } + // A busy renderer only defers the capture until the forced instant; after that the + // settled frame that exists now is captured (0.36's deadline behavior), never an error. + let ready = shot_ready(now, &shot, busy, self.ui_passes); + if !ready { + keep.push(shot); + continue; + } + if shot.headless { + // The shadow frame requests the renders the UI needs, even while the window shows nothing. + let img = if shadow_ticked { None } else { self.headless_screenshot(ctx, true) }; + shadow_ticked = true; + if let Some(img) = img { + let bytes = img.size[0].checked_mul(img.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); + match bytes { + Some(bytes) if bytes > shot.reserved => { + // A stale/undersized reservation is not a user error: grow it to the + // actual image and encode it when the per-image and aggregate caps allow. + if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); + self.release_shot_bytes(shot.reserved); + continue; + } + shot.reserved = bytes; + } + Some(_) => {} + None => { + let reply = serde_json::json!({ + "ok": false, + "error": "ui.screenshot: the rendered image has invalid dimensions", + }); + let _ = shot.reply.send(reply); + self.release_shot_bytes(shot.reserved); + continue; + } + } + let _ = shot.reply.send(control::save_screenshot(self, &img, shot.target.as_ref())); + self.release_shot_bytes(shot.reserved); + continue; + } + keep.push(shot); + continue; + } + // The compositor path needs a real, laid-out UI and fresh fonts; a queued request on a + // hidden window is kept alive by RequestPaintWhileHidden from `logic`. + if !self.fonts_ready || self.ui_passes == 0 { + keep.push(shot); + continue; + } + // Revalidate the reservation against the geometry the command is actually bound to. + let Some(bytes) = geometry.bytes(max_side) else { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": geometry.rejection(max_side)})); + self.release_shot_bytes(shot.reserved); + continue; + }; + if bytes > shot.reserved { + if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); + self.release_shot_bytes(shot.reserved); + continue; + } + shot.reserved = bytes; + } + // One slot and one callback per issue; the callback only holds a weak slot and wakes + // the viewport. File writes and replies happen in `collect_screenshots`. + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_SHOT_BYTES); + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); + log::debug!("ui.screenshot {}: issued at {:?}, {} bytes reserved", shot.token, geometry.viewport, shot.reserved); + shot.slot = Some(slot); + shot.sent_at_ms = Some(now); + shot.issued = Some(geometry); + shot.issued_library = library; + keep.push(shot); + } + self.pending_screenshots = keep; + if !self.pending_screenshots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// The system's appearance: the host's answer first, then egui's (none: unknown). + pub fn system_theme(&self, ctx: &egui::Context) -> Option { + self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) + } + + /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light + /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. + pub fn sync_theme(&mut self, ctx: &egui::Context) { + let wanted = self.ui.settings.theme(self.system_theme(ctx)); + if theme::current(ctx) != Some(wanted) { + theme::apply_kind(ctx, wanted); + } + } + + /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it + /// on the CPU (photo textures come from the renderer's CPU copies). + pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { + let mut view = self.shadow.take().unwrap_or_default(); + let size = main.input(|i| i.content_rect()).size(); + let size = if size.x >= 1.0 && size.y >= 1.0 { size } else { egui::vec2(1600.0, 1000.0) }; + // Feed native viewport points; the shadow context applies the copied UI zoom. + let zoom = main.zoom_factor(); + let size = size * zoom; + let ppp = main.pixels_per_point() / zoom; + let time = main.input(|i| i.time); + if view.frames() == 0 { + // warm-up pass: activates our fonts (pending font definitions live in `Memory`, which + // is replaced below) + view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |_| {}); + } + // same scroll offsets, open sections, style… as the window + let memory = main.memory(|m| m.clone()); + view.ctx.memory_mut(|m| *m = memory); + view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |ui| self.draw_window(ui)); + let img = capture.then(|| { + let mut tex = self.renderer.cpu_textures(); + if let (Some((t, ..)), Some(px)) = (&self.merge.preview, &self.merge.preview_pixels) { + tex.insert(t.id(), crate::softpaint::CpuTexture::linear(px.clone())); + } + view.paint(&tex) + }); + self.shadow = Some(view); + img + } + + /// Take each completed capture once, reject stale generations before writing anything, and + /// reply exactly once. A late callback after cancel/drop finds no slot and is inert. + pub(crate) fn collect_screenshots(&mut self, _ctx: &egui::Context) { + if self.pending_screenshots.is_empty() { + return; + } + let now = self.tick_now_ms(); + let current = self.last_shot_geometry; + let library = self.shot_document_generation(); + let mut shots = std::mem::take(&mut self.pending_screenshots); + let mut keep = Vec::with_capacity(shots.len()); + for mut shot in shots.drain(..) { + let completed = shot.slot.as_ref().and_then(craft_ui::capture::CaptureSlot::take); + match completed { + Some(Ok(image)) => { + let stale = + shot.issued.is_none_or(|issued| current.is_none_or(|now| now.epoch != issued.epoch)) || shot.issued_library != library; + if stale { + // A resize/DPI/zoom or library change between issue and completion: never + // combine old pixels with new geometry. Retry with a fresh slot. + shot.slot = None; + shot.sent_at_ms = None; + shot.issued = None; + shot.not_before_ms = now + SHOT_SETTLE_MS; + keep.push(shot); + continue; + } + let pixels = image.size[0].checked_mul(image.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); + let Some(bytes) = pixels else { + let reply = serde_json::json!({ + "ok": false, + "error": "ui.screenshot: the rendered image has invalid dimensions", + }); + let _ = shot.reply.send(reply); + self.release_shot_bytes(shot.reserved); + continue; + }; + if bytes > shot.reserved { + // Wayland reports no `inner_rect`, so the reservation can be based on a stale + // content rect while the compositor returns the new, larger surface. Treat a + // larger-but-legal image as a stale reservation and grow it; never mix it + // with geometry of another epoch (checked above). + if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); + self.release_shot_bytes(shot.reserved); + continue; + } + shot.reserved = bytes; + } + self.release_shot_bytes(shot.reserved); + let _ = shot.reply.send(control::save_screenshot(self, &image, shot.target.as_ref())); + } + Some(Err(error)) => { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error.to_string()})); + self.release_shot_bytes(shot.reserved); + } + None => { + if now > shot.deadline_ms { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)})); + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + self.release_shot_bytes(shot.reserved); + } else { + keep.push(shot); + } + } + } + } + self.pending_screenshots = keep; + } + + /// The process is about to end (settings and library are saved by then): make sure nothing is + /// inside the GPU driver when it does (issue #620: a render running on a worker thread while + /// the process exits crashes in the driver). No GPU work starts from here on; the render + /// workers get up to `timeout` to finish their jobs, and what is left of it goes to GPU work + /// on other threads (export, denoise, the device warm-up). `false`: the deadline passed first + /// (the host exits anyway). The app renders nothing afterwards. + pub fn shutdown(&mut self, timeout: std::time::Duration) -> bool { + lightcraft_engine::gpu::begin_shutdown(); + #[cfg(not(target_arch = "wasm32"))] + let t0 = std::time::Instant::now(); + let stopped = self.renderer.shutdown(timeout); + #[cfg(not(target_arch = "wasm32"))] + let timeout = timeout.saturating_sub(t0.elapsed()); + let idle = lightcraft_engine::gpu::wait_idle(timeout); + stopped && idle + } + + /// Background and queue work. Called before layout on every host pass and, while the window is + /// hidden, from `Context::run_logic`. It may read the refreshed viewport fields only: never + /// `i.time`, events, keys or dropped files. + pub fn logic(&mut self, ctx: &egui::Context) { + i18n::set_language(self.ui.language); + // The fork's AccessKit root language and bidi runs follow the locale; presentation only. + // Compare against the cached string: `Context::language()` allocates on every call, and + // this runs on every UI pass and every hidden logic tick. + let language = self.ui.language.code(); + if self.accesskit_language.as_deref() != Some(language) { + ctx.set_language(Some(language.to_owned())); + self.accesskit_language = Some(language.to_owned()); + } + let t0 = monotonic_ms(); + self.note_max_texture_side(ctx); + self.note_shot_geometry(ctx); + self.ensure_fonts(ctx); + // Applying the theme belongs to logic: a host that only calls `logic` (and every hidden + // tick) must see an Appearance Mode / system-theme change, and the UI pass that follows + // then draws with the new visuals. `ui` keeps its own call for a settings change made by + // a command during the same pass; it is a no-op when the theme already matches. + self.sync_theme(ctx); + self.logic_background(ctx); + self.perf.logic_ms = monotonic_ms() - t0; + } + + /// Install/refresh fonts once and watch the device and availability caches. Setup, not input. + fn ensure_fonts(&mut self, ctx: &egui::Context) { + if !self.styled { + theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); + theme::apply(ctx); + Self::drop_legacy_panel_state(ctx); + // File → Add from Device lists cards scanned in the background: show hot-plugs + let repaint = ctx.clone(); + lightcraft_engine::devices::on_change(move || repaint.request_repaint()); + // "is the original there?" (grid, Info panel, Missing Photos) answers from a cache a + // worker fills: a sleeping NAS or a dropped share never stalls a frame + let repaint = ctx.clone(); + self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint())); + self.styled = true; + self.font_language = self.ui.language; + } else if self.font_language != self.ui.language { + // Shared Han characters take the active language's forms (Japanese faces for 日本語, + // the Simplified Chinese face for 简体中文): rebuild the fallback order. + theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); + self.font_language = self.ui.language; + } else { + self.fonts_ready = true; + } + } + + /// 0.36 stored each `PanelState` under the panel's exact `Id`; 0.37 stores it under + /// `parent_ui.scope_id().with_salt(id_salt)`, so the old entries are unreachable and the + /// panels reset once by simply not finding them. Remove the old keys anyway so egui memory + /// does not retain dead entries (no document, workspace JSON, catalog or XMP data is + /// migrated; nothing above a user's photo edits depends on them). + fn drop_legacy_panel_state(ctx: &egui::Context) { + for salt in ["top_bar", "bottom_bar", "tool_strip", "library-problem-banner"] { + ctx.data_mut(|d| d.remove::(egui::Id::unique(salt))); + } + } + + /// Queues and timers. Each consumed queue is drained once per call; time-gated work uses the + /// monotonic app clock, so a hidden `run_logic` keeps it advancing and a second layout pass is + /// a no-op rather than a duplicate. + fn logic_background(&mut self, ctx: &egui::Context) { + panels::notices::logic(self); + // closing the window (or Quit) with changes only in memory: retry, else ask first + if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + let now = self.tick_now(); + self.drain_control(ctx); + self.apply_settings(ctx); + if self.queued_input_needs_repaint() { + ctx.request_repaint(); + } + self.renderer.poll(ctx, &mut self.session); + merge::poll(self, ctx); + import::poll_scan(self, ctx); + sync::poll(self, ctx); + sync::poll_run(self, ctx); + // Import commits batches and must not run twice in one host frame. The first layout pass + // and a hidden logic tick both report pass index 0; a second pass reports 1. A wall-clock + // gate cannot express that (a slow discarded first pass would re-run it). + if import_tick_allowed(ctx) { + import::tick(self, ctx); + } + lightroom_import::tick(self, ctx); + recheck::tick(self); + tasks::poll(self, ctx); + updates_ui::tick(self, ctx); + self.preview_build_status(ctx); + self.save_status(ctx); + self.ai_mask_detail(ctx, now); + self.place_names_outcome(ctx); + self.slideshow_tick(ctx, now); + panels::denoise::pump_background(self, ctx, now); + panels::faces::pump_background(self, ctx, now); + model_setup::pump(self, ctx, now); + focus_scan::pump(self, ctx, now); + // auto import: list the watched folder every few seconds (on a worker thread: it may be on + // a network share), then import what's new like any import (also on a worker thread) + #[cfg(not(target_arch = "wasm32"))] + if let Some(folder) = self.session.import_defaults.auto_folder.clone() { + const LABEL: &str = "Auto Import"; + if now - self.ui.auto_import_at >= 3.0 + && self.import.is_none() + && self.sync_run.is_none() + && self.lightroom.is_none() + && !self.tasks.is_running(LABEL) + { + self.ui.auto_import_at = now; + let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder); + let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result, String>| { + let listing = match listing { + Ok(l) => l, + Err(e) => return log::debug!("auto import: {e}"), + }; + let p = serde_json::json!({"listing": listing, "start": false}); + let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return }; + let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return }; + let paths: Vec = + params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); + if let Some(o) = params.as_object_mut() { + o.remove("paths"); + } + let undo0 = app.session.undo.len(); + app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto()); + }; + // listed every few seconds: quiet, no row in the activity stack + if let Err(e) = tasks::spawn(self, LABEL, None, work, done) { + log::warn!("{e}"); + } + } + ctx.request_repaint_after(std::time::Duration::from_secs(3)); + } + // automatic XMP writes that failed (a network share offline): tried again every few seconds once their files + // are reachable, and said once when they start waiting and once when they're written + #[cfg(not(target_arch = "wasm32"))] + { + if self.session.unsaved_sidecars() > 0 { + if now - self.ui.sidecar_retry_at >= 10.0 { + self.ui.sidecar_retry_at = now; + self.session.retry_unsaved_sidecars(); + } + ctx.request_repaint_after(std::time::Duration::from_secs(10)); + } + let waiting = self.session.unsaved_sidecars(); + if waiting > 0 && self.ui.sidecars_waiting == 0 { + self.toast_for( + ctx, + crate::i18n::tr_format!( + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + n = waiting + ), + 8.0, + ); + } else if waiting == 0 && self.ui.sidecars_waiting > 0 { + self.toast(ctx, crate::i18n::tr("Wrote the XMP sidecars that were waiting")); + } + self.ui.sidecars_waiting = waiting; + } + // Settings → Import: open the import review when a camera or card is connected + #[cfg(not(target_arch = "wasm32"))] + if self.session.import_defaults.show_on_card { + if let Some(now) = lightcraft_engine::devices::devices_scanned() { + import::cards_seen(self, now); + } + ctx.request_repaint_after(lightcraft_engine::devices::MAX_AGE); + } else { + self.ui.known_cards = None; + } + self.session.persist_if_dirty(); + self.expire_gestures(); + self.collect_screenshots(ctx); + self.issue_screenshots(ctx); + self.request_paint_while_hidden(ctx); + } + + /// While hidden, queued captures and an injected gesture step need a real UI pass; a pending + /// screenshot command paints by itself once issued. + fn request_paint_while_hidden(&self, ctx: &egui::Context) { + // `logic` runs per layout pass; one command per host frame is enough. + if ctx.current_pass_index() != 0 { + return; + } + // `ViewportInfo::visible()`: minimized or fully occluded is `Some(false)`; `None` + // (headless, `RawInput::default()`) is normal and must not be treated as hidden. + let hidden = ctx.input(|i| i.viewport().visible() == Some(false)); + if hidden && self.needs_ui_pass_while_hidden() { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + } + + /// A hidden window needs a real UI pass for queued/settling captures, for control gestures + /// that still have to resolve or deliver, and for an injected synthetic step. An already + /// issued screenshot has its own compositor-driven paint and must not request another + /// `RequestPaintWhileHidden`; its fallback and timeout keep running from `logic`. + pub(crate) fn needs_ui_pass_while_hidden(&self) -> bool { + !self.pending_gestures.is_empty() + || self.pointer_cleanup + || self.pending_screenshots.iter().any(|shot| shot.sent_at_ms.is_none()) + || !self.synthetic.is_empty() + || self.synthetic_in_flight + } + + /// Queued synthetic input, including a queued pointer cancellation that is not part of + /// `synthetic`, needs the next visible frame even when an earlier stall left the synthetic + /// queue empty. + pub(crate) fn queued_input_needs_repaint(&self) -> bool { + !self.synthetic.is_empty() || self.pointer_cleanup + } + + /// Input, layout and setup for a known UI phase: at most once per host frame, and never from a + /// hidden logic tick. `current_pass_index() == 0` is the first pass of this `run_ui`; a second + /// pass has empty events. + fn ui_pass(&mut self, ctx: &egui::Context) { + if ctx.current_pass_index() != 0 { + return; + } + // A prompt owns the keyboard and focus. A notice may have been added by this frame's logic + // after `raw_input_hook`; capture its Tab/Enter/Space/Escape and drop everything else + // before any panel, focused control or shortcut reads the input. A new prompt gets a + // shielding pass whose background is not drawn at all (see `draw_window`). + let batch_in_flight = self.synthetic_in_flight; + if panels::notices::prepare_ui_pass(self, ctx) { + self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared"); + } + panels::library_problem::prepare_ui_pass(self, ctx, batch_in_flight); + let now = self.tick_now(); + // the last pass's time is fine here: this is a UI phase, not a hidden tick + let t = ctx.input(|i| i.time); + let dt = t - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = t; + panels::denoise::pump_ui_input(self, ctx, now); + panels::faces::pump_ui_input(self, ctx, now); + // A prompt owns the library too: files dropped while it is up wait in the queue and are + // imported on the first UI pass after it goes away, never behind it. + if !panels::notices::blocks_input(self) { + self.import_dropped_files(ctx); + } + self.check_external_editor(ctx); + // Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI + // phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract). + panels::library_problem::logic(self, ctx); + if self.fonts_ready && !updates_ui::blocks_input(self) { + shortcuts::handle(self, ctx); + } + } + + /// Take the host's dropped files, exactly once. The queue is filled by `raw_input_hook`, so a + /// hidden logic tick can never replay the last pass's drop. Web hosts call this in their UI + /// phase (they need `bytes_async`, which is not available on a native file path). + pub fn take_dropped_files(&mut self) -> Vec { + std::mem::take(&mut self.dropped_files) + } + + /// Import native drops as presets, face models or photos. + fn import_dropped_files(&mut self, ctx: &egui::Context) { + let dropped = self.take_dropped_files(); + if dropped.is_empty() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + self.handle_dropped_files(ctx, dropped); + #[cfg(target_arch = "wasm32")] + let _ = (dropped, ctx); + } + + /// Files dropped on the window: face models open their licence dialog, presets import as + /// presets, photos and folders go to the import review (or straight in, Settings ▸ Import). + #[cfg(not(target_arch = "wasm32"))] + fn handle_dropped_files(&mut self, ctx: &egui::Context, dropped: Vec) { + let dropped: Vec = + dropped.iter().map(|f| egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect(); + if dropped.is_empty() { + return; + } + // a dialog is on screen: the review would replace it, losing what it holds + if self.ui.dialog.is_some() { + self.toast(ctx, crate::i18n::tr("Close the open dialog, then drop the files again")); + return; + } + let drop = drop_overlay::classify(dropped); + if let Some(model) = drop.models.first() + && let Err(e) = panels::faces::open_dialog(self, model) + { + self.toast(ctx, e); + } + if !drop.presets.is_empty() { + // presets live in a library: with none open they would go with the session it replaces + if panels::library_problem::holds_drops(self) { + self.toast(ctx, crate::i18n::tr("Open a library first, then drop the presets again")); + } else { + let _ = self.run("file.importPresets", serde_json::json!({"paths": drop.presets})); + } + } + if drop.photos.is_empty() { + return; + } + // no library open yet (the launch list, a library that failed to open): they wait for one + let Some(photos) = panels::library_problem::hold_dropped(self, ctx, drop.photos) else { return }; + if self.lightroom.is_some() { + self.toast(ctx, crate::i18n::tr("Finish Lightroom catalog import before adding photos")); + return; + } + if import::running(self) { + self.toast(ctx, crate::i18n::tr("An import is running")); + return; + } + let started = if self.ui.settings.import_dropped_directly { + // read and added on a worker thread (dropped folders can be large, or on a slow drive) + import::start_paths(self, photos).map(|_| ()) + } else { + import::open(self, photos).map(|_| ()) + }; + if let Err(e) = started { + self.toast(ctx, e); + } + } + + /// A file dropped by the host, kept for the next UI pass (web hosts consume their own queue). + fn queue_dropped_files(&mut self, raw: &mut egui::RawInput) { + if !raw.dropped_files.is_empty() { + self.dropped_files.append(&mut raw.dropped_files); + } + } + + /// Back from an external editor: pick up the files it saved. Reads `i.focused`, which + /// `run_logic` does not refresh, so it belongs to the UI phase. + fn check_external_editor(&mut self, ctx: &egui::Context) { + let focused = ctx.input(|i| i.focused); + if focused && !self.ui.was_focused && !self.ui.external_edits.is_empty() { + let ids = self.ui.external_edits.clone(); + if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) + && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) + { + self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor")); + } + } + self.ui.was_focused = focused; + } + + /// Apply settings that act outside the UI state: GPU rendering and window full screen. + fn apply_settings(&mut self, ctx: &egui::Context) { + if self.gpu_applied != Some(self.ui.settings.gpu) { + self.gpu_applied = Some(self.ui.settings.gpu); + let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); + // GPU device + kernels off the UI thread, once the window is up and only when GPU + // rendering is on: a broken driver must not keep the window from appearing (issue #136) + if self.ui.settings.gpu { + lightcraft_engine::gpu::warm_up(); + } + } + let mb = self.ui.settings.memory_mb; + // automatic at startup: leave the engine's default alone + if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) { + let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb }; + let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb})); + } + self.memory_applied = Some(mb); + // the monitor profile (Settings ▸ Display): previews follow it from the next frame + if self.display_applied.as_ref() != Some(&self.ui.settings.display_profile) { + let path = self.ui.settings.display_profile.clone(); + match lightcraft_engine::display::Display::load_opt(&path) { + Ok(d) => { + self.renderer.set_display(d); + self.display_error = None; + } + Err(msg) => { + // a profile that can't be used: previews are sRGB, the setting is kept + self.renderer.set_display(None); + log::warn!("display profile: {msg}"); + self.toast_error(ctx, format!("{}: {msg}", crate::i18n::tr("Display profile not used"))); + self.display_error = Some(msg); + } + } + self.display_applied = Some(path); + // none chosen again: the screen's profile is read afresh + self.screen_profile = None; + self.screen_profile_read = None; + } + if self.ui.settings.display_profile.is_empty() { + self.follow_screen_profile(ctx); + } + if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { + self.window_is_fullscreen = fs; + } + if let Some(on) = self.ui.window_fullscreen.take() { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on)); + self.window_is_fullscreen = on; + } + } + + /// While no display profile is chosen, show previews through the profile of the screen the + /// window is on (`services.screen_profile`), read about once a second so moving the window to + /// another screen is followed. Without the service (or a profile) the display is sRGB. + fn follow_screen_profile(&mut self, _ctx: &egui::Context) { + let now = self.tick_now(); + if self.services.screen_profile.is_none() || self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) { + return; + } + self.screen_profile_read = Some(now); + self.apply_screen_profile(); + } + + /// Read the screen's profile now and show previews through it (sRGB without one). + pub(crate) fn apply_screen_profile(&mut self) { + let Some(read) = self.services.screen_profile.as_mut() else { return }; + let icc = read(); + if icc == self.screen_profile { + return; + } + let display = icc.as_deref().and_then(|bytes| match lightcraft_engine::display::Display::from_bytes(SCREEN_PROFILE, bytes) { + Ok(d) => Some(std::sync::Arc::new(d)), + Err(msg) => { + // a profile we can't use: previews stay sRGB, as before #787 + log::warn!("screen profile: {msg}"); + None + } + }); + self.renderer.set_display(display); + self.screen_profile = icc; + } + + /// Take the host's dropped files once and inject synthetic events (pointer events one per + /// frame; key sequences up to the release). + /// + /// The hook also runs before a hidden logic-only tick, so it injects at most one batch and + /// keeps that batch in flight until a completed UI pass consumed it. It never injects while + /// the fonts are not installed (the pass would draw nothing and the events would be lost). + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + let file = |p: &String| (!p.is_empty()).then(|| std::path::PathBuf::from(p)); + if !self.drop_pending.is_empty() { + raw.dropped_files = std::mem::take(&mut self.drop_pending) + .into_iter() + .map(|p| std::sync::Arc::new(InjectedDrop(p.into())) as egui::DroppedFileHandle) + .collect(); + } + if !self.drop_hover.is_empty() { + raw.hovered_files = self.drop_hover.iter().map(|p| egui::HoveredFile { path: file(p), ..Default::default() }).collect(); + } + // Queue after injecting, so a `ui.dropFiles` request is consumed exactly once by the UI phase. + self.queue_dropped_files(raw); + // Track the real host modifier state separately from the app's synthetic ownership. A host + // change means the host now owns the modifier state: relinquish any synthetic claim and do + // not later synthesise a release for a key that is physically held. + if let Some(host_mods) = raw.events.iter().rev().find_map(|event| match event { + egui::Event::ModifiersChanged(modifiers) => Some(*modifiers), + _ => None, + }) { + self.host_mods = host_mods; + self.synthetic_mods = egui::Modifiers::NONE; + self.synthetic_mods_release = false; + } + // A prompt owns all input. On its first host frame this also removes pointer/touch input + // before egui computes its hit test; later frames keep only key releases, the prompt's own + // keys, and AccessKit actions targeted at a prompt button. See `notices::filter_raw_input`. + let batch_in_flight = self.synthetic_in_flight; + if crate::panels::notices::prepare_raw_input(self, ctx, raw) { + // A prompt appearing during a synthetic drag must abort the drag, not let its remaining + // move/release events act under the prompt or after it is dismissed. If a whole batch + // was already injected, it is unsafe as well: this frame's prompt filter drops it + // before a panel sees it, so it must not report a successful delivery. + self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared"); + self.queue_pointer_cleanup(); + } + // Enforce the stall deadline before injecting another event: a request that expired during + // a long frame gap must not get one more event on the frame that resumes. + self.expire_gestures(); + // A canceled press is released by resetting egui's pointer state before its next input + // pass, even while another batch is in flight or the fonts are not ready, so the pointer + // cannot stay down. No move/release event is injected: either could start a drag or + // become a click on the widget being canceled. + self.apply_pointer_cleanup(ctx, raw); + if !self.fonts_ready || self.synthetic_in_flight { + return; + } + if self.drop_gestures_stale_before_injection(ctx) { + // The stale check can cancel the head gesture while its press is already down. + self.apply_pointer_cleanup(ctx, raw); + return; + } + // the frame after a synthetic release / key: restore the host's physical modifiers, not + // necessarily NONE (Shift may still be held while a synthetic Alt gesture is released) + if std::mem::take(&mut self.synthetic_mods_release) && self.synthetic_mods != egui::Modifiers::NONE { + self.synthetic_mods = egui::Modifiers::NONE; + raw.events.push(egui::Event::ModifiersChanged(self.host_mods)); + } + if self.synthetic.is_empty() { + return; + } + let n = match self.synthetic[0] { + egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + // A press needs the pointer at its position before the button event; the move that is + // itself the first event is delivered by the drain below, so it must not be sent twice. + if let Some(egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + raw.events.push(egui::Event::PointerMoved(*p)); + } + // egui's `input.modifiers` follow `ModifiersChanged` events, not the modifiers carried by + // pointer/key events: announce the injected events' modifiers (held from button down to + // the frame after up), so ⌥-drag, ⇧-click … work through the control channel + let carried = self.synthetic[..n].iter().find_map(|e| match e { + egui::Event::PointerButton { pressed, modifiers, .. } => Some((*modifiers, !pressed)), + egui::Event::Key { modifiers, .. } => Some((*modifiers, true)), + _ => None, + }); + if let Some((m, ends)) = carried { + if m != self.synthetic_mods { + self.synthetic_mods = m; + raw.events.push(egui::Event::ModifiersChanged(m)); + } + self.synthetic_mods_release = ends; + } + let blocked_by_prompt = self.synthetic[..n].iter().any(|event| !crate::panels::notices::prompt_allows_event(self, event)); + let head = self.head_gesture(); + if let Some(head) = head { + let gesture = &mut self.pending_gestures[head]; + for event in &self.synthetic[..n] { + match event { + egui::Event::PointerButton { button, pressed: true, .. } => { + gesture.pressed = Some(InjectedPress { button: *button }); + } + egui::Event::PointerButton { button, pressed: false, .. } + if gesture.pressed.as_ref().is_some_and(|press| press.button == *button) => + { + gesture.pressed = None; + } + _ => {} + } + } + gesture.started = true; + gesture.remaining_events = gesture.remaining_events.saturating_sub(n); + } + raw.events.extend(self.synthetic.drain(..n)); + // The same barrier applies after injection: only the prompt's own keys and targeted + // AccessKit actions survive; host input and `ui.text`/clipboard never reach the background. + crate::panels::notices::filter_raw_input(self, raw); + if blocked_by_prompt && let Some(index) = head { + // A settled prompt is already laid out, so this is not the prompt-creation-cancels-all + // path: only the gesture whose batch the barrier removed fails. Allowed prompt keys, + // pointer input and targeted AccessKit actions still report a successful delivery. + let gesture = self.pending_gestures.remove(index); + self.cancel_gesture_events(&gesture, true); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: the open modal prompt blocked this input", gesture.method), + })); + } + // The 5 s deadline is a delivery-stall limit, not a limit on a gesture's frame count: a + // drag feeds one event per host frame, so every batch that the app consumes proves the UI + // is alive and restarts the clock for the whole queue. + let now = self.tick_now_ms(); + for gesture in &mut self.pending_gestures { + gesture.deadline_ms = now + GESTURE_TIMEOUT_MS; + } + self.synthetic_in_flight = true; + } + + /// Lights Out: veil everything but the photo (the loupe's image, or the grid / Compare / + /// Survey area) in black. Painted above the panels, below dialogs and notices. + fn lights_out(&self, ctx: &egui::Context) { + let a = self.ui.lights_out.veil(); + if a <= 0.0 { + return; + } + let screen = ctx.content_rect(); + let keep = if self.ui.view == state::ViewMode::Detail { self.image_rect.or(self.canvas_rect) } else { self.canvas_rect }; + let veil = egui::Color32::from_black_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::unique("lights-out"))); + let k = keep.unwrap_or(egui::Rect::NOTHING).intersect(screen); + if !k.is_positive() { + p.rect_filled(screen, 0.0, veil); + return; + } + // four bands around the photo + use egui::{Rect, pos2}; + p.rect_filled(Rect::from_min_max(screen.min, pos2(screen.right(), k.top())), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.bottom()), screen.max), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.top()), pos2(k.left(), k.bottom())), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(k.right(), k.top()), pos2(screen.right(), k.bottom())), 0.0, veil); + } + + /// Frame timings once layout is done (`t0`: when layout started). + fn end_frame(&mut self, ctx: &egui::Context, t0: f64) { + if self.ui.frame_rate { + frame_rate::show(ctx, self); + } else { + frame_rate::forget(ctx); + } + panels::edit::wheel_pointer_frame_end(ctx); + ctx.output(|o| { + for c in &o.commands { + if let egui::OutputCommand::CopyText(text) = c { + self.copied = Some(text.clone()); + } + } + }); + self.perf.frame_ms = monotonic_ms() - t0; + self.perf.update_ms = self.perf.logic_ms + self.perf.frame_ms; + self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms); + } + + /// Once per library opened (at launch too): name it in the window title, so nobody edits in + /// the wrong one, and put it first in File ▸ Open Recent Library. + pub(crate) fn library_changed(&mut self, ctx: &egui::Context) { + let generation = self.session.library_generation(); + if self.shown_library == Some(generation) { + return; + } + self.shown_library = Some(generation); + if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { + let path = l.dir.to_string_lossy().into_owned(); + panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); + // it is open: whatever an earlier check said about it is over + self.recent_checks.opened(&path); + // when, for the launch window (issue #1290); only the listed libraries keep a time + let settings = &mut self.ui.settings; + settings.recent_opened.insert(path, (self.session.clock)()); + settings.recent_opened.retain(|p, _| settings.recent_libraries.contains(p)); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); + } + + /// "LightCraft — " (just "LightCraft" for a library that isn't saved). + pub fn window_title(&self) -> String { + match panels::settings::open_library_name(self) { + Some(name) => format!("LightCraft — {name}"), + None => "LightCraft".to_string(), + } + } + + /// The browser page's title: "[ •] — LightCraft" for the active photo (• while + /// `unsaved`: changes not yet written to storage), else the window title. The web build has no + /// window for `ViewportCommand::Title`; a host embedding it reads this title to tell which + /// document a drop opened and whether it is saved (issue #2397). + pub fn page_title(&self, unsaved: bool) -> String { + let dot = if unsaved { " •" } else { "" }; + match self.session.active().and_then(|id| self.session.catalog.photo(id)) { + Some(p) => format!("{}{dot} — LightCraft", p.file_name), + None => format!("{}{dot}", self.window_title()), + } + } + + /// Lay out the whole window: input/setup for the known UI phase, then presentation, then the + /// frame bookkeeping. A hidden `run_logic` never reaches this. + pub fn ui(&mut self, ui: &mut egui::Ui) { + i18n::set_language(self.ui.language); + let ctx = ui.ctx().clone(); + if !self.fonts_ready { + ctx.request_repaint(); + return; + } + self.sync_theme(&ctx); + self.ui_pass(&ctx); + self.draw_window(ui); + self.finish_ui_pass(&ctx); + } + + /// A completed, nondiscarded pass is a settled frame; a discarded pass threw its widgets away + /// and must not advance the geometry generation a capture binds to. + pub(crate) fn finish_ui_pass(&mut self, ctx: &egui::Context) { + // Any pass consumed the injected batch; a discarded pass still read its events and the + // next pass has none. Only a nondiscarded pass, however, is a settled frame a capture may + // bind to and a control gesture may resolve against. + self.synthetic_in_flight = false; + if ctx.will_discard() { + self.next_widgets.clear(); + self.next_widget_hits.clear(); + return; + } + self.widgets = std::mem::take(&mut self.next_widgets); + self.widget_hits = std::mem::take(&mut self.next_widget_hits); + panels::notices::finish_ui_pass(self, ctx); + panels::library_problem::finish_ui_pass(self, ctx); + self.ui_passes = self.ui_passes.wrapping_add(1); + self.layout_epoch = self.layout_epoch.wrapping_add(1); + self.resolve_pending_gestures(ctx); + self.reply_delivered_gestures(); + if self.pointer_cleanup { + // A canceled press may be discovered by a timeout after the synthetic queue emptied; + // nothing else may ask for the frame that applies its cancellation. + ctx.request_repaint(); + } + } + + /// Presentation only: the window's panels, dialogs and overlays. + fn draw_window(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let t0 = monotonic_ms(); + panels::library_problem::begin_draw(self); + // A command or shortcut drawn before this can create the window after `ui_pass`; catch it + // here before any panel draws with the still-focused background text field. + let batch_in_flight = self.synthetic_in_flight; + panels::library_problem::prepare_ui_pass(self, &ctx, batch_in_flight); + // A newly shown blocking library window gets the same barrier as a prompt: its first Area + // layout is non-interactable and hit testing still uses the previous pass, so background + // panels (and a focused text field) must not draw in that pass. + if panels::library_problem::shield_pass(self, &ctx) { + #[cfg(test)] + self.library_passes.push(panels::library_problem::LibraryPass::Shield); + panels::library_problem::show(self, &ctx, false); + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + ctx.request_discard("library problem: shield the window's creation frame"); + self.end_frame(&ctx, t0); + return; + } + // A newly shown prompt gets one pass with no background. egui's first Area layout is + // non-interactable and its hit test uses the previous pass, so drawing panels here would + // let a pointer press/release in the prompt's creation frame act underneath it. `ui()` then + // reruns this pass with empty raw input, where the settled prompt and the full window draw + // together. This is a state/input-routing barrier, not a wholesale input disable: once + // settled, raw pointer input still reaches the prompt backdrop (a no-op) or its buttons. + if panels::notices::shield_pass(self, &ctx) { + panels::notices::show(self, &ctx); + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + ctx.request_discard("notice: shield the prompt's creation frame"); + self.end_frame(&ctx, t0); + return; + } + #[cfg(test)] + self.library_passes.push(panels::library_problem::LibraryPass::Background); + panels::dialogs::prepare(self, ui); + // Freeze this frame's notice barrier before `notices::show` can dismiss a prompt: the same + // click or key must never be acted on again by the dialog drawn after it. + let notice_blocks = panels::notices::blocks_input(self); + if std::mem::take(&mut self.ui.quit) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + self.library_changed(&ctx); + // panels set it again this frame while the pointer rests on a preset or profile + self.hover_preview = None; + if self.ui.fullscreen { + // full-screen preview: the photo alone on black + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) + .show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) }); + panels::second::show(self, &ctx); + if notice_blocks { + panels::notices::restore_prompt_pointer(self, &ctx); + } + panels::notices::show(self, &ctx); + if notice_blocks { + panels::notices::hide_background_pointer(&ctx); + } + panels::dialogs::show(self, &ctx, notice_blocks); + updates_ui::show(self, &ctx); + panels::library_problem::show(self, &ctx, notice_blocks); + panels::toast(self, &ctx); + drop_overlay::show(self, &ctx); + if notice_blocks { + // Keep the real press/release state for the next pass: an ordinary click spans + // host frames, and resetting it above must only hide it from this pass's dialogs. + panels::notices::restore_prompt_pointer(self, &ctx); + } + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + self.end_frame(&ctx, t0); + return; + } + // Order matters: earlier panels take the full edge (top bar spans the window; the tool strip, + // right panels and left panel run to the bottom; the bottom bar sits between them). + panels::topbar::show(self, ui); + panels::library_problem::banner(self, ui); + panels::strip::show(self, ui); + // filled: the gaps the dock leaves between its panes show chrome, not the dark clear colour + let chrome = theme::Tokens::get(&ctx).chrome; + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(chrome)).show(ui, |ui| docking::show(self, ui)); + self.lights_out(&ctx); + panels::second::show(self, &ctx); + if notice_blocks { + panels::notices::restore_prompt_pointer(self, &ctx); + } + panels::notices::show(self, &ctx); + if notice_blocks { + panels::notices::hide_background_pointer(&ctx); + } + panels::dialogs::show(self, &ctx, notice_blocks); + updates_ui::show(self, &ctx); + panels::library_problem::show(self, &ctx, notice_blocks); + export_task::poll(self, &ctx); + slideshow_export::poll(self, &ctx); + panels::activity::show(self, &ctx); + export_task::poll_photos(self, &ctx); + pick::poll(self, &ctx); + panels::grid::drag_feedback(self, &ctx); + panels::left::album_drag_feedback(self, &ctx); + panels::left::section_drag_feedback(self, &ctx); + panels::keyword_list::drag_feedback(self, &ctx); + panels::toast(self, &ctx); + drop_overlay::show(self, &ctx); + // AppKit moves the window on a drag anywhere in the title-bar band unless the window isn't + // movable: it stops being movable while the pointer is on a bar control or a panel over it + #[cfg(target_os = "macos")] + craft_window::title_bar::sync_movable(&ctx, &mut self.window_movable); + if notice_blocks { + // Keep the real press/release state for the next pass: an ordinary click spans host + // frames, and resetting it above must only hide it from this pass's dialogs. + panels::notices::restore_prompt_pointer(self, &ctx); + } + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + self.text_focus = ctx.egui_wants_keyboard_input(); + self.end_frame(&ctx, t0); + } +} + +/// Import commits batches at most once per host frame. The first layout pass and a hidden +/// `run_logic` tick both report pass index 0; a discarded frame's later passes report 1+. A +/// wall-clock gate cannot express that (a slow first pass could re-run it within one frame). +pub(crate) fn import_tick_allowed(ctx: &egui::Context) -> bool { + ctx.current_pass_index() == 0 +} + +/// Whether a pending request may be issued now: it has waited for the settle time and completed +/// UI frames, and either no render is in flight or the forced-capture instant has passed. +pub(crate) fn shot_ready(now_ms: f64, shot: &PendingShot, busy: bool, ui_passes: u64) -> bool { + let settled = ui_passes.saturating_sub(shot.admitted_passes) >= SHOT_SETTLE_FRAMES && now_ms >= shot.not_before_ms; + settled && (!busy || now_ms >= shot.force_ms) +} + +/// "Found 3 faces in 2 photos" for a `faces.detect` result. +pub(crate) fn detect_summary(v: &Value) -> String { + let photos = v["photos"].as_array().map(Vec::as_slice).unwrap_or_default(); + let counts: Vec = photos.iter().map(|p| p["faces"].as_array().map_or(0, Vec::len)).collect(); + let (faces, with) = (counts.iter().sum::(), counts.iter().filter(|n| **n > 0).count()); + let plural = |n: usize, w: &str| format!("{n} {w}{}", if n == 1 { "" } else { "s" }); + match (faces, photos.len()) { + (0, 1) => "No faces found".into(), + (0, n) => format!("No faces found in {}", plural(n, "photo")), + (f, 1) => format!("Found {}", plural(f, "face")), + (f, _) => format!("Found {} in {}", plural(f, "face"), plural(with, "photo")), + } +} + +/// What to say when a SAM 3 download ended while its dialog was closed: the text, and whether it is +/// shown as an error toast. A cancel is told by `cancelled`, not by the error's words. +pub(crate) fn sam_download_ended(download: &lightcraft_engine::segment::DownloadStatus) -> Option<(String, bool)> { + match &download.error { + Some(_) if download.cancelled => { + Some((crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time.").to_string(), false)) + } + Some(e) => Some((crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e), true)), + None if download.finished => Some((crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready.").to_string(), true)), + None => None, + } +} + +#[cfg(test)] +mod drop_tests { + #[test] + fn photo_sidecars_are_not_presets() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]); + let (presets, photos) = super::split_presets(dropped); + assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"])); + assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"])); + } + + #[test] + fn detection_results_are_summarised_in_words() { + let r = + |counts: &[usize]| serde_json::json!({"photos": counts.iter().map(|n| serde_json::json!({"faces": vec![0; *n]})).collect::>()}); + assert_eq!(super::detect_summary(&r(&[0])), "No faces found"); + assert_eq!(super::detect_summary(&r(&[1])), "Found 1 face"); + assert_eq!(super::detect_summary(&r(&[3])), "Found 3 faces"); + assert_eq!(super::detect_summary(&r(&[0, 0])), "No faces found in 2 photos"); + assert_eq!(super::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos"); + assert_eq!(super::detect_summary(&serde_json::json!({})), "No faces found in 0 photos"); + } + + #[test] + fn dropped_face_models_are_told_apart() { + for p in ["/a/model.onnx", "/a/dir/M.ONNX"] { + assert!(super::is_model_file(p), "{p}"); + } + for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] { + assert!(!super::is_model_file(p), "{p}"); + } + } + + #[test] + fn dropped_presets_are_told_apart_from_photos() { + for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] { + assert!(super::is_preset_file(p), "{p}"); + } + for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] { + assert!(!super::is_preset_file(p), "{p}"); + } + } +} diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs new file mode 100644 index 000000000..0fb5a4b2c --- /dev/null +++ b/crates/ui-egui/src/caches.rs @@ -0,0 +1,469 @@ +use super::*; + +/// The left panel's counts. +#[derive(Clone, Copy, Debug, Default)] +pub struct LibraryCounts { + pub total: usize, + pub picks: usize, + pub deleted: usize, +} + +/// The values the filter bar's pickers offer. +#[derive(Clone, Debug, Default)] +pub struct FilterValues { + pub cameras: Vec, + pub lenses: Vec, + pub keywords: Vec, +} + +/// What the smart-album rule dialog shows besides the rules themselves: their problems (each row +/// marks its own), how many photos they match and the albums an Album rule picks from. +pub struct RulesView { + pub problems: Vec, + pub count: usize, + pub albums: Vec, +} + +pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 { + use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher}; + BuildHasherDefault::::default().hash_one(parts) +} + +/// Whether a tally of the library made when the catalog was at `revision` is still right, given that it reads only +/// `reads` of a photo: nothing changed since, or only other things of some photos did (a rating changes no folder's +/// count, a slider changes none at all). Each of these tallies asks every photo, so one kept is a pass over the +/// library saved on every edit. +fn still_right(cat: &lightcraft_catalog::Catalog, revision: u64, reads: lightcraft_catalog::Aspects) -> bool { + revision == cat.revision || cat.aspects_since(revision).is_some_and(|changed| !changed.any_of(reads)) +} + +/// What the tallies below read of a photo, besides whether it is in the library. +const READS_FILE: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FILE); + +const READS_META: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::META); + +const READS_PEOPLE: lightcraft_catalog::Aspects = READS_META.with(lightcraft_catalog::Aspects::ANALYSIS); + +const READS_CAPTURED: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::CAPTURED); + +const READS_FLAG: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FLAG); + +impl Caches { + /// The folders the library's photos were imported from. + pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + let kept = self.folder_tree.take().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t); + let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.folder_tree())); + self.folder_tree = Some((cat.revision, t.clone())); + t + } + /// The folder tree as last built, when it still holds (a read-only check cannot rebuild it). + pub(crate) fn folder_tree_if_fresh(&self, cat: &lightcraft_catalog::Catalog) -> Option>> { + self.folder_tree.as_ref().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t.clone()) + } + /// The library's keyword tree. + pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t); + let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.keyword_tree())); + self.keyword_tree = Some((cat.revision, t.clone())); + t + } + /// The Keywording box's read-only names for the selection: what exported files carry + /// (`export`), or the keywords with those containing them. + pub(crate) fn keyword_names( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + export: bool, + ) -> std::sync::Arc> { + let key = key_of((selection, export)); + match &self.keyword_names { + Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), + _ => { + let c = std::sync::Arc::new(if export { + panels::keywording::will_export(cat, selection) + } else { + panels::keywording::with_containing(cat, selection) + }); + self.keyword_names = Some((cat.revision, key, c.clone())); + c + } + } + } + /// The selection's keywords (the Keywording box's chips). + pub(crate) fn keyword_chips( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc> { + let key = key_of(selection); + match &self.keyword_chips { + Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), + _ => { + let c = std::sync::Arc::new(panels::keywording::chips(cat, selection)); + self.keyword_chips = Some((cat.revision, key, c.clone())); + c + } + } + } + /// How many photos the selection is: each once, those the library has (what the Keywording + /// box's "N of M" counts). + pub(crate) fn selected_photos(&mut self, cat: &lightcraft_catalog::Catalog, selection: &[lightcraft_catalog::PhotoId]) -> usize { + let key = key_of(selection); + match self.selected_photos { + Some((r, k, n)) if r == cat.revision && k == key => n, + _ => { + let n = panels::keywording::distinct(cat, selection).len(); + self.selected_photos = Some((cat.revision, key, n)); + n + } + } + } + /// The selection's keywords grouped by the keyword they are inside (the grouped Keywording box). + pub(crate) fn keyword_groups( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc> { + let key = key_of(selection); + match &self.keyword_groups { + Some((r, k, g)) if *r == cat.revision && *k == key => g.clone(), + _ => { + let chips = self.keyword_chips(cat, selection); + let g = std::sync::Arc::new(panels::keywording::grouped(&chips)); + self.keyword_groups = Some((cat.revision, key, g.clone())); + g + } + } + } + /// How many selected photos have each keyword (the Keyword List's tick boxes). + pub(crate) fn keyword_ticks( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc { + let key = key_of(selection); + match &self.keyword_ticks { + Some((r, k, t)) if *r == cat.revision && *k == key => t.clone(), + _ => { + let t = std::sync::Arc::new(panels::keyword_list::Ticks::of(cat, selection)); + self.keyword_ticks = Some((cat.revision, key, t.clone())); + t + } + } + } + /// The people named on faces among the photos the filter lets through (its own `person` aside), + /// with photo counts. + pub fn people( + &mut self, + cat: &lightcraft_catalog::Catalog, + filter: &lightcraft_catalog::Filter, + ) -> std::sync::Arc> { + let key = lightcraft_catalog::Filter { person: None, ..filter.clone() }; + match &self.people { + Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(), + _ => { + let t = std::sync::Arc::new(cat.people_in(&key)); + self.people = Some((cat.revision, key, t.clone())); + t + } + } + } + /// All Photos / Picks / Recently Deleted counts. + pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts { + if let Some((r, c)) = self.counts + && still_right(cat, r, READS_FLAG) + { + self.counts = Some((cat.revision, c)); + return c; + } + let mut c = LibraryCounts::default(); + for p in cat.photos() { + if p.in_library() { + c.total += 1; + if p.flag == lightcraft_catalog::Flag::Pick { + c.picks += 1; + } + } else if p.deleted && !p.local { + c.deleted += 1; + } + } + self.counts = Some((cat.revision, c)); + c + } + /// Everyone named on a face in the library (for completing a name as it is typed). + pub fn person_names(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + let kept = self.person_names.take().filter(|(r, _)| still_right(cat, *r, READS_PEOPLE)).map(|(_, v)| v); + let v = kept.unwrap_or_else(|| std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::>())); + self.person_names = Some((cat.revision, v.clone())); + v + } + /// The By Date tree. + pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + let kept = self.date_groups.take().filter(|(r, _)| still_right(cat, *r, READS_CAPTURED)).map(|(_, g)| g); + let g = kept.unwrap_or_else(|| std::sync::Arc::new(cat.date_groups())); + self.date_groups = Some((cat.revision, g.clone())); + g + } + /// Cameras, lenses and keywords in the library (filter bar pickers). + pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc { + match &self.filter_values { + Some((r, v)) if *r == cat.revision => v.clone(), + _ => { + let distinct = |mut v: Vec| { + v.retain(|s| !s.trim().is_empty()); + v.sort_by_key(|s| s.to_lowercase()); + v.dedup(); + v + }; + let lib = || cat.photos().filter(|p| p.in_library()); + let v = std::sync::Arc::new(FilterValues { + cameras: distinct(lib().map(|p| p.meta.camera.clone()).collect()), + lenses: distinct(lib().map(|p| p.meta.lens.clone()).collect()), + keywords: cat.keywords().into_iter().map(|(k, _)| k).collect(), + }); + self.filter_values = Some((cat.revision, v.clone())); + v + } + } + } + /// What the Add keyword box offers for `typed` (see [`panels::keywording::offers`]). Kept apart + /// from [`Self::suggestions`]: both are asked for on every frame of typing, and each looks + /// through the library's keywords. + pub(crate) fn completions( + &mut self, + cat: &lightcraft_catalog::Catalog, + current: &[String], + typed: &str, + ) -> std::sync::Arc { + let k = key_of((cat.revision, current, typed)); + match &self.completions { + Some((h, v)) if *h == k => v.clone(), + _ => { + let v = std::sync::Arc::new(panels::keywording::offers(cat, current, typed)); + self.completions = Some((k, v.clone())); + v + } + } + } + /// Keyword suggestions for a photo with `current` keywords and the typed `prefix`. + pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc> { + let k = key_of((cat.revision, current, prefix, n)); + match &self.suggestions { + Some((h, v)) if *h == k => v.clone(), + _ => { + let v = std::sync::Arc::new(cat.keyword_suggestions(current, prefix, n)); + self.suggestions = Some((k, v.clone())); + v + } + } + } + /// Every album's photo count for the sidebar (a smart album evaluates its rules, which + /// scans the catalog). Recomputed when the catalog changes (photos, metadata, album rules + /// all bump its revision) and — only while some smart album has an "in the last…" rule — + /// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock + /// within a minute without rescanning every frame. + /// [`RulesView`] for `rules` edited in smart album `editing` (`None`: a new album), within + /// `folder` when it was made from a folder view: worked out again only when the catalog, the + /// rules, the language or (for "in the last…" rules) the minute changes, not every frame. + pub fn rules_view( + &mut self, + cat: &lightcraft_catalog::Catalog, + rules: &lightcraft_catalog::RuleSet, + editing: Option, + folder: Option, + now: &str, + ) -> std::sync::Arc { + let relative = rules.depends_on_now(); + let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; + let k = key_of((cat.revision, serde_json::to_string(rules).unwrap_or_default(), editing, &folder, minute, i18n::language().code())); + if let Some((key, v)) = &self.rules_view + && *key == k + { + return v.clone(); + } + if relative { + lightcraft_catalog::rules::set_now(Some(now.to_string())); + } + let problems = rules.check_for(cat, editing.map(lightcraft_catalog::AlbumId)); + let filter = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() }; + let count = if problems.is_empty() { cat.query(&filter, &Default::default()).len() } else { 0 }; + let v = std::sync::Arc::new(RulesView { problems, count, albums: panels::dialogs::album_entries(cat, editing) }); + self.rules_view_scans += 1; + self.rules_view = Some((k, v.clone())); + v + } + + /// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs + /// again only when the catalog or `what` (what is counted) changes, not every frame. + pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize { + let k = key_of((cat.revision, what)); + if let Some((key, n)) = self.dialog_count + && key == k + { + return n; + } + let n = count(); + self.dialog_count_scans += 1; + self.dialog_count = Some((k, n)); + n + } + + /// The problems of every saved smart album that has some (`Catalog::smart_album_problems`), + /// for the sidebar's ⚠ marks: worked out again only when the catalog changes. + pub fn smart_album_problems( + &mut self, + cat: &lightcraft_catalog::Catalog, + ) -> std::sync::Arc>> { + if let Some((rev, v)) = &self.smart_problems + && *rev == cat.revision + { + return v.clone(); + } + let v: std::sync::Arc> = std::sync::Arc::new( + cat.albums().filter(|a| a.is_smart()).map(|a| (a.id, cat.smart_album_problems(a.id))).filter(|(_, p)| !p.is_empty()).collect(), + ); + self.smart_problem_scans += 1; + self.smart_problems = Some((cat.revision, v.clone())); + v + } + + pub fn album_counts( + &mut self, + cat: &lightcraft_catalog::Catalog, + now: &str, + ) -> std::sync::Arc> { + let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now())); + let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; + // the counts of this minute, if there are any: an edit to a photo or a few is followed from them, a smart + // album asking only those photos again; anything more, or a new minute, is counted from every photo + let kept = self.album_counts.take().filter(|(of, _, _)| of == minute); + if let Some((_, tally, shown)) = &kept + && tally.revision() == cat.revision + { + let shown = shown.clone(); + self.album_counts = kept; + return shown; + } + if relative { + // "in the last N days" counts back from the session clock, as in the grid. (Within a minute an edit asks + // only the photos it touched, at this second; the others were asked earlier in the minute. That is as + // far out as the counts already were between edits, and the next minute counts them all again.) + lightcraft_catalog::rules::set_now(Some(now.to_string())); + } + let tally = match kept { + Some((_, mut tally, _)) => { + if !tally.update(cat) { + self.album_count_scans += 1; + } + tally + } + None => { + self.album_count_scans += 1; + lightcraft_catalog::tally::AlbumTally::of(cat) + } + }; + let shown = std::sync::Arc::new(tally.counts().clone()); + self.album_counts = Some((minute.to_string(), tally, shown.clone())); + shown + } +} + +#[cfg(test)] +mod cache_tests { + use lightcraft_catalog::{Album, AlbumId, Catalog, Filter, Op, Photo, PhotoId, RuleSet, Source}; + + fn photo(id: u64, captured: &str, rating: u8) -> Op { + let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("p{id}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + p.captured = Some(captured.into()); + p.rating = rating; + Op::AddPhoto { photo: Box::new(p) } + } + + fn smart(id: u64, rules: serde_json::Value) -> Op { + let mut a = Album::new(AlbumId(id), format!("smart {id}")); + let rules: RuleSet = serde_json::from_value(rules).unwrap(); + a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() })); + Op::AddAlbum { album: a } + } + + /// Smart-album counts: unchanged frames reuse them; metadata and rule changes and (for + /// "in the last…" rules) the clock crossing into a new minute recompute them. + #[test] + fn smart_album_counts_are_cached_until_something_changes() { + let mut cat = Catalog::default(); + let mut c = super::Caches::default(); + for (i, d) in ["2026-09-30T11:59:30", "2026-09-29T08:00:00", "2026-01-01T08:00:00"].iter().enumerate() { + cat.apply(photo(i as u64 + 1, d, if i == 0 { 5 } else { 1 })).unwrap(); + } + cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); + cat.apply(smart(11, serde_json::json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "hours"}}]}))).unwrap(); + let now = "2026-09-30T12:00:00"; + let n = c.album_counts(&cat, now); + assert_eq!((n[&AlbumId(10)], n[&AlbumId(11)]), (1, 1)); + assert_eq!(c.album_count_scans, 1); + for _ in 0..10 { + c.album_counts(&cat, "2026-09-30T12:00:40"); + } + assert_eq!(c.album_count_scans, 1, "unchanged frames in the same minute don't rescan"); + // a photo rated: followed from that photo, not counted from every one + cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); + assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2); + assert_eq!(c.album_count_scans, 1); + // rule change + let rs: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 5}]})).unwrap(); + cat.apply(Op::SetAlbumRules { id: AlbumId(10), rules: Box::new(Filter { rule_set: Some(rs), ..Default::default() }) }).unwrap(); + assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 1); + // an hour later the 11:59:30 photo has left "in the last hour", with no catalog change + assert_eq!(c.album_counts(&cat, "2026-09-30T13:00:10")[&AlbumId(11)], 0); + assert_eq!(c.album_count_scans, 3, "new rules and a new minute are counted from every photo; the rating was not"); + lightcraft_catalog::rules::set_now(None); + } + + /// The rule dialog's problems, live count and album list are worked out once per change to the + /// catalog or the rules, not every frame; so are the sidebar's smart-album problems. + #[test] + fn rule_dialog_and_problems_are_cached_until_something_changes() { + let mut cat = Catalog::default(); + let mut c = super::Caches::default(); + for i in 0..3u64 { + cat.apply(photo(i + 1, "2026-09-30T11:00:00", if i == 0 { 5 } else { 1 })).unwrap(); + } + let rules = + |v: u64| -> RuleSet { serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": v}]})).unwrap() }; + let now = "2026-09-30T12:00:00"; + let view = c.rules_view(&cat, &rules(3), None, None, now); + assert_eq!((view.count, view.problems.len()), (1, 0)); + for _ in 0..10 { + c.rules_view(&cat, &rules(3), None, None, now); + } + assert_eq!(c.rules_view_scans, 1, "unchanged frames reuse it"); + assert_eq!(c.rules_view(&cat, &rules(1), None, None, now).count, 3, "a rule change"); + assert_eq!(c.rules_view(&cat, &rules(9), None, None, now).problems.len(), 1); + cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); + assert_eq!(c.rules_view(&cat, &rules(3), None, None, now).count, 2, "a catalog change"); + assert_eq!(c.rules_view_scans, 4); + // the sidebar's problems + cat.apply(Op::AddAlbum { album: lightcraft_catalog::Album::new(AlbumId(20), "Trip") }).unwrap(); + cat.apply(smart(21, serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 20}]}))).unwrap(); + assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_none_or(Vec::is_empty)); + for _ in 0..10 { + c.smart_album_problems(&cat); + } + assert_eq!(c.smart_problem_scans, 1); + cat.apply(Op::RemoveAlbum { id: AlbumId(20) }).unwrap(); + assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_some_and(|p| !p.is_empty()), "its album is gone"); + assert_eq!(c.smart_problem_scans, 2); + } + + /// Without "in the last…" rules the clock never causes a rescan. + #[test] + fn absolute_rules_ignore_the_clock() { + let mut cat = Catalog::default(); + let mut c = super::Caches::default(); + cat.apply(photo(1, "2026-09-30T11:59:30", 5)).unwrap(); + cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); + c.album_counts(&cat, "2026-09-30T12:00:00"); + c.album_counts(&cat, "2027-01-01T00:00:00"); + assert_eq!(c.album_count_scans, 1); + } +} diff --git a/crates/ui-egui/src/clock.rs b/crates/ui-egui/src/clock.rs new file mode 100644 index 000000000..5650b0c44 --- /dev/null +++ b/crates/ui-egui/src/clock.rs @@ -0,0 +1,35 @@ +/// A monotonic clock for the app's own timers. +/// +/// `web_time::Instant` is backed by the browser's `performance.now()` on WASM and by +/// `std::time::Instant` natively: it never jumps with the wall clock and has no `Date.now()` +/// zero-origin problem. A headless host may supply its simulated time instead. +#[derive(Clone, Copy, Debug)] +pub(crate) struct AppClock { + start: web_time::Instant, + pub(crate) simulated: Option, +} + +impl AppClock { + /// Start the clock at the current monotonic instant with no simulated time. + pub(crate) fn new() -> Self { + Self { start: web_time::Instant::now(), simulated: None } + } + + /// Seconds on the clock: the supplied simulated time when set, otherwise time since `new`. + pub(crate) fn now(&self) -> f64 { + self.simulated.unwrap_or_else(|| self.start.elapsed().as_secs_f64()) + } +} + +/// Monotonic milliseconds since the first call, for durations and deadlines. +pub fn monotonic_ms() -> f64 { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() * 1000.0 +} + +/// Wall-clock milliseconds since the Unix epoch, only for names that must be unique in the world +/// (`library-restored-`), never for timing or deadlines. +pub fn now_ms() -> f64 { + use web_time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 906f3b377..8da88f9ae 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -68,126 +68,29 @@ pub use widgets::progress; pub use widgets::rating; pub use widgets::text_field; +mod app; +mod caches; +mod clock; +mod services; + +#[cfg(test)] +use app::{import_tick_allowed, sam_download_ended, shot_ready}; +pub(crate) use caches::key_of; +pub use caches::{FilterValues, LibraryCounts, RulesView}; +use clock::AppClock; +pub use clock::{monotonic_ms, now_ms}; +#[cfg(target_os = "linux")] +pub use services::OpenModelFolderFn; +pub use services::{ + CheckEditorFn, HostAction, OpenAllWithFn, OpenUrlFn, OpenWithFn, PickFiles, PickFolder, PngEncode, RevealFn, SaveFile, ScreenProfileFn, Services, + SharedWrite, SystemThemeFn, WriteFn, +}; +use state::{GestureFrame, InjectedPress}; + const TOAST_SECONDS: f64 = 1.4; /// The `path` of a display profile read from the screen rather than chosen as a file. pub const SCREEN_PROFILE: &str = "screen"; -pub type PickFiles = Box Vec>; -/// A save dialog: suggested file name → chosen path (`None` = cancelled). -pub type SaveFile = Box Option>; -pub type WriteFn = Box Result<(), String>>; -/// A writer other threads can use (background export). -pub type SharedWrite = std::sync::Arc Result<(), String> + Send + Sync>; -pub type PngEncode = Box Vec>; -/// A folder chooser (`None` = cancelled). -pub type PickFolder = Box Option>; -/// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux). -pub type RevealFn = Box Result<(), String>>; -/// Open a URL in the user's browser. -pub type OpenUrlFn = Box Result<(), String>>; -/// Open a file in an application (`app` = "" for the system's default one). -pub type OpenWithFn = Box Result<(), String>>; -/// Open several files with one start of an application (`app` as for [`OpenWithFn`]). -pub type OpenAllWithFn = Box Result<(), String>>; -/// Linux model-folder work: wait for the default opener on a background task. -#[cfg(target_os = "linux")] -pub type OpenModelFolderFn = std::sync::Arc Result<(), String> + Send + Sync>; -/// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: -/// not known). -pub type ScreenProfileFn = Box Option>>; -/// Whether an external editor can be started (its name as in [`Services::open_with`]); the -/// error says why not and what to do (e.g. PhotoCraft isn't installed). -pub type CheckEditorFn = Box Result<(), String>>; -/// Something the host does with the session (web: back up or restore the library in browser -/// storage); the work may finish asynchronously. -pub type HostAction = Box Result>; - -/// The system's dark/light appearance where egui doesn't report one (Linux desktops whose -/// windowing layer says nothing, e.g. many Wayland compositors). The host answers from what it -/// last heard and wakes the UI itself when that changes; it is never polled for changes. -pub type SystemThemeFn = Box Option>; - -/// Platform services injected by the host app (desktop or web). -#[derive(Default)] -pub struct Services { - /// Native file dialogs for commands, shown off the UI thread and answered later - /// (`pick`; #191). With it, the synchronous pickers below serve only the buttons inside - /// dialogs; without it (the web, tests) they answer the commands too. - pub picker: Option, - /// Show an open dialog for photos; returns paths. - pub pick_files: Option, - /// Local ONNX model; a manifest must accompany it. - pub pick_denoise_model: Option, - /// Open dialog for a face model (`.onnx`; Settings ▸ Faces ▸ Add a model file…). - pub pick_model_file: Option, - /// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`). - pub pick_preset_files: Option, - /// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…). - pub pick_tracklog: Option, - /// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only). - pub pick_display_profile: Option, - /// The profile of the screen showing the window, used while none is chosen in Settings ▸ - /// Display (macOS, whose windows aren't colour-managed: issue #787). - pub screen_profile: Option, - /// Save dialog for an exported `.lcpreset` file. - pub save_preset_file: Option, - /// Open dialog for a keyword list file (`.txt`, Photo Supreme's `.utf8`; File ▸ Import Keywords…). - pub pick_keyword_list: Option, - /// Save dialog for an exported keyword list (File ▸ Export Keywords…). - pub save_keyword_list: Option, - /// Open dialog for point-curve preset files (`.lccurve`). - pub pick_curve_preset_files: Option, - /// Save dialog for an exported `.lccurve` file. - pub save_curve_preset_file: Option, - /// Open dialog for slideshow template files (`.json`; Settings ▸ Slideshow ▸ Templates ▸ Import…). - pub pick_slideshow_templates: Option, - /// Save dialog for an exported slideshow template (`.json`). - pub save_slideshow_template: Option, - /// Open dialog for print template files (`.lcprint`; File ▸ Print… ▸ Templates ▸ Import…). - pub pick_print_templates: Option, - /// Save dialog for an exported print template (`.lcprint`). - pub save_print_template: Option, - pub write: Option, - /// Create-new writer (never replaces an existing path), for screenshots of destinations that - /// did not exist when the request was admitted. Required for such captures: falling back to - /// [`Self::write`] would reopen the admission-to-write race. - pub write_new: Option, - /// Thread-safe writer: with it, UI-started exports run in the background (desktop only). - pub write_shared: Option, - /// PNG encoder (the host links an image encoder; the UI crate stays codec-free). - pub png: Option, - /// Show a file in the system file manager (desktop only). - pub reveal: Option, - /// The host's current log file (`/logs/lightcraft.log`), which Help ▸ Open Log - /// Folder reveals (#260). None where no log is kept: the web, `--memory`, `LIGHTCRAFT_NO_PREFS`. - pub log_file: Option, - /// Choose a folder (Settings → General → Open Library…; desktop only). - pub pick_folder: Option, - /// Open a Lightroom Classic `.lrcat` catalog for read-only import. - pub pick_lightroom_catalog: Option, - /// Open a web link in the browser (Help menu, About, Discord button). - pub open_url: Option, - /// Open a file in an external editor (Edit in External Editor; desktop only). - pub open_with: Option, - /// Open several files with one start of the editor (Open Original; desktop only): an editor - /// that keeps one window takes them all. None = [`Services::open_with`] for each. - pub open_all_with: Option, - /// Optional worker-safe model-folder opener alongside `open_with` (Linux desktop only). - #[cfg(target_os = "linux")] - pub open_model_folder: Option, - /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = - /// assume it can). - pub check_editor: Option, - /// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the - /// user keeps (web only: there the library lives in browser storage, which the browser may - /// clear; on the desktop it is a folder backed up like any other). - pub backup_library: Option, - /// File ▸ Restore Library from Backup… (web only; keeps the current library). - pub restore_library: Option, - /// The system appearance for Appearance Mode ▸ Auto, ahead of egui's own report. - pub system_theme: Option, -} - #[derive(Clone, Copy, Debug, Default)] pub struct Perf { /// Layout of the last frame ([`LightcraftApp::ui`], including commands run from widgets). @@ -439,2099 +342,6 @@ pub struct LightcraftApp { pub activity_expanded: bool, } -impl LightcraftApp { - /// Build the app with default UI, renderer, cache and control state, and move AI segmentation - /// to a worker so frames apply results instead of waiting. - pub fn new(mut session: Session, services: Services) -> Self { - // AI mask requests run on the model's worker; frames apply their results (never wait) - session.segmenter.background = true; - Self { - session, - ui: UiState::default(), - services, - renderer: render::Renderer::default(), - perf: Perf::default(), - caches: Caches::default(), - integrated_titlebar: false, - window_movable: None, - native_menu: false, - native_shortcuts: Default::default(), - recording_shortcut: None, - text_focus: false, - headless_host: false, - notices: vec![], - updates_rt: Default::default(), - quit_prompt: None, - notice_input: Default::default(), - quit_confirmed: false, - control_rx: None, - pending_screenshots: vec![], - pending_gestures: vec![], - layout_epoch: 0, - screenshot_token: 0, - shot_bytes: 0, - shot_geometry_epoch: 0, - last_shot_geometry: None, - max_texture_side: MAX_TEXTURE_SIDE, - clock: AppClock::new(), - accesskit_language: None, - slideshow_hidden_at: None, - slideshow_auto_paused: false, - dropped_files: vec![], - synthetic_in_flight: false, - ui_passes: 0, - #[cfg(test)] - library_passes: vec![], - shadow: None, - synthetic: vec![], - pointer_cleanup: false, - drop_hover: vec![], - drop_pending: vec![], - copied: None, - pending_picks: vec![], - synthetic_mods: egui::Modifiers::NONE, - synthetic_mods_release: false, - host_mods: egui::Modifiers::NONE, - styled: false, - font_language: i18n::Locale::En, - chinese_font: None, - fonts_ready: false, - last_time: 0.0, - canvas_rect: None, - image_rect: None, - grid_scroll: None, - film_scroll: None, - widgets: vec![], - widget_hits: vec![], - next_widgets: vec![], - next_widget_hits: vec![], - gesture: None, - brush_live: None, - loupe_shown: None, - region_view: None, - region_before_view: None, - size_hold: Default::default(), - drag_scale: Default::default(), - window_refused: None, - region_tiles: Default::default(), - compare_windows: [None; 2], - compare_tiles: Default::default(), - compare_hold_until: f64::NEG_INFINITY, - merge: merge::MergeState::default(), - import: None, - scan: None, - sync: None, - sync_owns_changes: false, - sync_run: None, - lightroom: None, - lightroom_last: None, - lightroom_result: None, - review_pending: None, - shown_library: None, - export: None, - tasks: Default::default(), - last_export_result: None, - slideshow_export_reported: true, - hover_preview: None, - window_is_fullscreen: false, - gpu_applied: None, - gpu_notice: false, - memory_applied: None, - display_applied: None, - display_error: None, - screen_profile: None, - screen_profile_read: None, - library_problem: None, - library_window_input: Default::default(), - alt_asks_for_library: false, - recent_checks: Default::default(), - activity_expanded: false, - model_setup: Default::default(), - } - } - - /// Supply the browser's separately downloaded Simplified Chinese font before the first frame. - pub fn with_chinese_font(mut self, bytes: Vec) -> Self { - if !bytes.is_empty() { - self.chinese_font = Some(std::sync::Arc::new(egui::FontData::from_owned(bytes))); - } - self - } - - /// Attach the control-channel receiver and keep CPU copies of photo textures, which headless - /// screenshots need; returns the app for chaining. - pub fn with_control(mut self, rx: Receiver) -> Self { - self.control_rx = Some(rx); - // keep CPU copies of photo textures so `ui.screenshot {"headless": true}` can draw them - self.renderer.keep_pixels = true; - self - } - - /// Seconds on the app's monotonic clock. Every timer that must advance while the window is - /// hidden (imports, autosave, playback, toasts, captures) uses this, never `i.time`. - pub fn tick_now(&self) -> f64 { - self.clock.now() - } - - /// The app clock in milliseconds, for control deadlines and timers. - fn tick_now_ms(&self) -> f64 { - self.clock.now() * 1000.0 - } - - /// The immutable identity a capture is bound to: the library generation. It changes when a - /// different library is opened, not on every edit, import batch or keyword op (`catalog.revision` - /// bumps on all of those), so a screenshot during interactive editing still belongs to the - /// document the user asked for. - fn shot_document_generation(&self) -> u64 { - self.session.library_generation() - } - - /// Give a headless driver its simulated time (`None` returns to the monotonic host clock). - pub fn set_simulated_clock(&mut self, seconds: Option) { - self.clock.simulated = seconds.filter(|s| s.is_finite() && *s >= 0.0); - } - - /// The window's current surface geometry as native points plus native pixels per point. - /// `run_logic` refreshes `input.viewports`, so this is fresh even while the UI is not laid out; - /// without one, the last laid-out content rect is used. - fn shot_geometry(&self, ctx: &egui::Context) -> ShotGeometry { - // Both `viewport.inner_rect` and the content rect are in egui points, and `pixels_per_point` - // already includes the UI zoom (egui-winit divides by the zoomed ppp). Multiplying by the - // zoom again would reserve a surface that is too small, so the zoom is only tracked as a - // change signal. - let zoom = ctx.zoom_factor(); - let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; - let pixels_per_point = ctx.input(|i| i.pixels_per_point()); - let viewport = ctx.input(|i| { - i.viewport().inner_rect.filter(|r| r.width() > 0.0 && r.height() > 0.0).map_or_else(|| i.content_rect().size(), |inner| inner.size()) - }); - ShotGeometry { viewport, pixels_per_point, zoom, epoch: self.shot_geometry_epoch } - } - - /// Track geometry changes as a generation. Called once per logic tick from the refreshed - /// viewport information, so a hidden window still notices a resize. - fn note_shot_geometry(&mut self, ctx: &egui::Context) { - let mut now = self.shot_geometry(ctx); - if let Some(last) = self.last_shot_geometry - && (last.viewport != now.viewport || last.pixels_per_point != now.pixels_per_point || last.zoom != now.zoom) - { - self.shot_geometry_epoch = self.shot_geometry_epoch.wrapping_add(1); - now.epoch = self.shot_geometry_epoch; - } - self.last_shot_geometry = Some(now); - } - - /// The texture limit the host reported (16 384 unless it said otherwise). - fn max_texture_side(&self) -> usize { - self.max_texture_side - } - - /// Retain the host's `RawInput::max_texture_side`. Hosts (egui-winit, `Headless`) report it - /// only when it changes, so keep the last value instead of assuming 16 384. - fn note_max_texture_side(&mut self, ctx: &egui::Context) { - if let Some(limit) = ctx.input(|i| i.raw.max_texture_side) - && limit > 0 - { - self.max_texture_side = limit; - } - } - - /// Reserve a new image's `bytes` against the aggregate screenshot budget, or report why not. - fn reserve_shot_bytes(&mut self, bytes: usize) -> Result<(), String> { - if bytes > MAX_SHOT_BYTES { - return Err(format!("ui.screenshot: {bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit")); - } - let total = self.shot_bytes.checked_add(bytes).ok_or("ui.screenshot: byte reservation overflow")?; - if total > MAX_SHOT_TOTAL_BYTES { - return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit")); - } - self.shot_bytes = total; - Ok(()) - } - - /// A surface that grew after admission (a stale `content_rect` on Wayland, a resize between - /// issue and completion): bound the *new image* against the per-image limit, then reserve only - /// the growth against the aggregate. The old per-image check would compare the delta to the - /// per-image cap and name the delta as if it were the image. - fn grow_shot_reservation(&mut self, current_reserved: usize, image_bytes: usize) -> Result<(), String> { - if image_bytes > MAX_SHOT_BYTES { - return Err(format!("ui.screenshot: {image_bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit")); - } - let extra = image_bytes.saturating_sub(current_reserved); - let total = self.shot_bytes.checked_add(extra).ok_or("ui.screenshot: byte reservation overflow")?; - if total > MAX_SHOT_TOTAL_BYTES { - return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit")); - } - self.shot_bytes = total; - Ok(()) - } - - /// Release a reservation; never let an accounting bug underflow `usize`. - fn release_shot_bytes(&mut self, bytes: usize) { - self.shot_bytes = self.shot_bytes.saturating_sub(bytes); - } - - /// The open person page, the way back to it, and the photo it was opened from follow a rename. - fn follow_renamed_person(&mut self, from: &str, to: &str) { - let same = |n: &str| n.trim().to_lowercase() == from.trim().to_lowercase(); - if self.ui.person_page.as_deref().is_some_and(same) { - self.ui.person_page = Some(to.to_string()); - } - if self.ui.last_person.as_deref().is_some_and(same) { - self.ui.last_person = Some(to.to_string()); - } - if let Some((Some(name), photo)) = self.ui.person_from.clone() - && same(&name) - { - self.ui.person_from = Some((Some(to.to_string()), photo)); - } - } - - /// Run a UI or engine command by id. The single entry point for every frontend path. - pub fn run(&mut self, id: &str, params: Value) -> Result { - self.run_typed(id, params).map_err(|e| e.message) - } - - /// [`LightcraftApp::run`], keeping what kind of error a refusal is: for code that decides on it - /// (offer a model's download, treat a cancel as one) instead of reading the message. - pub fn run_typed(&mut self, id: &str, params: Value) -> Result { - if let Some(result) = model_setup::intercept(self, id, ¶ms) { - return result.map_err(CommandError::from); - } - if let Some(r) = menus::run_ui_command(self, id, ¶ms) { - if r.is_ok() { - docking::reveal_command(self, id)?; - } - return r.map_err(CommandError::from); - } - let params = if id == "crop.aspect" - && params.get("aspect").and_then(Value::as_str) == Some("toggle") - && self.crop_tool_aspect().is_some() - && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) - { - serde_json::json!({"aspect": "free"}) - } else { - params - }; - let r = self.session.execute(id, ¶ms).map_err(CommandError::from); - if r.is_ok() && matches!(id, "crop.aspect" | "crop.rotateAspect") { - self.ui.crop_default_aspect = if id == "crop.aspect" && params.get("aspect").and_then(Value::as_str) == Some("original") { - Some(serde_json::json!("original")) - } else { - self.session - .active() - .and_then(|photo| self.session.develop_of(photo)) - .map(|d| d.crop.aspect.map_or(serde_json::json!("free"), |(w, h)| serde_json::json!([w as f64 / 100.0, h as f64 / 100.0]))) - }; - } - if r.is_ok() && id == "library.toggleFilters" { - // the search field shows the text filter: empty while the filters are off, back with them - self.ui.search = self.session.filter.text.clone(); - } - if r.is_ok() && id == "library.clearFilter" { - // the search field shows the text filter: cleared from anywhere (the loupe's pill, the - // menu), it must not keep a search that no longer applies (issue #1648) - self.ui.search.clear(); - } - if r.is_ok() && id == "mask.adjust" { - // Judge local adjustments on the photo, without the selection overlay obscuring them. - // Keep it hidden after release; O / the overlay eye can show it again. - self.ui.mask_overlay = false; - } - if r.is_ok() && matches!(id, "mask.add" | "mask.addComponent" | "mask.duplicate" | "mask.select") { - // A new or newly picked mask shows its overlay again, as in Lightroom: hidden by a slider - // drag above, it stayed hidden while the Overlay menu still named a mode (issue #1800). - self.ui.mask_overlay = true; - } - if r.is_ok() { - // These commands finish synchronously, so their completion would otherwise - // be invisible once the activity stack is gone. Never show success on error. - let completed = match id { - "library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))), - "library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Discard Preview Cache"))), - "develop.autoSync" => Some(format!("{}: {}", i18n::tr("Auto Sync"), i18n::tr(if self.session.auto_sync { "On" } else { "Off" }))), - _ => None, - }; - if let Some(message) = completed { - let now = self.tick_now(); - self.ui.toast = Some((message, now + TOAST_SECONDS, None)); - } - } - if r.is_ok() && id == "photo.label" { - let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse); - let text = match label { - Some(label) => { - let name = - self.session.catalog.custom_label_name(label).map(str::to_owned).unwrap_or_else(|| i18n::tr(&format!("{label:?}")).into()); - i18n::tr_format!("{name} Label", name = name) - } - None => i18n::tr("Color label cleared").into(), - }; - let now = self.tick_now(); - self.ui.toast = Some((text, now + TOAST_SECONDS, label)); - } - match &r { - Err(e) => { - log::warn!("{id}: {e}"); - self.ui.status = e.message.clone(); - // no detector yet: offer it (its terms first) instead of only saying so - if id == "faces.detect" && e.kind == ErrorKind::ModelMissing(AiModel::FaceDetector) { - panels::faces::offer_detector(self); - } - } - Ok(v) if id == "faces.detect" => self.ui.status = detect_summary(v), - Ok(v) if id == "faces.renamePerson" => { - let to = v["to"].as_str().unwrap_or("").to_string(); - let from = params.get("from").and_then(Value::as_str).unwrap_or(""); - if !to.is_empty() { - self.follow_renamed_person(from, &to); - } - } - Ok(_) => {} - } - r - } - - /// The remembered ratio belongs to the tool until the user starts editing the crop. - pub(crate) fn crop_tool_aspect(&self) -> Option<(u32, u32)> { - let photo = self.session.active()?; - let d = self.session.develop_of(photo)?; - if d.crop != lightcraft_develop::Crop::default() { - return d.crop.aspect; - } - // nothing chosen yet: locked to the photo's own shape, as Lightroom starts (#991); one - // Lock toggle (remembered as "free") unlocks it for good - let remembered = self.ui.crop_default_aspect.clone().unwrap_or_else(|| Value::String("original".into())); - match &remembered { - Value::String(s) if s == "original" => { - let p = self.session.catalog.photo(photo)?; - let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100)); - Some(if d.orientation.swaps_axes() { (h, w) } else { (w, h) }) - } - Value::Array(a) => { - let number = |i: usize| { - let v = a.get(i)?.as_f64()? * 100.0; - (v.is_finite() && v >= 1.0 && v <= f64::from(u32::MAX)).then_some(v.round() as u32) - }; - Some((number(0)?, number(1)?)) - } - _ => None, - } - } - - /// Called only after a crop gesture begins, inside its undo transaction. - pub(crate) fn begin_crop(&mut self) -> Result<(), String> { - // nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it - let aspect = self.ui.crop_default_aspect.clone().unwrap_or_else(|| serde_json::json!("original")); - if self.session.interaction.is_some() - && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) - && aspect != serde_json::json!("free") - // the remembered ratio, turned like the new photo's crop (as a named ratio is) - && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"})) - { - // A remembered value the command refuses (a hand-edited settings file) would refuse - // every crop drag on an untouched photo: forget it and let this drag go ahead. - self.ui.crop_default_aspect = None; - self.ui.status = e.to_string(); - } - Ok(()) - } - - /// Advance a running slideshow, but not while the window is hidden: in 0.36 `i.time` froze - /// there, and a minimized show must not keep waking the event loop or requesting full-size - /// slide renders overnight. Playback is auto-paused on hide and resumed on unhide with the - /// slide's remaining time preserved. - fn slideshow_tick(&mut self, ctx: &egui::Context, now: f64) { - let hidden = ctx.input(|i| i.viewport().visible() == Some(false)); - let Some(run) = self.ui.slideshow.as_mut() else { - self.slideshow_hidden_at = None; - self.slideshow_auto_paused = false; - return; - }; - if hidden { - if self.slideshow_hidden_at.is_none() { - self.slideshow_hidden_at = Some(now); - if !run.play.is_paused() { - run.play.toggle_pause(now); - self.slideshow_auto_paused = true; - } - } - return; - } - if self.slideshow_hidden_at.take().is_some() && self.slideshow_auto_paused { - self.slideshow_auto_paused = false; - if run.play.is_paused() { - run.play.toggle_pause(now); - } - } - panels::slideshow::tick(self, ctx, now); - } - - /// Announce the end of a Build Previews run. - fn preview_build_status(&mut self, ctx: &egui::Context) { - use std::sync::atomic::Ordering; - let Some(b) = self.session.preview_build.clone() else { return }; - let key = std::sync::Arc::as_ptr(&b) as usize; - if b.finished.load(Ordering::Relaxed) { - if self.ui.preview_build_seen != Some((key, true)) { - self.ui.preview_build_seen = Some((key, true)); - let (done, failed) = (b.done.load(Ordering::Relaxed), b.failed.load(Ordering::Relaxed)); - let plural = if done == 1 { "" } else { "s" }; - let mut msg = match (b.error(), b.what) { - (Some(e), what) => format!("{}: {e}", if what.is_empty() { "previews" } else { what }), - (None, "") => crate::i18n::tr_format!("Previews ready for {done} photo{plural}", done = done, plural = plural), - (None, what) => { - crate::i18n::tr_format!("Done ({what}): {done} photo{plural}", done = done, plural = plural, what = crate::i18n::tr(what)) - } - }; - if failed > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {failed} couldn't be rendered", failed = failed)); - } - self.toast(ctx, msg); - } - } else { - // The activity stack shows the run while it works; poll for the end. - ctx.request_repaint_after(std::time::Duration::from_millis(250)); - } - } - - /// Announce when saving the library starts failing (changes then live only in memory and are - /// retried) and when it works again; the top bar's cloud icon shows the state meanwhile. - fn save_status(&mut self, ctx: &egui::Context) { - let unsaved = self.session.unsaved().map(|(n, e)| (n, e.to_string())); - match (unsaved, self.ui.unsaved_seen) { - (Some((n, e)), false) => { - self.ui.unsaved_seen = true; - let t = self.tick_now(); - self.ui.toast = Some(( - crate::i18n::tr_format!( - "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry", - if n == 1 { "" } else { "s" }, - n = n, - e = e - ), - t + 6.0, - None, - )); - } - (None, true) => { - self.ui.unsaved_seen = false; - self.toast(ctx, crate::i18n::tr("Library saved")); - } - (Some(_), true) => ctx.request_repaint_after(std::time::Duration::from_secs(2)), // retry - (None, false) => {} - } - } - - pub fn toast(&mut self, ctx: &egui::Context, text: impl Into) { - self.toast_for(ctx, text, TOAST_SECONDS); - } - - /// Auto straighten (the crop panel's Auto, a double-click with the Straighten tool), saying so - /// when it finds nothing to level by instead of leaving the photo as it is without a word. - pub fn auto_straighten(&mut self, ctx: &egui::Context) { - match self.run("crop.autoStraighten", serde_json::json!({})) { - Ok(r) if r.get("changed").and_then(Value::as_bool) == Some(false) => { - self.toast(ctx, crate::i18n::tr("No horizon or vertical lines to level by")); - } - Ok(_) => {} - Err(e) => self.toast(ctx, e), - } - } - - /// A toast that stays `secs` seconds (messages that say where to look or what to do next). - pub fn toast_for(&mut self, _ctx: &egui::Context, text: impl Into, secs: f64) { - let t = self.tick_now(); - self.ui.toast = Some((text.into(), t + secs, None)); - } - - /// Reverse geocoding: say how a place-names download ended (a failure stays readable, with what to do). - fn place_names_outcome(&mut self, ctx: &egui::Context) { - use lightcraft_engine::cmd::geocode::DownloadOutcome; - match self.session.place_names.take_outcome() { - Some(DownloadOutcome::Done(_)) => self.toast(ctx, crate::i18n::tr("Place names downloaded")), - Some(DownloadOutcome::Cancelled) => self.toast(ctx, crate::i18n::tr("Place names download cancelled")), - Some(DownloadOutcome::Failed(e)) => self.toast_error(ctx, e), - None if self.session.place_names.downloading() => ctx.request_repaint_after(std::time::Duration::from_millis(500)), - None => {} - } - } - - /// AI masks: apply finished background requests (clicks, descriptions, detail passes) and - /// show their errors; start a zoomed-in detail pass once the clicking stops - /// (`ui.detail_due`); watch the model download. Never waits for the model. - fn ai_mask_detail(&mut self, ctx: &egui::Context, now: f64) { - let polled = self.session.segment_poll(); - if polled.changed { - ctx.request_repaint(); - } - if let Some(mask) = polled.refine { - // a new selection from a click or a description: show it, as `run` does for new masks - self.ui.mask_overlay = true; - self.ui.detail_due = Some((now + 0.3, mask)); - } - if let Some(e) = polled.messages.into_iter().last() { - self.ai_error(ctx, e, None); - } - let seg = &self.session.segmenter; - if seg.busy() || seg.pending_clicks().is_some() { - ctx.request_repaint_after(std::time::Duration::from_millis(50)); - } - let download = seg.download_status(); - if download.running { - ctx.request_repaint_after(std::time::Duration::from_millis(250)); - } - // a download finishing while its dialog is closed: say so once - if self.ui.sam_downloading && !download.running { - self.ui.sam_downloading = false; - let open = matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. })); - match sam_download_ended(&download) { - _ if open => {} - Some((text, true)) => self.toast_error(ctx, text), - Some((text, false)) => self.toast(ctx, text), - None => {} - } - } - if let Some((due, mask)) = self.ui.detail_due { - if now >= due && !self.session.segmenter.busy() && !self.session.segmenter.detail_busy() { - self.ui.detail_due = None; - if let Err(e) = self.run("mask.refineDetail", serde_json::json!({"id": mask})) { - log::warn!("detail pass: {e}"); - } - } else { - ctx.request_repaint_after(std::time::Duration::from_millis(150)); - } - } - if self.session.segmenter.detail_busy() { - ctx.request_repaint_after(std::time::Duration::from_millis(200)); - } - } - - /// An AI mask error: when the model isn't installed, the dialog that offers to download it - /// (`then`: the AI mask to start afterwards); otherwise a toast. Decided on the error's kind - /// ([`ErrorKind::ModelMissing`]), not its words. - pub fn ai_error(&mut self, ctx: &egui::Context, e: impl Into, then: Option<(&str, &str)>) { - let e = e.into(); - if lightcraft_engine::segment::Segmenter::AVAILABLE && e.kind == ErrorKind::ModelMissing(AiModel::Sam3) { - self.offer_sam_download(then); - } else { - self.toast_error(ctx, e.message); - } - } - - /// Open the dialog offering the SAM 3 download (never downloads by itself). - pub fn offer_sam_download(&mut self, then: Option<(&str, &str)>) { - if self.ui.dialog.is_none() || matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. })) { - self.ui.dialog = Some(state::Dialog::SamModel { then: then.map(|(k, o)| (k.to_string(), o.to_string())), error: None }); - } - } - - /// A toast for an error the user has to read and act on (stays 6 s). - pub fn toast_error(&mut self, _ctx: &egui::Context, text: impl Into) { - let t = self.tick_now(); - self.ui.toast = Some((text.into(), t + 6.0, None)); - } - - /// Take the control receiver for this frame, run every queued request whose sender still - /// wants it, and reply directly or admit its screenshot/gesture to a later completed pass. - /// Requests abandoned by their sender are skipped rather than executed late. - fn drain_control(&mut self, ctx: &egui::Context) { - let Some(rx) = self.control_rx.take() else { return }; - while let Ok(req) = rx.try_recv() { - // a request its sender gave up on (the window drew nothing for a while) is not run - // late: it would happen twice once the sender retries (issue #718) - if !req.take() { - continue; - } - let reply = req.reply.clone(); - match control::handle(self, ctx, &req) { - control::Outcome::Done(v) => { - let _ = reply.send(v); - } - control::Outcome::Screenshot { target, headless } => { - if let Err(error) = self.admit_shot(ctx, target, headless, reply.clone()) { - let _ = reply.send(serde_json::json!({"ok": false, "error": error})); - } - } - control::Outcome::Gesture { method, params } => { - if let Err(error) = self.admit_gesture(ctx, method, params, reply.clone()) { - let _ = reply.send(serde_json::json!({"ok": false, "error": error})); - } - } - } - } - self.control_rx = Some(rx); - } - - /// The navigation/document/modal/panel state that decides what an automation id means. - fn gesture_frame(&self) -> GestureFrame { - GestureFrame { - view: self.ui.view, - active: self.session.active(), - library: self.session.library_generation(), - dialog: self.ui.dialog.as_ref().map(std::mem::discriminant), - left_panel: self.ui.left_panel, - right: self.ui.right, - presets: self.ui.presets, - filmstrip: self.ui.filmstrip, - } - } - - /// Queue a synthetic-input request. Nothing is answered and no event is queued until a - /// completed layout newer than this admission exists (see [`Self::resolve_pending_gestures`]). - fn admit_gesture(&mut self, ctx: &egui::Context, method: String, params: Value, reply: Sender) -> Result<(), String> { - if self.pending_gestures.len() >= MAX_PENDING_GESTURES { - return Err(format!("{}: {MAX_PENDING_GESTURES} control gestures are already pending", method)); - } - let now = self.tick_now_ms(); - self.pending_gestures.push(PendingGesture { - method, - params, - reply, - admitted_epoch: self.layout_epoch, - frame: self.gesture_frame(), - deadline_ms: now + GESTURE_TIMEOUT_MS, - resolved_pass: None, - remaining_events: 0, - started: false, - target: None, - pressed: None, - result: Value::Null, - }); - ctx.request_repaint(); - Ok(()) - } - - /// Resolve one queued gesture against the fresh widgets/image rect of the pass that just - /// completed. A changed view/document/dialog/panel state cancels instead of retargeting. - fn resolve_pending_gestures(&mut self, ctx: &egui::Context) { - if self.pending_gestures.is_empty() { - return; - } - let now = self.tick_now_ms(); - let frame = self.gesture_frame(); - let epoch = self.layout_epoch; - let mut gestures = std::mem::take(&mut self.pending_gestures); - let mut keep = Vec::with_capacity(gestures.len()); - for mut gesture in gestures.drain(..) { - if gesture.resolved_pass.is_some() { - // Already handed to the delivery queue; `reply_delivered_gestures` (or - // `expire_gestures`) owns its deadline and must remove its unsent events too. - keep.push(gesture); - continue; - } - if now > gesture.deadline_ms { - // Only this request failed to get a fresh layout. A later gesture has its own - // deadline and must not be canceled with it. - let _ = - gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)})); - continue; - } - if epoch <= gesture.admitted_epoch { - keep.push(gesture); - continue; - } - if gesture.frame != frame { - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: the view, document, dialog or panel layout changed before a fresh layout", gesture.method), - })); - continue; - } - let before = self.synthetic.len(); - match control::gesture_events(self, ctx, &gesture.method, &gesture.params) { - Ok(outcome) => { - gesture.target = outcome.target; - gesture.result = outcome.result; - gesture.resolved_pass = Some(self.ui_passes); - gesture.remaining_events = self.synthetic.len().saturating_sub(before); - gesture.started = gesture.remaining_events == 0; - keep.push(gesture); - } - Err(error) => { - let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": error})); - } - } - } - self.pending_gestures = keep; - } - - /// Answer gestures whose last synthetic event a completed, nondiscarded UI pass consumed. - /// A zero-event action waits for one completed pass after resolution. - /// - /// Delivery does not compare the frame again: the gesture's own event may legitimately change - /// the view, document, dialog or panel (a click that opens a dialog, a key that starts Compare), - /// so a second snapshot would cancel exactly the input that did its job. Freshness is checked - /// before resolution ([`Self::resolve_pending_gestures`]) and again immediately before the - /// batch is injected ([`Self::drop_gestures_stale_before_injection`]). - fn reply_delivered_gestures(&mut self) { - if self.pending_gestures.is_empty() { - return; - } - let now = self.tick_now_ms(); - // Cancel only the gestures that actually stalled: their unsent events are at the front of - // the queue, and gestures queued behind them are independent input that must survive. - let mut i = 0; - while i < self.pending_gestures.len() { - if self.pending_gestures[i].resolved_pass.is_none() || now <= self.pending_gestures[i].deadline_ms { - i += 1; - continue; - } - let at_head = self.head_gesture() == Some(i); - let gesture = self.pending_gestures.remove(i); - self.cancel_gesture_events(&gesture, at_head); - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} timed out before the UI consumed its last event", gesture.method), - })); - } - if !self.synthetic.is_empty() || self.synthetic_in_flight { - return; - } - let mut gestures = std::mem::take(&mut self.pending_gestures); - let mut keep = Vec::with_capacity(gestures.len()); - for mut gesture in gestures.drain(..) { - let Some(resolved) = gesture.resolved_pass else { - keep.push(gesture); - continue; - }; - if self.ui_passes <= resolved { - keep.push(gesture); - continue; - } - if gesture.remaining_events > 0 { - // A defensive queue reset (a non-head gesture owned unsent events) dropped this - // batch. It must not become an `ok: true` for input no panel ever saw. - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: its queued input was dropped before delivery", gesture.method), - })); - continue; - } - let result = std::mem::take(&mut gesture.result); - let _ = gesture.reply.send(serde_json::json!({"ok": true, "result": result})); - } - self.pending_gestures = keep; - } - - /// The gesture whose events sit at the head of the synthetic queue. - fn head_gesture(&self) -> Option { - self.pending_gestures.iter().position(|gesture| gesture.resolved_pass.is_some() && gesture.remaining_events > 0) - } - - /// Fail resolved gestures whose view/document/dialog/panel snapshot changed before their - /// first synthetic batch reached the next completed pass. A hidden logic tick runs between - /// passes and may change what an automation id or a photo click means; fail closed instead of - /// delivering it against the new state. - /// - /// A widget gesture also has to keep aiming at the requested widget. egui attributes a click - /// to the widget under the press point in the *previous completed pass*, while the control - /// batch spans several host frames (a move, then press, then release). Recheck the target - /// before the first event and before every press; if it moved or disappeared, cancel this - /// gesture with an error instead of clicking whatever is now at those coordinates. - /// - /// Only the gesture at the head of the queue is checked, and later batches of the same gesture - /// may see its own partial effect (the first click of a double click selects the photo the - /// second one opens). A second, concurrently queued gesture is checked when its events reach - /// the head. - fn drop_gestures_stale_before_injection(&mut self, ctx: &egui::Context) -> bool { - let Some(head) = self.head_gesture() else { return false }; - let first_batch = !self.pending_gestures[head].started; - if first_batch && self.pending_gestures[head].frame != self.gesture_frame() { - let gestures = std::mem::take(&mut self.pending_gestures); - for gesture in gestures { - if gesture.resolved_pass.is_none() { - self.pending_gestures.push(gesture); - } else { - if gesture.pressed.is_some() { - self.queue_pointer_cleanup(); - } - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: the view, document, dialog or panel layout changed before delivery", gesture.method), - })); - } - } - self.synthetic.clear(); - return true; - } - let Some(target) = self.pending_gestures[head].target.clone() else { return false }; - let press_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: true, .. })); - let release_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: false, .. })); - // A drag legitimately ends outside the widget it started on; only a click must still find - // its target at the release that egui attributes the click to. A hover must still find a - // visible widget for its single pointer move. - let check_release = release_next && target.check_release; - let check_visible = first_batch && target.check_visible; - if !first_batch && !press_next && !check_release && !check_visible { - return false; - } - let latest = control::widget_rect(self, &target.id); - let latest_hit = control::widget_hit(self, &target.id); - // A press must go to the widget that owns the point. A click's release must too (a drag - // may legitimately end outside the widget it started on), so an overlay that appears - // between press and release cancels the click before the release rather than activating it. - // A hover's single move must also still find the widget visible: the documented contract - // rejects a clipped or newly covered target instead of moving the pointer at stale coordinates. - let must_own = press_next || (release_next && target.check_release) || check_visible; - // A quit prompt or settings notice owns input even before its Foreground areas have - // settled (egui's first layout pass is non-interactable), and even when a control request - // is resolved against the registry of that first pass. Only the prompt's own buttons pass. - let notice_ok = !must_own || crate::panels::notices::allows_target(self, latest_hit); - let still_aimed = latest.is_some_and(|rect| { - // A changed widget cancels any gesture before its first event. Owned presses, click - // releases and hovers must also be inside egui's clipped, visible part and be the - // topmost widget hit: a target scrolled out of its panel, or one under a non-modal - // overlay, must not be pressed or hovered at stale coordinates. - let geometry_ok = rect == target.rect || rect.contains(target.at); - geometry_ok && notice_ok && (!must_own || control::widget_target_visible(ctx, latest_hit, target.at)) - }); - if still_aimed { - return false; - } - let outside_registered = !target.rect.contains(target.at); - let reason = match latest { - None => "disappeared", - Some(rect) if !(rect == target.rect || rect.contains(target.at)) => "moved", - Some(_) if outside_registered => "has no visible interaction area at the requested point", - Some(_) => "is no longer visible", - }; - let phase = if first_batch { - "its first event" - } else if check_release { - "its release" - } else { - "its press" - }; - // Only this gesture's unsent events are removed: they are at the front of the queue, and - // gestures resolved after it are checked in turn when they reach the head. Failing the - // whole click would also discard unrelated input. - let gesture = self.pending_gestures.remove(head); - self.cancel_gesture_events(&gesture, true); - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: widget `{}` {reason} before {phase}; nothing was retargeted", gesture.method, target.id), - })); - true - } - - /// Remove a canceled gesture's unsent events from the front of the queue and release a pointer - /// button it left down. Callers cancel in queue order, so the events removed are exactly this - /// gesture's unsent prefix; a later gesture's events stay queued. - fn cancel_gesture_events(&mut self, gesture: &PendingGesture, at_head: bool) { - if at_head { - let unsent = gesture.remaining_events.min(self.synthetic.len()); - self.synthetic.drain(..unsent); - } else if gesture.remaining_events > 0 { - // The queue order makes only the head own unsent events. If a caller ever breaks that - // invariant, fail safe: stale synthetic input must not reach an unrelated widget, even - // at the cost of dropping the rest of this batch instead of panicking or guessing a - // prefix. `reply_delivered_gestures` fails any gesture whose events disappeared this - // way instead of reporting a false success. - log::warn!("canceled non-head gesture still owned {} unsent synthetic events; dropping the queue", gesture.remaining_events); - self.synthetic.clear(); - self.synthetic_in_flight = false; - } - if gesture.pressed.is_some() { - self.queue_pointer_cleanup(); - } - } - - /// Cancel every synthetic gesture that a newly shown prompt or blocking library window makes - /// unsafe. - /// - /// A gesture whose input is still queued must not be delivered under the barrier or later when - /// it is gone. A gesture whose press already reached egui must be released without a click. A - /// gesture that already delivered all its events may still complete its reply: the barrier may - /// well be its own result (e.g. a key that opened the quit prompt). `all` is for a batch already - /// injected this host frame before the barrier was discovered after `raw_input_hook`: the - /// barrier filter drops it before any panel sees it, so even a fully injected gesture must fail - /// rather than report a successful delivery. - fn cancel_gestures_for_barrier(&mut self, all: bool, reason: &str) { - // All not-yet-injected events belong to the queue's head, one gesture at a time. Cancel - // them in order so only that gesture's unsent prefix is removed. - while let Some(head) = self.head_gesture() { - let gesture = self.pending_gestures.remove(head); - self.cancel_gesture_events(&gesture, true); - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: {reason} before its input was delivered", gesture.method), - })); - } - let mut i = 0; - while i < self.pending_gestures.len() { - if all || self.pending_gestures[i].resolved_pass.is_none() || self.pending_gestures[i].pressed.is_some() { - let gesture = self.pending_gestures.remove(i); - self.cancel_gesture_events(&gesture, false); - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: {reason} before its input was delivered", gesture.method), - })); - } else { - i += 1; - } - } - } - - /// Cancel a press that reached egui without making it a click or starting a drag. The reset - /// itself happens before egui's next input pass in [`Self::raw_input_hook`], where replacing - /// the pointer state and stopping the drag is safe (the same approach as the sibling - /// SoundCraft/VectorCraft apps). No pointer movement is injected: moving beyond - /// `max_click_dist` suppresses a click but can also start a drag on a slider or curve. - fn queue_pointer_cleanup(&mut self) { - self.pointer_cleanup = true; - } - - /// Apply a queued pointer cancellation before egui processes the next input pass. - fn apply_pointer_cleanup(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - if !std::mem::take(&mut self.pointer_cleanup) { - return; - } - ctx.input_mut(|input| input.pointer = egui::PointerState::default()); - ctx.stop_dragging(); - if self.synthetic_mods != egui::Modifiers::NONE { - // The cleanup removes only the app's synthetic modifiers, never a real host change. - // A host `ModifiersChanged` in this same raw batch is already authoritative and was - // recorded before this call; otherwise restore the host's last known state (which may - // be a physically held Shift), rather than clearing it unconditionally with NONE. - let host_changed = raw.events.iter().any(|event| matches!(event, egui::Event::ModifiersChanged(_))); - self.synthetic_mods = egui::Modifiers::NONE; - if !host_changed { - raw.events.push(egui::Event::ModifiersChanged(self.host_mods)); - } - } - self.synthetic_mods_release = false; - } - - /// Fail pending gestures at the monotonic deadline even when no UI pass runs (a hidden window - /// whose paint request the host ignored). `logic` calls this every tick. - fn expire_gestures(&mut self) { - if self.pending_gestures.is_empty() { - return; - } - let now = self.tick_now_ms(); - if !self.pending_gestures.iter().any(|gesture| now > gesture.deadline_ms) { - return; - } - // Cancel exactly the stalled requests, in queue order, so a later gesture's input survives. - let mut i = 0; - while i < self.pending_gestures.len() { - if now <= self.pending_gestures[i].deadline_ms { - i += 1; - continue; - } - let at_head = self.head_gesture() == Some(i); - let gesture = self.pending_gestures.remove(i); - self.cancel_gesture_events(&gesture, at_head); - let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)})); - } - } - - /// Admit a `ui.screenshot` request against the bounded capture budget. Nothing is written and - /// no capture is issued here: [`Self::issue_screenshots`] waits for a settled, nondiscarded UI - /// pass and a fresh geometry generation. - fn admit_shot( - &mut self, - ctx: &egui::Context, - target: Option, - headless: bool, - reply: Sender, - ) -> Result<(), String> { - if self.pending_screenshots.len() >= MAX_PENDING_SHOTS { - return Err(format!("ui.screenshot: {MAX_PENDING_SHOTS} requests are already pending; wait for a reply first")); - } - let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx)); - let bytes = geometry.bytes(self.max_texture_side()).ok_or_else(|| geometry.rejection(self.max_texture_side()))?; - self.reserve_shot_bytes(bytes)?; - self.screenshot_token = self.screenshot_token.wrapping_add(1); - let now = self.tick_now_ms(); - self.pending_screenshots.push(PendingShot { - token: self.screenshot_token, - target, - reply, - headless: headless && !self.headless_host, - admitted_passes: self.ui_passes, - not_before_ms: now + SHOT_SETTLE_MS, - // Capture the current settled frame at this instant even if renders are still in flight - // (0.36 did this at its three-second deadline, so automation never got an error instead - // of an image while a slider/import kept the renderer busy). - force_ms: now + SHOT_FORCE_BUSY_MS, - // Settle + forced capture + compositor fallback + the headless settle, all from admission. - deadline_ms: now + SHOT_SETTLE_MS + SCREENSHOT_SETTLE_MS + SCREENSHOT_FALLBACK_MS + SCREENSHOT_SETTLE_MS, - sent_at_ms: None, - reserved: bytes, - slot: None, - issued: None, - issued_library: self.shot_document_generation(), - }); - ctx.request_repaint(); - Ok(()) - } - - /// Advance pending screenshots: wait (≥ 3 completed UI frames, ≥ 120 ms) until no renders are - /// in flight — or until the forced-capture instant, so sustained rendering cannot turn a - /// request into a timeout — then capture through the compositor's one-shot callback, or - /// headlessly ([`Self::headless_screenshot`]) when asked or when the compositor delivers nothing. - fn issue_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_screenshots.is_empty() { - return; - } - let now = self.tick_now_ms(); - let busy = self.renderer.in_flight() > 0 || self.merge.busy(); - let max_side = self.max_texture_side(); - let library = self.shot_document_generation(); - let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx)); - let mut shots = std::mem::take(&mut self.pending_screenshots); - let mut shadow_ticked = false; - let mut keep = Vec::with_capacity(shots.len()); - for mut shot in shots.drain(..) { - // Hard deadline from admission: a compositor fallback does not extend it. - if now > shot.deadline_ms { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)})); - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - self.release_shot_bytes(shot.reserved); - continue; - } - if let Some(sent) = shot.sent_at_ms { - if now - sent < SCREENSHOT_FALLBACK_MS || self.headless_host { - keep.push(shot); - continue; - } - // The compositor delivered nothing (display asleep, window occluded): go headless. - log::warn!("ui.screenshot: no frame from the compositor after {SCREENSHOT_FALLBACK_MS} ms; rendering headlessly"); - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - shot.sent_at_ms = None; - shot.issued = None; - shot.headless = true; - } - // A busy renderer only defers the capture until the forced instant; after that the - // settled frame that exists now is captured (0.36's deadline behavior), never an error. - let ready = shot_ready(now, &shot, busy, self.ui_passes); - if !ready { - keep.push(shot); - continue; - } - if shot.headless { - // The shadow frame requests the renders the UI needs, even while the window shows nothing. - let img = if shadow_ticked { None } else { self.headless_screenshot(ctx, true) }; - shadow_ticked = true; - if let Some(img) = img { - let bytes = img.size[0].checked_mul(img.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); - match bytes { - Some(bytes) if bytes > shot.reserved => { - // A stale/undersized reservation is not a user error: grow it to the - // actual image and encode it when the per-image and aggregate caps allow. - if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); - self.release_shot_bytes(shot.reserved); - continue; - } - shot.reserved = bytes; - } - Some(_) => {} - None => { - let reply = serde_json::json!({ - "ok": false, - "error": "ui.screenshot: the rendered image has invalid dimensions", - }); - let _ = shot.reply.send(reply); - self.release_shot_bytes(shot.reserved); - continue; - } - } - let _ = shot.reply.send(control::save_screenshot(self, &img, shot.target.as_ref())); - self.release_shot_bytes(shot.reserved); - continue; - } - keep.push(shot); - continue; - } - // The compositor path needs a real, laid-out UI and fresh fonts; a queued request on a - // hidden window is kept alive by RequestPaintWhileHidden from `logic`. - if !self.fonts_ready || self.ui_passes == 0 { - keep.push(shot); - continue; - } - // Revalidate the reservation against the geometry the command is actually bound to. - let Some(bytes) = geometry.bytes(max_side) else { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": geometry.rejection(max_side)})); - self.release_shot_bytes(shot.reserved); - continue; - }; - if bytes > shot.reserved { - if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); - self.release_shot_bytes(shot.reserved); - continue; - } - shot.reserved = bytes; - } - // One slot and one callback per issue; the callback only holds a weak slot and wakes - // the viewport. File writes and replies happen in `collect_screenshots`. - let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_SHOT_BYTES); - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); - log::debug!("ui.screenshot {}: issued at {:?}, {} bytes reserved", shot.token, geometry.viewport, shot.reserved); - shot.slot = Some(slot); - shot.sent_at_ms = Some(now); - shot.issued = Some(geometry); - shot.issued_library = library; - keep.push(shot); - } - self.pending_screenshots = keep; - if !self.pending_screenshots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - } - - /// The system's appearance: the host's answer first, then egui's (none: unknown). - pub fn system_theme(&self, ctx: &egui::Context) -> Option { - self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) - } - - /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light - /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. - pub fn sync_theme(&mut self, ctx: &egui::Context) { - let wanted = self.ui.settings.theme(self.system_theme(ctx)); - if theme::current(ctx) != Some(wanted) { - theme::apply_kind(ctx, wanted); - } - } - - /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it - /// on the CPU (photo textures come from the renderer's CPU copies). - pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { - let mut view = self.shadow.take().unwrap_or_default(); - let size = main.input(|i| i.content_rect()).size(); - let size = if size.x >= 1.0 && size.y >= 1.0 { size } else { egui::vec2(1600.0, 1000.0) }; - // Feed native viewport points; the shadow context applies the copied UI zoom. - let zoom = main.zoom_factor(); - let size = size * zoom; - let ppp = main.pixels_per_point() / zoom; - let time = main.input(|i| i.time); - if view.frames() == 0 { - // warm-up pass: activates our fonts (pending font definitions live in `Memory`, which - // is replaced below) - view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |_| {}); - } - // same scroll offsets, open sections, style… as the window - let memory = main.memory(|m| m.clone()); - view.ctx.memory_mut(|m| *m = memory); - view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |ui| self.draw_window(ui)); - let img = capture.then(|| { - let mut tex = self.renderer.cpu_textures(); - if let (Some((t, ..)), Some(px)) = (&self.merge.preview, &self.merge.preview_pixels) { - tex.insert(t.id(), crate::softpaint::CpuTexture::linear(px.clone())); - } - view.paint(&tex) - }); - self.shadow = Some(view); - img - } - - /// Take each completed capture once, reject stale generations before writing anything, and - /// reply exactly once. A late callback after cancel/drop finds no slot and is inert. - fn collect_screenshots(&mut self, _ctx: &egui::Context) { - if self.pending_screenshots.is_empty() { - return; - } - let now = self.tick_now_ms(); - let current = self.last_shot_geometry; - let library = self.shot_document_generation(); - let mut shots = std::mem::take(&mut self.pending_screenshots); - let mut keep = Vec::with_capacity(shots.len()); - for mut shot in shots.drain(..) { - let completed = shot.slot.as_ref().and_then(craft_ui::capture::CaptureSlot::take); - match completed { - Some(Ok(image)) => { - let stale = - shot.issued.is_none_or(|issued| current.is_none_or(|now| now.epoch != issued.epoch)) || shot.issued_library != library; - if stale { - // A resize/DPI/zoom or library change between issue and completion: never - // combine old pixels with new geometry. Retry with a fresh slot. - shot.slot = None; - shot.sent_at_ms = None; - shot.issued = None; - shot.not_before_ms = now + SHOT_SETTLE_MS; - keep.push(shot); - continue; - } - let pixels = image.size[0].checked_mul(image.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); - let Some(bytes) = pixels else { - let reply = serde_json::json!({ - "ok": false, - "error": "ui.screenshot: the rendered image has invalid dimensions", - }); - let _ = shot.reply.send(reply); - self.release_shot_bytes(shot.reserved); - continue; - }; - if bytes > shot.reserved { - // Wayland reports no `inner_rect`, so the reservation can be based on a stale - // content rect while the compositor returns the new, larger surface. Treat a - // larger-but-legal image as a stale reservation and grow it; never mix it - // with geometry of another epoch (checked above). - if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); - self.release_shot_bytes(shot.reserved); - continue; - } - shot.reserved = bytes; - } - self.release_shot_bytes(shot.reserved); - let _ = shot.reply.send(control::save_screenshot(self, &image, shot.target.as_ref())); - } - Some(Err(error)) => { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error.to_string()})); - self.release_shot_bytes(shot.reserved); - } - None => { - if now > shot.deadline_ms { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)})); - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - self.release_shot_bytes(shot.reserved); - } else { - keep.push(shot); - } - } - } - } - self.pending_screenshots = keep; - } - - /// The process is about to end (settings and library are saved by then): make sure nothing is - /// inside the GPU driver when it does (issue #620: a render running on a worker thread while - /// the process exits crashes in the driver). No GPU work starts from here on; the render - /// workers get up to `timeout` to finish their jobs, and what is left of it goes to GPU work - /// on other threads (export, denoise, the device warm-up). `false`: the deadline passed first - /// (the host exits anyway). The app renders nothing afterwards. - pub fn shutdown(&mut self, timeout: std::time::Duration) -> bool { - lightcraft_engine::gpu::begin_shutdown(); - #[cfg(not(target_arch = "wasm32"))] - let t0 = std::time::Instant::now(); - let stopped = self.renderer.shutdown(timeout); - #[cfg(not(target_arch = "wasm32"))] - let timeout = timeout.saturating_sub(t0.elapsed()); - let idle = lightcraft_engine::gpu::wait_idle(timeout); - stopped && idle - } - - /// Background and queue work. Called before layout on every host pass and, while the window is - /// hidden, from `Context::run_logic`. It may read the refreshed viewport fields only: never - /// `i.time`, events, keys or dropped files. - pub fn logic(&mut self, ctx: &egui::Context) { - i18n::set_language(self.ui.language); - // The fork's AccessKit root language and bidi runs follow the locale; presentation only. - // Compare against the cached string: `Context::language()` allocates on every call, and - // this runs on every UI pass and every hidden logic tick. - let language = self.ui.language.code(); - if self.accesskit_language.as_deref() != Some(language) { - ctx.set_language(Some(language.to_owned())); - self.accesskit_language = Some(language.to_owned()); - } - let t0 = monotonic_ms(); - self.note_max_texture_side(ctx); - self.note_shot_geometry(ctx); - self.ensure_fonts(ctx); - // Applying the theme belongs to logic: a host that only calls `logic` (and every hidden - // tick) must see an Appearance Mode / system-theme change, and the UI pass that follows - // then draws with the new visuals. `ui` keeps its own call for a settings change made by - // a command during the same pass; it is a no-op when the theme already matches. - self.sync_theme(ctx); - self.logic_background(ctx); - self.perf.logic_ms = monotonic_ms() - t0; - } - - /// Install/refresh fonts once and watch the device and availability caches. Setup, not input. - fn ensure_fonts(&mut self, ctx: &egui::Context) { - if !self.styled { - theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); - theme::apply(ctx); - Self::drop_legacy_panel_state(ctx); - // File → Add from Device lists cards scanned in the background: show hot-plugs - let repaint = ctx.clone(); - lightcraft_engine::devices::on_change(move || repaint.request_repaint()); - // "is the original there?" (grid, Info panel, Missing Photos) answers from a cache a - // worker fills: a sleeping NAS or a dropped share never stalls a frame - let repaint = ctx.clone(); - self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint())); - self.styled = true; - self.font_language = self.ui.language; - } else if self.font_language != self.ui.language { - // Shared Han characters take the active language's forms (Japanese faces for 日本語, - // the Simplified Chinese face for 简体中文): rebuild the fallback order. - theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); - self.font_language = self.ui.language; - } else { - self.fonts_ready = true; - } - } - - /// 0.36 stored each `PanelState` under the panel's exact `Id`; 0.37 stores it under - /// `parent_ui.scope_id().with_salt(id_salt)`, so the old entries are unreachable and the - /// panels reset once by simply not finding them. Remove the old keys anyway so egui memory - /// does not retain dead entries (no document, workspace JSON, catalog or XMP data is - /// migrated; nothing above a user's photo edits depends on them). - fn drop_legacy_panel_state(ctx: &egui::Context) { - for salt in ["top_bar", "bottom_bar", "tool_strip", "library-problem-banner"] { - ctx.data_mut(|d| d.remove::(egui::Id::unique(salt))); - } - } - - /// Queues and timers. Each consumed queue is drained once per call; time-gated work uses the - /// monotonic app clock, so a hidden `run_logic` keeps it advancing and a second layout pass is - /// a no-op rather than a duplicate. - fn logic_background(&mut self, ctx: &egui::Context) { - panels::notices::logic(self); - // closing the window (or Quit) with changes only in memory: retry, else ask first - if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - } - let now = self.tick_now(); - self.drain_control(ctx); - self.apply_settings(ctx); - if self.queued_input_needs_repaint() { - ctx.request_repaint(); - } - self.renderer.poll(ctx, &mut self.session); - merge::poll(self, ctx); - import::poll_scan(self, ctx); - sync::poll(self, ctx); - sync::poll_run(self, ctx); - // Import commits batches and must not run twice in one host frame. The first layout pass - // and a hidden logic tick both report pass index 0; a second pass reports 1. A wall-clock - // gate cannot express that (a slow discarded first pass would re-run it). - if import_tick_allowed(ctx) { - import::tick(self, ctx); - } - lightroom_import::tick(self, ctx); - recheck::tick(self); - tasks::poll(self, ctx); - updates_ui::tick(self, ctx); - self.preview_build_status(ctx); - self.save_status(ctx); - self.ai_mask_detail(ctx, now); - self.place_names_outcome(ctx); - self.slideshow_tick(ctx, now); - panels::denoise::pump_background(self, ctx, now); - panels::faces::pump_background(self, ctx, now); - model_setup::pump(self, ctx, now); - focus_scan::pump(self, ctx, now); - // auto import: list the watched folder every few seconds (on a worker thread: it may be on - // a network share), then import what's new like any import (also on a worker thread) - #[cfg(not(target_arch = "wasm32"))] - if let Some(folder) = self.session.import_defaults.auto_folder.clone() { - const LABEL: &str = "Auto Import"; - if now - self.ui.auto_import_at >= 3.0 - && self.import.is_none() - && self.sync_run.is_none() - && self.lightroom.is_none() - && !self.tasks.is_running(LABEL) - { - self.ui.auto_import_at = now; - let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder); - let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result, String>| { - let listing = match listing { - Ok(l) => l, - Err(e) => return log::debug!("auto import: {e}"), - }; - let p = serde_json::json!({"listing": listing, "start": false}); - let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return }; - let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return }; - let paths: Vec = - params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); - if let Some(o) = params.as_object_mut() { - o.remove("paths"); - } - let undo0 = app.session.undo.len(); - app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto()); - }; - // listed every few seconds: quiet, no row in the activity stack - if let Err(e) = tasks::spawn(self, LABEL, None, work, done) { - log::warn!("{e}"); - } - } - ctx.request_repaint_after(std::time::Duration::from_secs(3)); - } - // automatic XMP writes that failed (a network share offline): tried again every few seconds once their files - // are reachable, and said once when they start waiting and once when they're written - #[cfg(not(target_arch = "wasm32"))] - { - if self.session.unsaved_sidecars() > 0 { - if now - self.ui.sidecar_retry_at >= 10.0 { - self.ui.sidecar_retry_at = now; - self.session.retry_unsaved_sidecars(); - } - ctx.request_repaint_after(std::time::Duration::from_secs(10)); - } - let waiting = self.session.unsaved_sidecars(); - if waiting > 0 && self.ui.sidecars_waiting == 0 { - self.toast_for( - ctx, - crate::i18n::tr_format!( - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", - n = waiting - ), - 8.0, - ); - } else if waiting == 0 && self.ui.sidecars_waiting > 0 { - self.toast(ctx, crate::i18n::tr("Wrote the XMP sidecars that were waiting")); - } - self.ui.sidecars_waiting = waiting; - } - // Settings → Import: open the import review when a camera or card is connected - #[cfg(not(target_arch = "wasm32"))] - if self.session.import_defaults.show_on_card { - if let Some(now) = lightcraft_engine::devices::devices_scanned() { - import::cards_seen(self, now); - } - ctx.request_repaint_after(lightcraft_engine::devices::MAX_AGE); - } else { - self.ui.known_cards = None; - } - self.session.persist_if_dirty(); - self.expire_gestures(); - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); - self.request_paint_while_hidden(ctx); - } - - /// While hidden, queued captures and an injected gesture step need a real UI pass; a pending - /// screenshot command paints by itself once issued. - fn request_paint_while_hidden(&self, ctx: &egui::Context) { - // `logic` runs per layout pass; one command per host frame is enough. - if ctx.current_pass_index() != 0 { - return; - } - // `ViewportInfo::visible()`: minimized or fully occluded is `Some(false)`; `None` - // (headless, `RawInput::default()`) is normal and must not be treated as hidden. - let hidden = ctx.input(|i| i.viewport().visible() == Some(false)); - if hidden && self.needs_ui_pass_while_hidden() { - ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); - } - } - - /// A hidden window needs a real UI pass for queued/settling captures, for control gestures - /// that still have to resolve or deliver, and for an injected synthetic step. An already - /// issued screenshot has its own compositor-driven paint and must not request another - /// `RequestPaintWhileHidden`; its fallback and timeout keep running from `logic`. - fn needs_ui_pass_while_hidden(&self) -> bool { - !self.pending_gestures.is_empty() - || self.pointer_cleanup - || self.pending_screenshots.iter().any(|shot| shot.sent_at_ms.is_none()) - || !self.synthetic.is_empty() - || self.synthetic_in_flight - } - - /// Queued synthetic input, including a queued pointer cancellation that is not part of - /// `synthetic`, needs the next visible frame even when an earlier stall left the synthetic - /// queue empty. - fn queued_input_needs_repaint(&self) -> bool { - !self.synthetic.is_empty() || self.pointer_cleanup - } - - /// Input, layout and setup for a known UI phase: at most once per host frame, and never from a - /// hidden logic tick. `current_pass_index() == 0` is the first pass of this `run_ui`; a second - /// pass has empty events. - fn ui_pass(&mut self, ctx: &egui::Context) { - if ctx.current_pass_index() != 0 { - return; - } - // A prompt owns the keyboard and focus. A notice may have been added by this frame's logic - // after `raw_input_hook`; capture its Tab/Enter/Space/Escape and drop everything else - // before any panel, focused control or shortcut reads the input. A new prompt gets a - // shielding pass whose background is not drawn at all (see `draw_window`). - let batch_in_flight = self.synthetic_in_flight; - if panels::notices::prepare_ui_pass(self, ctx) { - self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared"); - } - panels::library_problem::prepare_ui_pass(self, ctx, batch_in_flight); - let now = self.tick_now(); - // the last pass's time is fine here: this is a UI phase, not a hidden tick - let t = ctx.input(|i| i.time); - let dt = t - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; - } - self.last_time = t; - panels::denoise::pump_ui_input(self, ctx, now); - panels::faces::pump_ui_input(self, ctx, now); - // A prompt owns the library too: files dropped while it is up wait in the queue and are - // imported on the first UI pass after it goes away, never behind it. - if !panels::notices::blocks_input(self) { - self.import_dropped_files(ctx); - } - self.check_external_editor(ctx); - // Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI - // phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract). - panels::library_problem::logic(self, ctx); - if self.fonts_ready && !updates_ui::blocks_input(self) { - shortcuts::handle(self, ctx); - } - } - - /// Take the host's dropped files, exactly once. The queue is filled by `raw_input_hook`, so a - /// hidden logic tick can never replay the last pass's drop. Web hosts call this in their UI - /// phase (they need `bytes_async`, which is not available on a native file path). - pub fn take_dropped_files(&mut self) -> Vec { - std::mem::take(&mut self.dropped_files) - } - - /// Import native drops as presets, face models or photos. - fn import_dropped_files(&mut self, ctx: &egui::Context) { - let dropped = self.take_dropped_files(); - if dropped.is_empty() { - return; - } - #[cfg(not(target_arch = "wasm32"))] - self.handle_dropped_files(ctx, dropped); - #[cfg(target_arch = "wasm32")] - let _ = (dropped, ctx); - } - - /// Files dropped on the window: face models open their licence dialog, presets import as - /// presets, photos and folders go to the import review (or straight in, Settings ▸ Import). - #[cfg(not(target_arch = "wasm32"))] - fn handle_dropped_files(&mut self, ctx: &egui::Context, dropped: Vec) { - let dropped: Vec = - dropped.iter().map(|f| egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect(); - if dropped.is_empty() { - return; - } - // a dialog is on screen: the review would replace it, losing what it holds - if self.ui.dialog.is_some() { - self.toast(ctx, crate::i18n::tr("Close the open dialog, then drop the files again")); - return; - } - let drop = drop_overlay::classify(dropped); - if let Some(model) = drop.models.first() - && let Err(e) = panels::faces::open_dialog(self, model) - { - self.toast(ctx, e); - } - if !drop.presets.is_empty() { - // presets live in a library: with none open they would go with the session it replaces - if panels::library_problem::holds_drops(self) { - self.toast(ctx, crate::i18n::tr("Open a library first, then drop the presets again")); - } else { - let _ = self.run("file.importPresets", serde_json::json!({"paths": drop.presets})); - } - } - if drop.photos.is_empty() { - return; - } - // no library open yet (the launch list, a library that failed to open): they wait for one - let Some(photos) = panels::library_problem::hold_dropped(self, ctx, drop.photos) else { return }; - if self.lightroom.is_some() { - self.toast(ctx, crate::i18n::tr("Finish Lightroom catalog import before adding photos")); - return; - } - if import::running(self) { - self.toast(ctx, crate::i18n::tr("An import is running")); - return; - } - let started = if self.ui.settings.import_dropped_directly { - // read and added on a worker thread (dropped folders can be large, or on a slow drive) - import::start_paths(self, photos).map(|_| ()) - } else { - import::open(self, photos).map(|_| ()) - }; - if let Err(e) = started { - self.toast(ctx, e); - } - } - - /// A file dropped by the host, kept for the next UI pass (web hosts consume their own queue). - fn queue_dropped_files(&mut self, raw: &mut egui::RawInput) { - if !raw.dropped_files.is_empty() { - self.dropped_files.append(&mut raw.dropped_files); - } - } - - /// Back from an external editor: pick up the files it saved. Reads `i.focused`, which - /// `run_logic` does not refresh, so it belongs to the UI phase. - fn check_external_editor(&mut self, ctx: &egui::Context) { - let focused = ctx.input(|i| i.focused); - if focused && !self.ui.was_focused && !self.ui.external_edits.is_empty() { - let ids = self.ui.external_edits.clone(); - if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) - && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) - { - self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor")); - } - } - self.ui.was_focused = focused; - } - - /// Apply settings that act outside the UI state: GPU rendering and window full screen. - fn apply_settings(&mut self, ctx: &egui::Context) { - if self.gpu_applied != Some(self.ui.settings.gpu) { - self.gpu_applied = Some(self.ui.settings.gpu); - let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); - // GPU device + kernels off the UI thread, once the window is up and only when GPU - // rendering is on: a broken driver must not keep the window from appearing (issue #136) - if self.ui.settings.gpu { - lightcraft_engine::gpu::warm_up(); - } - } - let mb = self.ui.settings.memory_mb; - // automatic at startup: leave the engine's default alone - if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) { - let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb }; - let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb})); - } - self.memory_applied = Some(mb); - // the monitor profile (Settings ▸ Display): previews follow it from the next frame - if self.display_applied.as_ref() != Some(&self.ui.settings.display_profile) { - let path = self.ui.settings.display_profile.clone(); - match lightcraft_engine::display::Display::load_opt(&path) { - Ok(d) => { - self.renderer.set_display(d); - self.display_error = None; - } - Err(msg) => { - // a profile that can't be used: previews are sRGB, the setting is kept - self.renderer.set_display(None); - log::warn!("display profile: {msg}"); - self.toast_error(ctx, format!("{}: {msg}", crate::i18n::tr("Display profile not used"))); - self.display_error = Some(msg); - } - } - self.display_applied = Some(path); - // none chosen again: the screen's profile is read afresh - self.screen_profile = None; - self.screen_profile_read = None; - } - if self.ui.settings.display_profile.is_empty() { - self.follow_screen_profile(ctx); - } - if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { - self.window_is_fullscreen = fs; - } - if let Some(on) = self.ui.window_fullscreen.take() { - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on)); - self.window_is_fullscreen = on; - } - } - - /// While no display profile is chosen, show previews through the profile of the screen the - /// window is on (`services.screen_profile`), read about once a second so moving the window to - /// another screen is followed. Without the service (or a profile) the display is sRGB. - fn follow_screen_profile(&mut self, _ctx: &egui::Context) { - let now = self.tick_now(); - if self.services.screen_profile.is_none() || self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) { - return; - } - self.screen_profile_read = Some(now); - self.apply_screen_profile(); - } - - /// Read the screen's profile now and show previews through it (sRGB without one). - pub(crate) fn apply_screen_profile(&mut self) { - let Some(read) = self.services.screen_profile.as_mut() else { return }; - let icc = read(); - if icc == self.screen_profile { - return; - } - let display = icc.as_deref().and_then(|bytes| match lightcraft_engine::display::Display::from_bytes(SCREEN_PROFILE, bytes) { - Ok(d) => Some(std::sync::Arc::new(d)), - Err(msg) => { - // a profile we can't use: previews stay sRGB, as before #787 - log::warn!("screen profile: {msg}"); - None - } - }); - self.renderer.set_display(display); - self.screen_profile = icc; - } - - /// Take the host's dropped files once and inject synthetic events (pointer events one per - /// frame; key sequences up to the release). - /// - /// The hook also runs before a hidden logic-only tick, so it injects at most one batch and - /// keeps that batch in flight until a completed UI pass consumed it. It never injects while - /// the fonts are not installed (the pass would draw nothing and the events would be lost). - pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - let file = |p: &String| (!p.is_empty()).then(|| std::path::PathBuf::from(p)); - if !self.drop_pending.is_empty() { - raw.dropped_files = std::mem::take(&mut self.drop_pending) - .into_iter() - .map(|p| std::sync::Arc::new(InjectedDrop(p.into())) as egui::DroppedFileHandle) - .collect(); - } - if !self.drop_hover.is_empty() { - raw.hovered_files = self.drop_hover.iter().map(|p| egui::HoveredFile { path: file(p), ..Default::default() }).collect(); - } - // Queue after injecting, so a `ui.dropFiles` request is consumed exactly once by the UI phase. - self.queue_dropped_files(raw); - // Track the real host modifier state separately from the app's synthetic ownership. A host - // change means the host now owns the modifier state: relinquish any synthetic claim and do - // not later synthesise a release for a key that is physically held. - if let Some(host_mods) = raw.events.iter().rev().find_map(|event| match event { - egui::Event::ModifiersChanged(modifiers) => Some(*modifiers), - _ => None, - }) { - self.host_mods = host_mods; - self.synthetic_mods = egui::Modifiers::NONE; - self.synthetic_mods_release = false; - } - // A prompt owns all input. On its first host frame this also removes pointer/touch input - // before egui computes its hit test; later frames keep only key releases, the prompt's own - // keys, and AccessKit actions targeted at a prompt button. See `notices::filter_raw_input`. - let batch_in_flight = self.synthetic_in_flight; - if crate::panels::notices::prepare_raw_input(self, ctx, raw) { - // A prompt appearing during a synthetic drag must abort the drag, not let its remaining - // move/release events act under the prompt or after it is dismissed. If a whole batch - // was already injected, it is unsafe as well: this frame's prompt filter drops it - // before a panel sees it, so it must not report a successful delivery. - self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared"); - self.queue_pointer_cleanup(); - } - // Enforce the stall deadline before injecting another event: a request that expired during - // a long frame gap must not get one more event on the frame that resumes. - self.expire_gestures(); - // A canceled press is released by resetting egui's pointer state before its next input - // pass, even while another batch is in flight or the fonts are not ready, so the pointer - // cannot stay down. No move/release event is injected: either could start a drag or - // become a click on the widget being canceled. - self.apply_pointer_cleanup(ctx, raw); - if !self.fonts_ready || self.synthetic_in_flight { - return; - } - if self.drop_gestures_stale_before_injection(ctx) { - // The stale check can cancel the head gesture while its press is already down. - self.apply_pointer_cleanup(ctx, raw); - return; - } - // the frame after a synthetic release / key: restore the host's physical modifiers, not - // necessarily NONE (Shift may still be held while a synthetic Alt gesture is released) - if std::mem::take(&mut self.synthetic_mods_release) && self.synthetic_mods != egui::Modifiers::NONE { - self.synthetic_mods = egui::Modifiers::NONE; - raw.events.push(egui::Event::ModifiersChanged(self.host_mods)); - } - if self.synthetic.is_empty() { - return; - } - let n = match self.synthetic[0] { - egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - // A press needs the pointer at its position before the button event; the move that is - // itself the first event is delivered by the drain below, so it must not be sent twice. - if let Some(egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } - // egui's `input.modifiers` follow `ModifiersChanged` events, not the modifiers carried by - // pointer/key events: announce the injected events' modifiers (held from button down to - // the frame after up), so ⌥-drag, ⇧-click … work through the control channel - let carried = self.synthetic[..n].iter().find_map(|e| match e { - egui::Event::PointerButton { pressed, modifiers, .. } => Some((*modifiers, !pressed)), - egui::Event::Key { modifiers, .. } => Some((*modifiers, true)), - _ => None, - }); - if let Some((m, ends)) = carried { - if m != self.synthetic_mods { - self.synthetic_mods = m; - raw.events.push(egui::Event::ModifiersChanged(m)); - } - self.synthetic_mods_release = ends; - } - let blocked_by_prompt = self.synthetic[..n].iter().any(|event| !crate::panels::notices::prompt_allows_event(self, event)); - let head = self.head_gesture(); - if let Some(head) = head { - let gesture = &mut self.pending_gestures[head]; - for event in &self.synthetic[..n] { - match event { - egui::Event::PointerButton { button, pressed: true, .. } => { - gesture.pressed = Some(InjectedPress { button: *button }); - } - egui::Event::PointerButton { button, pressed: false, .. } - if gesture.pressed.as_ref().is_some_and(|press| press.button == *button) => - { - gesture.pressed = None; - } - _ => {} - } - } - gesture.started = true; - gesture.remaining_events = gesture.remaining_events.saturating_sub(n); - } - raw.events.extend(self.synthetic.drain(..n)); - // The same barrier applies after injection: only the prompt's own keys and targeted - // AccessKit actions survive; host input and `ui.text`/clipboard never reach the background. - crate::panels::notices::filter_raw_input(self, raw); - if blocked_by_prompt && let Some(index) = head { - // A settled prompt is already laid out, so this is not the prompt-creation-cancels-all - // path: only the gesture whose batch the barrier removed fails. Allowed prompt keys, - // pointer input and targeted AccessKit actions still report a successful delivery. - let gesture = self.pending_gestures.remove(index); - self.cancel_gesture_events(&gesture, true); - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: the open modal prompt blocked this input", gesture.method), - })); - } - // The 5 s deadline is a delivery-stall limit, not a limit on a gesture's frame count: a - // drag feeds one event per host frame, so every batch that the app consumes proves the UI - // is alive and restarts the clock for the whole queue. - let now = self.tick_now_ms(); - for gesture in &mut self.pending_gestures { - gesture.deadline_ms = now + GESTURE_TIMEOUT_MS; - } - self.synthetic_in_flight = true; - } - - /// Lights Out: veil everything but the photo (the loupe's image, or the grid / Compare / - /// Survey area) in black. Painted above the panels, below dialogs and notices. - fn lights_out(&self, ctx: &egui::Context) { - let a = self.ui.lights_out.veil(); - if a <= 0.0 { - return; - } - let screen = ctx.content_rect(); - let keep = if self.ui.view == state::ViewMode::Detail { self.image_rect.or(self.canvas_rect) } else { self.canvas_rect }; - let veil = egui::Color32::from_black_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); - let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::unique("lights-out"))); - let k = keep.unwrap_or(egui::Rect::NOTHING).intersect(screen); - if !k.is_positive() { - p.rect_filled(screen, 0.0, veil); - return; - } - // four bands around the photo - use egui::{Rect, pos2}; - p.rect_filled(Rect::from_min_max(screen.min, pos2(screen.right(), k.top())), 0.0, veil); - p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.bottom()), screen.max), 0.0, veil); - p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.top()), pos2(k.left(), k.bottom())), 0.0, veil); - p.rect_filled(Rect::from_min_max(pos2(k.right(), k.top()), pos2(screen.right(), k.bottom())), 0.0, veil); - } - - /// Frame timings once layout is done (`t0`: when layout started). - fn end_frame(&mut self, ctx: &egui::Context, t0: f64) { - if self.ui.frame_rate { - frame_rate::show(ctx, self); - } else { - frame_rate::forget(ctx); - } - panels::edit::wheel_pointer_frame_end(ctx); - ctx.output(|o| { - for c in &o.commands { - if let egui::OutputCommand::CopyText(text) = c { - self.copied = Some(text.clone()); - } - } - }); - self.perf.frame_ms = monotonic_ms() - t0; - self.perf.update_ms = self.perf.logic_ms + self.perf.frame_ms; - self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms); - } - - /// Once per library opened (at launch too): name it in the window title, so nobody edits in - /// the wrong one, and put it first in File ▸ Open Recent Library. - pub(crate) fn library_changed(&mut self, ctx: &egui::Context) { - let generation = self.session.library_generation(); - if self.shown_library == Some(generation) { - return; - } - self.shown_library = Some(generation); - if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { - let path = l.dir.to_string_lossy().into_owned(); - panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); - // it is open: whatever an earlier check said about it is over - self.recent_checks.opened(&path); - // when, for the launch window (issue #1290); only the listed libraries keep a time - let settings = &mut self.ui.settings; - settings.recent_opened.insert(path, (self.session.clock)()); - settings.recent_opened.retain(|p, _| settings.recent_libraries.contains(p)); - } - ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); - } - - /// "LightCraft — " (just "LightCraft" for a library that isn't saved). - pub fn window_title(&self) -> String { - match panels::settings::open_library_name(self) { - Some(name) => format!("LightCraft — {name}"), - None => "LightCraft".to_string(), - } - } - - /// The browser page's title: "[ •] — LightCraft" for the active photo (• while - /// `unsaved`: changes not yet written to storage), else the window title. The web build has no - /// window for `ViewportCommand::Title`; a host embedding it reads this title to tell which - /// document a drop opened and whether it is saved (issue #2397). - pub fn page_title(&self, unsaved: bool) -> String { - let dot = if unsaved { " •" } else { "" }; - match self.session.active().and_then(|id| self.session.catalog.photo(id)) { - Some(p) => format!("{}{dot} — LightCraft", p.file_name), - None => format!("{}{dot}", self.window_title()), - } - } - - /// Lay out the whole window: input/setup for the known UI phase, then presentation, then the - /// frame bookkeeping. A hidden `run_logic` never reaches this. - pub fn ui(&mut self, ui: &mut egui::Ui) { - i18n::set_language(self.ui.language); - let ctx = ui.ctx().clone(); - if !self.fonts_ready { - ctx.request_repaint(); - return; - } - self.sync_theme(&ctx); - self.ui_pass(&ctx); - self.draw_window(ui); - self.finish_ui_pass(&ctx); - } - - /// A completed, nondiscarded pass is a settled frame; a discarded pass threw its widgets away - /// and must not advance the geometry generation a capture binds to. - fn finish_ui_pass(&mut self, ctx: &egui::Context) { - // Any pass consumed the injected batch; a discarded pass still read its events and the - // next pass has none. Only a nondiscarded pass, however, is a settled frame a capture may - // bind to and a control gesture may resolve against. - self.synthetic_in_flight = false; - if ctx.will_discard() { - self.next_widgets.clear(); - self.next_widget_hits.clear(); - return; - } - self.widgets = std::mem::take(&mut self.next_widgets); - self.widget_hits = std::mem::take(&mut self.next_widget_hits); - panels::notices::finish_ui_pass(self, ctx); - panels::library_problem::finish_ui_pass(self, ctx); - self.ui_passes = self.ui_passes.wrapping_add(1); - self.layout_epoch = self.layout_epoch.wrapping_add(1); - self.resolve_pending_gestures(ctx); - self.reply_delivered_gestures(); - if self.pointer_cleanup { - // A canceled press may be discovered by a timeout after the synthetic queue emptied; - // nothing else may ask for the frame that applies its cancellation. - ctx.request_repaint(); - } - } - - /// Presentation only: the window's panels, dialogs and overlays. - fn draw_window(&mut self, ui: &mut egui::Ui) { - let ctx = ui.ctx().clone(); - let t0 = monotonic_ms(); - panels::library_problem::begin_draw(self); - // A command or shortcut drawn before this can create the window after `ui_pass`; catch it - // here before any panel draws with the still-focused background text field. - let batch_in_flight = self.synthetic_in_flight; - panels::library_problem::prepare_ui_pass(self, &ctx, batch_in_flight); - // A newly shown blocking library window gets the same barrier as a prompt: its first Area - // layout is non-interactable and hit testing still uses the previous pass, so background - // panels (and a focused text field) must not draw in that pass. - if panels::library_problem::shield_pass(self, &ctx) { - #[cfg(test)] - self.library_passes.push(panels::library_problem::LibraryPass::Shield); - panels::library_problem::show(self, &ctx, false); - (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); - ctx.request_discard("library problem: shield the window's creation frame"); - self.end_frame(&ctx, t0); - return; - } - // A newly shown prompt gets one pass with no background. egui's first Area layout is - // non-interactable and its hit test uses the previous pass, so drawing panels here would - // let a pointer press/release in the prompt's creation frame act underneath it. `ui()` then - // reruns this pass with empty raw input, where the settled prompt and the full window draw - // together. This is a state/input-routing barrier, not a wholesale input disable: once - // settled, raw pointer input still reaches the prompt backdrop (a no-op) or its buttons. - if panels::notices::shield_pass(self, &ctx) { - panels::notices::show(self, &ctx); - (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); - ctx.request_discard("notice: shield the prompt's creation frame"); - self.end_frame(&ctx, t0); - return; - } - #[cfg(test)] - self.library_passes.push(panels::library_problem::LibraryPass::Background); - panels::dialogs::prepare(self, ui); - // Freeze this frame's notice barrier before `notices::show` can dismiss a prompt: the same - // click or key must never be acted on again by the dialog drawn after it. - let notice_blocks = panels::notices::blocks_input(self); - if std::mem::take(&mut self.ui.quit) { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - self.library_changed(&ctx); - // panels set it again this frame while the pointer rests on a preset or profile - self.hover_preview = None; - if self.ui.fullscreen { - // full-screen preview: the photo alone on black - egui::CentralPanel::default() - .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) - .show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) }); - panels::second::show(self, &ctx); - if notice_blocks { - panels::notices::restore_prompt_pointer(self, &ctx); - } - panels::notices::show(self, &ctx); - if notice_blocks { - panels::notices::hide_background_pointer(&ctx); - } - panels::dialogs::show(self, &ctx, notice_blocks); - updates_ui::show(self, &ctx); - panels::library_problem::show(self, &ctx, notice_blocks); - panels::toast(self, &ctx); - drop_overlay::show(self, &ctx); - if notice_blocks { - // Keep the real press/release state for the next pass: an ordinary click spans - // host frames, and resetting it above must only hide it from this pass's dialogs. - panels::notices::restore_prompt_pointer(self, &ctx); - } - (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); - self.end_frame(&ctx, t0); - return; - } - // Order matters: earlier panels take the full edge (top bar spans the window; the tool strip, - // right panels and left panel run to the bottom; the bottom bar sits between them). - panels::topbar::show(self, ui); - panels::library_problem::banner(self, ui); - panels::strip::show(self, ui); - // filled: the gaps the dock leaves between its panes show chrome, not the dark clear colour - let chrome = theme::Tokens::get(&ctx).chrome; - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(chrome)).show(ui, |ui| docking::show(self, ui)); - self.lights_out(&ctx); - panels::second::show(self, &ctx); - if notice_blocks { - panels::notices::restore_prompt_pointer(self, &ctx); - } - panels::notices::show(self, &ctx); - if notice_blocks { - panels::notices::hide_background_pointer(&ctx); - } - panels::dialogs::show(self, &ctx, notice_blocks); - updates_ui::show(self, &ctx); - panels::library_problem::show(self, &ctx, notice_blocks); - export_task::poll(self, &ctx); - slideshow_export::poll(self, &ctx); - panels::activity::show(self, &ctx); - export_task::poll_photos(self, &ctx); - pick::poll(self, &ctx); - panels::grid::drag_feedback(self, &ctx); - panels::left::album_drag_feedback(self, &ctx); - panels::left::section_drag_feedback(self, &ctx); - panels::keyword_list::drag_feedback(self, &ctx); - panels::toast(self, &ctx); - drop_overlay::show(self, &ctx); - // AppKit moves the window on a drag anywhere in the title-bar band unless the window isn't - // movable: it stops being movable while the pointer is on a bar control or a panel over it - #[cfg(target_os = "macos")] - craft_window::title_bar::sync_movable(&ctx, &mut self.window_movable); - if notice_blocks { - // Keep the real press/release state for the next pass: an ordinary click spans host - // frames, and resetting it above must only hide it from this pass's dialogs. - panels::notices::restore_prompt_pointer(self, &ctx); - } - (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); - self.text_focus = ctx.egui_wants_keyboard_input(); - self.end_frame(&ctx, t0); - } -} - /// How long a screenshot waits for in-flight renders before it is captured anyway (and the /// headless fallback's settle budget). const SCREENSHOT_SETTLE_MS: f64 = 3000.0; @@ -2626,35 +436,6 @@ impl ShotGeometry { } } -/// Import commits batches at most once per host frame. The first layout pass and a hidden -/// `run_logic` tick both report pass index 0; a discarded frame's later passes report 1+. A -/// wall-clock gate cannot express that (a slow first pass could re-run it within one frame). -fn import_tick_allowed(ctx: &egui::Context) -> bool { - ctx.current_pass_index() == 0 -} - -/// Whether a pending request may be issued now: it has waited for the settle time and completed -/// UI frames, and either no render is in flight or the forced-capture instant has passed. -fn shot_ready(now_ms: f64, shot: &PendingShot, busy: bool, ui_passes: u64) -> bool { - let settled = ui_passes.saturating_sub(shot.admitted_passes) >= SHOT_SETTLE_FRAMES && now_ms >= shot.not_before_ms; - settled && (!busy || now_ms >= shot.force_ms) -} - -/// The navigation/document/modal/panel state a gesture was admitted under. The same automation -/// id can mean a different control after any of these changes; the request is canceled rather -/// than retargeted. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct GestureFrame { - view: crate::state::ViewMode, - active: Option, - library: u64, - dialog: Option>, - left_panel: bool, - right: crate::state::RightPanel, - presets: bool, - filmstrip: bool, -} - /// A synthetic-input control request waiting for a fresh layout and for its last event to be /// consumed by a completed UI pass. struct PendingGesture { @@ -2686,12 +467,6 @@ struct PendingGesture { result: Value, } -/// A pointer press delivered to the UI, kept so a canceled gesture knows that a button is down. -#[derive(Clone, Copy, Debug)] -struct InjectedPress { - button: egui::PointerButton, -} - /// A `ui.screenshot` request in progress. struct PendingShot { token: u64, @@ -2717,42 +492,6 @@ struct PendingShot { issued_library: u64, } -/// A monotonic clock for the app's own timers. -/// -/// `web_time::Instant` is backed by the browser's `performance.now()` on WASM and by -/// `std::time::Instant` natively: it never jumps with the wall clock and has no `Date.now()` -/// zero-origin problem. A headless host may supply its simulated time instead. -#[derive(Clone, Copy, Debug)] -struct AppClock { - start: web_time::Instant, - simulated: Option, -} - -impl AppClock { - /// Start the clock at the current monotonic instant with no simulated time. - fn new() -> Self { - Self { start: web_time::Instant::now(), simulated: None } - } - - /// Seconds on the clock: the supplied simulated time when set, otherwise time since `new`. - fn now(&self) -> f64 { - self.simulated.unwrap_or_else(|| self.start.elapsed().as_secs_f64()) - } -} - -/// Monotonic milliseconds since the first call, for durations and deadlines. -pub fn monotonic_ms() -> f64 { - static START: std::sync::OnceLock = std::sync::OnceLock::new(); - START.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() * 1000.0 -} - -/// Wall-clock milliseconds since the Unix epoch, only for names that must be unique in the world -/// (`library-restored-`), never for timing or deadlines. -pub fn now_ms() -> f64 { - use web_time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) -} - /// Whether the settings render in black & white. /// A temporary look for the loupe (hovering a preset or profile). #[derive(Clone, Debug, PartialEq)] @@ -2767,53 +506,6 @@ pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool { d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" || d.profile.id.starts_with("lc.bw.") } -/// "Found 3 faces in 2 photos" for a `faces.detect` result. -fn detect_summary(v: &Value) -> String { - let photos = v["photos"].as_array().map(Vec::as_slice).unwrap_or_default(); - let counts: Vec = photos.iter().map(|p| p["faces"].as_array().map_or(0, Vec::len)).collect(); - let (faces, with) = (counts.iter().sum::(), counts.iter().filter(|n| **n > 0).count()); - let plural = |n: usize, w: &str| format!("{n} {w}{}", if n == 1 { "" } else { "s" }); - match (faces, photos.len()) { - (0, 1) => "No faces found".into(), - (0, n) => format!("No faces found in {}", plural(n, "photo")), - (f, 1) => format!("Found {}", plural(f, "face")), - (f, _) => format!("Found {} in {}", plural(f, "face"), plural(with, "photo")), - } -} - -/// What to say when a SAM 3 download ended while its dialog was closed: the text, and whether it is -/// shown as an error toast. A cancel is told by `cancelled`, not by the error's words. -fn sam_download_ended(download: &lightcraft_engine::segment::DownloadStatus) -> Option<(String, bool)> { - match &download.error { - Some(_) if download.cancelled => { - Some((crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time.").to_string(), false)) - } - Some(e) => Some((crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e), true)), - None if download.finished => Some((crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready.").to_string(), true)), - None => None, - } -} - -/// A file `ui.dropFiles` delivers as dropped, as a real OS drop arrives in `raw.dropped_files`. -#[derive(Debug)] -struct InjectedDrop(std::path::PathBuf); - -impl egui::DroppedFile for InjectedDrop { - fn path(&self) -> &std::path::Path { - &self.0 - } - - #[cfg(not(target_arch = "wasm32"))] - fn bytes(&self) -> Result, String> { - std::fs::read(&self.0).map_err(|e| e.to_string()) - } - - #[cfg(target_arch = "wasm32")] - fn bytes_async(&self) -> std::pin::Pin, String>> + '_>> { - Box::pin(async { Err("the web build has no files to read".to_string()) }) - } -} - /// A dropped face model file (`.onnx`). pub fn is_model_file(path: &str) -> bool { std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) @@ -2847,50 +539,6 @@ pub fn split_presets(dropped: Vec) -> (Vec, Vec) { (presets, photos) } -#[cfg(test)] -mod drop_tests { - #[test] - fn photo_sidecars_are_not_presets() { - let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); - let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]); - let (presets, photos) = super::split_presets(dropped); - assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"])); - assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"])); - } - - #[test] - fn detection_results_are_summarised_in_words() { - let r = - |counts: &[usize]| serde_json::json!({"photos": counts.iter().map(|n| serde_json::json!({"faces": vec![0; *n]})).collect::>()}); - assert_eq!(super::detect_summary(&r(&[0])), "No faces found"); - assert_eq!(super::detect_summary(&r(&[1])), "Found 1 face"); - assert_eq!(super::detect_summary(&r(&[3])), "Found 3 faces"); - assert_eq!(super::detect_summary(&r(&[0, 0])), "No faces found in 2 photos"); - assert_eq!(super::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos"); - assert_eq!(super::detect_summary(&serde_json::json!({})), "No faces found in 0 photos"); - } - - #[test] - fn dropped_face_models_are_told_apart() { - for p in ["/a/model.onnx", "/a/dir/M.ONNX"] { - assert!(super::is_model_file(p), "{p}"); - } - for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] { - assert!(!super::is_model_file(p), "{p}"); - } - } - - #[test] - fn dropped_presets_are_told_apart_from_photos() { - for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] { - assert!(super::is_preset_file(p), "{p}"); - } - for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] { - assert!(!super::is_preset_file(p), "{p}"); - } - } -} - /// Results recomputed only when the catalog (or their inputs) change. #[derive(Default)] pub struct Caches { @@ -2966,467 +614,3 @@ pub struct Caches { /// What the grid did on its frames (benchmarks and tests check unchanged frames stay cheap). pub grid_stats: panels::grid::GridStats, } - -/// The left panel's counts. -#[derive(Clone, Copy, Debug, Default)] -pub struct LibraryCounts { - pub total: usize, - pub picks: usize, - pub deleted: usize, -} - -/// The values the filter bar's pickers offer. -#[derive(Clone, Debug, Default)] -pub struct FilterValues { - pub cameras: Vec, - pub lenses: Vec, - pub keywords: Vec, -} - -/// What the smart-album rule dialog shows besides the rules themselves: their problems (each row -/// marks its own), how many photos they match and the albums an Album rule picks from. -pub struct RulesView { - pub problems: Vec, - pub count: usize, - pub albums: Vec, -} - -pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 { - use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher}; - BuildHasherDefault::::default().hash_one(parts) -} - -/// Whether a tally of the library made when the catalog was at `revision` is still right, given that it reads only -/// `reads` of a photo: nothing changed since, or only other things of some photos did (a rating changes no folder's -/// count, a slider changes none at all). Each of these tallies asks every photo, so one kept is a pass over the -/// library saved on every edit. -fn still_right(cat: &lightcraft_catalog::Catalog, revision: u64, reads: lightcraft_catalog::Aspects) -> bool { - revision == cat.revision || cat.aspects_since(revision).is_some_and(|changed| !changed.any_of(reads)) -} - -/// What the tallies below read of a photo, besides whether it is in the library. -const READS_FILE: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FILE); -const READS_META: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::META); -const READS_PEOPLE: lightcraft_catalog::Aspects = READS_META.with(lightcraft_catalog::Aspects::ANALYSIS); -const READS_CAPTURED: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::CAPTURED); -const READS_FLAG: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FLAG); - -impl Caches { - /// The folders the library's photos were imported from. - pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { - let kept = self.folder_tree.take().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t); - let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.folder_tree())); - self.folder_tree = Some((cat.revision, t.clone())); - t - } - /// The folder tree as last built, when it still holds (a read-only check cannot rebuild it). - pub(crate) fn folder_tree_if_fresh(&self, cat: &lightcraft_catalog::Catalog) -> Option>> { - self.folder_tree.as_ref().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t.clone()) - } - /// The library's keyword tree. - pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { - let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t); - let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.keyword_tree())); - self.keyword_tree = Some((cat.revision, t.clone())); - t - } - /// The Keywording box's read-only names for the selection: what exported files carry - /// (`export`), or the keywords with those containing them. - pub(crate) fn keyword_names( - &mut self, - cat: &lightcraft_catalog::Catalog, - selection: &[lightcraft_catalog::PhotoId], - export: bool, - ) -> std::sync::Arc> { - let key = key_of((selection, export)); - match &self.keyword_names { - Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), - _ => { - let c = std::sync::Arc::new(if export { - panels::keywording::will_export(cat, selection) - } else { - panels::keywording::with_containing(cat, selection) - }); - self.keyword_names = Some((cat.revision, key, c.clone())); - c - } - } - } - /// The selection's keywords (the Keywording box's chips). - pub(crate) fn keyword_chips( - &mut self, - cat: &lightcraft_catalog::Catalog, - selection: &[lightcraft_catalog::PhotoId], - ) -> std::sync::Arc> { - let key = key_of(selection); - match &self.keyword_chips { - Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), - _ => { - let c = std::sync::Arc::new(panels::keywording::chips(cat, selection)); - self.keyword_chips = Some((cat.revision, key, c.clone())); - c - } - } - } - /// How many photos the selection is: each once, those the library has (what the Keywording - /// box's "N of M" counts). - pub(crate) fn selected_photos(&mut self, cat: &lightcraft_catalog::Catalog, selection: &[lightcraft_catalog::PhotoId]) -> usize { - let key = key_of(selection); - match self.selected_photos { - Some((r, k, n)) if r == cat.revision && k == key => n, - _ => { - let n = panels::keywording::distinct(cat, selection).len(); - self.selected_photos = Some((cat.revision, key, n)); - n - } - } - } - /// The selection's keywords grouped by the keyword they are inside (the grouped Keywording box). - pub(crate) fn keyword_groups( - &mut self, - cat: &lightcraft_catalog::Catalog, - selection: &[lightcraft_catalog::PhotoId], - ) -> std::sync::Arc> { - let key = key_of(selection); - match &self.keyword_groups { - Some((r, k, g)) if *r == cat.revision && *k == key => g.clone(), - _ => { - let chips = self.keyword_chips(cat, selection); - let g = std::sync::Arc::new(panels::keywording::grouped(&chips)); - self.keyword_groups = Some((cat.revision, key, g.clone())); - g - } - } - } - /// How many selected photos have each keyword (the Keyword List's tick boxes). - pub(crate) fn keyword_ticks( - &mut self, - cat: &lightcraft_catalog::Catalog, - selection: &[lightcraft_catalog::PhotoId], - ) -> std::sync::Arc { - let key = key_of(selection); - match &self.keyword_ticks { - Some((r, k, t)) if *r == cat.revision && *k == key => t.clone(), - _ => { - let t = std::sync::Arc::new(panels::keyword_list::Ticks::of(cat, selection)); - self.keyword_ticks = Some((cat.revision, key, t.clone())); - t - } - } - } - /// The people named on faces among the photos the filter lets through (its own `person` aside), - /// with photo counts. - pub fn people( - &mut self, - cat: &lightcraft_catalog::Catalog, - filter: &lightcraft_catalog::Filter, - ) -> std::sync::Arc> { - let key = lightcraft_catalog::Filter { person: None, ..filter.clone() }; - match &self.people { - Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(), - _ => { - let t = std::sync::Arc::new(cat.people_in(&key)); - self.people = Some((cat.revision, key, t.clone())); - t - } - } - } - /// All Photos / Picks / Recently Deleted counts. - pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts { - if let Some((r, c)) = self.counts - && still_right(cat, r, READS_FLAG) - { - self.counts = Some((cat.revision, c)); - return c; - } - let mut c = LibraryCounts::default(); - for p in cat.photos() { - if p.in_library() { - c.total += 1; - if p.flag == lightcraft_catalog::Flag::Pick { - c.picks += 1; - } - } else if p.deleted && !p.local { - c.deleted += 1; - } - } - self.counts = Some((cat.revision, c)); - c - } - /// Everyone named on a face in the library (for completing a name as it is typed). - pub fn person_names(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { - let kept = self.person_names.take().filter(|(r, _)| still_right(cat, *r, READS_PEOPLE)).map(|(_, v)| v); - let v = kept.unwrap_or_else(|| std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::>())); - self.person_names = Some((cat.revision, v.clone())); - v - } - /// The By Date tree. - pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { - let kept = self.date_groups.take().filter(|(r, _)| still_right(cat, *r, READS_CAPTURED)).map(|(_, g)| g); - let g = kept.unwrap_or_else(|| std::sync::Arc::new(cat.date_groups())); - self.date_groups = Some((cat.revision, g.clone())); - g - } - /// Cameras, lenses and keywords in the library (filter bar pickers). - pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc { - match &self.filter_values { - Some((r, v)) if *r == cat.revision => v.clone(), - _ => { - let distinct = |mut v: Vec| { - v.retain(|s| !s.trim().is_empty()); - v.sort_by_key(|s| s.to_lowercase()); - v.dedup(); - v - }; - let lib = || cat.photos().filter(|p| p.in_library()); - let v = std::sync::Arc::new(FilterValues { - cameras: distinct(lib().map(|p| p.meta.camera.clone()).collect()), - lenses: distinct(lib().map(|p| p.meta.lens.clone()).collect()), - keywords: cat.keywords().into_iter().map(|(k, _)| k).collect(), - }); - self.filter_values = Some((cat.revision, v.clone())); - v - } - } - } - /// What the Add keyword box offers for `typed` (see [`panels::keywording::offers`]). Kept apart - /// from [`Self::suggestions`]: both are asked for on every frame of typing, and each looks - /// through the library's keywords. - pub(crate) fn completions( - &mut self, - cat: &lightcraft_catalog::Catalog, - current: &[String], - typed: &str, - ) -> std::sync::Arc { - let k = key_of((cat.revision, current, typed)); - match &self.completions { - Some((h, v)) if *h == k => v.clone(), - _ => { - let v = std::sync::Arc::new(panels::keywording::offers(cat, current, typed)); - self.completions = Some((k, v.clone())); - v - } - } - } - /// Keyword suggestions for a photo with `current` keywords and the typed `prefix`. - pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc> { - let k = key_of((cat.revision, current, prefix, n)); - match &self.suggestions { - Some((h, v)) if *h == k => v.clone(), - _ => { - let v = std::sync::Arc::new(cat.keyword_suggestions(current, prefix, n)); - self.suggestions = Some((k, v.clone())); - v - } - } - } - /// Every album's photo count for the sidebar (a smart album evaluates its rules, which - /// scans the catalog). Recomputed when the catalog changes (photos, metadata, album rules - /// all bump its revision) and — only while some smart album has an "in the last…" rule — - /// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock - /// within a minute without rescanning every frame. - /// [`RulesView`] for `rules` edited in smart album `editing` (`None`: a new album), within - /// `folder` when it was made from a folder view: worked out again only when the catalog, the - /// rules, the language or (for "in the last…" rules) the minute changes, not every frame. - pub fn rules_view( - &mut self, - cat: &lightcraft_catalog::Catalog, - rules: &lightcraft_catalog::RuleSet, - editing: Option, - folder: Option, - now: &str, - ) -> std::sync::Arc { - let relative = rules.depends_on_now(); - let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; - let k = key_of((cat.revision, serde_json::to_string(rules).unwrap_or_default(), editing, &folder, minute, i18n::language().code())); - if let Some((key, v)) = &self.rules_view - && *key == k - { - return v.clone(); - } - if relative { - lightcraft_catalog::rules::set_now(Some(now.to_string())); - } - let problems = rules.check_for(cat, editing.map(lightcraft_catalog::AlbumId)); - let filter = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() }; - let count = if problems.is_empty() { cat.query(&filter, &Default::default()).len() } else { 0 }; - let v = std::sync::Arc::new(RulesView { problems, count, albums: panels::dialogs::album_entries(cat, editing) }); - self.rules_view_scans += 1; - self.rules_view = Some((k, v.clone())); - v - } - - /// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs - /// again only when the catalog or `what` (what is counted) changes, not every frame. - pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize { - let k = key_of((cat.revision, what)); - if let Some((key, n)) = self.dialog_count - && key == k - { - return n; - } - let n = count(); - self.dialog_count_scans += 1; - self.dialog_count = Some((k, n)); - n - } - - /// The problems of every saved smart album that has some (`Catalog::smart_album_problems`), - /// for the sidebar's ⚠ marks: worked out again only when the catalog changes. - pub fn smart_album_problems( - &mut self, - cat: &lightcraft_catalog::Catalog, - ) -> std::sync::Arc>> { - if let Some((rev, v)) = &self.smart_problems - && *rev == cat.revision - { - return v.clone(); - } - let v: std::sync::Arc> = std::sync::Arc::new( - cat.albums().filter(|a| a.is_smart()).map(|a| (a.id, cat.smart_album_problems(a.id))).filter(|(_, p)| !p.is_empty()).collect(), - ); - self.smart_problem_scans += 1; - self.smart_problems = Some((cat.revision, v.clone())); - v - } - - pub fn album_counts( - &mut self, - cat: &lightcraft_catalog::Catalog, - now: &str, - ) -> std::sync::Arc> { - let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now())); - let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; - // the counts of this minute, if there are any: an edit to a photo or a few is followed from them, a smart - // album asking only those photos again; anything more, or a new minute, is counted from every photo - let kept = self.album_counts.take().filter(|(of, _, _)| of == minute); - if let Some((_, tally, shown)) = &kept - && tally.revision() == cat.revision - { - let shown = shown.clone(); - self.album_counts = kept; - return shown; - } - if relative { - // "in the last N days" counts back from the session clock, as in the grid. (Within a minute an edit asks - // only the photos it touched, at this second; the others were asked earlier in the minute. That is as - // far out as the counts already were between edits, and the next minute counts them all again.) - lightcraft_catalog::rules::set_now(Some(now.to_string())); - } - let tally = match kept { - Some((_, mut tally, _)) => { - if !tally.update(cat) { - self.album_count_scans += 1; - } - tally - } - None => { - self.album_count_scans += 1; - lightcraft_catalog::tally::AlbumTally::of(cat) - } - }; - let shown = std::sync::Arc::new(tally.counts().clone()); - self.album_counts = Some((minute.to_string(), tally, shown.clone())); - shown - } -} - -#[cfg(test)] -mod cache_tests { - use lightcraft_catalog::{Album, AlbumId, Catalog, Filter, Op, Photo, PhotoId, RuleSet, Source}; - - fn photo(id: u64, captured: &str, rating: u8) -> Op { - let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("p{id}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); - p.captured = Some(captured.into()); - p.rating = rating; - Op::AddPhoto { photo: Box::new(p) } - } - - fn smart(id: u64, rules: serde_json::Value) -> Op { - let mut a = Album::new(AlbumId(id), format!("smart {id}")); - let rules: RuleSet = serde_json::from_value(rules).unwrap(); - a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() })); - Op::AddAlbum { album: a } - } - - /// Smart-album counts: unchanged frames reuse them; metadata and rule changes and (for - /// "in the last…" rules) the clock crossing into a new minute recompute them. - #[test] - fn smart_album_counts_are_cached_until_something_changes() { - let mut cat = Catalog::default(); - let mut c = super::Caches::default(); - for (i, d) in ["2026-09-30T11:59:30", "2026-09-29T08:00:00", "2026-01-01T08:00:00"].iter().enumerate() { - cat.apply(photo(i as u64 + 1, d, if i == 0 { 5 } else { 1 })).unwrap(); - } - cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); - cat.apply(smart(11, serde_json::json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "hours"}}]}))).unwrap(); - let now = "2026-09-30T12:00:00"; - let n = c.album_counts(&cat, now); - assert_eq!((n[&AlbumId(10)], n[&AlbumId(11)]), (1, 1)); - assert_eq!(c.album_count_scans, 1); - for _ in 0..10 { - c.album_counts(&cat, "2026-09-30T12:00:40"); - } - assert_eq!(c.album_count_scans, 1, "unchanged frames in the same minute don't rescan"); - // a photo rated: followed from that photo, not counted from every one - cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); - assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2); - assert_eq!(c.album_count_scans, 1); - // rule change - let rs: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 5}]})).unwrap(); - cat.apply(Op::SetAlbumRules { id: AlbumId(10), rules: Box::new(Filter { rule_set: Some(rs), ..Default::default() }) }).unwrap(); - assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 1); - // an hour later the 11:59:30 photo has left "in the last hour", with no catalog change - assert_eq!(c.album_counts(&cat, "2026-09-30T13:00:10")[&AlbumId(11)], 0); - assert_eq!(c.album_count_scans, 3, "new rules and a new minute are counted from every photo; the rating was not"); - lightcraft_catalog::rules::set_now(None); - } - - /// The rule dialog's problems, live count and album list are worked out once per change to the - /// catalog or the rules, not every frame; so are the sidebar's smart-album problems. - #[test] - fn rule_dialog_and_problems_are_cached_until_something_changes() { - let mut cat = Catalog::default(); - let mut c = super::Caches::default(); - for i in 0..3u64 { - cat.apply(photo(i + 1, "2026-09-30T11:00:00", if i == 0 { 5 } else { 1 })).unwrap(); - } - let rules = - |v: u64| -> RuleSet { serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": v}]})).unwrap() }; - let now = "2026-09-30T12:00:00"; - let view = c.rules_view(&cat, &rules(3), None, None, now); - assert_eq!((view.count, view.problems.len()), (1, 0)); - for _ in 0..10 { - c.rules_view(&cat, &rules(3), None, None, now); - } - assert_eq!(c.rules_view_scans, 1, "unchanged frames reuse it"); - assert_eq!(c.rules_view(&cat, &rules(1), None, None, now).count, 3, "a rule change"); - assert_eq!(c.rules_view(&cat, &rules(9), None, None, now).problems.len(), 1); - cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); - assert_eq!(c.rules_view(&cat, &rules(3), None, None, now).count, 2, "a catalog change"); - assert_eq!(c.rules_view_scans, 4); - // the sidebar's problems - cat.apply(Op::AddAlbum { album: lightcraft_catalog::Album::new(AlbumId(20), "Trip") }).unwrap(); - cat.apply(smart(21, serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 20}]}))).unwrap(); - assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_none_or(Vec::is_empty)); - for _ in 0..10 { - c.smart_album_problems(&cat); - } - assert_eq!(c.smart_problem_scans, 1); - cat.apply(Op::RemoveAlbum { id: AlbumId(20) }).unwrap(); - assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_some_and(|p| !p.is_empty()), "its album is gone"); - assert_eq!(c.smart_problem_scans, 2); - } - - /// Without "in the last…" rules the clock never causes a rescan. - #[test] - fn absolute_rules_ignore_the_clock() { - let mut cat = Catalog::default(); - let mut c = super::Caches::default(); - cat.apply(photo(1, "2026-09-30T11:59:30", 5)).unwrap(); - cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); - c.album_counts(&cat, "2026-09-30T12:00:00"); - c.album_counts(&cat, "2027-01-01T00:00:00"); - assert_eq!(c.album_count_scans, 1); - } -} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs new file mode 100644 index 000000000..ccce723fb --- /dev/null +++ b/crates/ui-egui/src/services.rs @@ -0,0 +1,130 @@ +use super::*; + +pub type PickFiles = Box Vec>; + +/// A save dialog: suggested file name → chosen path (`None` = cancelled). +pub type SaveFile = Box Option>; + +pub type WriteFn = Box Result<(), String>>; + +/// A writer other threads can use (background export). +pub type SharedWrite = std::sync::Arc Result<(), String> + Send + Sync>; + +pub type PngEncode = Box Vec>; + +/// A folder chooser (`None` = cancelled). +pub type PickFolder = Box Option>; + +/// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux). +pub type RevealFn = Box Result<(), String>>; + +/// Open a URL in the user's browser. +pub type OpenUrlFn = Box Result<(), String>>; + +/// Open a file in an application (`app` = "" for the system's default one). +pub type OpenWithFn = Box Result<(), String>>; + +/// Open several files with one start of an application (`app` as for [`OpenWithFn`]). +pub type OpenAllWithFn = Box Result<(), String>>; + +/// Linux model-folder work: wait for the default opener on a background task. +#[cfg(target_os = "linux")] +pub type OpenModelFolderFn = std::sync::Arc Result<(), String> + Send + Sync>; + +/// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: +/// not known). +pub type ScreenProfileFn = Box Option>>; + +/// Whether an external editor can be started (its name as in [`Services::open_with`]); the +/// error says why not and what to do (e.g. PhotoCraft isn't installed). +pub type CheckEditorFn = Box Result<(), String>>; + +/// Something the host does with the session (web: back up or restore the library in browser +/// storage); the work may finish asynchronously. +pub type HostAction = Box Result>; + +/// The system's dark/light appearance where egui doesn't report one (Linux desktops whose +/// windowing layer says nothing, e.g. many Wayland compositors). The host answers from what it +/// last heard and wakes the UI itself when that changes; it is never polled for changes. +pub type SystemThemeFn = Box Option>; + +/// Platform services injected by the host app (desktop or web). +#[derive(Default)] +pub struct Services { + /// Native file dialogs for commands, shown off the UI thread and answered later + /// (`pick`; #191). With it, the synchronous pickers below serve only the buttons inside + /// dialogs; without it (the web, tests) they answer the commands too. + pub picker: Option, + /// Show an open dialog for photos; returns paths. + pub pick_files: Option, + /// Local ONNX model; a manifest must accompany it. + pub pick_denoise_model: Option, + /// Open dialog for a face model (`.onnx`; Settings ▸ Faces ▸ Add a model file…). + pub pick_model_file: Option, + /// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`). + pub pick_preset_files: Option, + /// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…). + pub pick_tracklog: Option, + /// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only). + pub pick_display_profile: Option, + /// The profile of the screen showing the window, used while none is chosen in Settings ▸ + /// Display (macOS, whose windows aren't colour-managed: issue #787). + pub screen_profile: Option, + /// Save dialog for an exported `.lcpreset` file. + pub save_preset_file: Option, + /// Open dialog for a keyword list file (`.txt`, Photo Supreme's `.utf8`; File ▸ Import Keywords…). + pub pick_keyword_list: Option, + /// Save dialog for an exported keyword list (File ▸ Export Keywords…). + pub save_keyword_list: Option, + /// Open dialog for point-curve preset files (`.lccurve`). + pub pick_curve_preset_files: Option, + /// Save dialog for an exported `.lccurve` file. + pub save_curve_preset_file: Option, + /// Open dialog for slideshow template files (`.json`; Settings ▸ Slideshow ▸ Templates ▸ Import…). + pub pick_slideshow_templates: Option, + /// Save dialog for an exported slideshow template (`.json`). + pub save_slideshow_template: Option, + /// Open dialog for print template files (`.lcprint`; File ▸ Print… ▸ Templates ▸ Import…). + pub pick_print_templates: Option, + /// Save dialog for an exported print template (`.lcprint`). + pub save_print_template: Option, + pub write: Option, + /// Create-new writer (never replaces an existing path), for screenshots of destinations that + /// did not exist when the request was admitted. Required for such captures: falling back to + /// [`Self::write`] would reopen the admission-to-write race. + pub write_new: Option, + /// Thread-safe writer: with it, UI-started exports run in the background (desktop only). + pub write_shared: Option, + /// PNG encoder (the host links an image encoder; the UI crate stays codec-free). + pub png: Option, + /// Show a file in the system file manager (desktop only). + pub reveal: Option, + /// The host's current log file (`/logs/lightcraft.log`), which Help ▸ Open Log + /// Folder reveals (#260). None where no log is kept: the web, `--memory`, `LIGHTCRAFT_NO_PREFS`. + pub log_file: Option, + /// Choose a folder (Settings → General → Open Library…; desktop only). + pub pick_folder: Option, + /// Open a Lightroom Classic `.lrcat` catalog for read-only import. + pub pick_lightroom_catalog: Option, + /// Open a web link in the browser (Help menu, About, Discord button). + pub open_url: Option, + /// Open a file in an external editor (Edit in External Editor; desktop only). + pub open_with: Option, + /// Open several files with one start of the editor (Open Original; desktop only): an editor + /// that keeps one window takes them all. None = [`Services::open_with`] for each. + pub open_all_with: Option, + /// Optional worker-safe model-folder opener alongside `open_with` (Linux desktop only). + #[cfg(target_os = "linux")] + pub open_model_folder: Option, + /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = + /// assume it can). + pub check_editor: Option, + /// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the + /// user keeps (web only: there the library lives in browser storage, which the browser may + /// clear; on the desktop it is a folder backed up like any other). + pub backup_library: Option, + /// File ▸ Restore Library from Backup… (web only; keeps the current library). + pub restore_library: Option, + /// The system appearance for Appearance Mode ▸ Auto, ahead of egui's own report. + pub system_theme: Option, +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a84e926c3..38b50902c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -592,3 +592,44 @@ fn lenient_ids<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D: let value = serde_json::Value::deserialize(d)?; Ok(value.as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect()) } + +/// The navigation/document/modal/panel state a gesture was admitted under. The same automation +/// id can mean a different control after any of these changes; the request is canceled rather +/// than retargeted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct GestureFrame { + pub(crate) view: crate::state::ViewMode, + pub(crate) active: Option, + pub(crate) library: u64, + pub(crate) dialog: Option>, + pub(crate) left_panel: bool, + pub(crate) right: crate::state::RightPanel, + pub(crate) presets: bool, + pub(crate) filmstrip: bool, +} + +/// A pointer press delivered to the UI, kept so a canceled gesture knows that a button is down. +#[derive(Clone, Copy, Debug)] +pub(crate) struct InjectedPress { + pub(crate) button: egui::PointerButton, +} + +/// A file `ui.dropFiles` delivers as dropped, as a real OS drop arrives in `raw.dropped_files`. +#[derive(Debug)] +pub(crate) struct InjectedDrop(pub(crate) std::path::PathBuf); + +impl egui::DroppedFile for InjectedDrop { + fn path(&self) -> &std::path::Path { + &self.0 + } + + #[cfg(not(target_arch = "wasm32"))] + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } + + #[cfg(target_arch = "wasm32")] + fn bytes_async(&self) -> std::pin::Pin, String>> + '_>> { + Box::pin(async { Err("the web build has no files to read".to_string()) }) + } +} From 26760e44d2d597f5a90fdff81e4fb049667c799a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:14:37 +0000 Subject: [PATCH 129/191] refactor(lightcraft-ui-egui): split app.rs into app/ --- crates/ui-egui/src/app.rs | 1578 +------------------------- crates/ui-egui/src/app/control.rs | 399 +++++++ crates/ui-egui/src/app/frame.rs | 448 ++++++++ crates/ui-egui/src/app/input.rs | 261 +++++ crates/ui-egui/src/app/prefs.rs | 114 ++ crates/ui-egui/src/app/screenshot.rs | 362 ++++++ 6 files changed, 1595 insertions(+), 1567 deletions(-) create mode 100644 crates/ui-egui/src/app/control.rs create mode 100644 crates/ui-egui/src/app/frame.rs create mode 100644 crates/ui-egui/src/app/input.rs create mode 100644 crates/ui-egui/src/app/prefs.rs create mode 100644 crates/ui-egui/src/app/screenshot.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 413927fd1..30a672c52 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -3,6 +3,17 @@ use super::services::Services; use super::state::{GestureFrame, InjectedDrop, InjectedPress}; use super::*; +mod control; +mod frame; +mod input; +mod prefs; +mod screenshot; + +#[cfg(test)] +pub(crate) use frame::import_tick_allowed; +#[cfg(test)] +pub(crate) use screenshot::shot_ready; + impl LightcraftApp { /// Build the app with default UI, renderer, cache and control state, and move AI segmentation /// to a worker so frames apply results instead of waiting. @@ -142,99 +153,11 @@ impl LightcraftApp { self.clock.now() * 1000.0 } - /// The immutable identity a capture is bound to: the library generation. It changes when a - /// different library is opened, not on every edit, import batch or keyword op (`catalog.revision` - /// bumps on all of those), so a screenshot during interactive editing still belongs to the - /// document the user asked for. - pub(crate) fn shot_document_generation(&self) -> u64 { - self.session.library_generation() - } - /// Give a headless driver its simulated time (`None` returns to the monotonic host clock). pub fn set_simulated_clock(&mut self, seconds: Option) { self.clock.simulated = seconds.filter(|s| s.is_finite() && *s >= 0.0); } - /// The window's current surface geometry as native points plus native pixels per point. - /// `run_logic` refreshes `input.viewports`, so this is fresh even while the UI is not laid out; - /// without one, the last laid-out content rect is used. - fn shot_geometry(&self, ctx: &egui::Context) -> ShotGeometry { - // Both `viewport.inner_rect` and the content rect are in egui points, and `pixels_per_point` - // already includes the UI zoom (egui-winit divides by the zoomed ppp). Multiplying by the - // zoom again would reserve a surface that is too small, so the zoom is only tracked as a - // change signal. - let zoom = ctx.zoom_factor(); - let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; - let pixels_per_point = ctx.input(|i| i.pixels_per_point()); - let viewport = ctx.input(|i| { - i.viewport().inner_rect.filter(|r| r.width() > 0.0 && r.height() > 0.0).map_or_else(|| i.content_rect().size(), |inner| inner.size()) - }); - ShotGeometry { viewport, pixels_per_point, zoom, epoch: self.shot_geometry_epoch } - } - - /// Track geometry changes as a generation. Called once per logic tick from the refreshed - /// viewport information, so a hidden window still notices a resize. - fn note_shot_geometry(&mut self, ctx: &egui::Context) { - let mut now = self.shot_geometry(ctx); - if let Some(last) = self.last_shot_geometry - && (last.viewport != now.viewport || last.pixels_per_point != now.pixels_per_point || last.zoom != now.zoom) - { - self.shot_geometry_epoch = self.shot_geometry_epoch.wrapping_add(1); - now.epoch = self.shot_geometry_epoch; - } - self.last_shot_geometry = Some(now); - } - - /// The texture limit the host reported (16 384 unless it said otherwise). - fn max_texture_side(&self) -> usize { - self.max_texture_side - } - - /// Retain the host's `RawInput::max_texture_side`. Hosts (egui-winit, `Headless`) report it - /// only when it changes, so keep the last value instead of assuming 16 384. - fn note_max_texture_side(&mut self, ctx: &egui::Context) { - if let Some(limit) = ctx.input(|i| i.raw.max_texture_side) - && limit > 0 - { - self.max_texture_side = limit; - } - } - - /// Reserve a new image's `bytes` against the aggregate screenshot budget, or report why not. - fn reserve_shot_bytes(&mut self, bytes: usize) -> Result<(), String> { - if bytes > MAX_SHOT_BYTES { - return Err(format!("ui.screenshot: {bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit")); - } - let total = self.shot_bytes.checked_add(bytes).ok_or("ui.screenshot: byte reservation overflow")?; - if total > MAX_SHOT_TOTAL_BYTES { - return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit")); - } - self.shot_bytes = total; - Ok(()) - } - - /// A surface that grew after admission (a stale `content_rect` on Wayland, a resize between - /// issue and completion): bound the *new image* against the per-image limit, then reserve only - /// the growth against the aggregate. The old per-image check would compare the delta to the - /// per-image cap and name the delta as if it were the image. - fn grow_shot_reservation(&mut self, current_reserved: usize, image_bytes: usize) -> Result<(), String> { - if image_bytes > MAX_SHOT_BYTES { - return Err(format!("ui.screenshot: {image_bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit")); - } - let extra = image_bytes.saturating_sub(current_reserved); - let total = self.shot_bytes.checked_add(extra).ok_or("ui.screenshot: byte reservation overflow")?; - if total > MAX_SHOT_TOTAL_BYTES { - return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit")); - } - self.shot_bytes = total; - Ok(()) - } - - /// Release a reservation; never let an accounting bug underflow `usize`. - fn release_shot_bytes(&mut self, bytes: usize) { - self.shot_bytes = self.shot_bytes.saturating_sub(bytes); - } - /// The open person page, the way back to it, and the photo it was opened from follow a rename. fn follow_renamed_person(&mut self, from: &str, to: &str) { let same = |n: &str| n.trim().to_lowercase() == from.trim().to_lowercase(); @@ -596,678 +519,6 @@ impl LightcraftApp { self.ui.toast = Some((text.into(), t + 6.0, None)); } - /// Take the control receiver for this frame, run every queued request whose sender still - /// wants it, and reply directly or admit its screenshot/gesture to a later completed pass. - /// Requests abandoned by their sender are skipped rather than executed late. - fn drain_control(&mut self, ctx: &egui::Context) { - let Some(rx) = self.control_rx.take() else { return }; - while let Ok(req) = rx.try_recv() { - // a request its sender gave up on (the window drew nothing for a while) is not run - // late: it would happen twice once the sender retries (issue #718) - if !req.take() { - continue; - } - let reply = req.reply.clone(); - match control::handle(self, ctx, &req) { - control::Outcome::Done(v) => { - let _ = reply.send(v); - } - control::Outcome::Screenshot { target, headless } => { - if let Err(error) = self.admit_shot(ctx, target, headless, reply.clone()) { - let _ = reply.send(serde_json::json!({"ok": false, "error": error})); - } - } - control::Outcome::Gesture { method, params } => { - if let Err(error) = self.admit_gesture(ctx, method, params, reply.clone()) { - let _ = reply.send(serde_json::json!({"ok": false, "error": error})); - } - } - } - } - self.control_rx = Some(rx); - } - - /// The navigation/document/modal/panel state that decides what an automation id means. - fn gesture_frame(&self) -> GestureFrame { - GestureFrame { - view: self.ui.view, - active: self.session.active(), - library: self.session.library_generation(), - dialog: self.ui.dialog.as_ref().map(std::mem::discriminant), - left_panel: self.ui.left_panel, - right: self.ui.right, - presets: self.ui.presets, - filmstrip: self.ui.filmstrip, - } - } - - /// Queue a synthetic-input request. Nothing is answered and no event is queued until a - /// completed layout newer than this admission exists (see [`Self::resolve_pending_gestures`]). - pub(crate) fn admit_gesture(&mut self, ctx: &egui::Context, method: String, params: Value, reply: Sender) -> Result<(), String> { - if self.pending_gestures.len() >= MAX_PENDING_GESTURES { - return Err(format!("{}: {MAX_PENDING_GESTURES} control gestures are already pending", method)); - } - let now = self.tick_now_ms(); - self.pending_gestures.push(PendingGesture { - method, - params, - reply, - admitted_epoch: self.layout_epoch, - frame: self.gesture_frame(), - deadline_ms: now + GESTURE_TIMEOUT_MS, - resolved_pass: None, - remaining_events: 0, - started: false, - target: None, - pressed: None, - result: Value::Null, - }); - ctx.request_repaint(); - Ok(()) - } - - /// Resolve one queued gesture against the fresh widgets/image rect of the pass that just - /// completed. A changed view/document/dialog/panel state cancels instead of retargeting. - pub(crate) fn resolve_pending_gestures(&mut self, ctx: &egui::Context) { - if self.pending_gestures.is_empty() { - return; - } - let now = self.tick_now_ms(); - let frame = self.gesture_frame(); - let epoch = self.layout_epoch; - let mut gestures = std::mem::take(&mut self.pending_gestures); - let mut keep = Vec::with_capacity(gestures.len()); - for mut gesture in gestures.drain(..) { - if gesture.resolved_pass.is_some() { - // Already handed to the delivery queue; `reply_delivered_gestures` (or - // `expire_gestures`) owns its deadline and must remove its unsent events too. - keep.push(gesture); - continue; - } - if now > gesture.deadline_ms { - // Only this request failed to get a fresh layout. A later gesture has its own - // deadline and must not be canceled with it. - let _ = - gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)})); - continue; - } - if epoch <= gesture.admitted_epoch { - keep.push(gesture); - continue; - } - if gesture.frame != frame { - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: the view, document, dialog or panel layout changed before a fresh layout", gesture.method), - })); - continue; - } - let before = self.synthetic.len(); - match control::gesture_events(self, ctx, &gesture.method, &gesture.params) { - Ok(outcome) => { - gesture.target = outcome.target; - gesture.result = outcome.result; - gesture.resolved_pass = Some(self.ui_passes); - gesture.remaining_events = self.synthetic.len().saturating_sub(before); - gesture.started = gesture.remaining_events == 0; - keep.push(gesture); - } - Err(error) => { - let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": error})); - } - } - } - self.pending_gestures = keep; - } - - /// Answer gestures whose last synthetic event a completed, nondiscarded UI pass consumed. - /// A zero-event action waits for one completed pass after resolution. - /// - /// Delivery does not compare the frame again: the gesture's own event may legitimately change - /// the view, document, dialog or panel (a click that opens a dialog, a key that starts Compare), - /// so a second snapshot would cancel exactly the input that did its job. Freshness is checked - /// before resolution ([`Self::resolve_pending_gestures`]) and again immediately before the - /// batch is injected ([`Self::drop_gestures_stale_before_injection`]). - pub(crate) fn reply_delivered_gestures(&mut self) { - if self.pending_gestures.is_empty() { - return; - } - let now = self.tick_now_ms(); - // Cancel only the gestures that actually stalled: their unsent events are at the front of - // the queue, and gestures queued behind them are independent input that must survive. - let mut i = 0; - while i < self.pending_gestures.len() { - if self.pending_gestures[i].resolved_pass.is_none() || now <= self.pending_gestures[i].deadline_ms { - i += 1; - continue; - } - let at_head = self.head_gesture() == Some(i); - let gesture = self.pending_gestures.remove(i); - self.cancel_gesture_events(&gesture, at_head); - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} timed out before the UI consumed its last event", gesture.method), - })); - } - if !self.synthetic.is_empty() || self.synthetic_in_flight { - return; - } - let mut gestures = std::mem::take(&mut self.pending_gestures); - let mut keep = Vec::with_capacity(gestures.len()); - for mut gesture in gestures.drain(..) { - let Some(resolved) = gesture.resolved_pass else { - keep.push(gesture); - continue; - }; - if self.ui_passes <= resolved { - keep.push(gesture); - continue; - } - if gesture.remaining_events > 0 { - // A defensive queue reset (a non-head gesture owned unsent events) dropped this - // batch. It must not become an `ok: true` for input no panel ever saw. - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: its queued input was dropped before delivery", gesture.method), - })); - continue; - } - let result = std::mem::take(&mut gesture.result); - let _ = gesture.reply.send(serde_json::json!({"ok": true, "result": result})); - } - self.pending_gestures = keep; - } - - /// The gesture whose events sit at the head of the synthetic queue. - fn head_gesture(&self) -> Option { - self.pending_gestures.iter().position(|gesture| gesture.resolved_pass.is_some() && gesture.remaining_events > 0) - } - - /// Fail resolved gestures whose view/document/dialog/panel snapshot changed before their - /// first synthetic batch reached the next completed pass. A hidden logic tick runs between - /// passes and may change what an automation id or a photo click means; fail closed instead of - /// delivering it against the new state. - /// - /// A widget gesture also has to keep aiming at the requested widget. egui attributes a click - /// to the widget under the press point in the *previous completed pass*, while the control - /// batch spans several host frames (a move, then press, then release). Recheck the target - /// before the first event and before every press; if it moved or disappeared, cancel this - /// gesture with an error instead of clicking whatever is now at those coordinates. - /// - /// Only the gesture at the head of the queue is checked, and later batches of the same gesture - /// may see its own partial effect (the first click of a double click selects the photo the - /// second one opens). A second, concurrently queued gesture is checked when its events reach - /// the head. - fn drop_gestures_stale_before_injection(&mut self, ctx: &egui::Context) -> bool { - let Some(head) = self.head_gesture() else { return false }; - let first_batch = !self.pending_gestures[head].started; - if first_batch && self.pending_gestures[head].frame != self.gesture_frame() { - let gestures = std::mem::take(&mut self.pending_gestures); - for gesture in gestures { - if gesture.resolved_pass.is_none() { - self.pending_gestures.push(gesture); - } else { - if gesture.pressed.is_some() { - self.queue_pointer_cleanup(); - } - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: the view, document, dialog or panel layout changed before delivery", gesture.method), - })); - } - } - self.synthetic.clear(); - return true; - } - let Some(target) = self.pending_gestures[head].target.clone() else { return false }; - let press_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: true, .. })); - let release_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: false, .. })); - // A drag legitimately ends outside the widget it started on; only a click must still find - // its target at the release that egui attributes the click to. A hover must still find a - // visible widget for its single pointer move. - let check_release = release_next && target.check_release; - let check_visible = first_batch && target.check_visible; - if !first_batch && !press_next && !check_release && !check_visible { - return false; - } - let latest = control::widget_rect(self, &target.id); - let latest_hit = control::widget_hit(self, &target.id); - // A press must go to the widget that owns the point. A click's release must too (a drag - // may legitimately end outside the widget it started on), so an overlay that appears - // between press and release cancels the click before the release rather than activating it. - // A hover's single move must also still find the widget visible: the documented contract - // rejects a clipped or newly covered target instead of moving the pointer at stale coordinates. - let must_own = press_next || (release_next && target.check_release) || check_visible; - // A quit prompt or settings notice owns input even before its Foreground areas have - // settled (egui's first layout pass is non-interactable), and even when a control request - // is resolved against the registry of that first pass. Only the prompt's own buttons pass. - let notice_ok = !must_own || crate::panels::notices::allows_target(self, latest_hit); - let still_aimed = latest.is_some_and(|rect| { - // A changed widget cancels any gesture before its first event. Owned presses, click - // releases and hovers must also be inside egui's clipped, visible part and be the - // topmost widget hit: a target scrolled out of its panel, or one under a non-modal - // overlay, must not be pressed or hovered at stale coordinates. - let geometry_ok = rect == target.rect || rect.contains(target.at); - geometry_ok && notice_ok && (!must_own || control::widget_target_visible(ctx, latest_hit, target.at)) - }); - if still_aimed { - return false; - } - let outside_registered = !target.rect.contains(target.at); - let reason = match latest { - None => "disappeared", - Some(rect) if !(rect == target.rect || rect.contains(target.at)) => "moved", - Some(_) if outside_registered => "has no visible interaction area at the requested point", - Some(_) => "is no longer visible", - }; - let phase = if first_batch { - "its first event" - } else if check_release { - "its release" - } else { - "its press" - }; - // Only this gesture's unsent events are removed: they are at the front of the queue, and - // gestures resolved after it are checked in turn when they reach the head. Failing the - // whole click would also discard unrelated input. - let gesture = self.pending_gestures.remove(head); - self.cancel_gesture_events(&gesture, true); - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: widget `{}` {reason} before {phase}; nothing was retargeted", gesture.method, target.id), - })); - true - } - - /// Remove a canceled gesture's unsent events from the front of the queue and release a pointer - /// button it left down. Callers cancel in queue order, so the events removed are exactly this - /// gesture's unsent prefix; a later gesture's events stay queued. - fn cancel_gesture_events(&mut self, gesture: &PendingGesture, at_head: bool) { - if at_head { - let unsent = gesture.remaining_events.min(self.synthetic.len()); - self.synthetic.drain(..unsent); - } else if gesture.remaining_events > 0 { - // The queue order makes only the head own unsent events. If a caller ever breaks that - // invariant, fail safe: stale synthetic input must not reach an unrelated widget, even - // at the cost of dropping the rest of this batch instead of panicking or guessing a - // prefix. `reply_delivered_gestures` fails any gesture whose events disappeared this - // way instead of reporting a false success. - log::warn!("canceled non-head gesture still owned {} unsent synthetic events; dropping the queue", gesture.remaining_events); - self.synthetic.clear(); - self.synthetic_in_flight = false; - } - if gesture.pressed.is_some() { - self.queue_pointer_cleanup(); - } - } - - /// Cancel every synthetic gesture that a newly shown prompt or blocking library window makes - /// unsafe. - /// - /// A gesture whose input is still queued must not be delivered under the barrier or later when - /// it is gone. A gesture whose press already reached egui must be released without a click. A - /// gesture that already delivered all its events may still complete its reply: the barrier may - /// well be its own result (e.g. a key that opened the quit prompt). `all` is for a batch already - /// injected this host frame before the barrier was discovered after `raw_input_hook`: the - /// barrier filter drops it before any panel sees it, so even a fully injected gesture must fail - /// rather than report a successful delivery. - pub(crate) fn cancel_gestures_for_barrier(&mut self, all: bool, reason: &str) { - // All not-yet-injected events belong to the queue's head, one gesture at a time. Cancel - // them in order so only that gesture's unsent prefix is removed. - while let Some(head) = self.head_gesture() { - let gesture = self.pending_gestures.remove(head); - self.cancel_gesture_events(&gesture, true); - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: {reason} before its input was delivered", gesture.method), - })); - } - let mut i = 0; - while i < self.pending_gestures.len() { - if all || self.pending_gestures[i].resolved_pass.is_none() || self.pending_gestures[i].pressed.is_some() { - let gesture = self.pending_gestures.remove(i); - self.cancel_gesture_events(&gesture, false); - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: {reason} before its input was delivered", gesture.method), - })); - } else { - i += 1; - } - } - } - - /// Cancel a press that reached egui without making it a click or starting a drag. The reset - /// itself happens before egui's next input pass in [`Self::raw_input_hook`], where replacing - /// the pointer state and stopping the drag is safe (the same approach as the sibling - /// SoundCraft/VectorCraft apps). No pointer movement is injected: moving beyond - /// `max_click_dist` suppresses a click but can also start a drag on a slider or curve. - fn queue_pointer_cleanup(&mut self) { - self.pointer_cleanup = true; - } - - /// Apply a queued pointer cancellation before egui processes the next input pass. - fn apply_pointer_cleanup(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - if !std::mem::take(&mut self.pointer_cleanup) { - return; - } - ctx.input_mut(|input| input.pointer = egui::PointerState::default()); - ctx.stop_dragging(); - if self.synthetic_mods != egui::Modifiers::NONE { - // The cleanup removes only the app's synthetic modifiers, never a real host change. - // A host `ModifiersChanged` in this same raw batch is already authoritative and was - // recorded before this call; otherwise restore the host's last known state (which may - // be a physically held Shift), rather than clearing it unconditionally with NONE. - let host_changed = raw.events.iter().any(|event| matches!(event, egui::Event::ModifiersChanged(_))); - self.synthetic_mods = egui::Modifiers::NONE; - if !host_changed { - raw.events.push(egui::Event::ModifiersChanged(self.host_mods)); - } - } - self.synthetic_mods_release = false; - } - - /// Fail pending gestures at the monotonic deadline even when no UI pass runs (a hidden window - /// whose paint request the host ignored). `logic` calls this every tick. - fn expire_gestures(&mut self) { - if self.pending_gestures.is_empty() { - return; - } - let now = self.tick_now_ms(); - if !self.pending_gestures.iter().any(|gesture| now > gesture.deadline_ms) { - return; - } - // Cancel exactly the stalled requests, in queue order, so a later gesture's input survives. - let mut i = 0; - while i < self.pending_gestures.len() { - if now <= self.pending_gestures[i].deadline_ms { - i += 1; - continue; - } - let at_head = self.head_gesture() == Some(i); - let gesture = self.pending_gestures.remove(i); - self.cancel_gesture_events(&gesture, at_head); - let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)})); - } - } - - /// Admit a `ui.screenshot` request against the bounded capture budget. Nothing is written and - /// no capture is issued here: [`Self::issue_screenshots`] waits for a settled, nondiscarded UI - /// pass and a fresh geometry generation. - pub(crate) fn admit_shot( - &mut self, - ctx: &egui::Context, - target: Option, - headless: bool, - reply: Sender, - ) -> Result<(), String> { - if self.pending_screenshots.len() >= MAX_PENDING_SHOTS { - return Err(format!("ui.screenshot: {MAX_PENDING_SHOTS} requests are already pending; wait for a reply first")); - } - let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx)); - let bytes = geometry.bytes(self.max_texture_side()).ok_or_else(|| geometry.rejection(self.max_texture_side()))?; - self.reserve_shot_bytes(bytes)?; - self.screenshot_token = self.screenshot_token.wrapping_add(1); - let now = self.tick_now_ms(); - self.pending_screenshots.push(PendingShot { - token: self.screenshot_token, - target, - reply, - headless: headless && !self.headless_host, - admitted_passes: self.ui_passes, - not_before_ms: now + SHOT_SETTLE_MS, - // Capture the current settled frame at this instant even if renders are still in flight - // (0.36 did this at its three-second deadline, so automation never got an error instead - // of an image while a slider/import kept the renderer busy). - force_ms: now + SHOT_FORCE_BUSY_MS, - // Settle + forced capture + compositor fallback + the headless settle, all from admission. - deadline_ms: now + SHOT_SETTLE_MS + SCREENSHOT_SETTLE_MS + SCREENSHOT_FALLBACK_MS + SCREENSHOT_SETTLE_MS, - sent_at_ms: None, - reserved: bytes, - slot: None, - issued: None, - issued_library: self.shot_document_generation(), - }); - ctx.request_repaint(); - Ok(()) - } - - /// Advance pending screenshots: wait (≥ 3 completed UI frames, ≥ 120 ms) until no renders are - /// in flight — or until the forced-capture instant, so sustained rendering cannot turn a - /// request into a timeout — then capture through the compositor's one-shot callback, or - /// headlessly ([`Self::headless_screenshot`]) when asked or when the compositor delivers nothing. - pub(crate) fn issue_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_screenshots.is_empty() { - return; - } - let now = self.tick_now_ms(); - let busy = self.renderer.in_flight() > 0 || self.merge.busy(); - let max_side = self.max_texture_side(); - let library = self.shot_document_generation(); - let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx)); - let mut shots = std::mem::take(&mut self.pending_screenshots); - let mut shadow_ticked = false; - let mut keep = Vec::with_capacity(shots.len()); - for mut shot in shots.drain(..) { - // Hard deadline from admission: a compositor fallback does not extend it. - if now > shot.deadline_ms { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)})); - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - self.release_shot_bytes(shot.reserved); - continue; - } - if let Some(sent) = shot.sent_at_ms { - if now - sent < SCREENSHOT_FALLBACK_MS || self.headless_host { - keep.push(shot); - continue; - } - // The compositor delivered nothing (display asleep, window occluded): go headless. - log::warn!("ui.screenshot: no frame from the compositor after {SCREENSHOT_FALLBACK_MS} ms; rendering headlessly"); - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - shot.sent_at_ms = None; - shot.issued = None; - shot.headless = true; - } - // A busy renderer only defers the capture until the forced instant; after that the - // settled frame that exists now is captured (0.36's deadline behavior), never an error. - let ready = shot_ready(now, &shot, busy, self.ui_passes); - if !ready { - keep.push(shot); - continue; - } - if shot.headless { - // The shadow frame requests the renders the UI needs, even while the window shows nothing. - let img = if shadow_ticked { None } else { self.headless_screenshot(ctx, true) }; - shadow_ticked = true; - if let Some(img) = img { - let bytes = img.size[0].checked_mul(img.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); - match bytes { - Some(bytes) if bytes > shot.reserved => { - // A stale/undersized reservation is not a user error: grow it to the - // actual image and encode it when the per-image and aggregate caps allow. - if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); - self.release_shot_bytes(shot.reserved); - continue; - } - shot.reserved = bytes; - } - Some(_) => {} - None => { - let reply = serde_json::json!({ - "ok": false, - "error": "ui.screenshot: the rendered image has invalid dimensions", - }); - let _ = shot.reply.send(reply); - self.release_shot_bytes(shot.reserved); - continue; - } - } - let _ = shot.reply.send(control::save_screenshot(self, &img, shot.target.as_ref())); - self.release_shot_bytes(shot.reserved); - continue; - } - keep.push(shot); - continue; - } - // The compositor path needs a real, laid-out UI and fresh fonts; a queued request on a - // hidden window is kept alive by RequestPaintWhileHidden from `logic`. - if !self.fonts_ready || self.ui_passes == 0 { - keep.push(shot); - continue; - } - // Revalidate the reservation against the geometry the command is actually bound to. - let Some(bytes) = geometry.bytes(max_side) else { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": geometry.rejection(max_side)})); - self.release_shot_bytes(shot.reserved); - continue; - }; - if bytes > shot.reserved { - if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); - self.release_shot_bytes(shot.reserved); - continue; - } - shot.reserved = bytes; - } - // One slot and one callback per issue; the callback only holds a weak slot and wakes - // the viewport. File writes and replies happen in `collect_screenshots`. - let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_SHOT_BYTES); - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); - log::debug!("ui.screenshot {}: issued at {:?}, {} bytes reserved", shot.token, geometry.viewport, shot.reserved); - shot.slot = Some(slot); - shot.sent_at_ms = Some(now); - shot.issued = Some(geometry); - shot.issued_library = library; - keep.push(shot); - } - self.pending_screenshots = keep; - if !self.pending_screenshots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - } - - /// The system's appearance: the host's answer first, then egui's (none: unknown). - pub fn system_theme(&self, ctx: &egui::Context) -> Option { - self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) - } - - /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light - /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. - pub fn sync_theme(&mut self, ctx: &egui::Context) { - let wanted = self.ui.settings.theme(self.system_theme(ctx)); - if theme::current(ctx) != Some(wanted) { - theme::apply_kind(ctx, wanted); - } - } - - /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it - /// on the CPU (photo textures come from the renderer's CPU copies). - pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { - let mut view = self.shadow.take().unwrap_or_default(); - let size = main.input(|i| i.content_rect()).size(); - let size = if size.x >= 1.0 && size.y >= 1.0 { size } else { egui::vec2(1600.0, 1000.0) }; - // Feed native viewport points; the shadow context applies the copied UI zoom. - let zoom = main.zoom_factor(); - let size = size * zoom; - let ppp = main.pixels_per_point() / zoom; - let time = main.input(|i| i.time); - if view.frames() == 0 { - // warm-up pass: activates our fonts (pending font definitions live in `Memory`, which - // is replaced below) - view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |_| {}); - } - // same scroll offsets, open sections, style… as the window - let memory = main.memory(|m| m.clone()); - view.ctx.memory_mut(|m| *m = memory); - view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |ui| self.draw_window(ui)); - let img = capture.then(|| { - let mut tex = self.renderer.cpu_textures(); - if let (Some((t, ..)), Some(px)) = (&self.merge.preview, &self.merge.preview_pixels) { - tex.insert(t.id(), crate::softpaint::CpuTexture::linear(px.clone())); - } - view.paint(&tex) - }); - self.shadow = Some(view); - img - } - - /// Take each completed capture once, reject stale generations before writing anything, and - /// reply exactly once. A late callback after cancel/drop finds no slot and is inert. - pub(crate) fn collect_screenshots(&mut self, _ctx: &egui::Context) { - if self.pending_screenshots.is_empty() { - return; - } - let now = self.tick_now_ms(); - let current = self.last_shot_geometry; - let library = self.shot_document_generation(); - let mut shots = std::mem::take(&mut self.pending_screenshots); - let mut keep = Vec::with_capacity(shots.len()); - for mut shot in shots.drain(..) { - let completed = shot.slot.as_ref().and_then(craft_ui::capture::CaptureSlot::take); - match completed { - Some(Ok(image)) => { - let stale = - shot.issued.is_none_or(|issued| current.is_none_or(|now| now.epoch != issued.epoch)) || shot.issued_library != library; - if stale { - // A resize/DPI/zoom or library change between issue and completion: never - // combine old pixels with new geometry. Retry with a fresh slot. - shot.slot = None; - shot.sent_at_ms = None; - shot.issued = None; - shot.not_before_ms = now + SHOT_SETTLE_MS; - keep.push(shot); - continue; - } - let pixels = image.size[0].checked_mul(image.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); - let Some(bytes) = pixels else { - let reply = serde_json::json!({ - "ok": false, - "error": "ui.screenshot: the rendered image has invalid dimensions", - }); - let _ = shot.reply.send(reply); - self.release_shot_bytes(shot.reserved); - continue; - }; - if bytes > shot.reserved { - // Wayland reports no `inner_rect`, so the reservation can be based on a stale - // content rect while the compositor returns the new, larger surface. Treat a - // larger-but-legal image as a stale reservation and grow it; never mix it - // with geometry of another epoch (checked above). - if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); - self.release_shot_bytes(shot.reserved); - continue; - } - shot.reserved = bytes; - } - self.release_shot_bytes(shot.reserved); - let _ = shot.reply.send(control::save_screenshot(self, &image, shot.target.as_ref())); - } - Some(Err(error)) => { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error.to_string()})); - self.release_shot_bytes(shot.reserved); - } - None => { - if now > shot.deadline_ms { - let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)})); - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - self.release_shot_bytes(shot.reserved); - } else { - keep.push(shot); - } - } - } - } - self.pending_screenshots = keep; - } - /// The process is about to end (settings and library are saved by then): make sure nothing is /// inside the GPU driver when it does (issue #620: a render running on a worker thread while /// the process exits crashes in the driver). No GPU work starts from here on; the render @@ -1311,569 +562,6 @@ impl LightcraftApp { self.perf.logic_ms = monotonic_ms() - t0; } - /// Install/refresh fonts once and watch the device and availability caches. Setup, not input. - fn ensure_fonts(&mut self, ctx: &egui::Context) { - if !self.styled { - theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); - theme::apply(ctx); - Self::drop_legacy_panel_state(ctx); - // File → Add from Device lists cards scanned in the background: show hot-plugs - let repaint = ctx.clone(); - lightcraft_engine::devices::on_change(move || repaint.request_repaint()); - // "is the original there?" (grid, Info panel, Missing Photos) answers from a cache a - // worker fills: a sleeping NAS or a dropped share never stalls a frame - let repaint = ctx.clone(); - self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint())); - self.styled = true; - self.font_language = self.ui.language; - } else if self.font_language != self.ui.language { - // Shared Han characters take the active language's forms (Japanese faces for 日本語, - // the Simplified Chinese face for 简体中文): rebuild the fallback order. - theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); - self.font_language = self.ui.language; - } else { - self.fonts_ready = true; - } - } - - /// 0.36 stored each `PanelState` under the panel's exact `Id`; 0.37 stores it under - /// `parent_ui.scope_id().with_salt(id_salt)`, so the old entries are unreachable and the - /// panels reset once by simply not finding them. Remove the old keys anyway so egui memory - /// does not retain dead entries (no document, workspace JSON, catalog or XMP data is - /// migrated; nothing above a user's photo edits depends on them). - fn drop_legacy_panel_state(ctx: &egui::Context) { - for salt in ["top_bar", "bottom_bar", "tool_strip", "library-problem-banner"] { - ctx.data_mut(|d| d.remove::(egui::Id::unique(salt))); - } - } - - /// Queues and timers. Each consumed queue is drained once per call; time-gated work uses the - /// monotonic app clock, so a hidden `run_logic` keeps it advancing and a second layout pass is - /// a no-op rather than a duplicate. - fn logic_background(&mut self, ctx: &egui::Context) { - panels::notices::logic(self); - // closing the window (or Quit) with changes only in memory: retry, else ask first - if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - } - let now = self.tick_now(); - self.drain_control(ctx); - self.apply_settings(ctx); - if self.queued_input_needs_repaint() { - ctx.request_repaint(); - } - self.renderer.poll(ctx, &mut self.session); - merge::poll(self, ctx); - import::poll_scan(self, ctx); - sync::poll(self, ctx); - sync::poll_run(self, ctx); - // Import commits batches and must not run twice in one host frame. The first layout pass - // and a hidden logic tick both report pass index 0; a second pass reports 1. A wall-clock - // gate cannot express that (a slow discarded first pass would re-run it). - if import_tick_allowed(ctx) { - import::tick(self, ctx); - } - lightroom_import::tick(self, ctx); - recheck::tick(self); - tasks::poll(self, ctx); - updates_ui::tick(self, ctx); - self.preview_build_status(ctx); - self.save_status(ctx); - self.ai_mask_detail(ctx, now); - self.place_names_outcome(ctx); - self.slideshow_tick(ctx, now); - panels::denoise::pump_background(self, ctx, now); - panels::faces::pump_background(self, ctx, now); - model_setup::pump(self, ctx, now); - focus_scan::pump(self, ctx, now); - // auto import: list the watched folder every few seconds (on a worker thread: it may be on - // a network share), then import what's new like any import (also on a worker thread) - #[cfg(not(target_arch = "wasm32"))] - if let Some(folder) = self.session.import_defaults.auto_folder.clone() { - const LABEL: &str = "Auto Import"; - if now - self.ui.auto_import_at >= 3.0 - && self.import.is_none() - && self.sync_run.is_none() - && self.lightroom.is_none() - && !self.tasks.is_running(LABEL) - { - self.ui.auto_import_at = now; - let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder); - let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result, String>| { - let listing = match listing { - Ok(l) => l, - Err(e) => return log::debug!("auto import: {e}"), - }; - let p = serde_json::json!({"listing": listing, "start": false}); - let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return }; - let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return }; - let paths: Vec = - params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); - if let Some(o) = params.as_object_mut() { - o.remove("paths"); - } - let undo0 = app.session.undo.len(); - app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto()); - }; - // listed every few seconds: quiet, no row in the activity stack - if let Err(e) = tasks::spawn(self, LABEL, None, work, done) { - log::warn!("{e}"); - } - } - ctx.request_repaint_after(std::time::Duration::from_secs(3)); - } - // automatic XMP writes that failed (a network share offline): tried again every few seconds once their files - // are reachable, and said once when they start waiting and once when they're written - #[cfg(not(target_arch = "wasm32"))] - { - if self.session.unsaved_sidecars() > 0 { - if now - self.ui.sidecar_retry_at >= 10.0 { - self.ui.sidecar_retry_at = now; - self.session.retry_unsaved_sidecars(); - } - ctx.request_repaint_after(std::time::Duration::from_secs(10)); - } - let waiting = self.session.unsaved_sidecars(); - if waiting > 0 && self.ui.sidecars_waiting == 0 { - self.toast_for( - ctx, - crate::i18n::tr_format!( - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", - n = waiting - ), - 8.0, - ); - } else if waiting == 0 && self.ui.sidecars_waiting > 0 { - self.toast(ctx, crate::i18n::tr("Wrote the XMP sidecars that were waiting")); - } - self.ui.sidecars_waiting = waiting; - } - // Settings → Import: open the import review when a camera or card is connected - #[cfg(not(target_arch = "wasm32"))] - if self.session.import_defaults.show_on_card { - if let Some(now) = lightcraft_engine::devices::devices_scanned() { - import::cards_seen(self, now); - } - ctx.request_repaint_after(lightcraft_engine::devices::MAX_AGE); - } else { - self.ui.known_cards = None; - } - self.session.persist_if_dirty(); - self.expire_gestures(); - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); - self.request_paint_while_hidden(ctx); - } - - /// While hidden, queued captures and an injected gesture step need a real UI pass; a pending - /// screenshot command paints by itself once issued. - fn request_paint_while_hidden(&self, ctx: &egui::Context) { - // `logic` runs per layout pass; one command per host frame is enough. - if ctx.current_pass_index() != 0 { - return; - } - // `ViewportInfo::visible()`: minimized or fully occluded is `Some(false)`; `None` - // (headless, `RawInput::default()`) is normal and must not be treated as hidden. - let hidden = ctx.input(|i| i.viewport().visible() == Some(false)); - if hidden && self.needs_ui_pass_while_hidden() { - ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); - } - } - - /// A hidden window needs a real UI pass for queued/settling captures, for control gestures - /// that still have to resolve or deliver, and for an injected synthetic step. An already - /// issued screenshot has its own compositor-driven paint and must not request another - /// `RequestPaintWhileHidden`; its fallback and timeout keep running from `logic`. - pub(crate) fn needs_ui_pass_while_hidden(&self) -> bool { - !self.pending_gestures.is_empty() - || self.pointer_cleanup - || self.pending_screenshots.iter().any(|shot| shot.sent_at_ms.is_none()) - || !self.synthetic.is_empty() - || self.synthetic_in_flight - } - - /// Queued synthetic input, including a queued pointer cancellation that is not part of - /// `synthetic`, needs the next visible frame even when an earlier stall left the synthetic - /// queue empty. - pub(crate) fn queued_input_needs_repaint(&self) -> bool { - !self.synthetic.is_empty() || self.pointer_cleanup - } - - /// Input, layout and setup for a known UI phase: at most once per host frame, and never from a - /// hidden logic tick. `current_pass_index() == 0` is the first pass of this `run_ui`; a second - /// pass has empty events. - fn ui_pass(&mut self, ctx: &egui::Context) { - if ctx.current_pass_index() != 0 { - return; - } - // A prompt owns the keyboard and focus. A notice may have been added by this frame's logic - // after `raw_input_hook`; capture its Tab/Enter/Space/Escape and drop everything else - // before any panel, focused control or shortcut reads the input. A new prompt gets a - // shielding pass whose background is not drawn at all (see `draw_window`). - let batch_in_flight = self.synthetic_in_flight; - if panels::notices::prepare_ui_pass(self, ctx) { - self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared"); - } - panels::library_problem::prepare_ui_pass(self, ctx, batch_in_flight); - let now = self.tick_now(); - // the last pass's time is fine here: this is a UI phase, not a hidden tick - let t = ctx.input(|i| i.time); - let dt = t - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; - } - self.last_time = t; - panels::denoise::pump_ui_input(self, ctx, now); - panels::faces::pump_ui_input(self, ctx, now); - // A prompt owns the library too: files dropped while it is up wait in the queue and are - // imported on the first UI pass after it goes away, never behind it. - if !panels::notices::blocks_input(self) { - self.import_dropped_files(ctx); - } - self.check_external_editor(ctx); - // Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI - // phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract). - panels::library_problem::logic(self, ctx); - if self.fonts_ready && !updates_ui::blocks_input(self) { - shortcuts::handle(self, ctx); - } - } - - /// Take the host's dropped files, exactly once. The queue is filled by `raw_input_hook`, so a - /// hidden logic tick can never replay the last pass's drop. Web hosts call this in their UI - /// phase (they need `bytes_async`, which is not available on a native file path). - pub fn take_dropped_files(&mut self) -> Vec { - std::mem::take(&mut self.dropped_files) - } - - /// Import native drops as presets, face models or photos. - fn import_dropped_files(&mut self, ctx: &egui::Context) { - let dropped = self.take_dropped_files(); - if dropped.is_empty() { - return; - } - #[cfg(not(target_arch = "wasm32"))] - self.handle_dropped_files(ctx, dropped); - #[cfg(target_arch = "wasm32")] - let _ = (dropped, ctx); - } - - /// Files dropped on the window: face models open their licence dialog, presets import as - /// presets, photos and folders go to the import review (or straight in, Settings ▸ Import). - #[cfg(not(target_arch = "wasm32"))] - fn handle_dropped_files(&mut self, ctx: &egui::Context, dropped: Vec) { - let dropped: Vec = - dropped.iter().map(|f| egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect(); - if dropped.is_empty() { - return; - } - // a dialog is on screen: the review would replace it, losing what it holds - if self.ui.dialog.is_some() { - self.toast(ctx, crate::i18n::tr("Close the open dialog, then drop the files again")); - return; - } - let drop = drop_overlay::classify(dropped); - if let Some(model) = drop.models.first() - && let Err(e) = panels::faces::open_dialog(self, model) - { - self.toast(ctx, e); - } - if !drop.presets.is_empty() { - // presets live in a library: with none open they would go with the session it replaces - if panels::library_problem::holds_drops(self) { - self.toast(ctx, crate::i18n::tr("Open a library first, then drop the presets again")); - } else { - let _ = self.run("file.importPresets", serde_json::json!({"paths": drop.presets})); - } - } - if drop.photos.is_empty() { - return; - } - // no library open yet (the launch list, a library that failed to open): they wait for one - let Some(photos) = panels::library_problem::hold_dropped(self, ctx, drop.photos) else { return }; - if self.lightroom.is_some() { - self.toast(ctx, crate::i18n::tr("Finish Lightroom catalog import before adding photos")); - return; - } - if import::running(self) { - self.toast(ctx, crate::i18n::tr("An import is running")); - return; - } - let started = if self.ui.settings.import_dropped_directly { - // read and added on a worker thread (dropped folders can be large, or on a slow drive) - import::start_paths(self, photos).map(|_| ()) - } else { - import::open(self, photos).map(|_| ()) - }; - if let Err(e) = started { - self.toast(ctx, e); - } - } - - /// A file dropped by the host, kept for the next UI pass (web hosts consume their own queue). - fn queue_dropped_files(&mut self, raw: &mut egui::RawInput) { - if !raw.dropped_files.is_empty() { - self.dropped_files.append(&mut raw.dropped_files); - } - } - - /// Back from an external editor: pick up the files it saved. Reads `i.focused`, which - /// `run_logic` does not refresh, so it belongs to the UI phase. - fn check_external_editor(&mut self, ctx: &egui::Context) { - let focused = ctx.input(|i| i.focused); - if focused && !self.ui.was_focused && !self.ui.external_edits.is_empty() { - let ids = self.ui.external_edits.clone(); - if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) - && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) - { - self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor")); - } - } - self.ui.was_focused = focused; - } - - /// Apply settings that act outside the UI state: GPU rendering and window full screen. - fn apply_settings(&mut self, ctx: &egui::Context) { - if self.gpu_applied != Some(self.ui.settings.gpu) { - self.gpu_applied = Some(self.ui.settings.gpu); - let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); - // GPU device + kernels off the UI thread, once the window is up and only when GPU - // rendering is on: a broken driver must not keep the window from appearing (issue #136) - if self.ui.settings.gpu { - lightcraft_engine::gpu::warm_up(); - } - } - let mb = self.ui.settings.memory_mb; - // automatic at startup: leave the engine's default alone - if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) { - let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb }; - let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb})); - } - self.memory_applied = Some(mb); - // the monitor profile (Settings ▸ Display): previews follow it from the next frame - if self.display_applied.as_ref() != Some(&self.ui.settings.display_profile) { - let path = self.ui.settings.display_profile.clone(); - match lightcraft_engine::display::Display::load_opt(&path) { - Ok(d) => { - self.renderer.set_display(d); - self.display_error = None; - } - Err(msg) => { - // a profile that can't be used: previews are sRGB, the setting is kept - self.renderer.set_display(None); - log::warn!("display profile: {msg}"); - self.toast_error(ctx, format!("{}: {msg}", crate::i18n::tr("Display profile not used"))); - self.display_error = Some(msg); - } - } - self.display_applied = Some(path); - // none chosen again: the screen's profile is read afresh - self.screen_profile = None; - self.screen_profile_read = None; - } - if self.ui.settings.display_profile.is_empty() { - self.follow_screen_profile(ctx); - } - if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { - self.window_is_fullscreen = fs; - } - if let Some(on) = self.ui.window_fullscreen.take() { - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on)); - self.window_is_fullscreen = on; - } - } - - /// While no display profile is chosen, show previews through the profile of the screen the - /// window is on (`services.screen_profile`), read about once a second so moving the window to - /// another screen is followed. Without the service (or a profile) the display is sRGB. - fn follow_screen_profile(&mut self, _ctx: &egui::Context) { - let now = self.tick_now(); - if self.services.screen_profile.is_none() || self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) { - return; - } - self.screen_profile_read = Some(now); - self.apply_screen_profile(); - } - - /// Read the screen's profile now and show previews through it (sRGB without one). - pub(crate) fn apply_screen_profile(&mut self) { - let Some(read) = self.services.screen_profile.as_mut() else { return }; - let icc = read(); - if icc == self.screen_profile { - return; - } - let display = icc.as_deref().and_then(|bytes| match lightcraft_engine::display::Display::from_bytes(SCREEN_PROFILE, bytes) { - Ok(d) => Some(std::sync::Arc::new(d)), - Err(msg) => { - // a profile we can't use: previews stay sRGB, as before #787 - log::warn!("screen profile: {msg}"); - None - } - }); - self.renderer.set_display(display); - self.screen_profile = icc; - } - - /// Take the host's dropped files once and inject synthetic events (pointer events one per - /// frame; key sequences up to the release). - /// - /// The hook also runs before a hidden logic-only tick, so it injects at most one batch and - /// keeps that batch in flight until a completed UI pass consumed it. It never injects while - /// the fonts are not installed (the pass would draw nothing and the events would be lost). - pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - let file = |p: &String| (!p.is_empty()).then(|| std::path::PathBuf::from(p)); - if !self.drop_pending.is_empty() { - raw.dropped_files = std::mem::take(&mut self.drop_pending) - .into_iter() - .map(|p| std::sync::Arc::new(InjectedDrop(p.into())) as egui::DroppedFileHandle) - .collect(); - } - if !self.drop_hover.is_empty() { - raw.hovered_files = self.drop_hover.iter().map(|p| egui::HoveredFile { path: file(p), ..Default::default() }).collect(); - } - // Queue after injecting, so a `ui.dropFiles` request is consumed exactly once by the UI phase. - self.queue_dropped_files(raw); - // Track the real host modifier state separately from the app's synthetic ownership. A host - // change means the host now owns the modifier state: relinquish any synthetic claim and do - // not later synthesise a release for a key that is physically held. - if let Some(host_mods) = raw.events.iter().rev().find_map(|event| match event { - egui::Event::ModifiersChanged(modifiers) => Some(*modifiers), - _ => None, - }) { - self.host_mods = host_mods; - self.synthetic_mods = egui::Modifiers::NONE; - self.synthetic_mods_release = false; - } - // A prompt owns all input. On its first host frame this also removes pointer/touch input - // before egui computes its hit test; later frames keep only key releases, the prompt's own - // keys, and AccessKit actions targeted at a prompt button. See `notices::filter_raw_input`. - let batch_in_flight = self.synthetic_in_flight; - if crate::panels::notices::prepare_raw_input(self, ctx, raw) { - // A prompt appearing during a synthetic drag must abort the drag, not let its remaining - // move/release events act under the prompt or after it is dismissed. If a whole batch - // was already injected, it is unsafe as well: this frame's prompt filter drops it - // before a panel sees it, so it must not report a successful delivery. - self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared"); - self.queue_pointer_cleanup(); - } - // Enforce the stall deadline before injecting another event: a request that expired during - // a long frame gap must not get one more event on the frame that resumes. - self.expire_gestures(); - // A canceled press is released by resetting egui's pointer state before its next input - // pass, even while another batch is in flight or the fonts are not ready, so the pointer - // cannot stay down. No move/release event is injected: either could start a drag or - // become a click on the widget being canceled. - self.apply_pointer_cleanup(ctx, raw); - if !self.fonts_ready || self.synthetic_in_flight { - return; - } - if self.drop_gestures_stale_before_injection(ctx) { - // The stale check can cancel the head gesture while its press is already down. - self.apply_pointer_cleanup(ctx, raw); - return; - } - // the frame after a synthetic release / key: restore the host's physical modifiers, not - // necessarily NONE (Shift may still be held while a synthetic Alt gesture is released) - if std::mem::take(&mut self.synthetic_mods_release) && self.synthetic_mods != egui::Modifiers::NONE { - self.synthetic_mods = egui::Modifiers::NONE; - raw.events.push(egui::Event::ModifiersChanged(self.host_mods)); - } - if self.synthetic.is_empty() { - return; - } - let n = match self.synthetic[0] { - egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - // A press needs the pointer at its position before the button event; the move that is - // itself the first event is delivered by the drain below, so it must not be sent twice. - if let Some(egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } - // egui's `input.modifiers` follow `ModifiersChanged` events, not the modifiers carried by - // pointer/key events: announce the injected events' modifiers (held from button down to - // the frame after up), so ⌥-drag, ⇧-click … work through the control channel - let carried = self.synthetic[..n].iter().find_map(|e| match e { - egui::Event::PointerButton { pressed, modifiers, .. } => Some((*modifiers, !pressed)), - egui::Event::Key { modifiers, .. } => Some((*modifiers, true)), - _ => None, - }); - if let Some((m, ends)) = carried { - if m != self.synthetic_mods { - self.synthetic_mods = m; - raw.events.push(egui::Event::ModifiersChanged(m)); - } - self.synthetic_mods_release = ends; - } - let blocked_by_prompt = self.synthetic[..n].iter().any(|event| !crate::panels::notices::prompt_allows_event(self, event)); - let head = self.head_gesture(); - if let Some(head) = head { - let gesture = &mut self.pending_gestures[head]; - for event in &self.synthetic[..n] { - match event { - egui::Event::PointerButton { button, pressed: true, .. } => { - gesture.pressed = Some(InjectedPress { button: *button }); - } - egui::Event::PointerButton { button, pressed: false, .. } - if gesture.pressed.as_ref().is_some_and(|press| press.button == *button) => - { - gesture.pressed = None; - } - _ => {} - } - } - gesture.started = true; - gesture.remaining_events = gesture.remaining_events.saturating_sub(n); - } - raw.events.extend(self.synthetic.drain(..n)); - // The same barrier applies after injection: only the prompt's own keys and targeted - // AccessKit actions survive; host input and `ui.text`/clipboard never reach the background. - crate::panels::notices::filter_raw_input(self, raw); - if blocked_by_prompt && let Some(index) = head { - // A settled prompt is already laid out, so this is not the prompt-creation-cancels-all - // path: only the gesture whose batch the barrier removed fails. Allowed prompt keys, - // pointer input and targeted AccessKit actions still report a successful delivery. - let gesture = self.pending_gestures.remove(index); - self.cancel_gesture_events(&gesture, true); - let _ = gesture.reply.send(serde_json::json!({ - "ok": false, - "error": format!("{} canceled: the open modal prompt blocked this input", gesture.method), - })); - } - // The 5 s deadline is a delivery-stall limit, not a limit on a gesture's frame count: a - // drag feeds one event per host frame, so every batch that the app consumes proves the UI - // is alive and restarts the clock for the whole queue. - let now = self.tick_now_ms(); - for gesture in &mut self.pending_gestures { - gesture.deadline_ms = now + GESTURE_TIMEOUT_MS; - } - self.synthetic_in_flight = true; - } - - /// Lights Out: veil everything but the photo (the loupe's image, or the grid / Compare / - /// Survey area) in black. Painted above the panels, below dialogs and notices. - fn lights_out(&self, ctx: &egui::Context) { - let a = self.ui.lights_out.veil(); - if a <= 0.0 { - return; - } - let screen = ctx.content_rect(); - let keep = if self.ui.view == state::ViewMode::Detail { self.image_rect.or(self.canvas_rect) } else { self.canvas_rect }; - let veil = egui::Color32::from_black_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); - let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::unique("lights-out"))); - let k = keep.unwrap_or(egui::Rect::NOTHING).intersect(screen); - if !k.is_positive() { - p.rect_filled(screen, 0.0, veil); - return; - } - // four bands around the photo - use egui::{Rect, pos2}; - p.rect_filled(Rect::from_min_max(screen.min, pos2(screen.right(), k.top())), 0.0, veil); - p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.bottom()), screen.max), 0.0, veil); - p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.top()), pos2(k.left(), k.bottom())), 0.0, veil); - p.rect_filled(Rect::from_min_max(pos2(k.right(), k.top()), pos2(screen.right(), k.bottom())), 0.0, veil); - } - /// Frame timings once layout is done (`t0`: when layout started). fn end_frame(&mut self, ctx: &egui::Context, t0: f64) { if self.ui.frame_rate { @@ -1894,47 +582,6 @@ impl LightcraftApp { self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms); } - /// Once per library opened (at launch too): name it in the window title, so nobody edits in - /// the wrong one, and put it first in File ▸ Open Recent Library. - pub(crate) fn library_changed(&mut self, ctx: &egui::Context) { - let generation = self.session.library_generation(); - if self.shown_library == Some(generation) { - return; - } - self.shown_library = Some(generation); - if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { - let path = l.dir.to_string_lossy().into_owned(); - panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); - // it is open: whatever an earlier check said about it is over - self.recent_checks.opened(&path); - // when, for the launch window (issue #1290); only the listed libraries keep a time - let settings = &mut self.ui.settings; - settings.recent_opened.insert(path, (self.session.clock)()); - settings.recent_opened.retain(|p, _| settings.recent_libraries.contains(p)); - } - ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); - } - - /// "LightCraft — " (just "LightCraft" for a library that isn't saved). - pub fn window_title(&self) -> String { - match panels::settings::open_library_name(self) { - Some(name) => format!("LightCraft — {name}"), - None => "LightCraft".to_string(), - } - } - - /// The browser page's title: "[ •] — LightCraft" for the active photo (• while - /// `unsaved`: changes not yet written to storage), else the window title. The web build has no - /// window for `ViewportCommand::Title`; a host embedding it reads this title to tell which - /// document a drop opened and whether it is saved (issue #2397). - pub fn page_title(&self, unsaved: bool) -> String { - let dot = if unsaved { " •" } else { "" }; - match self.session.active().and_then(|id| self.session.catalog.photo(id)) { - Some(p) => format!("{}{dot} — LightCraft", p.file_name), - None => format!("{}{dot}", self.window_title()), - } - } - /// Lay out the whole window: input/setup for the known UI phase, then presentation, then the /// frame bookkeeping. A hidden `run_logic` never reaches this. pub fn ui(&mut self, ui: &mut egui::Ui) { @@ -1949,165 +596,6 @@ impl LightcraftApp { self.draw_window(ui); self.finish_ui_pass(&ctx); } - - /// A completed, nondiscarded pass is a settled frame; a discarded pass threw its widgets away - /// and must not advance the geometry generation a capture binds to. - pub(crate) fn finish_ui_pass(&mut self, ctx: &egui::Context) { - // Any pass consumed the injected batch; a discarded pass still read its events and the - // next pass has none. Only a nondiscarded pass, however, is a settled frame a capture may - // bind to and a control gesture may resolve against. - self.synthetic_in_flight = false; - if ctx.will_discard() { - self.next_widgets.clear(); - self.next_widget_hits.clear(); - return; - } - self.widgets = std::mem::take(&mut self.next_widgets); - self.widget_hits = std::mem::take(&mut self.next_widget_hits); - panels::notices::finish_ui_pass(self, ctx); - panels::library_problem::finish_ui_pass(self, ctx); - self.ui_passes = self.ui_passes.wrapping_add(1); - self.layout_epoch = self.layout_epoch.wrapping_add(1); - self.resolve_pending_gestures(ctx); - self.reply_delivered_gestures(); - if self.pointer_cleanup { - // A canceled press may be discovered by a timeout after the synthetic queue emptied; - // nothing else may ask for the frame that applies its cancellation. - ctx.request_repaint(); - } - } - - /// Presentation only: the window's panels, dialogs and overlays. - fn draw_window(&mut self, ui: &mut egui::Ui) { - let ctx = ui.ctx().clone(); - let t0 = monotonic_ms(); - panels::library_problem::begin_draw(self); - // A command or shortcut drawn before this can create the window after `ui_pass`; catch it - // here before any panel draws with the still-focused background text field. - let batch_in_flight = self.synthetic_in_flight; - panels::library_problem::prepare_ui_pass(self, &ctx, batch_in_flight); - // A newly shown blocking library window gets the same barrier as a prompt: its first Area - // layout is non-interactable and hit testing still uses the previous pass, so background - // panels (and a focused text field) must not draw in that pass. - if panels::library_problem::shield_pass(self, &ctx) { - #[cfg(test)] - self.library_passes.push(panels::library_problem::LibraryPass::Shield); - panels::library_problem::show(self, &ctx, false); - (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); - ctx.request_discard("library problem: shield the window's creation frame"); - self.end_frame(&ctx, t0); - return; - } - // A newly shown prompt gets one pass with no background. egui's first Area layout is - // non-interactable and its hit test uses the previous pass, so drawing panels here would - // let a pointer press/release in the prompt's creation frame act underneath it. `ui()` then - // reruns this pass with empty raw input, where the settled prompt and the full window draw - // together. This is a state/input-routing barrier, not a wholesale input disable: once - // settled, raw pointer input still reaches the prompt backdrop (a no-op) or its buttons. - if panels::notices::shield_pass(self, &ctx) { - panels::notices::show(self, &ctx); - (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); - ctx.request_discard("notice: shield the prompt's creation frame"); - self.end_frame(&ctx, t0); - return; - } - #[cfg(test)] - self.library_passes.push(panels::library_problem::LibraryPass::Background); - panels::dialogs::prepare(self, ui); - // Freeze this frame's notice barrier before `notices::show` can dismiss a prompt: the same - // click or key must never be acted on again by the dialog drawn after it. - let notice_blocks = panels::notices::blocks_input(self); - if std::mem::take(&mut self.ui.quit) { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - self.library_changed(&ctx); - // panels set it again this frame while the pointer rests on a preset or profile - self.hover_preview = None; - if self.ui.fullscreen { - // full-screen preview: the photo alone on black - egui::CentralPanel::default() - .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) - .show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) }); - panels::second::show(self, &ctx); - if notice_blocks { - panels::notices::restore_prompt_pointer(self, &ctx); - } - panels::notices::show(self, &ctx); - if notice_blocks { - panels::notices::hide_background_pointer(&ctx); - } - panels::dialogs::show(self, &ctx, notice_blocks); - updates_ui::show(self, &ctx); - panels::library_problem::show(self, &ctx, notice_blocks); - panels::toast(self, &ctx); - drop_overlay::show(self, &ctx); - if notice_blocks { - // Keep the real press/release state for the next pass: an ordinary click spans - // host frames, and resetting it above must only hide it from this pass's dialogs. - panels::notices::restore_prompt_pointer(self, &ctx); - } - (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); - self.end_frame(&ctx, t0); - return; - } - // Order matters: earlier panels take the full edge (top bar spans the window; the tool strip, - // right panels and left panel run to the bottom; the bottom bar sits between them). - panels::topbar::show(self, ui); - panels::library_problem::banner(self, ui); - panels::strip::show(self, ui); - // filled: the gaps the dock leaves between its panes show chrome, not the dark clear colour - let chrome = theme::Tokens::get(&ctx).chrome; - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(chrome)).show(ui, |ui| docking::show(self, ui)); - self.lights_out(&ctx); - panels::second::show(self, &ctx); - if notice_blocks { - panels::notices::restore_prompt_pointer(self, &ctx); - } - panels::notices::show(self, &ctx); - if notice_blocks { - panels::notices::hide_background_pointer(&ctx); - } - panels::dialogs::show(self, &ctx, notice_blocks); - updates_ui::show(self, &ctx); - panels::library_problem::show(self, &ctx, notice_blocks); - export_task::poll(self, &ctx); - slideshow_export::poll(self, &ctx); - panels::activity::show(self, &ctx); - export_task::poll_photos(self, &ctx); - pick::poll(self, &ctx); - panels::grid::drag_feedback(self, &ctx); - panels::left::album_drag_feedback(self, &ctx); - panels::left::section_drag_feedback(self, &ctx); - panels::keyword_list::drag_feedback(self, &ctx); - panels::toast(self, &ctx); - drop_overlay::show(self, &ctx); - // AppKit moves the window on a drag anywhere in the title-bar band unless the window isn't - // movable: it stops being movable while the pointer is on a bar control or a panel over it - #[cfg(target_os = "macos")] - craft_window::title_bar::sync_movable(&ctx, &mut self.window_movable); - if notice_blocks { - // Keep the real press/release state for the next pass: an ordinary click spans host - // frames, and resetting it above must only hide it from this pass's dialogs. - panels::notices::restore_prompt_pointer(self, &ctx); - } - (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); - self.text_focus = ctx.egui_wants_keyboard_input(); - self.end_frame(&ctx, t0); - } -} - -/// Import commits batches at most once per host frame. The first layout pass and a hidden -/// `run_logic` tick both report pass index 0; a discarded frame's later passes report 1+. A -/// wall-clock gate cannot express that (a slow first pass could re-run it within one frame). -pub(crate) fn import_tick_allowed(ctx: &egui::Context) -> bool { - ctx.current_pass_index() == 0 -} - -/// Whether a pending request may be issued now: it has waited for the settle time and completed -/// UI frames, and either no render is in flight or the forced-capture instant has passed. -pub(crate) fn shot_ready(now_ms: f64, shot: &PendingShot, busy: bool, ui_passes: u64) -> bool { - let settled = ui_passes.saturating_sub(shot.admitted_passes) >= SHOT_SETTLE_FRAMES && now_ms >= shot.not_before_ms; - settled && (!busy || now_ms >= shot.force_ms) } /// "Found 3 faces in 2 photos" for a `faces.detect` result. @@ -2136,47 +624,3 @@ pub(crate) fn sam_download_ended(download: &lightcraft_engine::segment::Download None => None, } } - -#[cfg(test)] -mod drop_tests { - #[test] - fn photo_sidecars_are_not_presets() { - let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); - let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]); - let (presets, photos) = super::split_presets(dropped); - assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"])); - assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"])); - } - - #[test] - fn detection_results_are_summarised_in_words() { - let r = - |counts: &[usize]| serde_json::json!({"photos": counts.iter().map(|n| serde_json::json!({"faces": vec![0; *n]})).collect::>()}); - assert_eq!(super::detect_summary(&r(&[0])), "No faces found"); - assert_eq!(super::detect_summary(&r(&[1])), "Found 1 face"); - assert_eq!(super::detect_summary(&r(&[3])), "Found 3 faces"); - assert_eq!(super::detect_summary(&r(&[0, 0])), "No faces found in 2 photos"); - assert_eq!(super::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos"); - assert_eq!(super::detect_summary(&serde_json::json!({})), "No faces found in 0 photos"); - } - - #[test] - fn dropped_face_models_are_told_apart() { - for p in ["/a/model.onnx", "/a/dir/M.ONNX"] { - assert!(super::is_model_file(p), "{p}"); - } - for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] { - assert!(!super::is_model_file(p), "{p}"); - } - } - - #[test] - fn dropped_presets_are_told_apart_from_photos() { - for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] { - assert!(super::is_preset_file(p), "{p}"); - } - for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] { - assert!(!super::is_preset_file(p), "{p}"); - } - } -} diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs new file mode 100644 index 000000000..00b66e4b0 --- /dev/null +++ b/crates/ui-egui/src/app/control.rs @@ -0,0 +1,399 @@ +use super::*; +use crate::control; + +impl LightcraftApp { + /// Take the control receiver for this frame, run every queued request whose sender still + /// wants it, and reply directly or admit its screenshot/gesture to a later completed pass. + /// Requests abandoned by their sender are skipped rather than executed late. + pub(super) fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + // a request its sender gave up on (the window drew nothing for a while) is not run + // late: it would happen twice once the sender retries (issue #718) + if !req.take() { + continue; + } + let reply = req.reply.clone(); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { target, headless } => { + if let Err(error) = self.admit_shot(ctx, target, headless, reply.clone()) { + let _ = reply.send(serde_json::json!({"ok": false, "error": error})); + } + } + control::Outcome::Gesture { method, params } => { + if let Err(error) = self.admit_gesture(ctx, method, params, reply.clone()) { + let _ = reply.send(serde_json::json!({"ok": false, "error": error})); + } + } + } + } + self.control_rx = Some(rx); + } + + /// The navigation/document/modal/panel state that decides what an automation id means. + fn gesture_frame(&self) -> GestureFrame { + GestureFrame { + view: self.ui.view, + active: self.session.active(), + library: self.session.library_generation(), + dialog: self.ui.dialog.as_ref().map(std::mem::discriminant), + left_panel: self.ui.left_panel, + right: self.ui.right, + presets: self.ui.presets, + filmstrip: self.ui.filmstrip, + } + } + + /// Queue a synthetic-input request. Nothing is answered and no event is queued until a + /// completed layout newer than this admission exists (see [`Self::resolve_pending_gestures`]). + pub(crate) fn admit_gesture(&mut self, ctx: &egui::Context, method: String, params: Value, reply: Sender) -> Result<(), String> { + if self.pending_gestures.len() >= MAX_PENDING_GESTURES { + return Err(format!("{}: {MAX_PENDING_GESTURES} control gestures are already pending", method)); + } + let now = self.tick_now_ms(); + self.pending_gestures.push(PendingGesture { + method, + params, + reply, + admitted_epoch: self.layout_epoch, + frame: self.gesture_frame(), + deadline_ms: now + GESTURE_TIMEOUT_MS, + resolved_pass: None, + remaining_events: 0, + started: false, + target: None, + pressed: None, + result: Value::Null, + }); + ctx.request_repaint(); + Ok(()) + } + + /// Resolve one queued gesture against the fresh widgets/image rect of the pass that just + /// completed. A changed view/document/dialog/panel state cancels instead of retargeting. + pub(crate) fn resolve_pending_gestures(&mut self, ctx: &egui::Context) { + if self.pending_gestures.is_empty() { + return; + } + let now = self.tick_now_ms(); + let frame = self.gesture_frame(); + let epoch = self.layout_epoch; + let mut gestures = std::mem::take(&mut self.pending_gestures); + let mut keep = Vec::with_capacity(gestures.len()); + for mut gesture in gestures.drain(..) { + if gesture.resolved_pass.is_some() { + // Already handed to the delivery queue; `reply_delivered_gestures` (or + // `expire_gestures`) owns its deadline and must remove its unsent events too. + keep.push(gesture); + continue; + } + if now > gesture.deadline_ms { + // Only this request failed to get a fresh layout. A later gesture has its own + // deadline and must not be canceled with it. + let _ = + gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)})); + continue; + } + if epoch <= gesture.admitted_epoch { + keep.push(gesture); + continue; + } + if gesture.frame != frame { + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: the view, document, dialog or panel layout changed before a fresh layout", gesture.method), + })); + continue; + } + let before = self.synthetic.len(); + match control::gesture_events(self, ctx, &gesture.method, &gesture.params) { + Ok(outcome) => { + gesture.target = outcome.target; + gesture.result = outcome.result; + gesture.resolved_pass = Some(self.ui_passes); + gesture.remaining_events = self.synthetic.len().saturating_sub(before); + gesture.started = gesture.remaining_events == 0; + keep.push(gesture); + } + Err(error) => { + let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": error})); + } + } + } + self.pending_gestures = keep; + } + + /// Answer gestures whose last synthetic event a completed, nondiscarded UI pass consumed. + /// A zero-event action waits for one completed pass after resolution. + /// + /// Delivery does not compare the frame again: the gesture's own event may legitimately change + /// the view, document, dialog or panel (a click that opens a dialog, a key that starts Compare), + /// so a second snapshot would cancel exactly the input that did its job. Freshness is checked + /// before resolution ([`Self::resolve_pending_gestures`]) and again immediately before the + /// batch is injected ([`Self::drop_gestures_stale_before_injection`]). + pub(crate) fn reply_delivered_gestures(&mut self) { + if self.pending_gestures.is_empty() { + return; + } + let now = self.tick_now_ms(); + // Cancel only the gestures that actually stalled: their unsent events are at the front of + // the queue, and gestures queued behind them are independent input that must survive. + let mut i = 0; + while i < self.pending_gestures.len() { + if self.pending_gestures[i].resolved_pass.is_none() || now <= self.pending_gestures[i].deadline_ms { + i += 1; + continue; + } + let at_head = self.head_gesture() == Some(i); + let gesture = self.pending_gestures.remove(i); + self.cancel_gesture_events(&gesture, at_head); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} timed out before the UI consumed its last event", gesture.method), + })); + } + if !self.synthetic.is_empty() || self.synthetic_in_flight { + return; + } + let mut gestures = std::mem::take(&mut self.pending_gestures); + let mut keep = Vec::with_capacity(gestures.len()); + for mut gesture in gestures.drain(..) { + let Some(resolved) = gesture.resolved_pass else { + keep.push(gesture); + continue; + }; + if self.ui_passes <= resolved { + keep.push(gesture); + continue; + } + if gesture.remaining_events > 0 { + // A defensive queue reset (a non-head gesture owned unsent events) dropped this + // batch. It must not become an `ok: true` for input no panel ever saw. + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: its queued input was dropped before delivery", gesture.method), + })); + continue; + } + let result = std::mem::take(&mut gesture.result); + let _ = gesture.reply.send(serde_json::json!({"ok": true, "result": result})); + } + self.pending_gestures = keep; + } + + /// The gesture whose events sit at the head of the synthetic queue. + pub(super) fn head_gesture(&self) -> Option { + self.pending_gestures.iter().position(|gesture| gesture.resolved_pass.is_some() && gesture.remaining_events > 0) + } + + /// Fail resolved gestures whose view/document/dialog/panel snapshot changed before their + /// first synthetic batch reached the next completed pass. A hidden logic tick runs between + /// passes and may change what an automation id or a photo click means; fail closed instead of + /// delivering it against the new state. + /// + /// A widget gesture also has to keep aiming at the requested widget. egui attributes a click + /// to the widget under the press point in the *previous completed pass*, while the control + /// batch spans several host frames (a move, then press, then release). Recheck the target + /// before the first event and before every press; if it moved or disappeared, cancel this + /// gesture with an error instead of clicking whatever is now at those coordinates. + /// + /// Only the gesture at the head of the queue is checked, and later batches of the same gesture + /// may see its own partial effect (the first click of a double click selects the photo the + /// second one opens). A second, concurrently queued gesture is checked when its events reach + /// the head. + pub(super) fn drop_gestures_stale_before_injection(&mut self, ctx: &egui::Context) -> bool { + let Some(head) = self.head_gesture() else { return false }; + let first_batch = !self.pending_gestures[head].started; + if first_batch && self.pending_gestures[head].frame != self.gesture_frame() { + let gestures = std::mem::take(&mut self.pending_gestures); + for gesture in gestures { + if gesture.resolved_pass.is_none() { + self.pending_gestures.push(gesture); + } else { + if gesture.pressed.is_some() { + self.queue_pointer_cleanup(); + } + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: the view, document, dialog or panel layout changed before delivery", gesture.method), + })); + } + } + self.synthetic.clear(); + return true; + } + let Some(target) = self.pending_gestures[head].target.clone() else { return false }; + let press_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: true, .. })); + let release_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: false, .. })); + // A drag legitimately ends outside the widget it started on; only a click must still find + // its target at the release that egui attributes the click to. A hover must still find a + // visible widget for its single pointer move. + let check_release = release_next && target.check_release; + let check_visible = first_batch && target.check_visible; + if !first_batch && !press_next && !check_release && !check_visible { + return false; + } + let latest = control::widget_rect(self, &target.id); + let latest_hit = control::widget_hit(self, &target.id); + // A press must go to the widget that owns the point. A click's release must too (a drag + // may legitimately end outside the widget it started on), so an overlay that appears + // between press and release cancels the click before the release rather than activating it. + // A hover's single move must also still find the widget visible: the documented contract + // rejects a clipped or newly covered target instead of moving the pointer at stale coordinates. + let must_own = press_next || (release_next && target.check_release) || check_visible; + // A quit prompt or settings notice owns input even before its Foreground areas have + // settled (egui's first layout pass is non-interactable), and even when a control request + // is resolved against the registry of that first pass. Only the prompt's own buttons pass. + let notice_ok = !must_own || crate::panels::notices::allows_target(self, latest_hit); + let still_aimed = latest.is_some_and(|rect| { + // A changed widget cancels any gesture before its first event. Owned presses, click + // releases and hovers must also be inside egui's clipped, visible part and be the + // topmost widget hit: a target scrolled out of its panel, or one under a non-modal + // overlay, must not be pressed or hovered at stale coordinates. + let geometry_ok = rect == target.rect || rect.contains(target.at); + geometry_ok && notice_ok && (!must_own || control::widget_target_visible(ctx, latest_hit, target.at)) + }); + if still_aimed { + return false; + } + let outside_registered = !target.rect.contains(target.at); + let reason = match latest { + None => "disappeared", + Some(rect) if !(rect == target.rect || rect.contains(target.at)) => "moved", + Some(_) if outside_registered => "has no visible interaction area at the requested point", + Some(_) => "is no longer visible", + }; + let phase = if first_batch { + "its first event" + } else if check_release { + "its release" + } else { + "its press" + }; + // Only this gesture's unsent events are removed: they are at the front of the queue, and + // gestures resolved after it are checked in turn when they reach the head. Failing the + // whole click would also discard unrelated input. + let gesture = self.pending_gestures.remove(head); + self.cancel_gesture_events(&gesture, true); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: widget `{}` {reason} before {phase}; nothing was retargeted", gesture.method, target.id), + })); + true + } + + /// Remove a canceled gesture's unsent events from the front of the queue and release a pointer + /// button it left down. Callers cancel in queue order, so the events removed are exactly this + /// gesture's unsent prefix; a later gesture's events stay queued. + pub(super) fn cancel_gesture_events(&mut self, gesture: &PendingGesture, at_head: bool) { + if at_head { + let unsent = gesture.remaining_events.min(self.synthetic.len()); + self.synthetic.drain(..unsent); + } else if gesture.remaining_events > 0 { + // The queue order makes only the head own unsent events. If a caller ever breaks that + // invariant, fail safe: stale synthetic input must not reach an unrelated widget, even + // at the cost of dropping the rest of this batch instead of panicking or guessing a + // prefix. `reply_delivered_gestures` fails any gesture whose events disappeared this + // way instead of reporting a false success. + log::warn!("canceled non-head gesture still owned {} unsent synthetic events; dropping the queue", gesture.remaining_events); + self.synthetic.clear(); + self.synthetic_in_flight = false; + } + if gesture.pressed.is_some() { + self.queue_pointer_cleanup(); + } + } + + /// Cancel every synthetic gesture that a newly shown prompt or blocking library window makes + /// unsafe. + /// + /// A gesture whose input is still queued must not be delivered under the barrier or later when + /// it is gone. A gesture whose press already reached egui must be released without a click. A + /// gesture that already delivered all its events may still complete its reply: the barrier may + /// well be its own result (e.g. a key that opened the quit prompt). `all` is for a batch already + /// injected this host frame before the barrier was discovered after `raw_input_hook`: the + /// barrier filter drops it before any panel sees it, so even a fully injected gesture must fail + /// rather than report a successful delivery. + pub(crate) fn cancel_gestures_for_barrier(&mut self, all: bool, reason: &str) { + // All not-yet-injected events belong to the queue's head, one gesture at a time. Cancel + // them in order so only that gesture's unsent prefix is removed. + while let Some(head) = self.head_gesture() { + let gesture = self.pending_gestures.remove(head); + self.cancel_gesture_events(&gesture, true); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: {reason} before its input was delivered", gesture.method), + })); + } + let mut i = 0; + while i < self.pending_gestures.len() { + if all || self.pending_gestures[i].resolved_pass.is_none() || self.pending_gestures[i].pressed.is_some() { + let gesture = self.pending_gestures.remove(i); + self.cancel_gesture_events(&gesture, false); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: {reason} before its input was delivered", gesture.method), + })); + } else { + i += 1; + } + } + } + + /// Cancel a press that reached egui without making it a click or starting a drag. The reset + /// itself happens before egui's next input pass in [`Self::raw_input_hook`], where replacing + /// the pointer state and stopping the drag is safe (the same approach as the sibling + /// SoundCraft/VectorCraft apps). No pointer movement is injected: moving beyond + /// `max_click_dist` suppresses a click but can also start a drag on a slider or curve. + pub(super) fn queue_pointer_cleanup(&mut self) { + self.pointer_cleanup = true; + } + + /// Apply a queued pointer cancellation before egui processes the next input pass. + pub(super) fn apply_pointer_cleanup(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + if !std::mem::take(&mut self.pointer_cleanup) { + return; + } + ctx.input_mut(|input| input.pointer = egui::PointerState::default()); + ctx.stop_dragging(); + if self.synthetic_mods != egui::Modifiers::NONE { + // The cleanup removes only the app's synthetic modifiers, never a real host change. + // A host `ModifiersChanged` in this same raw batch is already authoritative and was + // recorded before this call; otherwise restore the host's last known state (which may + // be a physically held Shift), rather than clearing it unconditionally with NONE. + let host_changed = raw.events.iter().any(|event| matches!(event, egui::Event::ModifiersChanged(_))); + self.synthetic_mods = egui::Modifiers::NONE; + if !host_changed { + raw.events.push(egui::Event::ModifiersChanged(self.host_mods)); + } + } + self.synthetic_mods_release = false; + } + + /// Fail pending gestures at the monotonic deadline even when no UI pass runs (a hidden window + /// whose paint request the host ignored). `logic` calls this every tick. + pub(super) fn expire_gestures(&mut self) { + if self.pending_gestures.is_empty() { + return; + } + let now = self.tick_now_ms(); + if !self.pending_gestures.iter().any(|gesture| now > gesture.deadline_ms) { + return; + } + // Cancel exactly the stalled requests, in queue order, so a later gesture's input survives. + let mut i = 0; + while i < self.pending_gestures.len() { + if now <= self.pending_gestures[i].deadline_ms { + i += 1; + continue; + } + let at_head = self.head_gesture() == Some(i); + let gesture = self.pending_gestures.remove(i); + self.cancel_gesture_events(&gesture, at_head); + let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)})); + } + } +} diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs new file mode 100644 index 000000000..5d9ed2f37 --- /dev/null +++ b/crates/ui-egui/src/app/frame.rs @@ -0,0 +1,448 @@ +use super::*; + +impl LightcraftApp { + /// Install/refresh fonts once and watch the device and availability caches. Setup, not input. + pub(super) fn ensure_fonts(&mut self, ctx: &egui::Context) { + if !self.styled { + theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); + theme::apply(ctx); + Self::drop_legacy_panel_state(ctx); + // File → Add from Device lists cards scanned in the background: show hot-plugs + let repaint = ctx.clone(); + lightcraft_engine::devices::on_change(move || repaint.request_repaint()); + // "is the original there?" (grid, Info panel, Missing Photos) answers from a cache a + // worker fills: a sleeping NAS or a dropped share never stalls a frame + let repaint = ctx.clone(); + self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint())); + self.styled = true; + self.font_language = self.ui.language; + } else if self.font_language != self.ui.language { + // Shared Han characters take the active language's forms (Japanese faces for 日本語, + // the Simplified Chinese face for 简体中文): rebuild the fallback order. + theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); + self.font_language = self.ui.language; + } else { + self.fonts_ready = true; + } + } + + /// 0.36 stored each `PanelState` under the panel's exact `Id`; 0.37 stores it under + /// `parent_ui.scope_id().with_salt(id_salt)`, so the old entries are unreachable and the + /// panels reset once by simply not finding them. Remove the old keys anyway so egui memory + /// does not retain dead entries (no document, workspace JSON, catalog or XMP data is + /// migrated; nothing above a user's photo edits depends on them). + fn drop_legacy_panel_state(ctx: &egui::Context) { + for salt in ["top_bar", "bottom_bar", "tool_strip", "library-problem-banner"] { + ctx.data_mut(|d| d.remove::(egui::Id::unique(salt))); + } + } + + /// Queues and timers. Each consumed queue is drained once per call; time-gated work uses the + /// monotonic app clock, so a hidden `run_logic` keeps it advancing and a second layout pass is + /// a no-op rather than a duplicate. + pub(super) fn logic_background(&mut self, ctx: &egui::Context) { + panels::notices::logic(self); + // closing the window (or Quit) with changes only in memory: retry, else ask first + if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + let now = self.tick_now(); + self.drain_control(ctx); + self.apply_settings(ctx); + if self.queued_input_needs_repaint() { + ctx.request_repaint(); + } + self.renderer.poll(ctx, &mut self.session); + merge::poll(self, ctx); + import::poll_scan(self, ctx); + sync::poll(self, ctx); + sync::poll_run(self, ctx); + // Import commits batches and must not run twice in one host frame. The first layout pass + // and a hidden logic tick both report pass index 0; a second pass reports 1. A wall-clock + // gate cannot express that (a slow discarded first pass would re-run it). + if import_tick_allowed(ctx) { + import::tick(self, ctx); + } + lightroom_import::tick(self, ctx); + recheck::tick(self); + tasks::poll(self, ctx); + updates_ui::tick(self, ctx); + self.preview_build_status(ctx); + self.save_status(ctx); + self.ai_mask_detail(ctx, now); + self.place_names_outcome(ctx); + self.slideshow_tick(ctx, now); + panels::denoise::pump_background(self, ctx, now); + panels::faces::pump_background(self, ctx, now); + model_setup::pump(self, ctx, now); + focus_scan::pump(self, ctx, now); + // auto import: list the watched folder every few seconds (on a worker thread: it may be on + // a network share), then import what's new like any import (also on a worker thread) + #[cfg(not(target_arch = "wasm32"))] + if let Some(folder) = self.session.import_defaults.auto_folder.clone() { + const LABEL: &str = "Auto Import"; + if now - self.ui.auto_import_at >= 3.0 + && self.import.is_none() + && self.sync_run.is_none() + && self.lightroom.is_none() + && !self.tasks.is_running(LABEL) + { + self.ui.auto_import_at = now; + let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder); + let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result, String>| { + let listing = match listing { + Ok(l) => l, + Err(e) => return log::debug!("auto import: {e}"), + }; + let p = serde_json::json!({"listing": listing, "start": false}); + let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return }; + let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return }; + let paths: Vec = + params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); + if let Some(o) = params.as_object_mut() { + o.remove("paths"); + } + let undo0 = app.session.undo.len(); + app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto()); + }; + // listed every few seconds: quiet, no row in the activity stack + if let Err(e) = tasks::spawn(self, LABEL, None, work, done) { + log::warn!("{e}"); + } + } + ctx.request_repaint_after(std::time::Duration::from_secs(3)); + } + // automatic XMP writes that failed (a network share offline): tried again every few seconds once their files + // are reachable, and said once when they start waiting and once when they're written + #[cfg(not(target_arch = "wasm32"))] + { + if self.session.unsaved_sidecars() > 0 { + if now - self.ui.sidecar_retry_at >= 10.0 { + self.ui.sidecar_retry_at = now; + self.session.retry_unsaved_sidecars(); + } + ctx.request_repaint_after(std::time::Duration::from_secs(10)); + } + let waiting = self.session.unsaved_sidecars(); + if waiting > 0 && self.ui.sidecars_waiting == 0 { + self.toast_for( + ctx, + crate::i18n::tr_format!( + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + n = waiting + ), + 8.0, + ); + } else if waiting == 0 && self.ui.sidecars_waiting > 0 { + self.toast(ctx, crate::i18n::tr("Wrote the XMP sidecars that were waiting")); + } + self.ui.sidecars_waiting = waiting; + } + // Settings → Import: open the import review when a camera or card is connected + #[cfg(not(target_arch = "wasm32"))] + if self.session.import_defaults.show_on_card { + if let Some(now) = lightcraft_engine::devices::devices_scanned() { + import::cards_seen(self, now); + } + ctx.request_repaint_after(lightcraft_engine::devices::MAX_AGE); + } else { + self.ui.known_cards = None; + } + self.session.persist_if_dirty(); + self.expire_gestures(); + self.collect_screenshots(ctx); + self.issue_screenshots(ctx); + self.request_paint_while_hidden(ctx); + } + + /// While hidden, queued captures and an injected gesture step need a real UI pass; a pending + /// screenshot command paints by itself once issued. + fn request_paint_while_hidden(&self, ctx: &egui::Context) { + // `logic` runs per layout pass; one command per host frame is enough. + if ctx.current_pass_index() != 0 { + return; + } + // `ViewportInfo::visible()`: minimized or fully occluded is `Some(false)`; `None` + // (headless, `RawInput::default()`) is normal and must not be treated as hidden. + let hidden = ctx.input(|i| i.viewport().visible() == Some(false)); + if hidden && self.needs_ui_pass_while_hidden() { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + } + + /// A hidden window needs a real UI pass for queued/settling captures, for control gestures + /// that still have to resolve or deliver, and for an injected synthetic step. An already + /// issued screenshot has its own compositor-driven paint and must not request another + /// `RequestPaintWhileHidden`; its fallback and timeout keep running from `logic`. + pub(crate) fn needs_ui_pass_while_hidden(&self) -> bool { + !self.pending_gestures.is_empty() + || self.pointer_cleanup + || self.pending_screenshots.iter().any(|shot| shot.sent_at_ms.is_none()) + || !self.synthetic.is_empty() + || self.synthetic_in_flight + } + + /// Queued synthetic input, including a queued pointer cancellation that is not part of + /// `synthetic`, needs the next visible frame even when an earlier stall left the synthetic + /// queue empty. + pub(crate) fn queued_input_needs_repaint(&self) -> bool { + !self.synthetic.is_empty() || self.pointer_cleanup + } + + /// Input, layout and setup for a known UI phase: at most once per host frame, and never from a + /// hidden logic tick. `current_pass_index() == 0` is the first pass of this `run_ui`; a second + /// pass has empty events. + pub(super) fn ui_pass(&mut self, ctx: &egui::Context) { + if ctx.current_pass_index() != 0 { + return; + } + // A prompt owns the keyboard and focus. A notice may have been added by this frame's logic + // after `raw_input_hook`; capture its Tab/Enter/Space/Escape and drop everything else + // before any panel, focused control or shortcut reads the input. A new prompt gets a + // shielding pass whose background is not drawn at all (see `draw_window`). + let batch_in_flight = self.synthetic_in_flight; + if panels::notices::prepare_ui_pass(self, ctx) { + self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared"); + } + panels::library_problem::prepare_ui_pass(self, ctx, batch_in_flight); + let now = self.tick_now(); + // the last pass's time is fine here: this is a UI phase, not a hidden tick + let t = ctx.input(|i| i.time); + let dt = t - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = t; + panels::denoise::pump_ui_input(self, ctx, now); + panels::faces::pump_ui_input(self, ctx, now); + // A prompt owns the library too: files dropped while it is up wait in the queue and are + // imported on the first UI pass after it goes away, never behind it. + if !panels::notices::blocks_input(self) { + self.import_dropped_files(ctx); + } + self.check_external_editor(ctx); + // Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI + // phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract). + panels::library_problem::logic(self, ctx); + if self.fonts_ready && !updates_ui::blocks_input(self) { + shortcuts::handle(self, ctx); + } + } + + /// Lights Out: veil everything but the photo (the loupe's image, or the grid / Compare / + /// Survey area) in black. Painted above the panels, below dialogs and notices. + fn lights_out(&self, ctx: &egui::Context) { + let a = self.ui.lights_out.veil(); + if a <= 0.0 { + return; + } + let screen = ctx.content_rect(); + let keep = if self.ui.view == state::ViewMode::Detail { self.image_rect.or(self.canvas_rect) } else { self.canvas_rect }; + let veil = egui::Color32::from_black_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::unique("lights-out"))); + let k = keep.unwrap_or(egui::Rect::NOTHING).intersect(screen); + if !k.is_positive() { + p.rect_filled(screen, 0.0, veil); + return; + } + // four bands around the photo + use egui::{Rect, pos2}; + p.rect_filled(Rect::from_min_max(screen.min, pos2(screen.right(), k.top())), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.bottom()), screen.max), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.top()), pos2(k.left(), k.bottom())), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(k.right(), k.top()), pos2(screen.right(), k.bottom())), 0.0, veil); + } + + /// Once per library opened (at launch too): name it in the window title, so nobody edits in + /// the wrong one, and put it first in File ▸ Open Recent Library. + pub(crate) fn library_changed(&mut self, ctx: &egui::Context) { + let generation = self.session.library_generation(); + if self.shown_library == Some(generation) { + return; + } + self.shown_library = Some(generation); + if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { + let path = l.dir.to_string_lossy().into_owned(); + panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); + // it is open: whatever an earlier check said about it is over + self.recent_checks.opened(&path); + // when, for the launch window (issue #1290); only the listed libraries keep a time + let settings = &mut self.ui.settings; + settings.recent_opened.insert(path, (self.session.clock)()); + settings.recent_opened.retain(|p, _| settings.recent_libraries.contains(p)); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); + } + + /// "LightCraft — " (just "LightCraft" for a library that isn't saved). + pub fn window_title(&self) -> String { + match panels::settings::open_library_name(self) { + Some(name) => format!("LightCraft — {name}"), + None => "LightCraft".to_string(), + } + } + + /// The browser page's title: "[ •] — LightCraft" for the active photo (• while + /// `unsaved`: changes not yet written to storage), else the window title. The web build has no + /// window for `ViewportCommand::Title`; a host embedding it reads this title to tell which + /// document a drop opened and whether it is saved (issue #2397). + pub fn page_title(&self, unsaved: bool) -> String { + let dot = if unsaved { " •" } else { "" }; + match self.session.active().and_then(|id| self.session.catalog.photo(id)) { + Some(p) => format!("{}{dot} — LightCraft", p.file_name), + None => format!("{}{dot}", self.window_title()), + } + } + + /// A completed, nondiscarded pass is a settled frame; a discarded pass threw its widgets away + /// and must not advance the geometry generation a capture binds to. + pub(crate) fn finish_ui_pass(&mut self, ctx: &egui::Context) { + // Any pass consumed the injected batch; a discarded pass still read its events and the + // next pass has none. Only a nondiscarded pass, however, is a settled frame a capture may + // bind to and a control gesture may resolve against. + self.synthetic_in_flight = false; + if ctx.will_discard() { + self.next_widgets.clear(); + self.next_widget_hits.clear(); + return; + } + self.widgets = std::mem::take(&mut self.next_widgets); + self.widget_hits = std::mem::take(&mut self.next_widget_hits); + panels::notices::finish_ui_pass(self, ctx); + panels::library_problem::finish_ui_pass(self, ctx); + self.ui_passes = self.ui_passes.wrapping_add(1); + self.layout_epoch = self.layout_epoch.wrapping_add(1); + self.resolve_pending_gestures(ctx); + self.reply_delivered_gestures(); + if self.pointer_cleanup { + // A canceled press may be discovered by a timeout after the synthetic queue emptied; + // nothing else may ask for the frame that applies its cancellation. + ctx.request_repaint(); + } + } + + /// Presentation only: the window's panels, dialogs and overlays. + pub(super) fn draw_window(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let t0 = monotonic_ms(); + panels::library_problem::begin_draw(self); + // A command or shortcut drawn before this can create the window after `ui_pass`; catch it + // here before any panel draws with the still-focused background text field. + let batch_in_flight = self.synthetic_in_flight; + panels::library_problem::prepare_ui_pass(self, &ctx, batch_in_flight); + // A newly shown blocking library window gets the same barrier as a prompt: its first Area + // layout is non-interactable and hit testing still uses the previous pass, so background + // panels (and a focused text field) must not draw in that pass. + if panels::library_problem::shield_pass(self, &ctx) { + #[cfg(test)] + self.library_passes.push(panels::library_problem::LibraryPass::Shield); + panels::library_problem::show(self, &ctx, false); + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + ctx.request_discard("library problem: shield the window's creation frame"); + self.end_frame(&ctx, t0); + return; + } + // A newly shown prompt gets one pass with no background. egui's first Area layout is + // non-interactable and its hit test uses the previous pass, so drawing panels here would + // let a pointer press/release in the prompt's creation frame act underneath it. `ui()` then + // reruns this pass with empty raw input, where the settled prompt and the full window draw + // together. This is a state/input-routing barrier, not a wholesale input disable: once + // settled, raw pointer input still reaches the prompt backdrop (a no-op) or its buttons. + if panels::notices::shield_pass(self, &ctx) { + panels::notices::show(self, &ctx); + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + ctx.request_discard("notice: shield the prompt's creation frame"); + self.end_frame(&ctx, t0); + return; + } + #[cfg(test)] + self.library_passes.push(panels::library_problem::LibraryPass::Background); + panels::dialogs::prepare(self, ui); + // Freeze this frame's notice barrier before `notices::show` can dismiss a prompt: the same + // click or key must never be acted on again by the dialog drawn after it. + let notice_blocks = panels::notices::blocks_input(self); + if std::mem::take(&mut self.ui.quit) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + self.library_changed(&ctx); + // panels set it again this frame while the pointer rests on a preset or profile + self.hover_preview = None; + if self.ui.fullscreen { + // full-screen preview: the photo alone on black + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) + .show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) }); + panels::second::show(self, &ctx); + if notice_blocks { + panels::notices::restore_prompt_pointer(self, &ctx); + } + panels::notices::show(self, &ctx); + if notice_blocks { + panels::notices::hide_background_pointer(&ctx); + } + panels::dialogs::show(self, &ctx, notice_blocks); + updates_ui::show(self, &ctx); + panels::library_problem::show(self, &ctx, notice_blocks); + panels::toast(self, &ctx); + drop_overlay::show(self, &ctx); + if notice_blocks { + // Keep the real press/release state for the next pass: an ordinary click spans + // host frames, and resetting it above must only hide it from this pass's dialogs. + panels::notices::restore_prompt_pointer(self, &ctx); + } + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + self.end_frame(&ctx, t0); + return; + } + // Order matters: earlier panels take the full edge (top bar spans the window; the tool strip, + // right panels and left panel run to the bottom; the bottom bar sits between them). + panels::topbar::show(self, ui); + panels::library_problem::banner(self, ui); + panels::strip::show(self, ui); + // filled: the gaps the dock leaves between its panes show chrome, not the dark clear colour + let chrome = theme::Tokens::get(&ctx).chrome; + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(chrome)).show(ui, |ui| docking::show(self, ui)); + self.lights_out(&ctx); + panels::second::show(self, &ctx); + if notice_blocks { + panels::notices::restore_prompt_pointer(self, &ctx); + } + panels::notices::show(self, &ctx); + if notice_blocks { + panels::notices::hide_background_pointer(&ctx); + } + panels::dialogs::show(self, &ctx, notice_blocks); + updates_ui::show(self, &ctx); + panels::library_problem::show(self, &ctx, notice_blocks); + export_task::poll(self, &ctx); + slideshow_export::poll(self, &ctx); + panels::activity::show(self, &ctx); + export_task::poll_photos(self, &ctx); + pick::poll(self, &ctx); + panels::grid::drag_feedback(self, &ctx); + panels::left::album_drag_feedback(self, &ctx); + panels::left::section_drag_feedback(self, &ctx); + panels::keyword_list::drag_feedback(self, &ctx); + panels::toast(self, &ctx); + drop_overlay::show(self, &ctx); + // AppKit moves the window on a drag anywhere in the title-bar band unless the window isn't + // movable: it stops being movable while the pointer is on a bar control or a panel over it + #[cfg(target_os = "macos")] + craft_window::title_bar::sync_movable(&ctx, &mut self.window_movable); + if notice_blocks { + // Keep the real press/release state for the next pass: an ordinary click spans host + // frames, and resetting it above must only hide it from this pass's dialogs. + panels::notices::restore_prompt_pointer(self, &ctx); + } + (self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx); + self.text_focus = ctx.egui_wants_keyboard_input(); + self.end_frame(&ctx, t0); + } +} + +/// Import commits batches at most once per host frame. The first layout pass and a hidden +/// `run_logic` tick both report pass index 0; a discarded frame's later passes report 1+. A +/// wall-clock gate cannot express that (a slow first pass could re-run it within one frame). +pub(crate) fn import_tick_allowed(ctx: &egui::Context) -> bool { + ctx.current_pass_index() == 0 +} diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs new file mode 100644 index 000000000..0757ecbe0 --- /dev/null +++ b/crates/ui-egui/src/app/input.rs @@ -0,0 +1,261 @@ +use super::*; + +impl LightcraftApp { + /// Take the host's dropped files, exactly once. The queue is filled by `raw_input_hook`, so a + /// hidden logic tick can never replay the last pass's drop. Web hosts call this in their UI + /// phase (they need `bytes_async`, which is not available on a native file path). + pub fn take_dropped_files(&mut self) -> Vec { + std::mem::take(&mut self.dropped_files) + } + + /// Import native drops as presets, face models or photos. + pub(super) fn import_dropped_files(&mut self, ctx: &egui::Context) { + let dropped = self.take_dropped_files(); + if dropped.is_empty() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + self.handle_dropped_files(ctx, dropped); + #[cfg(target_arch = "wasm32")] + let _ = (dropped, ctx); + } + + /// Files dropped on the window: face models open their licence dialog, presets import as + /// presets, photos and folders go to the import review (or straight in, Settings ▸ Import). + #[cfg(not(target_arch = "wasm32"))] + fn handle_dropped_files(&mut self, ctx: &egui::Context, dropped: Vec) { + let dropped: Vec = + dropped.iter().map(|f| egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect(); + if dropped.is_empty() { + return; + } + // a dialog is on screen: the review would replace it, losing what it holds + if self.ui.dialog.is_some() { + self.toast(ctx, crate::i18n::tr("Close the open dialog, then drop the files again")); + return; + } + let drop = drop_overlay::classify(dropped); + if let Some(model) = drop.models.first() + && let Err(e) = panels::faces::open_dialog(self, model) + { + self.toast(ctx, e); + } + if !drop.presets.is_empty() { + // presets live in a library: with none open they would go with the session it replaces + if panels::library_problem::holds_drops(self) { + self.toast(ctx, crate::i18n::tr("Open a library first, then drop the presets again")); + } else { + let _ = self.run("file.importPresets", serde_json::json!({"paths": drop.presets})); + } + } + if drop.photos.is_empty() { + return; + } + // no library open yet (the launch list, a library that failed to open): they wait for one + let Some(photos) = panels::library_problem::hold_dropped(self, ctx, drop.photos) else { return }; + if self.lightroom.is_some() { + self.toast(ctx, crate::i18n::tr("Finish Lightroom catalog import before adding photos")); + return; + } + if import::running(self) { + self.toast(ctx, crate::i18n::tr("An import is running")); + return; + } + let started = if self.ui.settings.import_dropped_directly { + // read and added on a worker thread (dropped folders can be large, or on a slow drive) + import::start_paths(self, photos).map(|_| ()) + } else { + import::open(self, photos).map(|_| ()) + }; + if let Err(e) = started { + self.toast(ctx, e); + } + } + + /// A file dropped by the host, kept for the next UI pass (web hosts consume their own queue). + fn queue_dropped_files(&mut self, raw: &mut egui::RawInput) { + if !raw.dropped_files.is_empty() { + self.dropped_files.append(&mut raw.dropped_files); + } + } + + /// Take the host's dropped files once and inject synthetic events (pointer events one per + /// frame; key sequences up to the release). + /// + /// The hook also runs before a hidden logic-only tick, so it injects at most one batch and + /// keeps that batch in flight until a completed UI pass consumed it. It never injects while + /// the fonts are not installed (the pass would draw nothing and the events would be lost). + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + let file = |p: &String| (!p.is_empty()).then(|| std::path::PathBuf::from(p)); + if !self.drop_pending.is_empty() { + raw.dropped_files = std::mem::take(&mut self.drop_pending) + .into_iter() + .map(|p| std::sync::Arc::new(InjectedDrop(p.into())) as egui::DroppedFileHandle) + .collect(); + } + if !self.drop_hover.is_empty() { + raw.hovered_files = self.drop_hover.iter().map(|p| egui::HoveredFile { path: file(p), ..Default::default() }).collect(); + } + // Queue after injecting, so a `ui.dropFiles` request is consumed exactly once by the UI phase. + self.queue_dropped_files(raw); + // Track the real host modifier state separately from the app's synthetic ownership. A host + // change means the host now owns the modifier state: relinquish any synthetic claim and do + // not later synthesise a release for a key that is physically held. + if let Some(host_mods) = raw.events.iter().rev().find_map(|event| match event { + egui::Event::ModifiersChanged(modifiers) => Some(*modifiers), + _ => None, + }) { + self.host_mods = host_mods; + self.synthetic_mods = egui::Modifiers::NONE; + self.synthetic_mods_release = false; + } + // A prompt owns all input. On its first host frame this also removes pointer/touch input + // before egui computes its hit test; later frames keep only key releases, the prompt's own + // keys, and AccessKit actions targeted at a prompt button. See `notices::filter_raw_input`. + let batch_in_flight = self.synthetic_in_flight; + if crate::panels::notices::prepare_raw_input(self, ctx, raw) { + // A prompt appearing during a synthetic drag must abort the drag, not let its remaining + // move/release events act under the prompt or after it is dismissed. If a whole batch + // was already injected, it is unsafe as well: this frame's prompt filter drops it + // before a panel sees it, so it must not report a successful delivery. + self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared"); + self.queue_pointer_cleanup(); + } + // Enforce the stall deadline before injecting another event: a request that expired during + // a long frame gap must not get one more event on the frame that resumes. + self.expire_gestures(); + // A canceled press is released by resetting egui's pointer state before its next input + // pass, even while another batch is in flight or the fonts are not ready, so the pointer + // cannot stay down. No move/release event is injected: either could start a drag or + // become a click on the widget being canceled. + self.apply_pointer_cleanup(ctx, raw); + if !self.fonts_ready || self.synthetic_in_flight { + return; + } + if self.drop_gestures_stale_before_injection(ctx) { + // The stale check can cancel the head gesture while its press is already down. + self.apply_pointer_cleanup(ctx, raw); + return; + } + // the frame after a synthetic release / key: restore the host's physical modifiers, not + // necessarily NONE (Shift may still be held while a synthetic Alt gesture is released) + if std::mem::take(&mut self.synthetic_mods_release) && self.synthetic_mods != egui::Modifiers::NONE { + self.synthetic_mods = egui::Modifiers::NONE; + raw.events.push(egui::Event::ModifiersChanged(self.host_mods)); + } + if self.synthetic.is_empty() { + return; + } + let n = match self.synthetic[0] { + egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + // A press needs the pointer at its position before the button event; the move that is + // itself the first event is delivered by the drain below, so it must not be sent twice. + if let Some(egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + raw.events.push(egui::Event::PointerMoved(*p)); + } + // egui's `input.modifiers` follow `ModifiersChanged` events, not the modifiers carried by + // pointer/key events: announce the injected events' modifiers (held from button down to + // the frame after up), so ⌥-drag, ⇧-click … work through the control channel + let carried = self.synthetic[..n].iter().find_map(|e| match e { + egui::Event::PointerButton { pressed, modifiers, .. } => Some((*modifiers, !pressed)), + egui::Event::Key { modifiers, .. } => Some((*modifiers, true)), + _ => None, + }); + if let Some((m, ends)) = carried { + if m != self.synthetic_mods { + self.synthetic_mods = m; + raw.events.push(egui::Event::ModifiersChanged(m)); + } + self.synthetic_mods_release = ends; + } + let blocked_by_prompt = self.synthetic[..n].iter().any(|event| !crate::panels::notices::prompt_allows_event(self, event)); + let head = self.head_gesture(); + if let Some(head) = head { + let gesture = &mut self.pending_gestures[head]; + for event in &self.synthetic[..n] { + match event { + egui::Event::PointerButton { button, pressed: true, .. } => { + gesture.pressed = Some(InjectedPress { button: *button }); + } + egui::Event::PointerButton { button, pressed: false, .. } + if gesture.pressed.as_ref().is_some_and(|press| press.button == *button) => + { + gesture.pressed = None; + } + _ => {} + } + } + gesture.started = true; + gesture.remaining_events = gesture.remaining_events.saturating_sub(n); + } + raw.events.extend(self.synthetic.drain(..n)); + // The same barrier applies after injection: only the prompt's own keys and targeted + // AccessKit actions survive; host input and `ui.text`/clipboard never reach the background. + crate::panels::notices::filter_raw_input(self, raw); + if blocked_by_prompt && let Some(index) = head { + // A settled prompt is already laid out, so this is not the prompt-creation-cancels-all + // path: only the gesture whose batch the barrier removed fails. Allowed prompt keys, + // pointer input and targeted AccessKit actions still report a successful delivery. + let gesture = self.pending_gestures.remove(index); + self.cancel_gesture_events(&gesture, true); + let _ = gesture.reply.send(serde_json::json!({ + "ok": false, + "error": format!("{} canceled: the open modal prompt blocked this input", gesture.method), + })); + } + // The 5 s deadline is a delivery-stall limit, not a limit on a gesture's frame count: a + // drag feeds one event per host frame, so every batch that the app consumes proves the UI + // is alive and restarts the clock for the whole queue. + let now = self.tick_now_ms(); + for gesture in &mut self.pending_gestures { + gesture.deadline_ms = now + GESTURE_TIMEOUT_MS; + } + self.synthetic_in_flight = true; + } +} + +#[cfg(test)] +mod drop_tests { + #[test] + fn photo_sidecars_are_not_presets() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]); + let (presets, photos) = super::split_presets(dropped); + assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"])); + assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"])); + } + + #[test] + fn detection_results_are_summarised_in_words() { + let r = + |counts: &[usize]| serde_json::json!({"photos": counts.iter().map(|n| serde_json::json!({"faces": vec![0; *n]})).collect::>()}); + assert_eq!(super::detect_summary(&r(&[0])), "No faces found"); + assert_eq!(super::detect_summary(&r(&[1])), "Found 1 face"); + assert_eq!(super::detect_summary(&r(&[3])), "Found 3 faces"); + assert_eq!(super::detect_summary(&r(&[0, 0])), "No faces found in 2 photos"); + assert_eq!(super::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos"); + assert_eq!(super::detect_summary(&serde_json::json!({})), "No faces found in 0 photos"); + } + + #[test] + fn dropped_face_models_are_told_apart() { + for p in ["/a/model.onnx", "/a/dir/M.ONNX"] { + assert!(super::is_model_file(p), "{p}"); + } + for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] { + assert!(!super::is_model_file(p), "{p}"); + } + } + + #[test] + fn dropped_presets_are_told_apart_from_photos() { + for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] { + assert!(super::is_preset_file(p), "{p}"); + } + for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] { + assert!(!super::is_preset_file(p), "{p}"); + } + } +} diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs new file mode 100644 index 000000000..a85a5f751 --- /dev/null +++ b/crates/ui-egui/src/app/prefs.rs @@ -0,0 +1,114 @@ +use super::*; + +impl LightcraftApp { + /// The system's appearance: the host's answer first, then egui's (none: unknown). + pub fn system_theme(&self, ctx: &egui::Context) -> Option { + self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) + } + + /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light + /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. + pub fn sync_theme(&mut self, ctx: &egui::Context) { + let wanted = self.ui.settings.theme(self.system_theme(ctx)); + if theme::current(ctx) != Some(wanted) { + theme::apply_kind(ctx, wanted); + } + } + + /// Back from an external editor: pick up the files it saved. Reads `i.focused`, which + /// `run_logic` does not refresh, so it belongs to the UI phase. + pub(super) fn check_external_editor(&mut self, ctx: &egui::Context) { + let focused = ctx.input(|i| i.focused); + if focused && !self.ui.was_focused && !self.ui.external_edits.is_empty() { + let ids = self.ui.external_edits.clone(); + if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) + && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) + { + self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor")); + } + } + self.ui.was_focused = focused; + } + + /// Apply settings that act outside the UI state: GPU rendering and window full screen. + pub(super) fn apply_settings(&mut self, ctx: &egui::Context) { + if self.gpu_applied != Some(self.ui.settings.gpu) { + self.gpu_applied = Some(self.ui.settings.gpu); + let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); + // GPU device + kernels off the UI thread, once the window is up and only when GPU + // rendering is on: a broken driver must not keep the window from appearing (issue #136) + if self.ui.settings.gpu { + lightcraft_engine::gpu::warm_up(); + } + } + let mb = self.ui.settings.memory_mb; + // automatic at startup: leave the engine's default alone + if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) { + let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb }; + let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb})); + } + self.memory_applied = Some(mb); + // the monitor profile (Settings ▸ Display): previews follow it from the next frame + if self.display_applied.as_ref() != Some(&self.ui.settings.display_profile) { + let path = self.ui.settings.display_profile.clone(); + match lightcraft_engine::display::Display::load_opt(&path) { + Ok(d) => { + self.renderer.set_display(d); + self.display_error = None; + } + Err(msg) => { + // a profile that can't be used: previews are sRGB, the setting is kept + self.renderer.set_display(None); + log::warn!("display profile: {msg}"); + self.toast_error(ctx, format!("{}: {msg}", crate::i18n::tr("Display profile not used"))); + self.display_error = Some(msg); + } + } + self.display_applied = Some(path); + // none chosen again: the screen's profile is read afresh + self.screen_profile = None; + self.screen_profile_read = None; + } + if self.ui.settings.display_profile.is_empty() { + self.follow_screen_profile(ctx); + } + if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { + self.window_is_fullscreen = fs; + } + if let Some(on) = self.ui.window_fullscreen.take() { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on)); + self.window_is_fullscreen = on; + } + } + + /// While no display profile is chosen, show previews through the profile of the screen the + /// window is on (`services.screen_profile`), read about once a second so moving the window to + /// another screen is followed. Without the service (or a profile) the display is sRGB. + fn follow_screen_profile(&mut self, _ctx: &egui::Context) { + let now = self.tick_now(); + if self.services.screen_profile.is_none() || self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) { + return; + } + self.screen_profile_read = Some(now); + self.apply_screen_profile(); + } + + /// Read the screen's profile now and show previews through it (sRGB without one). + pub(crate) fn apply_screen_profile(&mut self) { + let Some(read) = self.services.screen_profile.as_mut() else { return }; + let icc = read(); + if icc == self.screen_profile { + return; + } + let display = icc.as_deref().and_then(|bytes| match lightcraft_engine::display::Display::from_bytes(SCREEN_PROFILE, bytes) { + Ok(d) => Some(std::sync::Arc::new(d)), + Err(msg) => { + // a profile we can't use: previews stay sRGB, as before #787 + log::warn!("screen profile: {msg}"); + None + } + }); + self.renderer.set_display(display); + self.screen_profile = icc; + } +} diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs new file mode 100644 index 000000000..e97d257ea --- /dev/null +++ b/crates/ui-egui/src/app/screenshot.rs @@ -0,0 +1,362 @@ +use super::*; +use crate::control; + +impl LightcraftApp { + /// The immutable identity a capture is bound to: the library generation. It changes when a + /// different library is opened, not on every edit, import batch or keyword op (`catalog.revision` + /// bumps on all of those), so a screenshot during interactive editing still belongs to the + /// document the user asked for. + pub(crate) fn shot_document_generation(&self) -> u64 { + self.session.library_generation() + } + + /// The window's current surface geometry as native points plus native pixels per point. + /// `run_logic` refreshes `input.viewports`, so this is fresh even while the UI is not laid out; + /// without one, the last laid-out content rect is used. + fn shot_geometry(&self, ctx: &egui::Context) -> ShotGeometry { + // Both `viewport.inner_rect` and the content rect are in egui points, and `pixels_per_point` + // already includes the UI zoom (egui-winit divides by the zoomed ppp). Multiplying by the + // zoom again would reserve a surface that is too small, so the zoom is only tracked as a + // change signal. + let zoom = ctx.zoom_factor(); + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + let pixels_per_point = ctx.input(|i| i.pixels_per_point()); + let viewport = ctx.input(|i| { + i.viewport().inner_rect.filter(|r| r.width() > 0.0 && r.height() > 0.0).map_or_else(|| i.content_rect().size(), |inner| inner.size()) + }); + ShotGeometry { viewport, pixels_per_point, zoom, epoch: self.shot_geometry_epoch } + } + + /// Track geometry changes as a generation. Called once per logic tick from the refreshed + /// viewport information, so a hidden window still notices a resize. + pub(super) fn note_shot_geometry(&mut self, ctx: &egui::Context) { + let mut now = self.shot_geometry(ctx); + if let Some(last) = self.last_shot_geometry + && (last.viewport != now.viewport || last.pixels_per_point != now.pixels_per_point || last.zoom != now.zoom) + { + self.shot_geometry_epoch = self.shot_geometry_epoch.wrapping_add(1); + now.epoch = self.shot_geometry_epoch; + } + self.last_shot_geometry = Some(now); + } + + /// The texture limit the host reported (16 384 unless it said otherwise). + fn max_texture_side(&self) -> usize { + self.max_texture_side + } + + /// Retain the host's `RawInput::max_texture_side`. Hosts (egui-winit, `Headless`) report it + /// only when it changes, so keep the last value instead of assuming 16 384. + pub(super) fn note_max_texture_side(&mut self, ctx: &egui::Context) { + if let Some(limit) = ctx.input(|i| i.raw.max_texture_side) + && limit > 0 + { + self.max_texture_side = limit; + } + } + + /// Reserve a new image's `bytes` against the aggregate screenshot budget, or report why not. + fn reserve_shot_bytes(&mut self, bytes: usize) -> Result<(), String> { + if bytes > MAX_SHOT_BYTES { + return Err(format!("ui.screenshot: {bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit")); + } + let total = self.shot_bytes.checked_add(bytes).ok_or("ui.screenshot: byte reservation overflow")?; + if total > MAX_SHOT_TOTAL_BYTES { + return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit")); + } + self.shot_bytes = total; + Ok(()) + } + + /// A surface that grew after admission (a stale `content_rect` on Wayland, a resize between + /// issue and completion): bound the *new image* against the per-image limit, then reserve only + /// the growth against the aggregate. The old per-image check would compare the delta to the + /// per-image cap and name the delta as if it were the image. + fn grow_shot_reservation(&mut self, current_reserved: usize, image_bytes: usize) -> Result<(), String> { + if image_bytes > MAX_SHOT_BYTES { + return Err(format!("ui.screenshot: {image_bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit")); + } + let extra = image_bytes.saturating_sub(current_reserved); + let total = self.shot_bytes.checked_add(extra).ok_or("ui.screenshot: byte reservation overflow")?; + if total > MAX_SHOT_TOTAL_BYTES { + return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit")); + } + self.shot_bytes = total; + Ok(()) + } + + /// Release a reservation; never let an accounting bug underflow `usize`. + fn release_shot_bytes(&mut self, bytes: usize) { + self.shot_bytes = self.shot_bytes.saturating_sub(bytes); + } + + /// Admit a `ui.screenshot` request against the bounded capture budget. Nothing is written and + /// no capture is issued here: [`Self::issue_screenshots`] waits for a settled, nondiscarded UI + /// pass and a fresh geometry generation. + pub(crate) fn admit_shot( + &mut self, + ctx: &egui::Context, + target: Option, + headless: bool, + reply: Sender, + ) -> Result<(), String> { + if self.pending_screenshots.len() >= MAX_PENDING_SHOTS { + return Err(format!("ui.screenshot: {MAX_PENDING_SHOTS} requests are already pending; wait for a reply first")); + } + let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx)); + let bytes = geometry.bytes(self.max_texture_side()).ok_or_else(|| geometry.rejection(self.max_texture_side()))?; + self.reserve_shot_bytes(bytes)?; + self.screenshot_token = self.screenshot_token.wrapping_add(1); + let now = self.tick_now_ms(); + self.pending_screenshots.push(PendingShot { + token: self.screenshot_token, + target, + reply, + headless: headless && !self.headless_host, + admitted_passes: self.ui_passes, + not_before_ms: now + SHOT_SETTLE_MS, + // Capture the current settled frame at this instant even if renders are still in flight + // (0.36 did this at its three-second deadline, so automation never got an error instead + // of an image while a slider/import kept the renderer busy). + force_ms: now + SHOT_FORCE_BUSY_MS, + // Settle + forced capture + compositor fallback + the headless settle, all from admission. + deadline_ms: now + SHOT_SETTLE_MS + SCREENSHOT_SETTLE_MS + SCREENSHOT_FALLBACK_MS + SCREENSHOT_SETTLE_MS, + sent_at_ms: None, + reserved: bytes, + slot: None, + issued: None, + issued_library: self.shot_document_generation(), + }); + ctx.request_repaint(); + Ok(()) + } + + /// Advance pending screenshots: wait (≥ 3 completed UI frames, ≥ 120 ms) until no renders are + /// in flight — or until the forced-capture instant, so sustained rendering cannot turn a + /// request into a timeout — then capture through the compositor's one-shot callback, or + /// headlessly ([`Self::headless_screenshot`]) when asked or when the compositor delivers nothing. + pub(crate) fn issue_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_screenshots.is_empty() { + return; + } + let now = self.tick_now_ms(); + let busy = self.renderer.in_flight() > 0 || self.merge.busy(); + let max_side = self.max_texture_side(); + let library = self.shot_document_generation(); + let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx)); + let mut shots = std::mem::take(&mut self.pending_screenshots); + let mut shadow_ticked = false; + let mut keep = Vec::with_capacity(shots.len()); + for mut shot in shots.drain(..) { + // Hard deadline from admission: a compositor fallback does not extend it. + if now > shot.deadline_ms { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)})); + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + self.release_shot_bytes(shot.reserved); + continue; + } + if let Some(sent) = shot.sent_at_ms { + if now - sent < SCREENSHOT_FALLBACK_MS || self.headless_host { + keep.push(shot); + continue; + } + // The compositor delivered nothing (display asleep, window occluded): go headless. + log::warn!("ui.screenshot: no frame from the compositor after {SCREENSHOT_FALLBACK_MS} ms; rendering headlessly"); + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + shot.sent_at_ms = None; + shot.issued = None; + shot.headless = true; + } + // A busy renderer only defers the capture until the forced instant; after that the + // settled frame that exists now is captured (0.36's deadline behavior), never an error. + let ready = shot_ready(now, &shot, busy, self.ui_passes); + if !ready { + keep.push(shot); + continue; + } + if shot.headless { + // The shadow frame requests the renders the UI needs, even while the window shows nothing. + let img = if shadow_ticked { None } else { self.headless_screenshot(ctx, true) }; + shadow_ticked = true; + if let Some(img) = img { + let bytes = img.size[0].checked_mul(img.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); + match bytes { + Some(bytes) if bytes > shot.reserved => { + // A stale/undersized reservation is not a user error: grow it to the + // actual image and encode it when the per-image and aggregate caps allow. + if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); + self.release_shot_bytes(shot.reserved); + continue; + } + shot.reserved = bytes; + } + Some(_) => {} + None => { + let reply = serde_json::json!({ + "ok": false, + "error": "ui.screenshot: the rendered image has invalid dimensions", + }); + let _ = shot.reply.send(reply); + self.release_shot_bytes(shot.reserved); + continue; + } + } + let _ = shot.reply.send(control::save_screenshot(self, &img, shot.target.as_ref())); + self.release_shot_bytes(shot.reserved); + continue; + } + keep.push(shot); + continue; + } + // The compositor path needs a real, laid-out UI and fresh fonts; a queued request on a + // hidden window is kept alive by RequestPaintWhileHidden from `logic`. + if !self.fonts_ready || self.ui_passes == 0 { + keep.push(shot); + continue; + } + // Revalidate the reservation against the geometry the command is actually bound to. + let Some(bytes) = geometry.bytes(max_side) else { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": geometry.rejection(max_side)})); + self.release_shot_bytes(shot.reserved); + continue; + }; + if bytes > shot.reserved { + if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); + self.release_shot_bytes(shot.reserved); + continue; + } + shot.reserved = bytes; + } + // One slot and one callback per issue; the callback only holds a weak slot and wakes + // the viewport. File writes and replies happen in `collect_screenshots`. + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_SHOT_BYTES); + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); + log::debug!("ui.screenshot {}: issued at {:?}, {} bytes reserved", shot.token, geometry.viewport, shot.reserved); + shot.slot = Some(slot); + shot.sent_at_ms = Some(now); + shot.issued = Some(geometry); + shot.issued_library = library; + keep.push(shot); + } + self.pending_screenshots = keep; + if !self.pending_screenshots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it + /// on the CPU (photo textures come from the renderer's CPU copies). + pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { + let mut view = self.shadow.take().unwrap_or_default(); + let size = main.input(|i| i.content_rect()).size(); + let size = if size.x >= 1.0 && size.y >= 1.0 { size } else { egui::vec2(1600.0, 1000.0) }; + // Feed native viewport points; the shadow context applies the copied UI zoom. + let zoom = main.zoom_factor(); + let size = size * zoom; + let ppp = main.pixels_per_point() / zoom; + let time = main.input(|i| i.time); + if view.frames() == 0 { + // warm-up pass: activates our fonts (pending font definitions live in `Memory`, which + // is replaced below) + view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |_| {}); + } + // same scroll offsets, open sections, style… as the window + let memory = main.memory(|m| m.clone()); + view.ctx.memory_mut(|m| *m = memory); + view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |ui| self.draw_window(ui)); + let img = capture.then(|| { + let mut tex = self.renderer.cpu_textures(); + if let (Some((t, ..)), Some(px)) = (&self.merge.preview, &self.merge.preview_pixels) { + tex.insert(t.id(), crate::softpaint::CpuTexture::linear(px.clone())); + } + view.paint(&tex) + }); + self.shadow = Some(view); + img + } + + /// Take each completed capture once, reject stale generations before writing anything, and + /// reply exactly once. A late callback after cancel/drop finds no slot and is inert. + pub(crate) fn collect_screenshots(&mut self, _ctx: &egui::Context) { + if self.pending_screenshots.is_empty() { + return; + } + let now = self.tick_now_ms(); + let current = self.last_shot_geometry; + let library = self.shot_document_generation(); + let mut shots = std::mem::take(&mut self.pending_screenshots); + let mut keep = Vec::with_capacity(shots.len()); + for mut shot in shots.drain(..) { + let completed = shot.slot.as_ref().and_then(craft_ui::capture::CaptureSlot::take); + match completed { + Some(Ok(image)) => { + let stale = + shot.issued.is_none_or(|issued| current.is_none_or(|now| now.epoch != issued.epoch)) || shot.issued_library != library; + if stale { + // A resize/DPI/zoom or library change between issue and completion: never + // combine old pixels with new geometry. Retry with a fresh slot. + shot.slot = None; + shot.sent_at_ms = None; + shot.issued = None; + shot.not_before_ms = now + SHOT_SETTLE_MS; + keep.push(shot); + continue; + } + let pixels = image.size[0].checked_mul(image.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::())); + let Some(bytes) = pixels else { + let reply = serde_json::json!({ + "ok": false, + "error": "ui.screenshot: the rendered image has invalid dimensions", + }); + let _ = shot.reply.send(reply); + self.release_shot_bytes(shot.reserved); + continue; + }; + if bytes > shot.reserved { + // Wayland reports no `inner_rect`, so the reservation can be based on a stale + // content rect while the compositor returns the new, larger surface. Treat a + // larger-but-legal image as a stale reservation and grow it; never mix it + // with geometry of another epoch (checked above). + if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error})); + self.release_shot_bytes(shot.reserved); + continue; + } + shot.reserved = bytes; + } + self.release_shot_bytes(shot.reserved); + let _ = shot.reply.send(control::save_screenshot(self, &image, shot.target.as_ref())); + } + Some(Err(error)) => { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error.to_string()})); + self.release_shot_bytes(shot.reserved); + } + None => { + if now > shot.deadline_ms { + let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)})); + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + self.release_shot_bytes(shot.reserved); + } else { + keep.push(shot); + } + } + } + } + self.pending_screenshots = keep; + } +} + +/// Whether a pending request may be issued now: it has waited for the settle time and completed +/// UI frames, and either no render is in flight or the forced-capture instant has passed. +pub(crate) fn shot_ready(now_ms: f64, shot: &PendingShot, busy: bool, ui_passes: u64) -> bool { + let settled = ui_passes.saturating_sub(shot.admitted_passes) >= SHOT_SETTLE_FRAMES && now_ms >= shot.not_before_ms; + settled && (!busy || now_ms >= shot.force_ms) +} From 69048937d084128a4483806eaa89f3ec757e08f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:25:09 +0000 Subject: [PATCH 130/191] docs: update source paths after the layout moves --- docs/ai-masks.md | 2 +- docs/background-tasks.md | 2 +- docs/contributors.md | 2 +- docs/egui-037-migration-plan.md | 4 +- docs/file-format-parity.md | 2 +- docs/focus-points.md | 4 +- docs/parity-checklist.md | 172 ++++++++++++++++---------------- docs/ui-parity.md | 4 +- docs/xmp-interop.md | 2 +- 9 files changed, 97 insertions(+), 97 deletions(-) diff --git a/docs/ai-masks.md b/docs/ai-masks.md index 84e082163..845dc0e23 100644 --- a/docs/ai-masks.md +++ b/docs/ai-masks.md @@ -115,7 +115,7 @@ The model is fetched from an ordered list of mirrors: base URLs where `/mo 2. the file `models/sam3-mirrors.txt` in LightCraft's settings folder (one URL per line, `#` comments) — `~/Library/Application Support/LightCraft/` (macOS), `%APPDATA%\LightCraft\` (Windows), `~/.config/lightcraft/` (Linux); -3. the built-in list, `DEFAULT_MIRRORS` in `crates/segment/src/fetch/mod.rs`. +3. the built-in list, `DEFAULT_MIRRORS` in `crates/segment/src/fetch.rs`. > **Maintainers:** the built-in list is **empty** until LightCraft's own CDN locations exist > (see the `TODO(maintainer)` there): add them in order of preference, host the three files diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 86deac24d..0c9281dfc 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -66,6 +66,6 @@ guard.detail(&file_name); ## Testing Assert the row (`session.activity.list()`: kind, label, total, `cancellable`), that `activity.cancel(id)` stops the -job, and that the row is gone once the job ends. `crates/ui-egui/src/tests_activity.rs` has the helpers, and the +job, and that the row is gone once the job ends. `crates/ui-egui/src/tests/activity.rs` has the helpers, and the job-specific tests show the pattern (`tests_sync.rs`, `export_task.rs`, `tests_denoise.rs`…). A row only appears on screen once its task is 0.5 s old, so tests that click its ✕ wait for `activity:cancel:` first. diff --git a/docs/contributors.md b/docs/contributors.md index e2ef889e5..78e234dfa 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -23,7 +23,7 @@ this page is the local copy of the decision. ## In LightCraft -- The About dialog (`crates/ui-egui/src/panels/dialogs.rs`, Help ▸ About LightCraft) has the tabs +- The About dialog (`crates/ui-egui/src/dialogs.rs`, Help ▸ About LightCraft) has the tabs **About · Contributors · Models**; the control channel can switch them with `ui.clickWidget {"id": "button:aboutTab-contributors"}` (`-about`, `-models`), and the list view with `button:creditsGrabBag` / `button:creditsTable`. diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 6606d4e88..72b516f9e 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -616,7 +616,7 @@ admission and completion (neither destination receives a screenshot). ### 4.7 Panels, IDs and docking -Anchors: `crates/ui-egui/src/panels/mod.rs:52-77` (`resizable_side`), +Anchors: `crates/ui-egui/src/panels.rs:52-77` (`resizable_side`), `panels/bottombar.rs:24,371`, `panels/strip.rs:19`, `panels/topbar.rs:57`, `panels/library_problem.rs:169`, `crates/ui-egui/src/docking.rs:430`, `shortcuts.rs:339`. @@ -1389,7 +1389,7 @@ exactly (the pinned fork's winit byte-range event). ### Tests added (source only) -`crates/ui-egui/src/tests_lifecycle_037.rs`: +`crates/ui-egui/src/tests/lifecycle_037.rs`: - bundled monochrome emoji + explicit font-feature gate; - parent-salted panel state (and no legacy exact-`Id` state); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e8b86a670..8bf7d6d6f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -21,7 +21,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | PSD / PSB | ✅ (maximized-compatibility composite) | ✅ (Classic: PSD export, edit-in round trip) | ✅ merged composite | ⬜ (external-editor round trip writes 16-bit TIFF) | `crates/codecs/src/psd.rs` | | HEIC / HEIF | ✅ | ⬜ (Classic) / HDR only | ✅ in every release build (`crates/heif`, heic-rs) | ⬜ | `heif` feature on by packaging scripts | | AVIF | ✅ | ✅ (HDR) | ⬜ recognised, not decoded (no permissive pure-Rust AV1 decoder) | ✅ 8/10-bit, PQ HDR | | -| JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ✅ lossy / lossless, 8/16-bit, PQ HDR (`jixel`, native only; ⬜ web) | `crates/engine/src/tests_export.rs` | +| JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ✅ lossy / lossless, 8/16-bit, PQ HDR (`jixel`, native only; ⬜ web) | `crates/engine/src/tests/export.rs` | | WebP | not in its document types | ⬜ | ✅ | ✅ | beyond target | | GIF, BMP | ⬜ | ⬜ | ✅ | ⬜ | beyond target | | DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed, LJ92, Deflate or lossy (JPEG tiles, optional long edge); ⬜ embedded original | `crates/raw/src/dngwrite.rs` | diff --git a/docs/focus-points.md b/docs/focus-points.md index 2171a7c15..e17e60d1d 100644 --- a/docs/focus-points.md +++ b/docs/focus-points.md @@ -113,7 +113,7 @@ DMF (AF, then manual touch-up) keeps its point. - `crates/meta/src/focus_read.rs`: the header-only read; its tests check it against the whole-file read on intact, truncated and corrupted files and count what it reads. - `crates/engine/src/focus_scan.rs`: the background job (`photo.readFocusPoints`, `focus.pump`) and - `crates/engine/src/tests_focus_scan.rs`, with an opt-in comparison over real files + `crates/engine/src/tests/focus_scan.rs`, with an opt-in comparison over real files (`LIGHTCRAFT_FOCUS_FILES=`); `crates/ui-egui/src/focus_scan.rs` drives it from the frame loop. - `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `focus_pan`, `draw_focus_point`): mapping through the user's - rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests_focus_point.rs`. + rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests/focus_point.rs`. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index afbed8675..90cfc248f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -69,13 +69,13 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/album_picker.rs`, `crates/ui-egui/src/tests_album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | -| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → the import review), `crates/ui-egui/src/drop_overlay.rs`, `crates/ui-egui/src/tests_drop_import.rs` | while files are dragged over the window a veil and a card say what dropping does (review N items for import, import N presets, open the face model; a note to close the open dialog first when one is open); photos and folders open the import review as File ▸ Import does (Settings ▸ Import ▸ Import dropped files without review imports them at once; the web build always does); with no library open on the desktop (the launch list, a library that couldn't open) photos wait for the library opened and presets are refused, and the card says so; a drop while a dialog is open (also in the web build) or while an import is running is refused with a toast, and the card says so; a finished scan waits for an open dialog to close before its review opens; the control channel's `ui.dropFiles` hovers or drops files for scripts; dropping onto a specific album not supported | -| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | -| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/import.rs`) | -| LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/dialogs/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/widgets/album_picker.rs`, `crates/ui-egui/src/tests/album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests/import.rs`) | +| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → the import review), `crates/ui-egui/src/drop_overlay.rs`, `crates/ui-egui/src/tests/drop_import.rs` | while files are dragged over the window a veil and a card say what dropping does (review N items for import, import N presets, open the face model; a note to close the open dialog first when one is open); photos and folders open the import review as File ▸ Import does (Settings ▸ Import ▸ Import dropped files without review imports them at once; the web build always does); with no library open on the desktop (the launch list, a library that couldn't open) photos wait for the library opened and presets are refused, and the card says so; a drop while a dialog is open (also in the web build) or while an import is running is refused with a toast, and the card says so; a finished scan waits for an open dialog to close before its review opens; the control channel's `ui.dropFiles` hovers or drops files for scripts; dropping onto a specific album not supported | +| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests/import.rs` | | +| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/dialogs/import.rs`) | +| LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/dialogs/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | -| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | +| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/dialogs/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back, each on its own from the row's list when several are hidden, or all at once with Restore All (issue #2114); a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | @@ -84,8 +84,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/it/corpus.rs`, `crates/raw/tests/it/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/it/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`), `crates/raw/src/dng.rs` (`decode_reduced`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs; a DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom HDR/panorama merges) develops a thumbnail or preview from the smallest copy that covers it (512 px of a 23836 × 7078 pano: 38 s → 1.1 s) | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | -| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | -| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | +| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests/import_move.rs`, `crates/ui-egui/src/dialogs/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | +| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/dialogs/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | ## B. Library management (LIB) @@ -94,13 +94,13 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | -| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | -| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | +| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests/albums_local.rs`, `crates/ui-egui/src/tests/panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | +| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests/panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:library.holdView`, `cmd:library.refresh`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; views and source counts with direct or nested relative-date rules expire on the session-clock minute; sidebar counts remain cached without building id lists; View ▸ Hold View Until Refreshed (`library.holdView`, off by default) keeps a photo that an edit or clock expiry made stop matching (a smart album, Picks, the filter bar) in the view until Refresh View, Reshuffle or another source, filter or sort, while newly matching photos enter at once | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | -| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/rating.rs` | one shared star row in the bottom bar, Info panel and filter bar (hover previews, ←/→ on a focused star) and one painter for badges (grid cells, filmstrip, Compare caption); menu rows with star text (Photo ▸ Set Rating, Select By Rating, the grid context menu) are separate menu items, unchanged; 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. A rating, flag or label that takes the active photo out of the view (filter bar, Picks, smart album) makes the photo after it active (the one before at the end), and Shift / Auto Advance doesn't step past it (#2312). Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | +| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/widgets/rating.rs` | one shared star row in the bottom bar, Info panel and filter bar (hover previews, ←/→ on a focused star) and one painter for badges (grid cells, filmstrip, Compare caption); menu rows with star text (Photo ▸ Set Rating, Select By Rating, the grid context menu) are separate menu items, unchanged; 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. A rating, flag or label that takes the active photo out of the view (filter bar, Picks, smart album) makes the photo after it active (the one before at the end), and Shift / Auto Advance doesn't step past it (#2312). Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file; a pick shows as a filled flag on thumbnails, in Compare, in the loupe and on the Pick button, a reject outlined (#1013) | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | @@ -108,7 +108,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges (a click expands / collapses the stack, selected or not), expand/collapse (a click or double-click on a grid badge toggles the stack once, #2233), auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | -| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | +| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests/album_remove.rs`, `crates/ui-egui/src/tests/album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | | LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. A capture time that doesn't fully read (cut short, or text after it) is left as it is and counted (`unreadable`); it can't anchor a set that moves other photos, and set alone repairs it. | @@ -130,10 +130,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with an output-device profile proof or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. Photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; only an output-device-profile proof renders on the CPU, where a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with an output-device-profile soft proof (the CPU path) | -| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | +| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests/focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | -| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | -| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | +| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests/before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | +| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys, the bottom bar and the Info panel's stars and colour labels act on the active photo, each tile's caption shows its colour label, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions`, `crates/ui-egui/src/panels/detail.rs` (`info_overlay`) | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel); the star rating and flag show under the lines when the photo has them, and in the full-screen preview on macOS the overlay starts below the window controls (issue #2460) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | @@ -165,11 +165,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `cmd:view.infoSelectedPhotos`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`, `mixed_meta`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. With several photos selected, Show Metadata For picks Selected Photos (the default: the values they share; a text field or GPS where they differ is empty and says Mixed, Copyright Status shows Mixed, and only what is typed or picked there is saved, to every selected photo) or Target Photo (the active photo's values; an edit goes to that photo alone). No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | -| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | +| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/dialogs/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | -| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests/xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | +| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests/xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -190,7 +190,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | | | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | -| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | +| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests/wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | | LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; Canon CR2 `ColorData` presets, PowerShot G1 X Mark III: the camera's own As Shot temperature reads back within 5 % on its 9 white-balance settings; or built-in presets from the camera's own stated temperatures, `crates/raw/src/kelvin_builtin.rs`: Nikon Z5II, whose Natural Auto shot reads back at 5036 K against the 5030 K the camera wrote). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | @@ -198,7 +198,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode | | LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | `ctl:bw.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) | | LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | | -| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests_grading.rs`); a wheel drag holds the pointer as in Lightroom: hidden while dragging (the puck follows the mouse's travel; on macOS the pointer is locked in place), back where the button went down on release, and given back at once on Esc, a lost window focus or a wheel that goes away mid-drag; the browser can't move the pointer, so there it only shows again where the drag ended (issue #1899; tests `a_held_wheel_follows_the_mouses_motion_not_where_the_pointer_sits`, `a_wheel_drag_hides_the_pointer_and_releasing_puts_it_back_where_it_was_pressed`, `escape_or_losing_focus_gives_the_pointer_back_mid_drag`, `a_wheel_that_goes_away_mid_drag_gives_the_pointer_back`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | +| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests/grading.rs`); a wheel drag holds the pointer as in Lightroom: hidden while dragging (the puck follows the mouse's travel; on macOS the pointer is locked in place), back where the button went down on release, and given back at once on Esc, a lost window focus or a wheel that goes away mid-drag; the browser can't move the pointer, so there it only shows again where the drag ended (issue #1899; tests `a_held_wheel_follows_the_mouses_motion_not_where_the_pointer_sits`, `a_wheel_drag_hides_the_pointer_and_releasing_puts_it_back_where_it_was_pressed`, `escape_or_losing_focus_gives_the_pointer_back_mid_drag`, `a_wheel_that_goes_away_mid_drag_gives_the_pointer_back`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | | LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | @@ -218,12 +218,12 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | | LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | -| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`), `crates/engine/src/lib.rs` (`fit_constrained_crop`) | Ticking it, or changing Geometry, Upright or lens corrections while it is ticked, shrinks the stored crop about its centre until no blank corner is left, so the Crop tool shows it (it used to apply at render time only, outside the Crop tool: issue #1927; tests in `crates/engine/src/tests_constrain_crop.rs`). A crop drawn by hand is kept as drawn; unticking keeps the tighter crop | +| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`), `crates/engine/src/lib.rs` (`fit_constrained_crop`) | Ticking it, or changing Geometry, Upright or lens corrections while it is ticked, shrinks the stored crop about its centre until no blank corner is left, so the Crop tool shows it (it used to apply at render time only, outside the Crop tool: issue #1927; tests in `crates/engine/src/tests/constrain_crop.rs`). A crop drawn by hand is kept as drawn; unticking keeps the tighter crop | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | | LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled`, `crates/develop/src/lib.rs` (`effective`) | the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) | -| LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `cmd:section.calibration`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests_solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on (agents: `section.` `{solo: true}`, refused for a section the Edit panel has not got; header clicks run `section.` too, Calibration included). `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | +| LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `cmd:section.calibration`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests/solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on (agents: `section.` `{solo: true}`, refused for a section the Edit panel has not got; header clicks run `section.` too, Calibration included). `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | | LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" | | LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | | @@ -249,11 +249,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return); on a straightened photo a moved box slides along the tilted edges to where the pointer asks, so a box that fills the photo's height still reaches its sides (issue #2299) | | LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A, its own row in Keyboard Shortcuts: `crop.lockAspect` toggles it, sharing A with Visualize Spots by context), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | -| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests/crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests/crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required); turns by at most 10° (`STRAIGHTEN_MAX_DEGREES`) and only on at least 3 agreeing lines (`STRAIGHTEN_MIN_LINES`), and says so when it finds nothing (`auto_straighten_says_when_it_finds_nothing`). Measured on 68 photos straightened by hand in Lightroom Classic and 300 left as shot: it levels 14 of the 68 within 0.5° (1 more than 1° off) and turns 19 of the 300 by more than 1° (before the limits: 79, some by 20° or more). 🟡 because it still leaves most tilted photos alone: a soft sea horizon among wave crests, and verticals that converge in perspective, aren't used yet | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | -| LR-GUIDED-SELECT | Guide selection in Guided Upright mode | P0 | ✅ | `cmd:tool.guidedUpright`, `cmd:geometry.guides`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/state.rs` (`selected_guide`), `crates/ui-egui/src/tests_guided_guides.rs` | a press near a guide's line selects it (`selected_guide = Some((photo id, index))`; another press on it deselects), a press on an empty spot starts a new guide at that point, and an endpoint drags to reshape the guide (`Gesture::GuideEndpoint`, one undo step; clicking an endpoint only selects); tested in `tests_guided_guides` 8/8 | -| LR-GUIDED-DELETE | Delete removes the selected guide alone | P0 | ✅ | `cmd:photo.delete`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_guided_guides.rs` | with the Guided Upright tool open (`RightPanel::Crop && tool == "guidedUpright"`), Delete / Backspace removes only the selected guide and clears the selection; with nothing selected it removes nothing (the photo itself is never deleted while the tool is open); tested in `tests_guided_guides` | +| LR-GUIDED-SELECT | Guide selection in Guided Upright mode | P0 | ✅ | `cmd:tool.guidedUpright`, `cmd:geometry.guides`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/state.rs` (`selected_guide`), `crates/ui-egui/src/tests/guided_guides.rs` | a press near a guide's line selects it (`selected_guide = Some((photo id, index))`; another press on it deselects), a press on an empty spot starts a new guide at that point, and an endpoint drags to reshape the guide (`Gesture::GuideEndpoint`, one undo step; clicking an endpoint only selects); tested in `tests_guided_guides` 8/8 | +| LR-GUIDED-DELETE | Delete removes the selected guide alone | P0 | ✅ | `cmd:photo.delete`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests/guided_guides.rs` | with the Guided Upright tool open (`RightPanel::Crop && tool == "guidedUpright"`), Delete / Backspace removes only the selected guide and clears the selection; with nothing selected it removes nothing (the photo itself is never deleted while the tool is open); tested in `tests_guided_guides` | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | | LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical`, `crates/pipeline/src/geometry.rs` | the crop tool's uncropped view shows the flips too, with the crop box and its dimming mirrored to match | | LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays | @@ -293,7 +293,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) | | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`), `crates/pipeline/src/masks.rs` (`Mattes`), `crates/raw/src/semantic.rs` | heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) | | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject mask (heuristic, or the photo's person mattes) | -| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | +| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | | LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | @@ -308,9 +308,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve | | LR-MASK-EFFECTS | Local adjustment (effect / brush) presets | P1 | ✅ | `cmd:mask.effects`, `cmd:mask.applyEffect`, `cmd:mask.saveEffect`, `cmd:mask.updateEffect`, `cmd:mask.renameEffect`, `cmd:mask.duplicateEffect`, `cmd:mask.deleteEffect`, `cmd:mask.favoriteEffect`, `cmd:mask.setEffectGroup`, `cmd:mask.renameEffectGroup`, `cmd:mask.toolEffect`, `cmd:mask.importEffects`, `cmd:mask.exportEffects`, `cmd:dialog.saveMaskEffect`, `cmd:dialog.manageMaskEffects`, `cmd:file.exportMaskEffects`, `crates/engine/src/cmd/mask_effects.rs`, `crates/engine/src/cmd/mask_effect_files.rs`, `crates/ui-egui/src/panels/mask_effects.rs` | Effect menu above the mask sliders: grouped (folders, ★ Favorites first), searchable, scrolls, ↑ ↓ Return, ✓ on the matching effect (else Custom), resting on an effect previews it on the photo; right-click: apply to all masks / to the same-named mask on the selected photos (one undo step), update, rename, duplicate, star, move to group, delete (asks first); Reset Sliders; New Effect Preset dialog: which sliders (All / None / Changed Only), Amount, brush size/feather/flow; any mask type; Manage Effect Presets window (search, inline rename, update, duplicate, stars, groups, export, delete with confirmation); a new mask starts with the effect last applied to a mask of its kind (per tool, kept with the library); imports Lightroom Classic local `.lrtemplate` presets, Camera Raw local-correction `.xmp` presets, `.lceffect` and `.zip` bundles, through File ▸ Import Profiles & Presets… and drag & drop too; folders and LrC divider presets (`10 ----- DETAIL ----- 10`) become groups; settings with no local slider here (`grain`, Point Color `pointColors`, `toningLuminance`) are reported and kept with the effect for export, not applied (🟡 for those); brush size/feather/flow aren't in LrC presets (it keeps them per brush), so only `.lceffect` carries them; no built-in effects | -| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until O / the eye shows it again or a mask is created, picked, duplicated or added to (issue #1800); shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | +| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until O / the eye shows it again or a mask is created, picked, duplicated or added to (issue #1800); shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests/preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | -| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests_tool_overlay.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range/AI components | +| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests/tool_overlay.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range/AI components | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | | LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | @@ -321,7 +321,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-PRE-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; the preset applied to a photo stays marked on returning to it until the photo is edited further (issue #2392); resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) | | LR-PRE-CREATE | Create preset | P0 | ✅ | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) | -| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); starred presets are listed in a Favorites section on top, as in the Profile browser (issue #1999, `crates/ui-egui/src/tests_preset_favorites.rs`); no hiding of groups | +| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); starred presets are listed in a Favorites section on top, as in the Profile browser (issue #1999, `crates/ui-egui/src/tests/preset_favorites.rs`); no hiding of groups | | LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | | | LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | | LR-PRE-PREMIUM | Built-in presets (own) | P2 | ✅ | `crates/engine/src/presets.rs` | 41 own-authored presets in 10 groups (Color, Film, B&W incl. toners, Portrait, Landscape, Urban, Food, Seasons, Vintage, Style) | @@ -389,24 +389,24 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | -| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | +| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests/export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | +| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | -| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | +| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | | LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text uses Inter SemiBold, the existing Mincho-first CJK chain, then Thai faces from `CRAFT_FONTS_DIR`, shared with contact-sheet captions and slideshow overlays (#2052, partial). Horizontal Thai mark positioning remains incomplete. Arabic export fallback is deferred until joining and bidi reordering exist; UI font fallback is unchanged. Text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (EXIF orientation applied before sizing; oversized graphics fit the rotated image bounds without stretching, tested by `graphic_watermark_honours_exif_orientation` and `graphic_watermark_keeps_aspect_when_fitted_and_rotated`; `image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | -| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | +| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP, none, or lossy (demosaiced linear camera RGB as 8-bit JPEG tiles at the export quality, a `LinearizationTable` restoring linear values, scaled to the resize long edge when set; `crates/raw/tests/it/lossy_dng.rs`); no embedded JPEG preview size | -| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | +| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests/export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/it/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | -| LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | +| LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | | LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). Imported PSD/PSB photos retain embedded capture dates; PSD edit copies retain EXIF/XMP fields in the catalog and All Metadata. Original with Remove Location Info is refused when embedded PSD/PSB GPS is detected. No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -429,15 +429,15 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; At launch: open the last library or ask (see LRC-LIB-CATALOG-SWITCH); a damaged settings file is kept as `.corrupt-