diff --git a/crates/catalog/src/folders.rs b/crates/catalog/src/folders.rs index aab3b867b..e2d3b312d 100644 --- a/crates/catalog/src/folders.rs +++ b/crates/catalog/src/folders.rs @@ -207,14 +207,31 @@ impl Catalog { /// folder that is no longer there and are dropped (undo restores them). Empty when there is /// nothing to carry or drop. pub fn folder_records_follow(&self, from: &str, to: &str) -> Vec { + self.folder_records_carried(from, to, true) + } + + /// [`Catalog::folder_records_follow`] for a folder located where it already is on disk + /// (`folder.updateLocation`): nothing arrived at `to`, the library only learned that the + /// folder is there. So what it knows about folders at or below `to` (other folders of the + /// library there) stays; a record carried over replaces the one at the same place. + pub fn folder_records_located(&self, from: &str, to: &str) -> Vec { + self.folder_records_carried(from, to, false) + } + + fn folder_records_carried(&self, from: &str, to: &str, clear_destination: bool) -> Vec { let (Some(root), Some(dest)) = (record_key(from), record_key(to)) else { return Vec::new() }; if root == dest { return Vec::new(); } + // a folder located below itself: what is already at or below `to` belongs to the folders + // of the library there, not to the folder located, so it is neither carried nor cleared + // (the record of `from` itself still lands at `to`, replacing the one there) + let nested = !clear_destination && key_within(&dest, &root); + let carried = |k: &str| key_within(k, &root) && !(nested && key_within(k, &dest)); let moved: Vec<(String, FolderRecord)> = self .folder_records .iter() - .filter(|(k, _)| key_within(k, &root)) + .filter(|(k, _)| carried(k)) .filter_map(|(k, r)| { let rest = folder_rest(k, &root)?; let base = dest.trim_end_matches('/'); @@ -224,7 +241,7 @@ impl Catalog { .collect(); // what was known at the old place goes, and so does what is left at the new one (also // when the moved folder carries nothing: the folder now there is not the one it was about) - let stale = self.folder_records.keys().filter(|k| key_within(k, &root) || key_within(k, &dest)); + let stale = self.folder_records.keys().filter(|k| carried(k) || (clear_destination && key_within(k, &dest))); let mut ops: Vec = stale.map(|k| Op::SetFolderRecord { folder: k.clone(), record: None }).collect(); ops.extend(moved.into_iter().map(|(k, r)| Op::SetFolderRecord { folder: k, record: Some(r) })); ops diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 8539a5b6a..7e094200c 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -36,7 +36,7 @@ pub use folders::{FolderNode, FolderRecord}; pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming}; pub use keywords::KeywordNode; use lightcraft_develop::DevelopSettings; -pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of}; +pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, LocalRecords, folder_of}; pub use lock::{LibraryLock, LockError, LockOwner}; pub use model::*; pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64}; @@ -284,7 +284,8 @@ pub struct Catalog { /// What the library keeps about its folders (folder identity → record), see [`folders`]. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] folder_records: BTreeMap, - /// Increments on every applied op. + /// Increments on every applied op, and when a batch fails and its rollback cannot undo it whole (the catalog + /// changed, so what is keyed by the revision is out of date). #[serde(skip)] pub revision: u64, /// Which photos the latest ops touched ([`Catalog::touched_since`]). @@ -348,6 +349,8 @@ struct Changes { /// outside (a library just loaded takes a range of its own, so that nothing kept from another library is /// taken for this one's): nothing is known of how the catalog got to where it is. seen: u64, + /// A batch that failed could not be rolled back whole: what it changed stays, so what it noted must too. + torn: bool, } impl PartialEq for Changes { @@ -610,13 +613,40 @@ impl Catalog { self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id)) } - /// Apply an op; returns its inverse. On error nothing changes. + /// Apply an op; returns its inverse. On error the catalog is as it was, the revision included, and nothing is + /// remembered of the op in what the latest ops touched. The one exception is a batch whose rollback fails: the + /// catalog did change, so the revision advances and anything may be taken to have changed. pub fn apply(&mut self, op: Op) -> Result { if self.changes.seen != self.revision { // the revision was set from outside: what is remembered is of another past self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() }; } - let inv = self.apply_inner(op)?; + let broad = self.changes.broad; + self.changes.torn = false; + let inv = match self.apply_inner(op) { + Ok(inv) => inv, + Err(e) => { + let made = self.revision.saturating_add(1); + if self.changes.torn { + // (a batch the rollback of which failed changed something and says not what: it makes a + // revision, so what is kept for the one before is dropped, and anything may have changed, as + // after an op of a wider reach) + self.changes.torn = false; + self.changes.broad = made; + self.revision = made; + self.changes.seen = made; + } else { + // (everything the failed op, or the rollback of a batch, noted was made for the revision it + // would have made: nothing earlier has that one. A note the cap already dropped stays + // dropped, which only says less than is known, as the cap does.) + while self.changes.touched.back().is_some_and(|(r, _, _)| *r == made) { + self.changes.touched.pop_back(); + } + self.changes.broad = broad; + } + return Err(e); + } + }; self.revision += 1; self.changes.seen = self.revision; Ok(inv) @@ -962,7 +992,9 @@ impl Catalog { Err(e) => { // roll back what was applied for inv in inverses.into_iter().rev() { - let _ = self.apply_inner(inv); + if self.apply_inner(inv).is_err() { + self.changes.torn = true; + } } return Err(e); } diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index 093c22427..20cb3d5b4 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -34,6 +34,15 @@ use serde::Serialize; use crate::{Catalog, Op, Photo, PhotoId, Source}; +/// The Local records of some files ([`Catalog::local_records_of`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LocalRecords { + /// Only a record of having looked: nothing was changed on them and nothing refers to them. + pub untouched: Vec, + /// Holding something of the person's, with their files. + pub kept: Vec<(PhotoId, String)>, +} + /// Default for how long a Local folder may go unbrowsed before its untouched records are /// forgotten (days). pub const DEFAULT_FORGET_DAYS: u32 = 30; @@ -64,7 +73,8 @@ pub fn folder_of(p: &Photo) -> Option { // `/` and `\` are both separators whatever the platform (a catalog can move between them) Source::File { path } => { let key = crate::query::folder_key(path); - key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { dir.to_string() }) + // a drive or share root keeps its trailing `/` in its key (`c:/`, `//srv/share/`) + key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { crate::query::folder_key(&format!("{dir}/")) }) } Source::Demo { .. } => None, } @@ -176,6 +186,34 @@ impl Catalog { &self.browsed } + /// The Local records of `files` (paths as stored), for library photos about to point at + /// those files (a folder located where it was browsed, `folder.updateLocation`): a file has one + /// record, so the Local one gives way. Told apart: those that are only a record of having + /// looked (untouched, see the module docs, and nothing refers to them), which can go, and + /// those that hold something of the person's (a rating, an edit, a place in an album), with + /// their files. Records in Recently Deleted are not Local records to this. + pub fn local_records_of(&self, files: &HashSet) -> LocalRecords { + let mut out = LocalRecords::default(); + if files.is_empty() { + return out; + } + let referenced = self.referenced(); + for p in self.photos.values().filter(|p| p.local && !p.deleted) { + let Source::File { path } = &p.source else { continue }; + if !files.contains(path) { + continue; + } + if p.untouched_local() && !referenced.contains(&p.id) { + out.untouched.push(p.id); + } else { + out.kept.push((p.id, path.clone())); + } + } + out.untouched.sort_unstable(); + out.kept.sort_unstable(); + out + } + /// Plan forgetting untouched Local records whose folder wasn't browsed for `days` days (as of /// `now`, ISO 8601). `in_use` keeps records the caller still needs. `days == 0` (never) or an /// unreadable `now` forgets nothing. See the module docs. diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 830857386..ff94268a8 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -240,6 +240,17 @@ fn has_person(p: &Photo, name: &str) -> bool { } impl Filter { + /// The labels that `label` and `labels` let through together (they AND), in `labels`' order; + /// `None` when neither is set, empty when they exclude each other. The chip and the filter + /// bar's buttons show this, so they match what is filtered. + pub fn labels_shown(&self) -> Option> { + match (self.label, self.labels.is_empty()) { + (None, true) => None, + (Some(l), true) => Some(vec![l]), + (l, false) => Some(self.labels.iter().copied().filter(|x| l.is_none_or(|l| l == *x)).collect()), + } + } + /// Human-readable summary of the active rules (smart album tooltips, `album.list`). pub fn describe(&self) -> String { self.describe_in(None) diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index f8b72dad8..579175086 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -952,12 +952,13 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() c.apply(Op::SetRating { id: a, rating: 1 }).unwrap(); assert_eq!(c.touched_since(loaded), Some(vec![a])); assert_eq!(c.touched_since(old), None); - // an op that fails changes nothing, and what is remembered never says less than what happened + // an op that fails changes nothing and is not remembered let before = c.revision; assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err()); assert_eq!(c.revision, before); + assert_eq!(c.touched_since(before), Some(vec![])); c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap(); - assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a))); + assert_eq!(c.touched_since(before), Some(vec![a])); assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG))); // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch // with an op that changes more than photos is not @@ -965,8 +966,8 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err()); assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1))); c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap(); - // (the photo the failed batch asked for and never found may be named too: more is said, never less) - assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a))); + // (the failed batch is not remembered: only the batch that worked) + assert_eq!(c.touched_since(before), Some(vec![a])); let before = c.revision; c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap(); assert_eq!(c.touched_since(before), None); @@ -978,3 +979,70 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() assert_eq!(c.touched_since(long_ago), None); assert_eq!(c.touched_since(c.revision - 10), Some(vec![a])); } + +/// An op that fails leaves nothing behind in what the catalog remembers of its ops: no photo it never changed (one +/// that does not even exist), and, for a batch rolled back, none of what its first ops had touched. +#[test] +fn an_op_that_fails_leaves_no_touched_photos_behind() { + let mut c = Catalog::default(); + let add = |c: &mut Catalog, name: &str| { + let id = c.alloc_photo_id(); + c.apply(Op::AddPhoto { + photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")), + }) + .unwrap(); + id + }; + let (a, b) = (add(&mut c, "a.jpg"), add(&mut c, "b.jpg")); + let missing = PhotoId(9_999); + let start = c.revision; + // every op that names a photo, on one that does not exist + for op in [Op::SetRating { id: missing, rating: 1 }, Op::SetFlag { id: missing, flag: Flag::Pick }, Op::RemovePhoto { id: missing }] { + assert!(c.apply(op).is_err()); + assert_eq!(c.revision, start, "a failed op makes no revision"); + assert_eq!(c.touched_since(start), Some(vec![])); + assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); + } + // an op that changes more than photos and fails does not make earlier revisions unknown + assert!(c.apply(Op::RenameAlbum { id: AlbumId(77), name: "x".into() }).is_err()); + assert_eq!(c.touched_since(start), Some(vec![])); + // a batch that fails on its second op names nothing of its first, nor of the rollback + assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: missing, rating: 1 }] }).is_err()); + assert_eq!(c.revision, start); + assert_eq!(c.touched_since(start), Some(vec![])); + assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); + assert_eq!(c.photo(a).map(|p| p.rating), Some(0)); + // a batch that ends in a failed removal of a photo that exists + assert!(c.apply(Op::Batch { ops: vec![Op::RemovePhoto { id: b }, Op::RemovePhoto { id: b }] }).is_err()); + assert!(c.photo(b).is_some()); + assert_eq!(c.touched_since(start), Some(vec![])); + // what succeeded after the failures is remembered, and only that + c.apply(Op::SetRating { id: a, rating: 2 }).unwrap(); + assert_eq!(c.touched_since(start), Some(vec![a])); + assert_eq!(c.aspects_since(start), Some(Aspects::RATING)); +} + +/// A batch the rollback of which fails halfway leaves the catalog changed (here a stack is lost), so what the +/// catalog remembers must not say that nothing changed: anything may have. +#[test] +fn a_batch_that_cannot_be_rolled_back_whole_makes_earlier_changes_unknown() { + let mut c = Catalog::default(); + let mut ids = Vec::new(); + for name in ["a.jpg", "b.jpg"] { + let id = c.alloc_photo_id(); + let photo = Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + c.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + ids.push(id); + } + let stack = c.alloc_stack_id(); + c.apply(Op::AddStack { stack: Stack { id: stack, photos: ids.clone(), collapsed: false } }).unwrap(); + let start = c.revision; + // the photo goes, then its stack, then an op fails: putting the stack back needs the photo, which comes later + let failing = + Op::Batch { ops: vec![Op::RemovePhoto { id: ids[0] }, Op::RemoveStack { id: stack }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }; + assert!(c.apply(failing).is_err()); + assert_eq!(c.revision, start + 1, "the catalog changed, so what is kept for the revision before is out of date"); + assert!(c.photo(ids[0]).is_some() && c.stack(stack).is_none(), "the rollback went only part of the way"); + assert_eq!(c.touched_since(start), None, "so nothing is said of what changed"); + assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again"); +} diff --git a/crates/catalog/src/tests_folder_records.rs b/crates/catalog/src/tests_folder_records.rs index 3ae19c33a..a572db995 100644 --- a/crates/catalog/src/tests_folder_records.rs +++ b/crates/catalog/src/tests_folder_records.rs @@ -161,6 +161,63 @@ fn an_unlabelled_folder_moved_onto_a_stale_label_does_not_inherit_it() { assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Purple)); } +#[test] +fn a_located_folder_keeps_what_the_library_knows_of_the_folders_already_at_the_destination() { + let mut c = library(); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green)); + // other folders of the library at the place it is said to be at: nothing arrived there + label(&mut c, "/pics/home", Some(ColorLabel::Blue)); + label(&mut c, "/pics/home/old", Some(ColorLabel::Yellow)); + let before = c.to_snapshot(); + let ops = c.folder_records_located("/pics/trip", "/pics/home"); + let undo = c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.folder_record("/pics/trip"), None, "what was known at the old place goes"); + assert_eq!(c.folder_record("/pics/trip/day1"), None); + assert_eq!(c.folder_color_label("/pics/home"), Some(ColorLabel::Red), "a record carried over replaces the one at the same place"); + assert_eq!(c.folder_color_label("/pics/home/old"), Some(ColorLabel::Yellow), "a folder of the library there keeps its own"); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +#[test] +fn a_located_folder_with_labels_inside_lands_them_at_the_same_places_below() { + let mut c = library(); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green)); + let ops = c.folder_records_located("/pics/trip", "/mnt/nas/trip"); + c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.folder_color_label("/mnt/nas/trip"), Some(ColorLabel::Red)); + assert_eq!(c.folder_color_label("/mnt/nas/trip/day1"), Some(ColorLabel::Green)); +} + +#[test] +fn a_folder_located_below_itself_leaves_the_records_of_the_folders_already_there_in_place() { + let mut c = library(); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green)); + // another folder of the library already sits at the place the folder is said to be at + label(&mut c, "/pics/trip/day1/raw", Some(ColorLabel::Blue)); + let before = c.to_snapshot(); + let ops = c.folder_records_located("/pics/trip", "/pics/trip/day1"); + let undo = c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.folder_record("/pics/trip"), None, "what was known at the old place goes"); + assert_eq!(c.folder_color_label("/pics/trip/day1"), Some(ColorLabel::Red), "a record carried over replaces the one at the same place"); + assert_eq!(c.folder_color_label("/pics/trip/day1/raw"), Some(ColorLabel::Blue), "a folder below the destination keeps its own"); + assert_eq!(c.folder_record("/pics/trip/day1/day1"), None, "the record at the destination is not carried below itself"); + assert_eq!(c.folder_record("/pics/trip/day1/day1/raw"), None); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +#[test] +fn a_folder_located_where_it_is_or_without_labels_gives_no_ops() { + let mut c = library(); + assert!(c.folder_records_located("/pics/trip", "/elsewhere/trip").is_empty()); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + assert!(c.folder_records_located("/pics/trip", "/pics/trip").is_empty()); +} + #[test] fn labels_survive_the_journal_without_a_snapshot() { let m = MemStore::new(); diff --git a/crates/catalog/src/tests_local.rs b/crates/catalog/src/tests_local.rs index 4b76dace6..0082763d0 100644 --- a/crates/catalog/src/tests_local.rs +++ b/crates/catalog/src/tests_local.rs @@ -1,5 +1,6 @@ //! Forgetting untouched Local browse records (see [`crate::local`]). +use std::collections::HashSet; use std::sync::Arc; use lightcraft_develop::DevelopSettings; @@ -265,3 +266,51 @@ fn op_photo_ids_cover_nested_ops() { op.photo_ids(&mut |id| ids.push(id.0)); assert_eq!(ids, vec![1, 2, 3, 4]); } + +#[test] +fn drive_and_share_root_records_are_forgotten() { + for (path, folder) in [("C:\\IMG_1.JPG", "C:\\"), ("//srv/share/x.jpg", "//srv/share/")] { + let mut c = Catalog::new(); + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, OLD); + p.local = true; + p.set_local_baseline(); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + c.apply(Op::SetBrowsed { folder: folder.into(), at: Some(OLD.into()) }).unwrap(); + assert_eq!(plan(&c, 30).evict, vec![id], "{path}"); + } +} + +fn paths(folder: &str, ns: &[u32]) -> HashSet { + ns.iter().map(|i| format!("/pics/{folder}/IMG_{i}.JPG")).collect() +} + +#[test] +fn local_records_of_files_tell_what_can_give_way_from_what_holds_something() { + let mut c = Catalog::new(); + let untouched = local(&mut c, "trip", 1); + let rated = local(&mut c, "trip", 2); + let in_album = local(&mut c, "trip", 3); + let _elsewhere = local(&mut c, "trip", 4); + c.apply(Op::SetRating { id: rated, rating: 3 }).unwrap(); + let a = c.alloc_album_id(); + let mut album = Album::new(a, "A"); + album.photos.push(in_album); + c.apply(Op::AddAlbum { album }).unwrap(); + + let found = c.local_records_of(&paths("trip", &[1, 2, 3])); + assert_eq!(found.untouched, vec![untouched]); + assert_eq!(found.kept, vec![(rated, "/pics/trip/IMG_2.JPG".to_string()), (in_album, "/pics/trip/IMG_3.JPG".to_string())]); +} + +#[test] +fn local_records_of_files_leave_out_library_photos_and_recently_deleted_ones() { + let mut c = Catalog::new(); + let gone = local(&mut c, "trip", 1); + c.apply(Op::SetDeleted { id: gone, deleted: true }).unwrap(); + let id = c.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: "/pics/trip/IMG_2.JPG".into() }, "IMG_2.JPG", "JPEG", 60, 40, OLD); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + assert_eq!(c.local_records_of(&paths("trip", &[1, 2])), LocalRecords::default()); + assert_eq!(c.local_records_of(&HashSet::new()), LocalRecords::default()); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b019e21fd..9d11cc5b3 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -399,6 +399,9 @@ pub fn specs() -> Vec { 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" => { @@ -800,10 +803,10 @@ pub fn specs() -> Vec { "{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::f64_req(p, "rating", "photo.rate")? as i64; - if !(0..=5).contains(&r) { - return Err(bad("photo.rate", "rating must be 0..5")); - } + 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 }))) } ), @@ -1887,3 +1890,7 @@ mod rated_out_tests; #[cfg(test)] #[path = "library/tests_enable_filters.rs"] mod enable_filters_tests; + +#[cfg(test)] +#[path = "library/tests_source_album_filter.rs"] +mod source_album_filter_tests; diff --git a/crates/engine/src/cmd/library/tests_source_album_filter.rs b/crates/engine/src/cmd/library/tests_source_album_filter.rs new file mode 100644 index 000000000..599ffdeb3 --- /dev/null +++ b/crates/engine/src/cmd/library/tests_source_album_filter.rs @@ -0,0 +1,61 @@ +//! `library.source` with an album while the filter holds an album: the album shown wins. +use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{Result, Selection, Session}; + +/// Photos 1 to 3 (rated 5, 5 and 2). Album X holds photo 1 and album A holds photo 3. +fn session_with_two_albums() -> Result<(Session, u64, u64)> { + let mut s = Session::new(); + for (id, rating) in [(1, 5), (2, 5), (3, 2)] { + let mut photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-source-album/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-23", + ); + photo.rating = rating; + photo.flag = Flag::None; + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + let mut make = |name: &str, id: u64| { + s.selection = Selection { ids: vec![PhotoId(id)], active: None }; + s.execute("album.create", &json!({"name": name, "addSelected": true})).unwrap()["id"].as_u64().unwrap() + }; + let (x, a) = (make("X", 1), make("A", 3)); + Ok((s, x, a)) +} + +#[test] +fn showing_an_album_drops_the_album_the_filter_held() { + let (mut s, x, a) = session_with_two_albums().unwrap(); + s.execute("library.filter", &json!({"album": x, "rating": 4})).unwrap(); + assert_eq!(s.visible().len(), 1, "the album narrows the grid, or the hidden album would not matter"); + s.execute("library.source", &json!({"kind": "album", "id": a})).unwrap(); + assert_eq!(s.filter.album, None, "one album at a time: the one shown wins, as for a library folder"); + assert_eq!(s.filter.rating, 4, "the rest of the filter stays"); + // the old album must not narrow the next source, with no chip to show it + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert_eq!(s.visible().len(), 2, "photos 1 and 2 have four stars or more"); +} + +#[test] +fn the_album_filter_stays_while_the_source_is_not_an_album() { + let (mut s, x, _) = session_with_two_albums().unwrap(); + s.execute("library.filter", &json!({"album": x})).unwrap(); + s.execute("library.source", &json!({"kind": "picks"})).unwrap(); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert_eq!(s.filter.album.map(|a| a.0), Some(x)); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn a_refused_album_source_leaves_the_filter_alone() { + let (mut s, x, _) = session_with_two_albums().unwrap(); + s.execute("library.filter", &json!({"album": x})).unwrap(); + assert!(s.execute("library.source", &json!({"kind": "album", "id": 9999})).is_err()); + assert_eq!(s.filter.album.map(|a| a.0), Some(x)); +} diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index c6c692c35..c06997eef 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -90,9 +90,77 @@ fn stored_hash(p: &Photo) -> Option<&str> { p.content_hash.as_deref().map(|h| h.split(':').next().unwrap_or(h)).filter(|h| !h.is_empty()) } -/// The content hash of the file at `path` (as import computes it), if it can be read. +/// The content hash of the file at `path` (as import computes it), if it can be read. Read in +/// pieces: the file is whatever is on the disk, of any size. fn file_hash(path: &str) -> Option { - std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string()) + hash_reader(std::fs::File::open(path).ok()?) +} + +/// The content hash of what `r` gives, read in pieces (the hash import computes of the whole). +fn hash_reader(mut r: impl std::io::Read) -> Option { + let mut hasher = lightcraft_preview::Hasher128::new(); + let mut piece = vec![0u8; 1 << 16]; + loop { + match r.read(&mut piece) { + Ok(0) => return Some(hasher.finish().to_string()), + Ok(n) => { + hasher.update(piece.get(..n)?); + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A reader that gives its bytes a few at a time, and an interruption in between. + struct Trickle<'a>(&'a [u8], bool); + + impl std::io::Read for Trickle<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.1 = !self.1; + if self.1 { + return Err(std::io::ErrorKind::Interrupted.into()); + } + let n = self.0.len().min(buf.len()).min(7); + buf[..n].copy_from_slice(&self.0[..n]); + self.0 = &self.0[n..]; + Ok(n) + } + } + + #[test] + fn a_hash_read_in_pieces_is_the_hash_of_the_whole() { + // longer than one piece, so the read loop runs more than once + let bytes: Vec = (0..200_000u32).map(|i| (i % 251) as u8).collect(); + let whole = lightcraft_preview::hash_bytes(&bytes).to_string(); + assert_eq!(hash_reader(bytes.as_slice()).as_deref(), Some(whole.as_str())); + assert_eq!(hash_reader(Trickle(&bytes[..1000], false)).as_deref(), Some(lightcraft_preview::hash_bytes(&bytes[..1000]).to_string().as_str())); + assert_eq!(hash_reader(std::io::empty()).as_deref(), Some(lightcraft_preview::hash_bytes(&[]).to_string().as_str())); + } + + /// A scratch folder that goes away with the test, however it ends. + struct Scratch(std::path::PathBuf); + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn a_file_hash_is_the_import_hash_and_a_missing_file_has_none() { + let dir = Scratch(std::env::temp_dir().join(format!("lc-file-hash-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&dir.0); + std::fs::create_dir_all(&dir.0).unwrap(); + let f = dir.0.join("a.bin"); + std::fs::write(&f, b"pixels").unwrap(); + assert_eq!(file_hash(f.to_str().unwrap()), Some(lightcraft_preview::hash_bytes(b"pixels").to_string())); + assert_eq!(file_hash(dir.0.join("nope").to_str().unwrap()), None); + } } /// An in-scope photo ([`checked_path`]) as Find Missing Photos sees it: its file, the size and diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a74144e2d..ff2b972c0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -195,6 +195,31 @@ pub struct Interaction { /// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole. const MAX_FOLLOWED: usize = 256; +/// The established clock precision for relative-date views and sidebar counts. +fn clock_minute(now: &str) -> &str { + now.get(..16).unwrap_or(now) +} + +/// Whether a query can change membership without a catalog edit, including the smart albums it tests. +fn filter_depends_on_clock(filter: &Filter, catalog: &Catalog) -> bool { + if filter.depends_on_now() { + return true; + } + let mut pending = filter.albums_tested(); + let mut seen = std::collections::HashSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + let Some(smart) = catalog.album(id).and_then(|album| album.smart.as_deref()) else { continue }; + if smart.depends_on_now() { + return true; + } + pending.extend(smart.albums_tested()); + } + false +} + /// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one). static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -215,6 +240,8 @@ pub struct Session { /// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]). visible: Arc<[PhotoId]>, visible_key: Option<(u64, String)>, + /// The minute used for a cached view whose rules depend on the session clock. + visible_clock_minute: Option, /// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop /// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once. pub hold_view: bool, @@ -234,7 +261,7 @@ pub struct Session { /// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids. visible_gen: u64, /// Photos in the current source with no filter on (cached like `visible`). - total: Option<((u64, String), usize)>, + total: Option<((u64, String), usize, Option)>, pub undo: Vec, /// Undo steps committed in this session, ever (the history drops its oldest past 1000, so /// its length can't tell how many steps a command just made; see [`Session::commits`]). @@ -429,6 +456,7 @@ impl Session { selection: Selection::default(), visible: Vec::new().into(), visible_key: None, + visible_clock_minute: None, hold_view: false, base: None, view_followed: 0, @@ -1054,9 +1082,16 @@ impl Session { // its first check changes nothing it shows) key.1.push_str(&format!("|{}", self.media.availability.gone_generation())); } - if self.visible_key.as_ref() != Some(&key) { - // "in the last N days" rules count back from the session's clock - lightcraft_catalog::rules::set_now(Some((self.clock)())); + let key_changed = self.visible_key.as_ref() != Some(&key); + // A time-dependent cache needs one clock read to test its minute stamp. A new view also + // needs one value for the query, even when its previous cached view did not use the clock. + let current_now = (key_changed || self.visible_clock_minute.is_some()).then(|| (self.clock)()); + let current_minute = current_now.as_deref().map(clock_minute); + let clock_changed = self.visible_clock_minute.as_deref().is_some_and(|cached| current_minute != Some(cached)); + if key_changed || clock_changed { + // Use this same value to evaluate relative rules and stamp the result. + let now = current_now.unwrap_or_else(|| (self.clock)()); + lightcraft_catalog::rules::set_now(Some(now.clone())); let f = self.view_filter(self.source, &self.filter); // A held view keeps the photos it shows that stopped matching what is asked of them (a smart album's // rules, a flag, the filter bar), as long as they are still where the view looks: in the library for @@ -1083,8 +1118,7 @@ impl Session { let whole = own_order || matches!(self.source, LibrarySource::RecentlyAdded | LibrarySource::Missing); // A view that asks about "the last N days" (itself, or through a smart album it looks into) changes // with the clock as well as with edits: a photo nobody touched can stop matching. It is asked whole. - let clocked = f.depends_on_now() - || (!f.albums_tested().is_empty() && self.catalog.albums().any(|al| al.smart.as_deref().is_some_and(Filter::depends_on_now))); + let clocked = filter_depends_on_clock(&f, &self.catalog); let base = self.base.take().filter(|(of, _, _)| *of == view); // A small edit to the same view: the photos it touched are taken out, asked about again, and put back // at their place in the order. The others neither stopped matching nor moved. @@ -1158,6 +1192,7 @@ impl Session { self.visible = visible.into(); self.visible_gen = VISIBLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.visible_key = Some(key); + self.visible_clock_minute = clocked.then(|| clock_minute(&now).to_owned()); } &self.visible } @@ -1190,12 +1225,20 @@ impl Session { /// view, so a list followed many times over is compared, not only one followed once. #[cfg(test)] pub(crate) fn visible_in_full(&mut self) -> Vec { - let kept = (self.base.clone(), self.visible_key.clone(), self.visible.clone(), self.visible_gen, self.view_followed, self.view_worked_out); + let kept = ( + self.base.clone(), + self.visible_key.clone(), + self.visible_clock_minute.clone(), + self.visible.clone(), + self.visible_gen, + self.view_followed, + self.view_worked_out, + ); self.never_follow = true; self.visible_key = None; let whole = self.visible().to_vec(); self.never_follow = false; - (self.base, self.visible_key, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept; + (self.base, self.visible_key, self.visible_clock_minute, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept; whole } @@ -1223,7 +1266,15 @@ impl Session { } } let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder)); - if self.total.as_ref().map(|t| &t.0) != Some(&key) { + let key_changed = self.total.as_ref().map(|total| &total.0) != Some(&key); + let cached_minute = self.total.as_ref().and_then(|total| total.2.as_deref()); + let current_now = cached_minute.map(|_| (self.clock)()); + let current_minute = current_now.as_deref().map(clock_minute); + let clock_changed = cached_minute.is_some_and(|cached| current_minute != Some(cached)); + if key_changed || clock_changed { + // The source count can be requested before `visible`; bind it to the session's clock itself. + let now = current_now.unwrap_or_else(|| (self.clock)()); + lightcraft_catalog::rules::set_now(Some(now.clone())); let mut f = self.source.to_filter(&Filter::default(), &self.catalog); if self.source == LibrarySource::Folder { let b = self.browse.clone().unwrap_or_default(); @@ -1235,7 +1286,8 @@ impl Session { } // (how many, not which: nothing to put in order) let n = self.catalog.count(&f); - self.total = Some((key, n)); + let clocked = filter_depends_on_clock(&f, &self.catalog); + self.total = Some((key, n, clocked.then(|| clock_minute(&now).to_owned()))); } self.total.as_ref().map(|t| t.1) } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 292a9fb1e..e76ef8169 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 69; +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]; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 218965812..93f4e6adc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -108,6 +108,20 @@ fn demo_library_loads() { assert!(albums.as_array().unwrap().iter().any(|a| a["folder"] == true)); } +#[test] +fn rating_rejects_fractional_values() { + let mut s = demo(); + let id = s.active().unwrap(); + for bad in [5.9, -0.9, 0.5] { + let err = s.execute("photo.rate", &json!({"rating": bad})).unwrap_err(); + assert!(matches!(&err, crate::EngineError::BadParams { cmd, .. } if cmd == "photo.rate"), "{bad}: {err}"); + assert_eq!(s.catalog.photo(id).unwrap().rating, 0, "{bad} changed the rating"); + } + s.execute("photo.rate", &json!({"rating": 5.0})).unwrap(); + s.execute("photo.rate", &json!({"rating": 3})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().rating, 3); +} + #[test] fn rating_flag_undo_redo() { let mut s = demo(); diff --git a/crates/engine/src/tests_hold_view.rs b/crates/engine/src/tests_hold_view.rs index f7082498c..f30d08c4e 100644 --- a/crates/engine/src/tests_hold_view.rs +++ b/crates/engine/src/tests_hold_view.rs @@ -1,11 +1,28 @@ //! A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop //! matching a smart album's rules, Picks or the filter bar leaves it where it is, and the others where they are. -use lightcraft_catalog::PhotoId; +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; use serde_json::json; use crate::{LibrarySource, Session}; +fn relative_clock_session(captures: &[&str]) -> (Session, std::sync::Arc>, Vec) { + let mut s = Session::new(); + let now = std::sync::Arc::new(std::sync::Mutex::new("2026-10-24T12:00:00".to_string())); + let clock = now.clone(); + s.clock = Box::new(move || clock.lock().unwrap().clone()); + let mut ids = Vec::new(); + for (index, captured) in captures.iter().enumerate() { + let id = PhotoId(index as u64 + 1); + let name = format!("synthetic-{index}.jpg"); + let mut photo = Photo::new(id, Source::Demo { scene: 0 }, &name, "JPEG", 2, 2, "2026-10-01T00:00:00"); + photo.captured = Some((*captured).to_string()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + ids.push(id); + } + (s, now, ids) +} + /// The demo library with no photo keyworded, showing a smart album of the photos without keywords, at random. fn unkeyworded() -> Session { let mut s = Session::with_demo(); @@ -198,3 +215,29 @@ fn asking_for_the_state_it_has_changes_nothing_and_a_word_is_not_a_bool() { assert!(s.execute("library.holdView", &json!({"on": "false"})).is_err()); assert!(s.hold_view, "a refused call changes nothing"); } + +#[test] +fn relative_clock_held_view_keeps_expired_photos_admits_new_matches_and_refreshes() { + let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30", "2026-10-22T12:00:30"]); + let rules = json!({ + "ruleSet": { + "match": "any", + "rules": [ + {"field": "captureDate", "op": "inLast", "value": 1}, + {"field": "captureDate", "op": "notInLast", "value": 2} + ] + } + }); + s.execute("library.filter", &rules).unwrap(); + s.execute("library.holdView", &json!({"on": true})).unwrap(); + assert_eq!(s.visible_cloned(), vec![ids[0]], "only the soon-to-expire photo matches initially"); + + *now.lock().unwrap() = "2026-10-24T12:01:00".to_string(); + let held = s.visible_cloned(); + assert_eq!(held.len(), 2, "the expired photo stays held while the newly matching photo enters: {held:?}"); + assert!(held.contains(&ids[0]), "the expired photo remains held"); + assert!(held.contains(&ids[1]), "the photo crossing the second relative boundary enters"); + + s.execute("library.refresh", &json!({})).unwrap(); + assert_eq!(s.visible_cloned(), vec![ids[1]], "explicit refresh releases the expired photo"); +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 82bdca457..b2eb8cb94 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -314,6 +314,29 @@ fn smart_album_rule_sets() { assert!(a.to_string().contains("rating is ≥ 4"), "{a}"); } +/// `label` and `labels` set together (only through `library.filter`, or a filter saved with the +/// single label) AND, and the chip says what they let through, not their union. +#[test] +fn filter_label_and_labels_together_chip() { + use lightcraft_catalog::ColorLabel; + let mut s = crate::Session::with_demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + for (id, l) in ids.iter().zip(["red", "yellow", "green"]) { + s.execute("photo.label", &json!({"ids": [id], "label": l})).unwrap(); + } + let reds = s.catalog.photos().filter(|p| p.in_library() && p.label == Some(ColorLabel::Red)).count(); + let chip = |s: &Session| crate::filter_chips(&s.filter, &s.catalog).into_iter().find(|c| c.label.starts_with("Label")).expect("a label chip"); + s.execute("library.filter", &json!({"label": "red", "labels": ["yellow", "red"]})).unwrap(); + assert_eq!(s.visible_cloned().len(), reds); + assert_eq!(chip(&s).label, "Label: Red"); + // nothing passes both: the chip names both constraints + s.execute("library.filter", &json!({"label": "red", "labels": ["yellow", "green"]})).unwrap(); + assert_eq!(s.visible_cloned().len(), 0); + assert_eq!(chip(&s).label, "Label: Red and (Yellow or Green)"); + s.execute("library.filter", &chip(&s).clear).unwrap(); + assert_eq!((s.filter.label, s.filter.labels.len()), (None, 0)); +} + /// The filter bar's label multi-select: any of the chosen labels. #[test] fn filter_any_of_several_labels() { diff --git a/crates/engine/src/tests_view_follow.rs b/crates/engine/src/tests_view_follow.rs index 391e38b3b..bc47d94c9 100644 --- a/crates/engine/src/tests_view_follow.rs +++ b/crates/engine/src/tests_view_follow.rs @@ -1,7 +1,7 @@ //! The view follows a small edit from the list it had ([`Session::visible`]): whatever the edit, the result has to //! be the list worked out from every photo. -use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use lightcraft_catalog::{Album, Op, Photo, PhotoId, Source}; use serde_json::{Value, json}; use crate::Session; @@ -113,6 +113,23 @@ fn edit(s: &mut Session, dice: &mut Dice, ids: &mut Vec, album: u64) { }; } +fn relative_clock_session(captures: &[&str]) -> (Session, std::sync::Arc>, Vec) { + let mut s = Session::new(); + let now = std::sync::Arc::new(std::sync::Mutex::new("2026-10-24T12:00:00".to_string())); + let clock = now.clone(); + s.clock = Box::new(move || clock.lock().unwrap().clone()); + let mut ids = Vec::new(); + for (index, captured) in captures.iter().enumerate() { + let id = PhotoId(index as u64 + 1); + let name = format!("synthetic-{index}.jpg"); + let mut photo = Photo::new(id, Source::Demo { scene: 0 }, &name, "JPEG", 2, 2, "2026-10-01T00:00:00"); + photo.captured = Some((*captured).to_string()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + ids.push(id); + } + (s, now, ids) +} + #[test] fn following_an_edit_gives_the_list_worked_out_from_every_photo() { let (mut s, mut ids, album, smart) = library(240); @@ -161,6 +178,129 @@ fn following_an_edit_gives_the_list_worked_out_from_every_photo() { assert!(s.view_followed > 400, "followed {} times, worked out {} times", s.view_followed, s.view_worked_out); } +#[test] +fn relative_clock_filter_expires_without_catalog_or_history_mutation() { + let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30"]); + s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}})).unwrap(); + let (before_generation, before) = s.visible_shared(); + assert_eq!(before.as_ref(), ids.as_slice()); + let revision = s.catalog.revision; + let commits = s.commits; + let undo = s.undo.len(); + let redo = s.redo.len(); + + *now.lock().unwrap() = "2026-10-24T12:01:00".to_string(); + let (after_generation, after) = s.visible_shared(); + + assert!(after.is_empty(), "a relative filter must expire when its minute changes"); + assert_ne!(after_generation, before_generation, "the changed list gets a new visible generation"); + assert_eq!(s.catalog.revision, revision, "clock expiry is not a catalog edit"); + assert_eq!(s.commits, commits, "clock expiry adds no undo step"); + assert_eq!(s.undo.len(), undo); + assert_eq!(s.redo.len(), redo); +} + +#[test] +fn relative_clock_direct_and_nested_smart_album_sources_expire() { + for nested in [false, true] { + let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30"]); + let recent = s + .execute( + "album.createSmart", + &json!({"name": "Recent", "rules": {"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let source = if nested { + s.execute( + "album.createSmart", + &json!({"name": "In Recent", "rules": {"ruleSet": {"rules": [{"field": "album", "op": "is", "value": recent}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap() + } else { + recent + }; + s.execute("library.source", &json!({"kind": "album", "id": source})).unwrap(); + assert_eq!(s.visible_cloned(), ids); + let revision = s.catalog.revision; + + *now.lock().unwrap() = "2026-10-24T12:01:00".to_string(); + assert!(s.visible_cloned().is_empty(), "nested={nested}"); + assert_eq!(s.catalog.revision, revision, "nested={nested}"); + } +} + +#[test] +fn relative_clock_source_total_expires_before_or_after_visible_refresh() { + for refresh_visible in [false, true] { + let (mut s, now, _) = relative_clock_session(&["2026-10-23T12:00:30"]); + let album = s + .execute( + "album.createSmart", + &json!({"name": "Recent", "rules": {"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.execute("library.filter", &json!({"text": "synthetic"})).unwrap(); + assert_eq!(s.source_total(), Some(1), "the initial total is computed without asking for visible photos"); + let revision = s.catalog.revision; + + *now.lock().unwrap() = "2026-10-24T12:01:00".to_string(); + if refresh_visible { + s.execute("library.refresh", &json!({})).unwrap(); + assert!(s.visible_cloned().is_empty()); + } + assert_eq!(s.source_total(), Some(0), "refresh_visible={refresh_visible}"); + assert_eq!(s.catalog.revision, revision, "refresh_visible={refresh_visible}"); + } +} + +#[test] +fn relative_clock_absolute_and_same_minute_views_keep_their_generations() { + let (mut absolute, absolute_now, _) = relative_clock_session(&["2026-10-23T12:00:30"]); + absolute.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "is", "value": "2026-10-23"}]}})).unwrap(); + let (absolute_generation, absolute_ids) = absolute.visible_shared(); + *absolute_now.lock().unwrap() = "2026-10-25T12:10:00".to_string(); + let (after_absolute_generation, after_absolute_ids) = absolute.visible_shared(); + assert_eq!(after_absolute_generation, absolute_generation); + assert_eq!(after_absolute_ids, absolute_ids); + + let (mut relative, relative_now, _) = relative_clock_session(&["2026-10-23T12:00:30"]); + relative.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}})).unwrap(); + let (relative_generation, relative_ids) = relative.visible_shared(); + *relative_now.lock().unwrap() = "2026-10-24T12:00:45".to_string(); + let (same_minute_generation, same_minute_ids) = relative.visible_shared(); + assert_eq!(same_minute_generation, relative_generation); + assert_eq!(same_minute_ids, relative_ids); +} + +#[test] +fn relative_clock_unrelated_smart_album_does_not_expire_album_filter_view() { + let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30"]); + let manual = s.catalog.alloc_album_id(); + s.catalog.apply(Op::AddAlbum { album: Album::new(manual, "Manual") }).unwrap(); + s.catalog.apply(Op::SetAlbumPhotos { id: manual, photos: vec![ids[0]] }).unwrap(); + s.execute( + "album.createSmart", + &json!({"name": "Unrelated recent", "rules": {"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}}}), + ) + .unwrap(); + s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": manual.0}]}})).unwrap(); + let (generation, visible) = s.visible_shared(); + assert_eq!(visible.as_ref(), ids.as_slice()); + + *now.lock().unwrap() = "2026-10-24T12:01:00".to_string(); + let (after_generation, after_visible) = s.visible_shared(); + + assert_eq!(after_generation, generation, "an unrelated relative smart album must not expire this view"); + assert_eq!(after_visible, visible); +} + #[test] fn a_view_that_asks_about_the_last_days_follows_the_clock_too() { let (mut s, ids, _, _) = library(60); diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 0d5cde3cf..dbfb0fade 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -161,11 +161,17 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { if let Some(fl) = f.flag { add(format!("Flag: {}", format!("{fl:?}").to_lowercase()), json!({"flag": Null})); } - let mut labels = f.labels.clone(); - labels.extend(f.label.filter(|l| !labels.contains(l))); - if !labels.is_empty() { - let names: Vec = labels.iter().map(|l| cat.label_name(*l)).collect(); - add(format!("Label: {}", names.join(" or ")), json!({"label": Null, "labels": []})); + if let Some(shown) = f.labels_shown() { + let or = |ls: &[lightcraft_catalog::ColorLabel]| ls.iter().map(|l| cat.label_name(*l)).collect::>().join(" or "); + // empty: `label` and `labels` exclude each other, so name both constraints + let names = if shown.is_empty() { + // parenthesised when plural, so "Red and Yellow or Green" cannot read as "(Red and Yellow) or Green" + let plural = if f.labels.len() > 1 { format!("({})", or(&f.labels)) } else { or(&f.labels) }; + format!("{} and {plural}", f.label.map(|l| cat.label_name(l)).unwrap_or_default()) + } else { + or(&shown) + }; + add(format!("Label: {names}"), json!({"label": Null, "labels": []})); } if let Some(k) = f.kind { let n = match k { diff --git a/crates/pipeline/src/curve.rs b/crates/pipeline/src/curve.rs new file mode 100644 index 000000000..b2c2b0b14 --- /dev/null +++ b/crates/pipeline/src/curve.rs @@ -0,0 +1,238 @@ +//! The parametric tone curve model shared by the pipeline renderer and the UI curve editor. +//! +//! Lightroom-style tone curve parametric model: four regions (shadows, darks, lights, highlights) +//! separated by three split points. Each region contributes a cosine-windowed bump, summed and +//! forced monotone. + +use lightcraft_color::spline::Lut1; +use lightcraft_develop::ToneCurve; + +/// The four shape controls of the parametric curve, in graph order: region 0 is the darkest. +pub const PARAM_AMOUNTS: [&str; 4] = ["curve.shadows", "curve.darks", "curve.lights", "curve.highlights"]; + +/// Resolution the parametric curve is sampled at, matching the renderer's LUT. +pub const PARAM_LUT: usize = 1024; + +/// The parametric (region) curve of a [`ToneCurve`]: the four region amounts, each a cosine bump +/// over its window between two splits, summed and then forced monotone. +/// +/// This is the renderer's curve, not an approximation of it — both `curve_luts` in the pipeline +/// and the curve editor in the UI use this struct so they can never disagree. +#[derive(Clone, Debug)] +pub struct ParametricCurve { + /// The curve, sampled and already forced monotone. + lut: [f32; PARAM_LUT], + /// The three region boundaries as fractions of the graph (0..1). + pub splits: [f64; 3], + /// The four region amounts, in graph order (0 = shadows, 3 = highlights). + pub amounts: [f64; 4], +} + +impl ParametricCurve { + pub fn new(c: &ToneCurve) -> ParametricCurve { + let s0 = if c.split_shadows.is_nan() { 0.25 } else { (c.split_shadows / 100.0).clamp(0.0, 1.0) as f32 }; + let s1 = if c.split_mid.is_nan() { 0.50_f32.max(s0) } else { (c.split_mid / 100.0).clamp(s0 as f64, 1.0) as f32 }; + let s2 = if c.split_highlights.is_nan() { 0.75_f32.max(s1) } else { (c.split_highlights / 100.0).clamp(s1 as f64, 1.0) as f32 }; + let clean_amt = |a: f64| if a.is_nan() { 0.0 } else { a.clamp(-100.0, 100.0) }; + let (a0, a1, a2, a3) = (clean_amt(c.shadows), clean_amt(c.darks), clean_amt(c.lights), clean_amt(c.highlights)); + let splits = [s0 as f64, s1 as f64, s2 as f64]; + let amounts = [a0, a1, a2, a3]; + let regions = [(0.0, s0, a0), (s0, s1, a1), (s1, s2, a2), (s2, 1.0, a3)]; + let mut lut = [0.0_f32; PARAM_LUT]; + let mut prev = 0.0_f32; + for (i, entry) in lut.iter_mut().enumerate() { + let x = i as f32 / (PARAM_LUT - 1) as f32; + let mut d = 0.0_f32; + for (a, b, amt) in regions { + if amt == 0.0 { + continue; + } + let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05); + if half <= f32::EPSILON { + continue; + } + let t = ((x - ctr) / half).clamp(-1.0, 1.0); + d += (amt / 100.0) as f32 * 0.22 * (0.5 + 0.5 * (t * std::f32::consts::PI).cos()); + } + let y = (x + d * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0); + prev = prev.max(y); + *entry = prev; + } + ParametricCurve { lut, splits, amounts } + } + + /// Convert the evaluated parametric curve into a 1024-entry [`Lut1`]. + pub fn to_lut(&self) -> Lut1 { + Lut1 { v: self.lut.to_vec() } + } + + /// The curve at `x` (0 = black, 1 = white), read off the table (so it never overshoots the ends). + pub fn at(&self, x: f64) -> f64 { + let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) as f32 }; + let at = x * (PARAM_LUT - 1) as f32; + let i = at.floor().clamp(0.0, (PARAM_LUT - 1) as f32) as usize; + let cur = self.lut.get(i).copied().unwrap_or(0.0); + let next = self.lut.get(i + 1).copied().unwrap_or(cur); + let f = at - i as f32; + (cur + (next - cur) * f) as f64 + } + + /// Where region `region`'s handle sits on the input axis: the middle of its own window, so a + /// handle never has to cross a split to be grabbed. + pub fn region_x(&self, region: usize) -> f64 { + let (a, b) = self.region_window(region); + (a + b) / 2.0 + } + + /// The window (two splits, as fractions of the graph) region `region` owns. + pub fn region_window(&self, region: usize) -> (f64, f64) { + let s0 = self.split_x(0); + let s1 = self.split_x(1); + let s2 = self.split_x(2); + match region { + 0 => (0.0, s0), + 1 => (s0, s1), + 2 => (s1, s2), + _ => (s2, 1.0), + } + } + + /// The half-width of region `region`'s own bump, as fraction of the graph. + pub fn region_half(&self, region: usize) -> f64 { + let (a, b) = self.region_window(region); + (b - a) * 0.75 + 0.05 + } + + /// The cosine window region `region` applies at `x` (1 at its centre, 0 at the window edges). + pub fn region_window_at(&self, region: usize, x: f64) -> f64 { + let ctr = self.region_x(region); + let half = self.region_half(region); + if half <= f64::EPSILON { + return 0.0; + } + let t = ((x - ctr) / half).clamp(-1.0, 1.0); + 0.5 + 0.5 * (t * std::f64::consts::PI).cos() + } + + /// The region that owns graph position `x`: the one whose own window contains it. The four + /// windows tile 0..1, so this is always defined and a drag anywhere on the curve moves exactly + /// one slider — the region under the pointer. + pub fn region_at(&self, x: f64) -> usize { + let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) }; + let s0 = self.split_x(0); + let s1 = self.split_x(1); + let s2 = self.split_x(2); + if x <= s0 { + 0 + } else if x <= s1 { + 1 + } else if x <= s2 { + 2 + } else { + 3 + } + } + + /// How much output level region `region`'s bump gains at `x` per unit of its slider: the bump is + /// linear in the amount, so this is a constant for a given `x`. A drag converts the pointer's + /// vertical motion into a slider change through it. + pub fn level_per_unit(&self, region: usize, x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + 0.22 / 100.0 * self.region_window_at(region, x) * 4.0 * x * (1.0 - x) + } + + /// The baseline level at `x` from the identity diagonal plus the contributions of all regions + /// *except* `region`. Used when inverting the model for `region` while holding the other three + /// regions fixed. + pub fn base_level_without(&self, region: usize, x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + let mut d = 0.0; + for i in 0..4 { + if i == region { + continue; + } + let amt = self.amounts.get(i).copied().unwrap_or(0.0); + if amt == 0.0 { + continue; + } + d += (amt / 100.0) * 0.22 * self.region_window_at(i, x); + } + x + d * 4.0 * x * (1.0 - x) + } + + /// The curve level region `region` sits at when its slider holds `amount`, at graph position `x`, + /// holding the other regions fixed at their current settings. + pub fn handle_level_at(&self, region: usize, amount: f64, x: f64) -> f64 { + (self.base_level_without(region, x) + amount * self.level_per_unit(region, x)).clamp(0.0, 1.0) + } + + /// The amount that puts region `region`'s curve at `level` at `x`, with the other three + /// regions held fixed — the inverse of [`Self::handle_level_at`]. A level the region cannot + /// reach saturates rather than jumping. + pub fn amount_at_level(&self, region: usize, level: f64, x: f64) -> f64 { + let per_unit = self.level_per_unit(region, x); + if per_unit.abs() <= f64::EPSILON { + return self.amounts.get(region).copied().unwrap_or(0.0); + } + let base = self.base_level_without(region, x); + ((level.clamp(0.0, 1.0) - base) / per_unit).clamp(-100.0, 100.0).round() + } + + /// The graph position split `split` (0..=2) sits at, 0..1. + pub fn split_x(&self, split: usize) -> f64 { + self.splits.get(split).copied().unwrap_or(0.5) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_finite_or_inverted_splits_do_not_panic_or_poison_curve() { + let c = ToneCurve { + split_shadows: 80.0, + split_mid: 20.0, + split_highlights: f64::NAN, + shadows: f64::NAN, + lights: f64::INFINITY, + ..Default::default() + }; + let model = ParametricCurve::new(&c); + assert!(model.at(0.5).is_finite()); + assert!(model.at(f64::NAN).is_finite()); + assert!(model.region_at(f64::NAN) < 4); + assert!((model.splits[0] - 0.8).abs() < 1e-6); + assert!((model.splits[1] - 0.8).abs() < 1e-6); + assert!((model.splits[2] - 0.8).abs() < 1e-6); + } + + #[test] + fn horizontal_inversion_at_x_0_51_with_darks_60_preserves_zero_lights() { + let c = ToneCurve { darks: 60.0, lights: 0.0, ..Default::default() }; + let model = ParametricCurve::new(&c); + assert_eq!(model.region_at(0.51), 2, "x=0.51 is inside the Lights region"); + let level = model.at(0.51); + assert!((level - 0.5619).abs() < 0.001, "extracted model at 0.51 is ~0.5619: {level}"); + // With other regions held fixed, inverting at level ~0.5619 must return Lights = 0, NOT 45! + let lights = model.amount_at_level(2, level, 0.51); + assert_eq!(lights, 0.0, "zero vertical delta preserves Lights=0: {lights}"); + } + + #[test] + fn a_curve_level_and_its_amount_are_inverses_with_other_regions_present() { + let c = ToneCurve { shadows: 20.0, darks: -30.0, lights: 40.0, highlights: -10.0, ..Default::default() }; + let model = ParametricCurve::new(&c); + for region in 0..4 { + let centre = model.region_x(region); + let (_, upper) = model.region_window(region); + for x in [centre, centre + (upper - centre) * 0.5] { + for amount in [-100.0, -40.0, 0.0, 30.0, 100.0] { + let level = model.handle_level_at(region, amount, x); + let back = model.amount_at_level(region, level, x); + assert!((back - amount).abs() <= 1.0, "region {region} at {x}: {amount} -> {level} -> {back}"); + } + } + } + } +} diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index b61b6a62c..c1e6a9bc2 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -29,31 +29,20 @@ fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> { return None; } const N: usize = 1024; - let (s1, s2, s3) = ((c.split_shadows / 100.0) as f32, (c.split_mid / 100.0) as f32, (c.split_highlights / 100.0) as f32); - let regions = [(0.0, s1, c.shadows), (s1, s2, c.darks), (s2, s3, c.lights), (s3, 1.0, c.highlights)]; - let mut base = Lut1::from_fn(N, |x| { - let mut d = 0.0; - for (a, b, amt) in regions { - if amt == 0.0 { - continue; - } - let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05); - let t = ((x - ctr) / half).clamp(-1.0, 1.0); - let win = 0.5 + 0.5 * (t * std::f32::consts::PI).cos(); - d += (amt / 100.0) as f32 * 0.22 * win; - } - (x + d * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0) - }); - // keep monotone - for i in 1..N { - base.v[i] = base.v[i].max(base.v[i - 1]); - } + let mut base = crate::curve::ParametricCurve::new(c).to_lut(); let to_pts = |p: &[Point]| p.iter().map(|q| (q.x, q.y)).collect::>(); if master { base = MonotoneCurve::new(&to_pts(&c.master)).to_lut(N).compose(&base); } let per = [&c.red, &c.green, &c.blue]; - Some(std::array::from_fn(|i| if chans[i] { MonotoneCurve::new(&to_pts(per[i])).to_lut(N).compose(&base) } else { base.clone() })) + Some(std::array::from_fn(|i| { + if chans.get(i).copied().unwrap_or(false) { + let pts = per.get(i).map(|&p| to_pts(p)).unwrap_or_default(); + MonotoneCurve::new(&pts).to_lut(N).compose(&base) + } else { + base.clone() + } + })) } /// Vignette (post-crop) parameters. diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 57cec33ec..8f2702760 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -27,6 +27,7 @@ pub mod auto; pub mod blemish; pub mod colorops; pub mod cull; +pub mod curve; pub mod dust; pub mod finish; pub mod geometry; @@ -44,6 +45,7 @@ pub mod transform; pub mod upright; pub mod visualize; +pub use curve::{PARAM_AMOUNTS, PARAM_LUT, ParametricCurve}; pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof, ProofLut, proof_profile, register_proof_profile}; pub use visualize::{MaskView, Overlay}; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 62c9a2505..10bb24d50 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -8,8 +8,9 @@ //! (HE / HE★, Z 8, Z 9, Z 6III, Z f): they keep compression 34713 but carry a wavelet codestream that starts with //! the JPEG XS markers SOC + CAP (ISO/IEC 21122-1, `FF10 FF50`) and have no `0x0096` table (issue #193). //! Strips labelled 34713 without a `0x0096` -//! linearization table are packed uncompressed data in several measured layouts ([`PackedLayout`]); Nikon small raw -//! (sRAW) and the D1X's genuinely Huffman-coded strip stay refused. +//! linearization table are packed uncompressed data in several measured layouts ([`PackedLayout`]) or, when the strip +//! is exactly three bytes per pixel, Nikon small raw ([`small_raw`]); the D1X's genuinely Huffman-coded strip stays +//! refused. use super::{nefc, white_from_data}; use crate::tiffraw::{Packing, check_image, read_image}; @@ -233,6 +234,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let make = ifd0.string(t::MAKE).unwrap_or_default(); let mn = tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)); + if let Some(strip) = small_raw_strip(&info, bytes.len(), mn.as_ref()) { + return small_raw(bytes, &tiff, &info, strip, mode); + } // the header probe reads only the rows the masked-column test needs let rows = match mode { Mode::Full => h, @@ -494,14 +498,7 @@ fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result { _ => None, }; let (Some(layout), [c]) = (layout, chunks.as_slice()) else { - let small = bits == 12 - && chunks.len() == 1 - && w.checked_mul(h).and_then(|n| n.checked_mul(3)).is_some_and(|n| u64::try_from(n).ok() == Some(chunks[0].len)); - return Err(RawError::Unsupported(if small { - "Nikon small raw (sRAW) not decoded yet".into() - } else { - "Nikon compressed NEF without a linearization table (maker note 0x96)".into() - })); + return Err(RawError::Unsupported("Nikon compressed NEF without a linearization table (maker note 0x96)".into())); }; let src = lightcraft_tiff::image::chunk_bytes(bytes, c) .filter(|src| u64::try_from(src.len()).ok() == Some(c.len)) @@ -512,6 +509,128 @@ fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result { Ok(RawData::U16(unpack_packed(src, w, rows.min(h), bits, layout))) } +/// Nikon small raw (sRAW), measured clean-room from the CC0 raw.pixls.us D810 (2036) and D4S (2227) samples. The raw SubIFD is labelled compression 34713, +/// 12 bits, one CFA sample per pixel, but it carries no linearization table (maker note `0x0096`) and its single strip +/// is exactly `3 * width * height` bytes. It holds a YCbCr 4:2:2 image of the IFD size, not a CFA mosaic (the CFA +/// tags are dummies): +/// +/// - the strip is an LSB-first 12-bit stream (`s0 = b0 | (b1 & 0x0f) << 8`, `s1 = b1 >> 4 | b2 << 4`), a row is `3 * width` +/// bytes with no padding, i.e. `2 * width` samples; +/// - the samples come in groups of four, `[Y_left, Y_right, Cb, Cr]`, for the pixel pair `(2k, 2k + 1)`: chroma is +/// halved horizontally and not vertically; +/// - luma is linear and already black-subtracted (the darkest samples are near 0) and never uses the top two bits +/// (maxima 873 and 1004 of 4095), so the white level is 1023; chroma is centred on 2048 (fitted neutral 2045-2049). +/// +/// The chroma is a camera colour-difference scale, not JFIF Cb/Cr. In linear terms the stored values behave as +/// `R - Y = R_GAIN * (Cr - 2048)` and `B - Y = B_GAIN * (Cb - 2048)` on the luma scale: regressing the camera JPEG's +/// linear `(R - Y) / Y` and `(B - Y) / Y` on `(Cr - 2048) / Y` and `(Cb - 2048) / Y` over mid-tone pixels gave slopes +/// 4.92 / 5.09 (D810, correlation 0.94 / 0.88) and 3.52 / 3.73 (D4S, 0.98 / 0.97); the gains below are the rounded +/// means. They are approximate (the two bodies differ by about 30 %), the luma is not tone-mapped, and the data is +/// treated as already white balanced (neutral D4S areas have `Cb`, `Cr` within 2 of 2048 and equal JPEG R, G, B). G +/// follows from the luma equation with the BT.601 weights. The chroma is upsampled horizontally with linear +/// interpolation, centred between the two pixels of a pair. Output is linear RGB scaled by 16 (to a 14-bit range), +/// like Sony's downsized ARWs. +const SMALL_RAW_WHITE: f32 = 1023.0; +const SMALL_RAW_SCALE: f32 = 16.0; +const SMALL_RAW_CHROMA_ZERO: f32 = 2048.0; +const SMALL_RAW_R_GAIN: f32 = 4.2; +const SMALL_RAW_B_GAIN: f32 = 4.4; + +/// The strip of a small raw: compression 34713, 12 bits, no maker note `0x0096`, one strip of exactly +/// `3 * width * height` bytes (even width). +fn small_raw_strip(info: &ImageInfo, file_len: usize, mn: Option<&makernote::MakerNote>) -> Option { + if info.compression != t::compression::NIKON || info.bits() != 12 || info.samples_per_pixel != 1 { + return None; + } + if mn.is_some_and(|m| m.ifd.bytes(LINEARIZATION_TABLE).is_some()) { + return None; + } + let (w, h) = (info.width as u64, info.height as u64); + let chunks = info.chunks(file_len as u64); + match chunks.as_slice() { + [c] if w.is_multiple_of(2) && w > 0 && h > 0 && w.checked_mul(h).and_then(|n| n.checked_mul(3)) == Some(c.len) => Some(*c), + _ => None, + } +} + +/// Decode one row of a small raw (`3 * width` bytes) into `3 * width` linear RGB values on the 14-bit scale. +/// Short input reads as zero bytes. +fn small_raw_row(src: &[u8], w: usize, out: &mut [u16]) { + let Some(n) = w.checked_mul(3).filter(|n| *n <= out.len() && *n <= crate::MAX_SAMPLES) else { return }; + if w == 0 || !w.is_multiple_of(2) { + return; + } + let mut s = vec![0u16; 2 * w]; + crate::unpack::unpack_lsb(&src[..src.len().min(n)], 12, &mut s); + let pairs = w / 2; + let chroma = |k: usize| { + let g = &s[4 * k + 2..4 * k + 4]; + (f32::from(g[0]) - SMALL_RAW_CHROMA_ZERO, f32::from(g[1]) - SMALL_RAW_CHROMA_ZERO) + }; + for k in 0..pairs { + let (cb, cr) = chroma(k); + for side in 0..2 { + let (nb, nr) = if side == 0 { chroma(k.saturating_sub(1)) } else { chroma((k + 1).min(pairs - 1)) }; + let (u, v) = (0.75 * cb + 0.25 * nb, 0.75 * cr + 0.25 * nr); + let y = f32::from(s[4 * k + side]); + let r = y + SMALL_RAW_R_GAIN * v; + let b = y + SMALL_RAW_B_GAIN * u; + let g = (y - 0.299 * r - 0.114 * b) / 0.587; + let dst = (2 * k + side) * 3; + for (i, value) in [r, g, b].into_iter().enumerate() { + out[dst + i] = (value * SMALL_RAW_SCALE).round().clamp(0.0, 65535.0) as u16; + } + } + } +} + +fn small_raw(bytes: &[u8], tiff: &Tiff, info: &ImageInfo, strip: lightcraft_tiff::image::Chunk, mode: Mode) -> Result { + use rayon::prelude::*; + let (w, h) = (info.width as usize, info.height as usize); + let stride = w.checked_mul(3).ok_or(RawError::Limit("image too large"))?; + let count = stride.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let src = lightcraft_tiff::image::chunk_bytes(bytes, &strip) + .filter(|src| u64::try_from(src.len()).ok() == Some(strip.len)) + .ok_or_else(|| RawError::Corrupt("NEF: incomplete image strip".into()))?; + let data = match mode { + Mode::Full => { + let mut data = vec![0u16; count]; + data.par_chunks_mut(stride).enumerate().for_each(|(y, out)| { + small_raw_row(src.get(y * stride..).unwrap_or(&[]), w, out); + }); + data + } + Mode::Header => Vec::new(), + }; + let mut metadata = lightcraft_meta::from_tiff(tiff); + metadata.width = Some(w as u32); + metadata.height = Some(h as u32); + let img = RawImage { + format: RawFormat::Nef, + width: w, + height: h, + cpp: 3, + data: RawData::U16(data), + cfa: None, + bits: 14, + black: BlackLevel::uniform(0.0), + white: vec![SMALL_RAW_WHITE * SMALL_RAW_SCALE], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::from_exif(tiff.ifds.first().and_then(|i| i.u16(t::ORIENTATION)).unwrap_or(1)), + color: ColorData::default(), + // the stored colour is already white balanced (see above) + wb_multipliers: Some([1.0; 3]), + linearized: false, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + metadata, + lens_metadata: Default::default(), + }; + img.validate_for(mode)?; + Ok(img) +} + #[cfg(test)] mod tests { use super::*; @@ -1089,9 +1208,119 @@ mod tests { // 14-bit rows must be padded to 16 bytes let d14 = samples(40 * 6, 14); assert!(matches!(decoded(strip(&d14, 40, 0, |r| pack_lsb(r, 14)), 14, 40, 6), Err(RawError::Unsupported(_)))); - // 3 bytes per IFD-width pixel is a Nikon small raw: still refused, with its own reason - let Err(RawError::Unsupported(why)) = decoded(vec![0; 3 * 40 * 6], 12, 40, 6) else { panic!("small raw decoded") }; - assert!(why.contains("sRAW"), "{why}"); + } + + /// A small raw strip of `[Y0 Y1 Cb Cr]` groups, rows of `3 * w` bytes, LSB-first 12-bit. + fn small_strip(groups: &[[u16; 4]], w: usize) -> Vec { + let flat: Vec = groups.iter().flatten().copied().collect(); + assert_eq!(flat.len() % (2 * w), 0); + flat.chunks(2 * w).flat_map(|r| pack_lsb(r, 12)).collect() + } + + fn small(strip: Vec, bits: u16, w: u32, h: u32) -> Result { + crate::decode(&nef(34713, bits, vec![strip], w, h, h)) + } + + #[test] + fn small_raw_422_round_trips() { + let (w, h) = (8usize, 3usize); + // flat chroma per row so the interpolation is exact; distinct luma everywhere + let mut groups = Vec::new(); + for y in 0..h { + for k in 0..w / 2 { + let (cb, cr) = (2048 + 10 * y as u16, 2048 - 5 * y as u16); + groups.push([100 + (y * w + 2 * k) as u16, 100 + (y * w + 2 * k + 1) as u16, cb, cr]); + } + } + let raw = small(small_strip(&groups, w), 12, w as u32, h as u32).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp, raw.cfa.is_none()), (w, h, 3, true)); + assert_eq!(raw.wb_multipliers, Some([1.0; 3])); + let RawData::U16(d) = &raw.data else { panic!() }; + for y in 0..h { + for x in 0..w { + let luma = (100 + y * w + x) as f32; + let (u, v) = (10.0 * y as f32, -5.0 * y as f32); + let (r, b) = (luma + SMALL_RAW_R_GAIN * v, luma + SMALL_RAW_B_GAIN * u); + let g = (luma - 0.299 * r - 0.114 * b) / 0.587; + for (i, want) in [r, g, b].into_iter().enumerate() { + let got = f32::from(d[(y * w + x) * 3 + i]) / SMALL_RAW_SCALE; + assert!((got - want).abs() < 0.1, "({x},{y}) channel {i}: {got} vs {want}"); + } + } + } + } + + #[test] + fn small_raw_neutral_chroma_is_grey_and_chroma_is_interpolated() { + let w = 8usize; + let groups: Vec<[u16; 4]> = (0..w / 2).map(|k| [200 + 40 * k as u16, 200 + 40 * k as u16, 2048, 2048]).collect(); + let raw = small(small_strip(&groups, w), 12, w as u32, 1).unwrap(); + let RawData::U16(d) = &raw.data else { panic!() }; + for x in 0..w { + let px = &d[x * 3..x * 3 + 3]; + assert!(px.iter().all(|&v| v == px[0]), "{px:?}"); + assert_eq!(f32::from(px[0]), (200 + 40 * (x / 2)) as f32 * SMALL_RAW_SCALE); + } + // a step in Cb between the first two pairs is shared 3:1 by the pixels next to the boundary + let mut groups = groups; + groups[1][2] = 2048 + 100; + let raw = small(small_strip(&groups, w), 12, w as u32, 1).unwrap(); + let RawData::U16(d) = &raw.data else { panic!() }; + let blue_minus_luma = |x: usize, luma: f32| f32::from(d[x * 3 + 2]) / SMALL_RAW_SCALE - luma; + assert!((blue_minus_luma(1, 200.0) - SMALL_RAW_B_GAIN * 25.0).abs() < 0.1); + assert!((blue_minus_luma(2, 240.0) - SMALL_RAW_B_GAIN * 75.0).abs() < 0.1); + } + + #[test] + fn small_raw_wrong_sizes_and_depths_are_refused() { + let (w, h) = (8usize, 3usize); + let good = small_strip(&vec![[100, 100, 2048, 2048]; w / 2 * h], w); + assert!(small(good.clone(), 12, w as u32, h as u32).is_ok()); + for len in [good.len() - 1, good.len() + 1] { + let mut s = good.clone(); + s.resize(len, 0); + assert!(matches!(small(s, 12, w as u32, h as u32), Err(RawError::Unsupported(_))), "len {len}"); + } + assert!(matches!(small(good.clone(), 14, w as u32, h as u32), Err(RawError::Unsupported(_)))); + // odd width: 3 bytes per pixel does not split into pixel pairs + assert!(matches!(small(vec![0; 3 * 7 * 3], 12, 7, 3), Err(RawError::Unsupported(_)))); + } + + #[test] + fn small_raw_short_input_does_not_panic() { + for len in [0, 1, 2, 3, 5, 11, 12, 13] { + let mut out = vec![0u16; 12]; + small_raw_row(&vec![0xFF; len], 4, &mut out); + } + let mut bytes = nef(34713, 12, vec![vec![0x55; 3 * 8 * 3]], 8, 3, 3); + bytes.truncate(bytes.len() - 40); + assert!(crate::decode(&bytes).is_err(), "a truncated small-raw strip is corrupt"); + } + + #[test] + fn small_raw_header_has_full_geometry_without_allocating_pixels() { + let (w, h) = (8usize, 3usize); + let bytes = nef(34713, 12, vec![small_strip(&vec![[100, 110, 2048, 2048]; w / 2 * h], w)], w as u32, h as u32, h as u32); + let header = decode(&bytes, Mode::Header).unwrap(); + let full = decode(&bytes, Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + assert!(matches!(header.data, RawData::U16(ref samples) if samples.is_empty())); + assert!(matches!(full.data, RawData::U16(ref samples) if samples.len() == 3 * w * h)); + } + + #[test] + fn small_raw_rejects_excessive_dimensions_before_allocation() { + let bytes = nef(34713, 12, vec![vec![0; 72]], 8, 3, 3); + let tiff = Tiff::parse(&bytes).unwrap(); + let mut info = raw_ifd(&tiff).unwrap().image().unwrap(); + let strip = info.chunks(bytes.len() as u64)[0]; + info.width = u32::MAX - 1; + info.height = u32::MAX; + assert!(matches!(small_raw(&bytes, &tiff, &info, strip, Mode::Header), Err(RawError::Limit(_)))); + let mut out = [0; 12]; + small_raw_row(&[], usize::MAX, &mut out); + small_raw_row(&[], 3, &mut out); + assert_eq!(out, [0; 12]); } /// Big-endian 16-bit words holding the 12-bit samples in their upper bits. @@ -1186,4 +1415,15 @@ mod tests { } } } + + #[test] + fn small_raw_rejects_tail_truncation_with_intact_directories() { + let bytes = nef(34713, 12, vec![vec![0x55; 72]], 8, 3, 3); + for keep in [0, 24, 71] { + let short = truncated_tail_strip(bytes.clone(), keep); + for mode in [Mode::Header, Mode::Full] { + assert!(matches!(decode(&short, mode), Err(RawError::Corrupt(_)))); + } + } + } } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 0aeb0720c..79390f136 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -10,7 +10,7 @@ " · {} not readable": " · {} nicht lesbar", " · {} original{} left at the source": " · Originale an der Quelle verblieben: {}{:.0}", " · {} restored from Recently Deleted": " · {} aus „Zuletzt gelöscht“ wiederhergestellt", - " — not carried over: {}{}": " — nicht übernommen: {}{}", + " — not carried over: {}{}": " – nicht übernommen: {}{}", "+{} more": "+{} weitere", ", {failed} file{} not readable": ", nicht lesbare Dateien: {failed}{:.0}", "; {kept} already had a location": "; bereits verortet: {kept}", @@ -54,7 +54,7 @@ "Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}", "Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}", "Exporting {} of {total}": "{} von {total} wird exportiert", - "Filter bar — {active} active filter{}": "Filterleiste — aktive Filter: {active}{:.0}", + "Filter bar — {active} active filter{}": "Filterleiste – aktive Filter: {active}{:.0}", "Focus {:.0}": "Schärfe {:.0}", "Folder “{}” (and its subfolders)": "Ordner „{}“ (und seine Unterordner)", "Found {n} missing photo{}; {left} still missing{unsure}": "Gefundene fehlende Fotos: {n}{:.0}; weiterhin fehlend: {left}{unsure}", @@ -63,11 +63,11 @@ "Import failed: {e}": "Import fehlgeschlagen: {e}", "Import warnings ({n}):\n{warnings}": "Importwarnungen ({n}):\n{warnings}", "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Die XMP-Begleitdateien von {n} Fotos konnten nicht geschrieben werden (ist ihr Ordner offline?). LightCraft versucht es weiter.", - "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil \"{profile}\" wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil „{profile}“ wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-Geometrie wurde für {path} nicht beibehalten; {mode} wird neu berechnet.", - "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil Upright deaktiviert ist.", - "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Guided-Upright-Transformation wird nicht unterstützt.", - "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Upright-Transformation wird nicht unterstützt.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil die Perspektivkorrektur aus ist.", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Perspektivkorrektur mit Hilfslinien wird nicht unterstützt.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Perspektivkorrektur wird nicht unterstützt.", "XMP adjustments were not imported for {path}: {fields}.": "XMP-Anpassungen wurden für {path} nicht importiert: {fields}.", "Imported {n} point curve preset{}": "Importierte Punktkurvenvorgaben: {n}{:.0}", "Imported {n} preset{}": "Importierte Vorgaben: {n}{:.0}", @@ -90,10 +90,10 @@ "Move {what} to Recently Deleted?": "{what} nach „Zuletzt gelöscht“ verschieben?", "Named ({named_n})": "Benannt ({named_n})", "Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“ auftragen: Fotos anklicken, um es hinzuzufügen oder zu entfernen · Esc beendet", - "Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen — nur kopierte Einstellungen werden eingefügt", + "Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen – nur kopierte Einstellungen werden eingefügt", "Preset: {name}": "Vorgabe: {name}", - "Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau — das eingebettete Kamera-JPEG wird bearbeitet ({})", - "Preview only — {}": "Nur Vorschau — {}", + "Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau – das eingebettete Kamera-JPEG wird bearbeitet ({})", + "Preview only — {}": "Nur Vorschau – {}", "Previews ready for {done} photo{plural}": "Vorschauen für {done} Foto{plural} fertig", "Profile: {}": "Profil: {}", "Proof Copy ({})": "Softproof-Kopie ({})", @@ -101,7 +101,7 @@ "Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos", "Quick Develop: {name}": "Schnellentwicklung: {name}", "Rated {}": "Bewertet mit {}", - "Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen … {done} von {total}", + "Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen… {done} von {total}", "Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}", "Reading photos… {} of {}": "Fotos werden gelesen… {} von {}", "Redo {}": "Wiederholen: {}", @@ -129,7 +129,7 @@ "Undo {}": "Rückgängig: {}", "Unknown tag{s} {} stay{} as typed (see Tags)": "Unbekannte Platzhalter{s:.0} {} bleiben unverändert{:.0} (siehe „Platzhalter“)", "Updated “{name}”": "„{name}“ aktualisiert", - "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} — eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} – eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.", "Version: {}": "Version: {}", "Virtual copy “{name}” of {of}": "Virtuelle Kopie „{name}“ von {of}", "Working on {what} for {} photos…": "{what} für {} Fotos wird verarbeitet…", @@ -153,16 +153,16 @@ "{name} Off": "{name} aus", "{name} On": "{name} ein", "{name} opened for editing; it is stacked with the original": "{name} zum Bearbeiten geöffnet; mit dem Original gestapelt", - "{name} — double-click to reset it": "{name} — zum Zurücksetzen doppelklicken", - "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nicht auf den Datenträger geschriebene Änderungen: {n}{0:.0}: {e}\nWenn du jetzt beendest, gehen diese Änderungen verloren.{1:.0}", - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Ungespeicherte Änderungen: {n}{0:.0} im Arbeitsspeicher, aber nicht auf dem Datenträger: {e}\nLightCraft versucht es automatisch erneut; beim Beenden gingen diese Änderungen verloren.{1:.0}", - "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Änderungen nur im Arbeitsspeicher gespeichert: {n}{:.0}: {e} — LightCraft versucht es erneut", + "{name} — double-click to reset it": "{name} – zum Zurücksetzen doppelklicken", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} Änderung(en) konnten nicht auf den Datenträger geschrieben werden{0:.0}: {e}\nWenn du jetzt beendest, gehen sie verloren.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} Änderung(en) nur im Arbeitsspeicher, nicht auf dem Datenträger gespeichert{0:.0}: {e}\nLightCraft versucht es automatisch erneut; wenn du jetzt beendest, gehen sie verloren.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} Änderung(en) nur im Arbeitsspeicher gespeichert{:.0}: {e} – LightCraft versucht es erneut", "{n} found · {sel} selected": "{n} gefunden · {sel} ausgewählt", "{n} photos": "Fotos: {n}", - "{n} photos in {} are hidden by the filters above": "Ausgeblendete Fotos in {}: {n} (durch die obigen Filter)", + "{n} photos in {} are hidden by the filters above": "{n} Fotos in {} werden durch die obigen Filter ausgeblendet", "{n} photo{}": "{n} Foto{}", "{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", - "{n} photo{} now · updates automatically as photos change": "Aktuelle Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", + "{n} photo{} now · updates automatically as photos change": "Derzeit {n} Foto{} · wird bei Änderungen an Fotos automatisch aktualisiert", "{n} selected photos · {pages} pages": "{n} ausgewählte Fotos · {pages} Seiten", "{pid} (missing)": "{pid} (fehlt)", "{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}", @@ -180,7 +180,7 @@ "{} files": "{} Dateien", "{} files and {f} folder{}": "{} Dateien und {f} Ordner{:.0}", "{} item{}": "Elemente: {}{:.0}", - "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien — entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien – entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.", "{} photos": "Fotos: {}", "{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV", "{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°", @@ -188,7 +188,7 @@ "{} sample{}": "Farbproben: {}{:.0}", "{} sec": "{} s", "{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto", - "{} — show their photos": "{} — Fotos dieser Person anzeigen", + "{} — show their photos": "{} – Fotos dieser Person anzeigen", "· {dups} already in the library": "· {dups} bereits in der Bibliothek", "· {n} photo{}": "· {n} Foto{}", "· {trashed} in Recently Deleted": "· {trashed} in „Zuletzt gelöscht“", @@ -196,17 +196,17 @@ "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.", "Filtered: {label}": "Gefiltert: {label}", "{n} similar photo{} · View ▸ Clear Filters to see all": "Ähnliche Fotos: {n}{:.0} · Ansicht → Filter löschen zeigt alle Fotos", - "Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft", - "{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)", + "Moved; {} photo(s) relinked": "Verschoben; {} Foto(s) neu verknüpft", + "{} label (click more labels to show any of them)": "Farbmarkierung {} (weitere Farbmarkierungen auswählen, um alle passenden Fotos zu zeigen)", ", {} star{}": ", Sterne: {}{:.0}", - ", {} label": ", Markierung: {}", + ", {} label": ", Farbmarkierung: {}", "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Lädt von {host} herunter. Sobald es angekommen und geprüft ist, installiert LightCraft es und {after}.", "{:.1} MB": "{:.1} MB", "Running on the processor: {}.": "Läuft auf dem Prozessor: {}.", "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Im Ordner „denoise“ der Bibliothek gespeichert; bei Erreichen der Grenze werden zuerst die ältesten entfernt, die kein Foto verwendet.", "Denoising {name}: {done} of {total} tiles{}": "{name} wird entrauscht: {done} von {total} Kacheln{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} von {} von {host}", + "{} of {} from {host}": "{} von {} (Quelle: {host})", "{} KB": "{} KB", "{} MB": "{} MB", "Making the denoised picture… {}%": "Entrauschtes Bild wird berechnet… {}%", @@ -227,14 +227,14 @@ "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ enthält bereits „{name}“. Beide zusammenführen?", "There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?", "Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt", - "On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos", + "On {have} of {of} selected photos": "Bei {have} von {of} ausgewählten Fotos", "Remove “{name}”": "„{name}“ entfernen", "There is a keyword set “{name}” already": "Es gibt bereits einen Stichwortsatz „{name}“", "Added {n} keyword{}": "Stichwörter hinzugefügt: {n}{:.0}", "; {n} gained synonyms": "; mit neuen Synonymen: {n}", "Exported {n} keyword{}": "Stichwörter exportiert: {n}{:.0}", - " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}", - " — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " – Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}", + " — left out, as a keyword list can't hold them: {names}": " – ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}", "{done} of {total}": "{done} von {total}", "{done} of {total} MB": "{done} von {total} MB", "{pct} %": "{pct} %", @@ -255,7 +255,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", " in {g} group{}": " in {g} Gruppe(n){:.0}", " · kept, not applied: {}": " · behalten, nicht angewendet: {}", - " — kept but not applied: {}": " — behalten, aber nicht angewendet: {}", + " — kept but not applied: {}": " – behalten, aber nicht angewendet: {}", "Apply to “{mask}” on Selected Photos": "Auf „{mask}“ in den ausgewählten Fotos anwenden", "Delete the effect preset “{name}”?": "Effektvorgabe „{name}“ löschen?", "Delete these {n} effect presets?": "Diese {n} Effektvorgaben löschen?", @@ -281,7 +281,7 @@ "Skipped {problem}": "Übersprungen: {problem}", "…and {more} more": "…und {more} weitere", "Template preview: {name}": "Vorlagenvorschau: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Folien · {time}", + "{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Dias · {time}", "{n} pages": "{n} Seiten", "{n} JPEG files": "{n} JPEG-Dateien", "{s} s of AV1 video": "{s} s AV1-Video", @@ -290,7 +290,7 @@ "{n} raws can now be developed from their raw data": "{n} RAW-Dateien können jetzt aus ihren Rohdaten entwickelt werden", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft wurde unerwartet beendet: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}", "LightCraft stopped unexpectedly: {e}": "LightCraft wurde unerwartet beendet: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}", + "Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesicht(er) in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-Zusammenfügung mit {mp} hinzugefügt: die volle Größe, {full}, braucht mehr Arbeitsspeicher, als dieser Computer hat", "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 27fc7b0dd..679e3b00f 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -14,7 +14,7 @@ "0: nothing is rejected.": "0: Nichts wird abgelehnt.", "1 day": "1 Tag", "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 auf studio-mac. Beide Programme können nicht gleichzeitig schreiben und gegenseitig Bearbeitungen verlieren. Ein Absturz lässt die", - "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuelle Bearbeitungen · eingepasst ohne Beschnitt", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuelle Bearbeitungen · eingepasst ohne Zuschnitt", "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080 großen Vorschau. Metadaten werden importiert: Aufnahmezeit, Kamera, Objektiv, Belichtung, GPS und XMP. Die RAW-Daten", "2026-04 or 2026-04-12": "2026-04 oder 2026-04-12", "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 neue eingebaute Vorgaben: Porträt, Landschaft, Stadt, Essen, Jahreszeiten, Vintage und Schwarzweißtonungen.", @@ -64,7 +64,7 @@ "Add/Subtract/Intersect Mask": "Maske hinzufügen/subtrahieren/schneiden", "Added to photos you import that don't already have it.": "Wird importierten Fotos hinzugefügt, die diese Angabe noch nicht enthalten.", "Adding": "Wird hinzugefügt", - "Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt …", + "Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt…", "Advanced search…": "Erweiterte Suche…", "After": "Nachher", "After 30 days": "Nach 30 Tagen", @@ -99,9 +99,9 @@ "Aqua Hue": "Aquamarin – Farbton", "Aqua Luminance": "Aquamarin – Luminanz", "Aqua Saturation": "Aquamarin – Sättigung", - "Arrange Panel": "Bereich anordnen", + "Arrange Panel": "Bedienfeld anordnen", "ArtCraft Website": "ArtCraft-Website", - "ArtCraft — more creative apps": "ArtCraft — weitere Kreativ-Apps", + "ArtCraft — more creative apps": "ArtCraft – weitere Kreativ-Apps", "As Shot": "Wie Aufnahme", "As shot": "Wie Aufnahme", "Ascending": "Aufsteigend", @@ -257,7 +257,7 @@ "Close Window": "Fenster schließen", "Close all": "Alle schließen", "Close file": "Datei schließen", - "Close panel": "Bereich schließen", + "Close panel": "Bedienfeld schließen", "Cloudy": "Bewölkt", "Cmd+Shift+R, keeps the photo and clears its edits.)": "mit ⌘⇧R die Bearbeitungen zurück und behält das Foto.", "Collapse": "Einklappen", @@ -296,10 +296,10 @@ "Confirm before deleting": "Vor dem Löschen bestätigen", "Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen", "Move Photos": "Fotos verschieben", - "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Ihre Dateien und XMP-Filialdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Die Dateien der Fotos und ihre XMP-Begleitdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.", "Don't ask again": "Nicht mehr fragen", "Another library was opened since the photos were dropped: nothing was moved": "Seit dem Ablegen der Fotos wurde eine andere Bibliothek geöffnet: Es wurde nichts verschoben", - "Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet es wieder ein", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet die Abfrage wieder ein", "Confirm before moving photos to another folder on disk": "Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen", "Constrain Crop": "Zuschnitt beschränken", "Contact Sheet PDF": "Kontaktabzug-PDF", @@ -463,7 +463,7 @@ "Edit Date": "Bearbeitungsdatum", "Edit Info": "Informationen bearbeiten", "Edit Label Names": "Namen der Farbmarkierungen bearbeiten", - "Edit Label Names…": "Markierungsnamen bearbeiten…", + "Edit Label Names…": "Namen der Farbmarkierungen bearbeiten…", "Edit Mask": "Maske bearbeiten", "Edit Mask Component": "Maskenkomponente bearbeiten", "Edit Sections": "Bearbeitungsbereiche", @@ -505,7 +505,7 @@ "Export Presets…": "Vorgaben exportieren…", "Export User Presets…": "Benutzervorgaben exportieren…", "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Exportdateinamen verwenden die Platzhalter der Fotoumbenennung ({title}, {seq:2}, {date:%Y-%m-%d}…) sowie {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", - "Export stopped unexpectedly": "Export unerwartet angehalten", + "Export stopped unexpectedly": "Export unerwartet abgebrochen", "Export with Preset": "Mit Vorgabe exportieren", "Export with Previous": "Mit vorherigen Einstellungen exportieren", "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Exportdateien werden zunächst temporär geschrieben und anschließend umbenannt. Ein voller Datenträger oder getrenntes Laufwerk", @@ -575,7 +575,7 @@ "Flip Overlay (⇧O)": "Überlagerung spiegeln (⇧O)", "Flip V": "V spiegeln", "Flip Vertical": "Vertikal spiegeln", - "Float panel": "Bereich frei platzieren", + "Float panel": "Bedienfeld frei platzieren", "Flow": "Fluss", "Fluorescent": "Leuchtstofflicht", "Focal Length": "Brennweite", @@ -591,7 +591,7 @@ "Form fields panel": "Formularfeldbereich", "Format": "Format", "Forward": "Vorwärts", - "Frame time": "Bilddauer", + "Frame time": "Frame-Zeit", "Frame update": "Bildaktualisierung", "Free": "Frei", "Fresh & Bright": "Frisch und hell", @@ -696,7 +696,7 @@ "Import": "Importieren", "Import Date": "Importdatum", "Import Lightroom Catalog": "Lightroom-Katalog importieren", - "Import Lightroom Catalog…": "Lightroom-Katalog importieren …", + "Import Lightroom Catalog…": "Lightroom-Katalog importieren…", "Import Photos": "Fotos importieren", "Import Photos…": "Fotos importieren…", "Import Point Curve Presets": "Punktkurvenvorgaben importieren", @@ -722,7 +722,7 @@ "In Photo Grid and Square Grid: expand or collapse the stack": "In Fotoraster und quadratischem Raster: Stapel auf- oder zuklappen", "In Recently Deleted": "In „Zuletzt gelöscht“", "In library": "In Bibliothek", - "In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert", + "In memory — nothing is saved": "Im Arbeitsspeicher – nichts wird gespeichert", "In use": "In Verwendung", "In-memory session: these settings last until LightCraft quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von LightCraft.", "Include selected photos": "Ausgewählte Fotos einschließen", @@ -803,7 +803,7 @@ "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft stürzt beim Start auf Windows-PCs mit defektem Vulkan-Treiber nicht mehr ab (Issue #136, etwa bestimmte Intel-UHD-630-Treiber).", "LightCraft on GitHub": "LightCraft auf GitHub", "LightCraft website": "LightCraft-Website", - "LightCraft — Second Window": "LightCraft — zweites Fenster", + "LightCraft — Second Window": "LightCraft – zweites Fenster", "Lightroom Classic Catalog": "Lightroom Classic-Katalog", "Lightroom import cancelled": "Lightroom-Import abgebrochen", "Lightroom inspection cancelled": "Lightroom-Prüfung abgebrochen", @@ -824,7 +824,7 @@ "Loading preview…": "Vorschau wird geladen…", "Local": "Lokal", "Local folders": "Lokale Ordner", - "Local library — no cloud account needed": "Lokale Bibliothek — kein Cloud-Konto erforderlich", + "Local library — no cloud account needed": "Lokale Bibliothek – kein Cloud-Konto erforderlich", "Locate Missing File…": "Fehlende Datei suchen…", "Locate Missing Files…": "Fehlende Dateien suchen…", "Locate Photo": "Foto suchen", @@ -880,7 +880,7 @@ "Midtones Hue": "Mitteltöne – Farbton", "Midtones Luminance": "Mitteltöne – Luminanz", "Midtones Saturation": "Mitteltöne – Sättigung", - "Midtones split": "Mitteltontrennung", + "Midtones split": "Mitteltongrenze", "Minimize": "Minimieren", "Missing Photos": "Fehlende Fotos", "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Fehlende Metadaten ({camera}, {title}…) hinterlassen eine Lücke; bei einem leeren Ergebnis bleibt der Originalname erhalten.", @@ -922,7 +922,7 @@ "Muted": "Gedämpft", "Muted Film": "Gedämpfter Film", "My Photos": "Meine Fotos", - "My Photos Panel": "Bereich „Meine Fotos“", + "My Photos Panel": "Bedienfeld „Meine Fotos“", "Name": "Name", "Name (A–Z)": "Name (A–Z)", "Name of the folder the original is in": "Name des Ordners mit dem Original", @@ -979,7 +979,7 @@ "No photo selected": "Kein Foto ausgewählt", "No photos": "Keine Fotos", "Your library is empty": "Deine Bibliothek ist leer", - "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Nichts wird kopiert oder verschoben, außer du wählst es.", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Kopiert oder verschoben wird nur, wenn du dich dafür entscheidest.", "or drop photos and folders into this window": "oder ziehe Fotos und Ordner in dieses Fenster", "Import photos to get started": "Importiere Fotos, um loszulegen", "or drop photos into this window": "oder ziehe Fotos in dieses Fenster", @@ -993,7 +993,7 @@ "No versions yet. Create one to keep this look.": "Noch keine Versionen. Erstelle eine, um diesen Look zu bewahren.", "Noise": "Rauschen", "Noise Reduction": "Rauschreduzierung", - "Non-raw photos": "Andere Fotos als RAW", + "Non-raw photos": "Nicht-RAW-Fotos", "None": "Keine", "Not Now": "Nicht jetzt", "Nothing to compare": "Nichts zum Vergleichen", @@ -1037,11 +1037,11 @@ "Overwrite": "Überschreiben", "Pages": "Seiten", "Paint": "Auftragen", - "Paint only areas like the one under the brush": "Nur Bereiche wie den unter dem Pinsel bearbeiten", + "Paint only areas like the one under the brush": "Nur Bereiche bemalen, die dem Bereich unter dem Pinsel ähneln", "Paint over a distraction on the photo to remove it.": "Übermale ein störendes Element im Foto, um es zu entfernen.", "Paint overlay": "Überlagerung auftragen", "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "RAW-Dateien von Panasonic und Leica, RW2, RWL und ältere RAW-Dateien von DMC-LX1 bis DC-S1R II, werden in", - "Panels": "Bereiche", + "Panels": "Bedienfelder", "Panorama": "Panorama", "Panorama Merge": "Zu Panorama zusammenfügen", "Panorama Merge Preview": "Panoramavorschau", @@ -1105,7 +1105,7 @@ "Presets (Shift+P)": "Vorgaben (Umschalt+P)", "Presets and profiles": "Vorgaben und Profile", "Presets ▾": "Vorgaben ⌄", - "Press keys…": "Tasten drücken …", + "Press keys…": "Tasten drücken…", "Preview": "Vorschau", "Preview Build Progress": "Fortschritt der Vorschauerstellung", "Preview only": "Nur Vorschau", @@ -1174,7 +1174,7 @@ "Read mode": "Lesemodus", "Read the SAM License": "SAM-Lizenz lesen", "Reading": "Wird gelesen", - "Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen …", + "Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen…", "Reading folder…": "Ordner wird gelesen…", "Real name": "Echter Name", "Recent": "Zuletzt verwendet", @@ -1219,7 +1219,7 @@ "Remove CA": "Chromatische Aberration entfernen", "Remove Chromatic Aberration": "Chromatische Aberration entfernen", "Camera local tone mapping": "Lokale Tonwertzuordnung der Kamera", - "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Die in der Raw-Datei gespeicherte lokale Tonwertzuordnung (Apple ProRAW) anwenden, wie die Kamera sie rendert", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Die in der RAW-Datei gespeicherte lokale Tonwertzuordnung (Apple ProRAW) anwenden, wie die Kamera sie rendert", "Remove Disk from Library": "Datenträger aus Bibliothek entfernen", "Remove Disk from Library…": "Datenträger aus Bibliothek entfernen…", "Remove Face Box": "Gesichtsrahmen entfernen", @@ -1269,7 +1269,7 @@ "Reset Control": "Regler zurücksetzen", "Reset Crop": "Zuschnitt zurücksetzen", "Reset Edits": "Bearbeitungen zurücksetzen", - "Reset Panel Layout": "Bereichsanordnung zurücksetzen", + "Reset Panel Layout": "Bedienfeldanordnung zurücksetzen", "Reset Parametric Curve": "Parametrische Kurve zurücksetzen", "Reset Section": "Bereich zurücksetzen", "Reset Tone Curve": "Gradationskurve zurücksetzen", @@ -1277,8 +1277,8 @@ "Reset all edits": "Alle Bearbeitungen zurücksetzen", "Reshuffle": "Neu mischen", "Resize Face Box": "Gesichtsrahmengröße ändern", - "Resize panel window": "Größe des Bereichsfensters ändern", - "Resize panels": "Bereichsgrößen ändern", + "Resize panel window": "Größe des Bedienfeldfensters ändern", + "Resize panels": "Bedienfeldgrößen ändern", "Resize to": "Größe ändern auf", "Resolution (ppi)": "Auflösung (ppi)", "Restore": "Wiederherstellen", @@ -1329,7 +1329,7 @@ "Save as set": "Als Satz speichern", "Save as…": "Speichern unter…", "Save preset": "Vorgabe speichern", - "Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert …", + "Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert…", "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): LightCraft führt seine", "Scale": "Skalierung", "Scan failed": "Durchsuchen fehlgeschlagen", @@ -1384,7 +1384,7 @@ "Set as Target Album (B adds to it)": "Als Zielalbum festlegen (B fügt Fotos hinzu)", "Set as Top of Stack": "Als oberstes Foto im Stapel festlegen", "Set date & time": "Datum und Uhrzeit festlegen", - "Set name": "Satzname", + "Set name": "Name des Satzes", "Set the mix from the photo's colours": "Mischung anhand der Farben des Fotos festlegen", "Settings": "Einstellungen", "Settings file problem": "Problem mit Einstellungsdatei", @@ -1399,7 +1399,7 @@ "Shadows Hue": "Tiefen – Farbton", "Shadows Luminance": "Tiefen – Luminanz", "Shadows Saturation": "Tiefen – Sättigung", - "Shadows Tint": "Tiefen – Tonung", + "Shadows Tint": "Tiefen – Tönung", "Shadows split": "Tiefengrenze", "Sharpen": "Schärfen", "Sharpening": "Schärfen", @@ -1434,7 +1434,7 @@ "Show the import review when a camera or card is connected": "Importfenster öffnen, wenn eine Kamera oder Karte angeschlossen wird", "Show the template tags; click one to insert it": "Vorlagenplatzhalter anzeigen; zum Einfügen anklicken", "Show/Hide Mask": "Maske ein-/ausblenden", - "Show/hide My Photos panel": "Bereich „Meine Fotos“ ein-/ausblenden", + "Show/hide My Photos panel": "Bedienfeld „Meine Fotos“ ein-/ausblenden", "Shutter speed": "Verschlusszeit", "Side panels": "Seitliche Bedienfelder", "Sidecar names": "Namen der Begleitdateien", @@ -1528,13 +1528,13 @@ "Temp": "Temp.", "Temperature": "Temperatur", "Template": "Vorlage", - "Temporary session — your library isn't open, and nothing you do here is saved.": "Vorübergehende Sitzung — deine Bibliothek ist nicht geöffnet; hier vorgenommene Änderungen werden nicht gespeichert.", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Vorübergehende Sitzung – deine Bibliothek ist nicht geöffnet; hier vorgenommene Änderungen werden nicht gespeichert.", "Text": "Text", "Text direction": "Textrichtung", "Texture": "Struktur", "That includes the photos in the folders inside it.": "Das schließt die Fotos in den enthaltenen Unterordnern ein.", "That key can't be used as a shortcut": "Diese Taste kann nicht als Tastenkürzel verwendet werden", - "The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmaske sind verfügbar.", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmasken sind verfügbar.", "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer LightCraft-Versionen werden beim Öffnen aktualisiert; eine Bibliothek", "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Der Steuerport (`--control`) schließt eine Verbindung, sobald er etwas anderes als eine JSON-Anfrage erhält", "The download didn't work:": "Der Download ist fehlgeschlagen:", @@ -1554,7 +1554,7 @@ "Thumbnail cache": "Miniaturbild-Cache", "Thursday": "Donnerstag", "Time zone": "Zeitzone", - "Tint": "Tonung", + "Tint": "Tönung", "Title": "Titel", "Title metadata": "Titel aus Metadaten", "Toggle Keyword from Set": "Stichwort aus Satz umschalten", @@ -1583,7 +1583,7 @@ "Unflagged": "Unmarkiert", "Ungroup Stack": "Stapel auflösen", "Unknown Date": "Unbekanntes Datum", - "Unknown tags stay as typed — check the preview for a {typo}.": "Unbekannte Platzhalter bleiben unverändert — prüfe die Vorschau auf einen {typo}.", + "Unknown tags stay as typed — check the preview for a {typo}.": "Unbekannte Platzhalter bleiben unverändert – prüfe die Vorschau auf einen {typo}.", "Unrated": "Unbewertet", "Unreadable": "Nicht lesbar", "Update": "Aktualisieren", @@ -1635,7 +1635,7 @@ "Watermark": "Wasserzeichen", "Wednesday": "Mittwoch", "What's New": "Neuigkeiten", - "When rated or hovered": "Bei Bewertung oder Mauszeigerkontakt", + "When rated or hovered": "Bei Bewertung oder unter dem Mauszeiger", "White Balance": "Weißabgleich", "White Balance Selector": "Weißabgleich-Pipette", "White Balance Selector (W)": "Weißabgleich-Pipette (W)", @@ -1643,7 +1643,7 @@ "Width": "Breite", "Width (% of photo)": "Breite (% des Fotos)", "Width (px)": "Breite (px)", - "Width & Height": "Breite & Höhe", + "Width & Height": "Breite und Höhe", "Will be replaced": "Wird ersetzt", "Will be restored": "Wird wiederhergestellt", "Window": "Fenster", @@ -1671,7 +1671,7 @@ "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`, zusammen mit ihren XMP-Begleitdateien. Jedes Original verlässt die Karte erst nach", "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`. Auf Laufwerken ohne diese Unterscheidung wird die Schreibweise weiterhin geändert.", "a full drive) no longer counts as built and Build Smart Previews replaces it.": "oder vollen Laufwerk gilt nicht mehr als fertig und wird beim Erstellen der Smart-Vorschauen ersetzt.", - "a literal %": "ein wörtliches %", + "a literal %": "ein %-Zeichen", "a removed photo": "einem entfernten Foto", "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "(Issue #101). Vor einem erneuten Versuch wird das Schreibfragment entfernt; beim nächsten Start werden alle Änderungen wiederhergestellt.", "again. Change the period (or turn it off) in Settings → Performance.": "wieder. Der Zeitraum lässt sich unter Einstellungen → Leistung ändern oder deaktivieren.", @@ -1749,7 +1749,7 @@ "isn't in the last": "liegt nicht in den letzten", "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "statt sie der anderen Datei wegzunehmen (Issue #92). Die Suche fehlender Fotos findet umbenannte Dateien anhand ihres Inhalts und bevorzugt passende Inhalte", "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "mit Fotos und XMP-Begleitdateien zurückverschoben; die Fotos verweisen wieder darauf. Ist der alte Speicherort inzwischen belegt,", - "keep names — or e.g. {date}_{seq:3}": "Namen beibehalten — oder z. B. {date}_{seq:3}", + "keep names — or e.g. {date}_{seq:3}": "Namen beibehalten – oder z. B. {date}_{seq:3}", "latitude, longitude": "Breitengrad, Längengrad", "layout is read from each file instead of assumed.": "wird aus jeder Datei gelesen, statt vorausgesetzt zu werden.", "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "hinterlässt kein DNG und behält die RAW-Datei. Smart-Vorschauen verwenden denselben Schreibweg; eine beschädigte Vorschau nach einem Absturz", @@ -1839,7 +1839,7 @@ "Any camera": "Jede Kamera", "Any lens": "Jedes Objektiv", "Any keyword": "Jedes Stichwort", - "Label Set": "Markierungssatz", + "Label Set": "Farbmarkierungssatz", ", picked": ", ausgewählt", ", rejected": ", abgelehnt", "Preview only: editing the camera's embedded JPEG": "Nur Vorschau: Bearbeitung des eingebetteten JPEGs der Kamera", @@ -1882,7 +1882,7 @@ "Make the denoised picture of the photos I am looking at": "Entrauschtes Bild der angezeigten Fotos berechnen", "Next up for AI Denoise…": "Als Nächstes beim KI-Entrauschen…", "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nichts wird geladen, bevor du zustimmst. Das Modell bleibt nur auf diesem Computer; LightCraft lädt es nie hoch und gibt es nie weiter.", - "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Nur Fotos mit einer KI-Entrauschungsstärke über 0 werden berechnet. Exporte berechnen ihres immer, wenn es fehlt.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Nur Fotos mit einer KI-Entrauschungsstärke über 0 werden berechnet. Beim Export wird ein fehlendes Bild immer berechnet.", "Open page": "Seite öffnen", "Open the model's page": "Seite des Modells öffnen", "Opens the model's own page in your browser, to read about it or get the file yourself.": "Öffnet die Seite des Modells im Browser, um mehr darüber zu lesen oder die Datei selbst herunterzuladen.", @@ -1906,7 +1906,7 @@ "Unknown licence": "Unbekannte Lizenz", "Use": "Verwenden", "Waiting to start…": "Wartet auf Start…", - "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funktioniert mit Raw-Dateien von Kameras mit Standardsensor (Bayer). Andere Dateien behalten ihre normale Rauschreduzierung.", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funktioniert mit RAW-Dateien von Kameras mit Standardsensor (Bayer). Andere Dateien behalten ihre normale Rauschreduzierung.", "cached picture": "zwischengespeichertes Bild", "cached pictures": "zwischengespeicherte Bilder", "commercial use allowed": "kommerzielle Nutzung erlaubt", @@ -1988,20 +1988,20 @@ "Sa": "Sa", "Su": "So", "Pick a date": "Datum wählen", - "would make this album include itself through other smart albums": "würde dieses Album über andere Smart-Alben sich selbst enthalten lassen", + "would make this album include itself through other smart albums": "würde dazu führen, dass dieses Album sich über andere Smart-Alben selbst enthält", "Search albums": "Alben suchen", "No albums match": "Kein Album passt", "This is the album you're editing": "Das ist das Album, das du bearbeitest", - "It tests this album, so testing it back would loop": "Es prüft dieses Album; es zurückzuprüfen wäre ein Kreis", - "Copy Name": "Kopiename", + "It tests this album, so testing it back would loop": "Es prüft bereits dieses Album; eine Prüfung in Gegenrichtung ergäbe eine Endlosschleife", + "Copy Name": "Name der Kopie", "In a Stack": "In einem Stapel", "File Extension": "Dateiendung", "Video Duration": "Videodauer", "Keyword Count": "Anzahl Stichwörter", "People Count": "Anzahl Personen", - "Shutter Speed": "Belichtungszeit", + "Shutter Speed": "Verschlusszeit", "Cropped": "Zugeschnitten", - "Keywords & People": "Stichwörter & Personen", + "Keywords & People": "Stichwörter und Personen", "Description": "Beschreibung", "Camera Info": "Kamera-Info", "Develop": "Bearbeitung", @@ -2012,7 +2012,7 @@ "Whole names": "Vollständige Namen", "Grouped by parent": "Nach übergeordnetem Stichwort gruppiert", "Keywords of the selection": "Stichwörter der Auswahl", - "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Gruppiert: Stichwörter im selben Stichwort teilen sich eine Zeile mit dessen Namen; lange Listen und die Vorschläge werden gekürzt.", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Gruppiert: Stichwörter mit demselben übergeordneten Stichwort teilen sich eine Zeile mit dessen Namen; lange Listen und die Vorschläge werden gekürzt.", "Show every keyword of the selected photos": "Alle Stichwörter der ausgewählten Fotos anzeigen", "new keyword": "neues Stichwort", "Return creates this keyword and gives it to the selected photos": "Die Eingabetaste erstellt dieses Stichwort und weist es den ausgewählten Fotos zu", @@ -2028,14 +2028,14 @@ "Create Keyword Tag": "Stichwort erstellen", "Edit Keyword Tag": "Stichwort bearbeiten", "Create": "Erstellen", - "No photo has it; the keywords below it go too.": "Kein Foto hat es; die Stichwörter darunter werden ebenfalls gelöscht.", + "No photo has it; the keywords below it go too.": "Kein Foto verwendet es; die Stichwörter darunter werden ebenfalls gelöscht.", "Edit Keyword Tag…": "Stichwort bearbeiten…", "Put New Keywords Inside This Keyword": "Neue Stichwörter in dieses Stichwort einfügen", "Purge Unused Keywords": "Nicht verwendete Stichwörter löschen", "Delete Keyword…": "Stichwort löschen…", "Drop here for the top level": "Hier ablegen für die oberste Ebene", - "Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen.", - "Select photos to give them keywords.": "Wähle Fotos aus, um ihnen Stichwörter zu geben.", + "Their photos and the keywords below them come together under one keyword.": "Die Fotos und die untergeordneten Stichwörter werden unter einem Stichwort zusammengeführt.", + "Select photos to give them keywords.": "Wähle Fotos aus, um ihnen Stichwörter zuzuweisen.", "Add to All Selected Photos": "Allen ausgewählten Fotos hinzufügen", "Will Export": "Wird exportiert", "No keywords are exported": "Es werden keine Stichwörter exportiert", @@ -2055,14 +2055,14 @@ "Synchronize Folder": "Ordner synchronisieren", "Synchronize": "Synchronisieren", "Scanning…": "Wird durchsucht…", - "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Fotos finden, die in diesem Ordner auf dem Datenträger hinzugekommen sind oder fehlen, und von anderen Apps geänderte XMP-Filialdateien", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Fotos finden, die in diesem Ordner auf dem Datenträger hinzugekommen sind oder fehlen, und von anderen Apps geänderte XMP-Begleitdateien", "Bring the library up to date with this folder and the folders inside it:": "Die Bibliothek mit diesem Ordner und den Ordnern darin abgleichen:", "Import new photos": "Neue Fotos importieren", "Remove missing photos from the library": "Fehlende Fotos aus der Bibliothek entfernen", - "Read metadata updates from XMP sidecars": "Metadaten-Änderungen aus XMP-Filialdateien lesen", + "Read metadata updates from XMP sidecars": "Metadaten-Änderungen aus XMP-Begleitdateien lesen", "The library is up to date with this folder.": "Die Bibliothek ist mit diesem Ordner auf dem neuesten Stand.", "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Fehlende Fotos werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.", - "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Filialdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Begleitdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.", "Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind", "Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden.", @@ -2101,7 +2101,7 @@ "Discarding smart previews": "Smart-Vorschauen werden verworfen", "Importing Lightroom catalog": "Lightroom-Katalog wird importiert", "Reading Lightroom catalog": "Lightroom-Katalog wird gelesen", - "Merging": "Zusammenfügen", + "Merging": "Wird zusammengefügt", "Downloading model": "Modell wird heruntergeladen", "Finding faces": "Gesichter werden gesucht", "Synchronizing folder": "Ordner wird synchronisiert", @@ -2149,7 +2149,7 @@ "Move to Folder": "In Ordner verschieben", "Move to Folder…": "In Ordner verschieben…", "Move Selected Photos Here": "Ausgewählte Fotos hierher verschieben", - "Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Filialdateien in diesen Ordner; nichts wird überschrieben", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Begleitdateien in diesen Ordner; nichts wird überschrieben", "Appearance Settings…": "Erscheinungsbild-Einstellungen…", "Add to favorites": "Zu Favoriten hinzufügen", "Remove from favorites": "Aus Favoriten entfernen", @@ -2166,7 +2166,7 @@ "Detect Faces": "Gesichter erkennen", "Detect Faces (Photo menu) needs it: a small one-time download.": "„Gesichter erkennen“ (Menü „Foto“) benötigt es: ein kleiner, einmaliger Download.", "Download Face Model": "Gesichtsmodell herunterladen", - "Drag Crop Handle": "Freistellungsgriff ziehen", + "Drag Crop Handle": "Zuschnittgriff ziehen", "Evaluate Face Recognition": "Gesichtserkennung bewerten", "Face Model Downloads": "Downloads von Gesichtsmodellen", "Face Models": "Gesichtsmodelle", @@ -2215,7 +2215,7 @@ "Right": "Rechts", "Top": "Oben", "Bottom": "Unten", - "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite der Folie, damit Folien auf jedem Bildschirm gleich aussehen.", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite des Dias, damit Dias auf jedem Bildschirm gleich aussehen.", "Show guides": "Hilfslinien anzeigen", "Aspect preview": "Seitenverhältnis-Vorschau", "Zoom to fill frame": "Zoomen, um Rahmen zu füllen", @@ -2229,15 +2229,15 @@ "Background image": "Hintergrundbild", "(not in the library)": "(nicht in der Bibliothek)", "Use selected photo": "Ausgewähltes Foto verwenden", - "Select a photo, then turn this on to fill the slide behind every photo with it.": "Wähle ein Foto aus und schalte dies ein, um die Folie hinter jedem Foto damit zu füllen.", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Wähle ein Foto aus und schalte dies ein, um das Dia hinter jedem Foto damit zu füllen.", "Reset layout": "Layout zurücksetzen", - "Select a photo to preview its slide": "Wähle ein Foto, um seine Folie in der Vorschau zu sehen", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau.", + "Select a photo to preview its slide": "Wähle ein Foto, um sein Dia in der Vorschau zu sehen", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Dia. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau.", "Overlays": "Überlagerungen", "Identity plate": "Erkennungstafel", "Your name or studio": "Dein Name oder Studio", "Anchored to": "Verankert an", - "Slide": "Folie", + "Slide": "Dia", "Render behind image": "Hinter dem Bild rendern", "Rating stars": "Sternebewertung", "Text overlays": "Textüberlagerungen", @@ -2282,7 +2282,7 @@ "Shooting Data": "Aufnahmedaten", "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Jedes Foto so groß wie möglich auf Schwarz, mit kurzer Überblendung. Nichts darüber gezeichnet.", "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Eine hellgraue Wand mit weißem Passepartout und weichem Schatten; Bildunterschrift und Bewertung unter dem Foto.", - "Every photo cropped to fill the whole screen, with slow crossfades.": "Jedes Foto auf den ganzen Bildschirm beschnitten, mit langsamen Überblendungen.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Jedes Foto auf den ganzen Bildschirm zugeschnitten, mit langsamen Überblendungen.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Ein 16:9-Bild mit schwarzen Balken oben und unten, dem Titel in der Ecke, Blenden über Schwarz und einer Abschlusstafel.", "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Ein dunkler Schieferhintergrund mit Belichtung, ISO und Brennweite, darunter Kamera und Objektiv, unter jedem Foto.", "Exporting slideshow": "Diashow wird exportiert", @@ -2295,11 +2295,11 @@ "Export JPEG…": "JPEG exportieren…", "Export Video…": "Video exportieren…", "Automatically show full screen": "Automatisch im Vollbild anzeigen", - "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Eine Seite pro Folie (lange Seite in Pixeln = Punkte), mit Foliendauer und Überblendung für den Präsentationsmodus des Betrachters.", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Eine Seite pro Dia (lange Seite in Pixeln = Punkte), mit Dia-Dauer und Überblendung für den Präsentationsmodus des Betrachters.", "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Nummerierte JPEG-Dateien (name-001.jpg…) in einem neuen Ordner, der im nächsten Schritt benannt wird.", - "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-Video, jede Folie für ihre Dauer mit ihren Überblendungen. Kein Ton: Musik wird noch nicht exportiert.", - "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Die Folien verwenden Layout, Überlagerungen sowie Intro- und Abspannbild der Diashow, in der Reihenfolge der Ansicht.", - "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Folien, mit den Einstellungen oben. Der Fortschritt erscheint in der Aktivitätsliste.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-Video, jedes Dia für seine Dauer mit seinen Überblendungen. Kein Ton: Musik wird noch nicht exportiert.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Die Dias verwenden Layout, Überlagerungen sowie Intro- und Abschlussbildschirm der Diashow, in der Reihenfolge der Ansicht.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Dias, mit den Einstellungen oben. Der Fortschritt erscheint im Aktivitätsstapel.", "Checking raw files": "RAW-Dateien werden geprüft", "1 raw can now be developed from its raw data": "1 RAW-Datei kann jetzt aus ihren Rohdaten entwickelt werden", "This raw is now developed from its raw data": "Diese RAW-Datei wird jetzt aus ihren Rohdaten entwickelt", @@ -2310,7 +2310,7 @@ "Same folder as original photo": "Im selben Ordner wie das Originalfoto", "Tidying the thumbnail cache": "Miniaturcache wird aufgeräumt", "Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Intelligente Alben aktualisieren sich selbst: Ändere ihre Regeln oder verwende Foto → Foto löschen", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Smart-Alben aktualisieren sich selbst: Ändere ihre Regeln oder verwende Foto → Foto löschen", "LightCraft stopped": "LightCraft wurde beendet", "Zoom to Focus Point": "Auf Fokuspunkt zoomen", "Focus": "Fokus", @@ -2336,7 +2336,7 @@ "Detection": "Erkennung", "Recognition": "Wiedererkennung", "Not loaded yet": "Noch nicht geladen", - "Checking the GPU…": "GPU wird geprüft …", + "Checking the GPU…": "GPU wird geprüft…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "Ein Gesichtsmodell nutzt die GPU nur, wenn eine Prüfung zeigt, dass sie dieselben Ergebnisse schneller liefert.", "Checking the GPU for faces": "GPU für Gesichter wird geprüft", @@ -2462,8 +2462,8 @@ "Edit in HDR: highlights above SDR white, for HDR exports and displays": "In HDR bearbeiten: Lichter über SDR-Weiß, für HDR-Exporte und -Bildschirme", "SDR": "SDR", "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.": "Links: der Standardbereich (SDR) bis SDR-Weiß. Rechts: der HDR-Bereich, ein Band pro Blendenstufe über SDR-Weiß (die orange Linie: der HDR-Spielraum). Mit eingeschaltetem „HDR visualisieren“ übernehmen die Bänder dessen Farben.", - "Click to hide clipping warnings for shadows and highlights.": "Klicken, um die Beschnittwarnungen für Tiefen und Lichter auszublenden.", - "Click to show clipping warnings for shadows and highlights.": "Klicken, um die Beschnittwarnungen für Tiefen und Lichter einzublenden.", + "Click to hide clipping warnings for shadows and highlights.": "Klicken, um die Beschneidungswarnungen für Tiefen und Lichter auszublenden.", + "Click to show clipping warnings for shadows and highlights.": "Klicken, um die Beschneidungswarnungen für Tiefen und Lichter einzublenden.", "Curve channel": "Kurvenkanal", "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Gezielte Anpassung aktiv: auf dem Foto nach oben oder unten ziehen · Esc beendet", "Color wheel": "Farbrad", @@ -2504,7 +2504,7 @@ "Show mask": "Maske anzeigen", "Overlay color": "Überlagerungsfarbe", "Rename Person…": "Person umbenennen…", - "Their photos in the grid": "Ihre Fotos im Raster", + "Their photos in the grid": "Fotos dieser Person im Raster", "Tool Overlay": "Werkzeugüberlagerung", "When pins show on the photo (Shift+H cycles)": "Wann Pins auf dem Foto erscheinen (Umschalt+H wechselt)", "External Editing": "Externe Bearbeitung", @@ -2526,7 +2526,7 @@ "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Die in den Farbeinstellungen deines Desktops zugewiesenen Profile liegen meist in ~/.local/share/icc.", "Slide duration": "Dia-Dauer", "Fade duration": "Überblendungsdauer", - "A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Ein Dia bleibt für seine Dauer stehen, die Überblendung eingeschlossen. Eine Überblendung von 0 schneidet hart.", + "A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Ein Dia bleibt für seine Dauer stehen, die Überblendung eingeschlossen. Bei 0 wird hart geschnitten.", "Fade": "Überblendung", "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).": "Berechnet die nächsten Dias, während das aktuelle zu sehen ist, sodass Überblendungen nie warten. Aus: Jedes Dia wird berechnet, wenn es an der Reihe ist (weniger Speicher und Rechenarbeit).", "Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library.": "„Diashow“ im Menü „Ansicht“ spielt die Fotos der Ansicht ab dem ausgewählten ab. Diese Einstellungen werden mit der Bibliothek gespeichert.", @@ -2828,7 +2828,7 @@ "\"LightCraft stopped unexpectedly\" and why, instead of \"could not open its window\", and the next start renders": "„LightCraft wurde unerwartet beendet“ und den Grund statt „konnte sein Fenster nicht öffnen“, und der nächste Start rendert", "on the CPU and explains how to turn the GPU back on.": "auf der CPU und erklärt, wie man die GPU wieder einschaltet.", "Resetting a slider": "Einen Regler zurücksetzen", - "Resting the pointer on a slider's name or its thumb now says that a double-click resets it (\"Exposure — double-click": "Ruht der Mauszeiger auf dem Namen oder dem Griff eines Reglers, wird jetzt angezeigt, dass ein Doppelklick ihn zurücksetzt („Belichtung — zum Zurücksetzen", + "Resting the pointer on a slider's name or its thumb now says that a double-click resets it (\"Exposure — double-click": "Ruht der Mauszeiger auf dem Namen oder dem Griff eines Reglers, wird jetzt angezeigt, dass ein Doppelklick ihn zurücksetzt („Belichtung – zum Zurücksetzen", "to reset it\"), in every language (issue #1436).": "doppelklicken“), in jeder Sprache (Issue #1436).", "A double-click on the thumb is a single step in History and Undo. Its first click no longer nudges the value to": "Ein Doppelklick auf den Griff ist ein einziger Schritt in Verlauf und Rückgängig. Sein erster Klick verschiebt den Wert nicht mehr dorthin,", "where the pointer landed, and a single click on the thumb leaves the value alone.": "wo der Zeiger landete, und ein einfacher Klick auf den Griff lässt den Wert unverändert.", @@ -2978,7 +2978,7 @@ "Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /": "Die Wahl eines Lightroom-Classic-Katalogordners unter Datei → Bibliothek öffnen… (oder eines seiner Ordner `Previews.lrdata` /", "`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The": "`.lrcat-data` oder der `.lrcat`) legt darin keine leere LightCraft-Bibliothek mehr an (Issue #891). Die", "library open now stays, and LightCraft says \"This is a Lightroom catalog, not a LightCraft library\" and offers": "geöffnete Bibliothek bleibt jetzt, und LightCraft meldet „Dies ist ein Lightroom-Katalog, keine LightCraft-Bibliothek“ und bietet", - "**Import Lightroom Catalog…** with that catalog, or Cancel. `lightcraft-cli --library` refuses those folders too.": "**Lightroom-Katalog importieren …** mit diesem Katalog an, oder Abbrechen. `lightcraft-cli --library` lehnt diese Ordner ebenfalls ab.", + "**Import Lightroom Catalog…** with that catalog, or Cancel. `lightcraft-cli --library` refuses those folders too.": "**Lightroom-Katalog importieren…** mit diesem Katalog an, oder Abbrechen. `lightcraft-cli --library` lehnt diese Ordner ebenfalls ab.", "Canon EOS R5 Mark II": "Canon EOS R5 Mark II", "Switching libraries": "Bibliotheken wechseln", "out. Clear Recent Libraries empties the list.": "ausgegraut. „Liste leeren“ leert die Liste.", @@ -3301,8 +3301,8 @@ "the container instead of decoding it; small previews use the file's embedded thumbnail. The release packages": "dem Container, statt es zu dekodieren; kleine Vorschauen verwenden die eingebettete Miniatur der Datei. Die Release-Pakete", "(macOS, Windows, Linux, FreeBSD, Nix) now really are built with HEIC support; a build without it reports": "(macOS, Windows, Linux, FreeBSD, Nix) werden jetzt wirklich mit HEIC-Unterstützung gebaut; ein Build ohne sie meldet", "\"HEIC/HEIF support isn't included in this build\" for each `.heic`/`.heif` at import.": "„HEIC/HEIF support isn't included in this build“ für jede `.heic`/`.heif`-Datei beim Import.", - "The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track": "Der Tonungsregler arbeitet in die richtige Richtung (Issues #188, #321): links mehr Grün, rechts mehr Magenta, wie seine Skala", - "shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint": "zeigt und wie in Lightroom, und Tonungswerte in Lightroom-XMP-Begleitdateien rendern jetzt wie dort. Eine benutzerdefinierte Tonung,", + "The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track": "Der Tönungsregler arbeitet in die richtige Richtung (Issues #188, #321): links mehr Grün, rechts mehr Magenta, wie seine Skala", + "shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint": "zeigt und wie in Lightroom, und Tönungswerte in Lightroom-XMP-Begleitdateien rendern jetzt wie dort. Eine benutzerdefinierte Tönung,", "saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker).": "die in einer früheren Version gespeichert wurde, verschiebt jetzt in die andere Richtung; stelle sie neu ein (oder führe Auto / die Weißabgleichpipette erneut aus).", "White balance on DNGs (and other raws with a colour matrix) re-develops the photo for the new white through the": "Der Weißabgleich bei DNGs (und anderen RAW-Dateien mit Farbmatrix) entwickelt das Foto für das neue Weiß über die", "camera's own colour matrices, as Lightroom does, instead of shifting the colours of the as-shot rendering: a grey": "eigenen Farbmatrizen der Kamera neu, wie Lightroom es tut, statt die Farben des Renderings „Wie Aufnahme“ zu verschieben: Ein Grau,", @@ -3374,7 +3374,7 @@ "Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the": "RAW-Dateien der Sony A7R II (und anderer), deren Kamera-JPEG objektivkorrigiert ist, öffnen nicht mehr grau und zu dunkel (Issue #232): Der", "starting look is fitted to the camera JPEG away from edges when the misaligned edges spoil the fit on all pixels.": "Ausgangslook wird abseits der Ränder an das Kamera-JPEG angepasst, wenn die verschobenen Ränder die Anpassung über alle Pixel verderben.", "Lightroom Classic catalogs": "Lightroom-Classic-Kataloge", - "File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.": "Datei → Lightroom-Katalog importieren … öffnet `.lrcat` direkt, wobei die Originale an ihrem Ort referenziert werden.", + "File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.": "Datei → Lightroom-Katalog importieren… öffnet `.lrcat` direkt, wobei die Originale an ihrem Ort referenziert werden.", "Ratings, flags, labels, keywords, collections/sets, virtual copies and supported edits migrate;": "Bewertungen, Markierungen, Farbmarkierungen, Stichwörter, Sammlungen/Sätze, virtuelle Kopien und unterstützte Bearbeitungen werden übernommen;", "existing LightCraft edits are preserved by default. Source settings/history are archived, unsupported": "vorhandene LightCraft-Bearbeitungen bleiben standardmäßig erhalten. Quelleinstellungen und -verlauf werden archiviert, nicht unterstützte", "fields are reported, and the original Lightroom database stays read-only. Rendering is approximate.": "Felder werden gemeldet, und die ursprüngliche Lightroom-Datenbank bleibt schreibgeschützt. Das Rendering ist eine Annäherung.", @@ -3420,7 +3420,7 @@ "inference on a Mac through SSH. Saved masks still render and export offline.": "Inferenz laufen über SSH auf einem Mac. Gespeicherte Masken werden weiterhin offline gerendert und exportiert.", "See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference).", "Before/After Side by Side": "Vorher/Nachher nebeneinander", - "Clipping": "Tonwertbeschneidung", + "Clipping": "Beschneidung", "Mask Overlay": "Maskenüberlagerung", "All Mask Overlays": "Alle Maskenüberlagerungen", "Mask Pins": "Maskenpins", diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index 5981a09a2..6456a6624 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -2276,10 +2276,12 @@ "or drop a .onnx file on the window": "tai pudota .onnx-tiedosto ikkunaan", "or drop photos and folders into this window": "tai pudota kuvia ja kansioita tähän ikkunaan", "or drop photos into this window": "tai pudota kuvia tähän ikkunaan", + "pick": "valittu kuva", "photo has its denoised picture.": "kuvalla on kohinanpoistettu kuvansa.", "photo waiting": "kuva odottaa", "photos have their denoised picture.": "kuvilla on kohinanpoistetut kuvansa.", "photos waiting": "kuvaa odottaa", + "reject": "hylätty", "saved in": "tallennettu kohteeseen", "second": "sekunti", "starts using it for AI Denoise": "alkaa käyttää sitä tekoälykohinanpoistoon", diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a5d05edac..f3d8d94cb 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2020,6 +2020,30 @@ mod tests { 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() { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index c9ee3878c..9ba1cd5af 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1149,6 +1149,20 @@ mod tests { 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() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 216d6cb61..026c5866d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -116,6 +116,8 @@ 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; diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index 3a8f0ce8e..e6945db01 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -41,10 +41,16 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt return format!("{}: {}", tr("Rules"), rules_label(rules, catalog)); } if chip.clear.get("label").is_some() { - let mut labels = filter.labels.clone(); - labels.extend(filter.label.filter(|label| !labels.contains(label))); - let names = labels.iter().map(|label| crate::i18n::color_label(catalog, *label)).collect::>(); - return format!("{}: {}", tr("Label"), names.join(&format!(" {} ", tr("or")))); + let shown = filter.labels_shown().unwrap_or_default(); + let name = |label: &lightcraft_catalog::ColorLabel| crate::i18n::color_label(catalog, *label); + let or = |labels: &[lightcraft_catalog::ColorLabel]| labels.iter().map(name).collect::>().join(&format!(" {} ", tr("or"))); + let names = if shown.is_empty() { + let plural = if filter.labels.len() > 1 { format!("({})", or(&filter.labels)) } else { or(&filter.labels) }; + format!("{} {} {plural}", filter.label.as_ref().map(name).unwrap_or_default(), tr("and")) + } else { + or(&shown) + }; + return format!("{}: {names}", tr("Label")); } if chip.clear.get("dateFrom").is_some() || chip.clear.get("dateTo").is_some() { return match (&filter.date_from, &filter.date_to) { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index e655e4969..444ef7e73 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -76,6 +76,13 @@ pub enum Gesture { at: Point, acc: f32, }, + /// A drag of one of the histogram's tonal zones (see [`super::histogram`]): the control the + /// press locked and the unrounded value its sideways travel has reached. It holds no canvas + /// geometry: the zone stays locked wherever the pointer goes. + HistogramDrag { + control: &'static str, + value: f64, + }, } /// Screen ↔ normalized-image mapping for the displayed frame. diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 1a770ebd8..2063580ae 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -6,6 +6,7 @@ use egui::{Align2, Color32, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; use lightcraft_catalog::PhotoId; use lightcraft_develop::{ControlSpec, DevelopSettings, Section, Track, WbMode, controls}; use lightcraft_geom::Point; +use lightcraft_pipeline::{PARAM_AMOUNTS, ParametricCurve}; use lightcraft_raster::Histogram; use serde_json::{Value, json}; @@ -630,12 +631,19 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // 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()); + 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.ctx(), "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 @@ -723,13 +731,11 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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 scroll = ui.spacing().scroll; - let inset = 20.0 + scroll.bar_width + scroll.bar_outer_margin; - for (name, x, label, clipped) in [ - ("shadows", plot.left() + inset, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), - ("highlights", plot.right() - inset, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), + 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 rect = Rect::from_center_size(pos2(x, plot.top() + 14.0), vec2(24.0, 22.0)); let response = ui.interact(rect, ui.id().with(("histogram-clipping", name)), Sense::click()); register(ui.ctx(), format!("histogram:{name}"), rect); response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label))); @@ -776,8 +782,11 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if clicked { let _ = app.run("view.clipping", json!({})); } - // EXIF strip - if let Some(ph) = app.session.catalog.photo(id) { + // 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.ctx(), "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}")), @@ -929,6 +938,115 @@ fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histo 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::new("curve-param-drag"); + let cursor_id = egui::Id::new("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 @@ -1005,29 +1123,17 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev 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" { - // draw the effective parametric curve by sampling the pipeline's region curve approximation + // 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 regions = [ - (0.0, c.split_shadows / 100.0, c.shadows), - (c.split_shadows / 100.0, c.split_mid / 100.0, c.darks), - (c.split_mid / 100.0, c.split_highlights / 100.0, c.lights), - (c.split_highlights / 100.0, 1.0, c.highlights), - ]; - let pts: Vec = (0..=64) - .map(|i| { - let x = i as f64 / 64.0; - let mut dd = 0.0; - for (a, b, amt) in regions { - let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05); - let tt = ((x - ctr) / half).clamp(-1.0, 1.0); - dd += amt / 100.0 * 0.22 * (0.5 + 0.5 * (tt * std::f64::consts::PI).cos()); - } - to_screen(x, (x + dd * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0)) - }) - .collect(); + 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 ["curve.highlights", "curve.lights", "curve.darks", "curve.shadows"] { + for &c in PARAM_AMOUNTS.iter().rev() { control(app, ui, d, c, true); } control(app, ui, d, "curve.refineSaturation", true); @@ -1159,9 +1265,11 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let arr: Vec<[f64; 2]> = v.iter().map(|p| [p.x, p.y]).collect(); let _ = app.run("develop.curve", json!({"channel": ch, "points": arr})); } - if resp.drag_stopped() { + // 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; - let _ = app.run("develop.endInteraction", json!({})); } ui.data_mut(|dd| dd.insert_temp(drag_id, dragging)); curve_footer(app, ui, d); diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index 8b524df11..592baf32f 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -158,13 +158,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // colour labels caption(ui, "Label"); - // several labels can be on at once (any of them matches) - let mut chosen: Vec = f.labels.clone(); - if let Some(l) = f.label - && !chosen.contains(&l) - { - chosen.push(l); - } + // several labels can be on at once (any of them matches); lit are those `label` and `labels` both let through + let chosen: Vec = f.labels_shown().unwrap_or_default(); for l in ColorLabel::ALL { let on = chosen.contains(&l); let name = format!("{l:?}").to_lowercase(); diff --git a/crates/ui-egui/src/panels/histogram.rs b/crates/ui-egui/src/panels/histogram.rs new file mode 100644 index 000000000..e6c0b50c1 --- /dev/null +++ b/crates/ui-egui/src/panels/histogram.rs @@ -0,0 +1,372 @@ +//! The histogram's tonal zones, as in Lightroom Classic: hovering one names it and shows its value, +//! dragging one sideways moves its Basic control, and a double-click puts that control back to its +//! default. +//! +//! The zone a press landed in stays locked for the whole drag, so the pointer may cross the others; +//! every step is a `develop.set` between `develop.beginInteraction` and `develop.endInteraction`, the +//! path the panel's sliders take: the draft render follows the drag live and the whole drag lands as +//! one undo step (`end_interaction`). + +use egui::{Painter, Pos2, Rect, Response, pos2}; +use lightcraft_catalog::PhotoId; +use lightcraft_develop::{ControlSpec, controls}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::panels::detail::Gesture; +use crate::theme::Tokens; +use crate::widgets::{register, shown_value}; + +/// One tonal zone across the histogram's plot. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Zone { + /// The develop control the zone adjusts: an id of [`lightcraft_develop::controls`]. + pub control: &'static str, + /// The zone's span of the plot's width, normalized 0 (left) to 1 (right). + pub from: f32, + pub to: f32, +} + +/// Lightroom Classic's five histogram regions, left to right: Blacks, Shadows, Exposure, Highlights, +/// Whites. Each names the Basic panel's own control, so the zone has that control's range, default +/// and steps. +pub const ZONES: [Zone; 5] = [ + Zone { control: "light.blacks", from: 0.00, to: 0.15 }, + Zone { control: "light.shadows", from: 0.15, to: 0.35 }, + Zone { control: "light.exposure", from: 0.35, to: 0.65 }, + Zone { control: "light.highlights", from: 0.65, to: 0.85 }, + Zone { control: "light.whites", from: 0.85, to: 1.00 }, +]; + +/// How far a sideways drag moves a control: crossing the whole plot sweeps the control's whole range, +/// as dragging a slider's thumb across its track does. Shift is a tenth of that, as on the sliders. +const SENSITIVITY: f64 = 1.0; +const FINE: f64 = 0.1; + +impl Zone { + /// The zone's control spec; `None` for an id the control table no longer has. + pub fn spec(self) -> Option<&'static ControlSpec> { + controls::find(self.control) + } + + /// The zone's band in `plot`. + pub fn rect(self, plot: Rect) -> Rect { + let x = |f: f32| plot.left() + plot.width() * f; + Rect::from_min_max(pos2(x(self.from), plot.top()), pos2(x(self.to), plot.bottom())) + } +} + +/// The zone covering `fx`, a position across the plot normalized to 0..1. `None` off the plot (NaN +/// too); 1.0 is the last zone's, whose right edge is the plot's. +pub fn zone_at(fx: f32) -> Option { + if !(0.0..=1.0).contains(&fx) { + return None; + } + ZONES.iter().copied().find(|zone| fx >= zone.from && fx < zone.to).or_else(|| ZONES.last().copied()) +} + +/// The value `base` moved by a sideways drag of `dx` points over a plot `width` points wide. The +/// result is clamped to the control's range; a drag that can't be measured (no width, a hostile +/// delta) or a non-finite `base` leaves `base` alone. +pub fn dragged(spec: &ControlSpec, base: f64, dx: f32, width: f32, fine: bool) -> f64 { + if !width.is_finite() || width <= 0.0 { + return base; + } + let travel = f64::from(dx) / f64::from(width) * if fine { FINE } else { SENSITIVITY }; + let v = base + travel * (spec.max - spec.min); + if v.is_finite() { v.clamp(spec.min, spec.max) } else { base } +} + +/// `v` on the control's own steps, as the panel's sliders round and dispatch it. A non-finite `v` +/// gives the control's default, as [`ControlSpec::clamp`] does. +pub fn quantized(spec: &ControlSpec, v: f64) -> f64 { + if !v.is_finite() { + return spec.default; + } + let step = spec.step.max(1e-9); + ((v / step).round() * step).clamp(spec.min, spec.max) +} + +/// What the histogram is acting on this frame. +#[derive(Clone, Copy, Debug)] +pub struct Active { + /// The zone under the pointer, or the one a drag locked. + pub zone: Zone, + /// The zone's control's live value. + pub value: f64, + /// A drag of the zone is in progress: its zone stays locked until the release. + pub dragging: bool, +} + +impl Active { + /// The readout under the histogram: "Exposure: +0.45 EV", "Shadows: +25". + pub fn readout(&self) -> Option { + let spec = self.zone.spec()?; + let shown = shown_value(spec, self.value); + let value = if spec.id == "light.exposure" { format!("{shown} EV") } else { shown }; + Some(format!("{}: {value}", crate::i18n::tr(spec.label))) + } +} + +/// The rectangles of the clipping buttons in the plot's top corners (shadows left, highlights right). +pub fn clipping_rects(ui: &egui::Ui, plot: Rect) -> [Rect; 2] { + let scroll = ui.spacing().scroll; + clipping_rects_in(plot, scroll.bar_width, scroll.bar_outer_margin) +} + +/// The clipping indicator rectangles for a given `plot` and scroll-bar geometry. +pub fn clipping_rects_in(plot: Rect, scroll_bar_width: f32, scroll_bar_outer_margin: f32) -> [Rect; 2] { + let inset = 20.0 + scroll_bar_width + scroll_bar_outer_margin; + [ + Rect::from_center_size(pos2(plot.left() + inset, plot.top() + 14.0), egui::vec2(24.0, 22.0)), + Rect::from_center_size(pos2(plot.right() - inset, plot.top() + 14.0), egui::vec2(24.0, 22.0)), + ] +} + +/// The histogram's pointer input for this frame: the press locks its zone and opens the interaction, +/// the drag moves that control, a double-click resets it. Returns what the highlight and the readout +/// should show, `None` when the pointer is nowhere near a zone. +/// +/// `plot` is the plot's rect: the strip under it (the readout and the EXIF line) is not a zone, and +/// neither are the clipping buttons in the plot's top corners, which take their own presses. +pub fn interact(app: &mut LightcraftApp, ui: &egui::Ui, resp: &Response, plot: Rect, id: PhotoId) -> Option { + let width = plot.width(); + let clips = clipping_rects(ui, plot); + let in_plot = |q: Pos2| plot.contains(q) && !clips.iter().any(|c| c.contains(q)); + let zone_of = |q: Pos2| in_plot(q).then(|| (q.x - plot.left()) / width).and_then(zone_at); + // the zone the pointer is over, and the one a press (or a double-click) landed in: the press's + // origin, so a drag is judged by where it started + let hovered = resp.hover_pos().and_then(zone_of); + let pressed = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()).and_then(zone_of); + let was_dragging = matches!(app.gesture, Some(Gesture::HistogramDrag { .. })); + // a drag cut short — the release went elsewhere, the panel closed — leaves the interaction open: + // with no button held the gesture is over, as the crop tool's gestures decide too + if was_dragging && !resp.drag_stopped() && !ui.input(|i| i.pointer.any_down()) { + finish(app); + } + // 1. the press locks its zone: one interaction holds the whole drag as one undo step + if resp.drag_started() + && !was_dragging + && let Some(zone) = pressed + && let Some(spec) = zone.spec() + && app.run("develop.beginInteraction", json!({"label": spec.label})).is_ok() + { + app.ui.dragging_control = Some(spec.id.to_string()); + app.gesture = Some(Gesture::HistogramDrag { control: zone.control, value: value_of(app, id, spec) }); + } + // 2. the drag moves the locked zone's control, wherever the pointer has gone since + if let Some(Gesture::HistogramDrag { control, value }) = app.gesture.clone() + && let Some(spec) = controls::find(control) + { + if resp.dragged() || resp.drag_stopped() { + // The frame a drag is first reported, take the whole travel from the press: egui only + // starts reporting a drag once the pointer has left its click threshold, and those first + // points are the user's movement too. Later frames add their own delta (a one-frame flick + // reports the drag as started and stopped together: then this is its delta), so Shift — + // a tenth — scales the travel it was held for, as the sliders' fine drag does. The + // unrounded value is kept from frame to frame, so a slow drag adds up instead of rounding + // back to the step it already has. + let dx = if resp.drag_started() { + ui.input(|i| i.pointer.total_drag_delta()).map_or(resp.drag_delta().x, |d| d.x) + } else { + resp.drag_delta().x + }; + let next = dragged(spec, value, dx, width, ui.input(|i| i.modifiers.shift)); + app.gesture = Some(Gesture::HistogramDrag { control, value: next }); + let v = quantized(spec, next); + if value_of(app, id, spec) != v { + let _ = app.run("develop.set", json!({"control": control, "value": v})); + } + } + if resp.drag_stopped() { + finish(app); + } + } + // 3. a double-click in a zone puts that control back to its default, in one undo step + if resp.double_clicked() + && let Some(zone) = pressed + && let Some(spec) = zone.spec() + { + let _ = app.run("develop.beginInteraction", json!({"label": spec.label})); + app.ui.dragging_control = Some(spec.id.to_string()); + let _ = app.run("develop.set", json!({"control": spec.id, "value": spec.default})); + finish(app); + } + // the highlight and the readout follow the drag's locked zone, else the pointer's + let zone = match app.gesture { + Some(Gesture::HistogramDrag { control, .. }) => ZONES.iter().copied().find(|zone| zone.control == control), + _ => hovered, + }?; + let spec = zone.spec()?; + let dragging = matches!(app.gesture, Some(Gesture::HistogramDrag { .. })); + if dragging || hovered.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + register(ui.ctx(), "histogram:zone", zone.rect(plot)); + Some(Active { zone, value: value_of(app, id, spec), dragging }) +} + +/// End the histogram drag: the interaction commits the whole drag as one undo step. +fn finish(app: &mut LightcraftApp) { + app.gesture = None; + app.ui.dragging_control = None; + let _ = app.run("develop.endInteraction", json!({})); +} + +/// A control's value on the photo, or its default when the photo has no settings yet. +fn value_of(app: &LightcraftApp, id: PhotoId, spec: &ControlSpec) -> f64 { + controls::get(&app.session.develop_of(id).unwrap_or_default(), spec.id).unwrap_or(spec.default) +} + +/// The band the active zone draws over the plot: a subtle translucent highlight, a little stronger +/// while the zone is being dragged. Painted behind the histogram's curves. +pub fn paint_zone(painter: &Painter, plot: Rect, active: &Active, tokens: &Tokens) { + let tint = if active.dragging { tokens.pressed } else { tokens.hover }; + painter.rect_filled(active.zone.rect(plot), 0.0, tint.gamma_multiply(if active.dragging { 0.75 } else { 0.45 })); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec_of(control: &str) -> &'static ControlSpec { + controls::find(control).unwrap_or_else(|| panic!("{control} is not a control")) + } + + /// The five zones tile the plot left to right, each naming a real Basic tone control of the + /// range this app documents (the panel's own specs, not a copy of them). + #[test] + fn the_zones_tile_the_plot_and_name_real_tone_controls() { + assert_eq!(ZONES.len(), 5); + assert_eq!(ZONES.first().map(|z| z.from), Some(0.0)); + assert_eq!(ZONES.last().map(|z| z.to), Some(1.0)); + for pair in ZONES.windows(2) { + assert_eq!(pair[0].to, pair[1].from, "{:?} does not meet {:?}", pair[0].control, pair[1].control); + } + for zone in ZONES { + let spec = spec_of(zone.control); + assert_eq!(spec.section, lightcraft_develop::Section::Light, "{}", spec.id); + assert_eq!(spec.default, 0.0, "{}", spec.id); + let (min, max) = if spec.id == "light.exposure" { (-5.0, 5.0) } else { (-100.0, 100.0) }; + assert_eq!((spec.min, spec.max), (min, max), "{}", spec.id); + } + } + + /// A position across the plot picks its zone, at the boundaries too; anything off the plot — + /// including NaN — picks none. + #[test] + fn a_position_across_the_plot_picks_its_zone() { + for (fx, control) in [ + (0.0, "light.blacks"), + (0.14, "light.blacks"), + (0.15, "light.shadows"), + (0.34, "light.shadows"), + (0.35, "light.exposure"), + (0.64, "light.exposure"), + (0.65, "light.highlights"), + (0.84, "light.highlights"), + (0.85, "light.whites"), + (0.99, "light.whites"), + (1.0, "light.whites"), + ] { + assert_eq!(zone_at(fx).map(|z| z.control), Some(control), "{fx}"); + } + for off in [-0.01, -1.0, 1.01, f32::NAN, f32::INFINITY] { + assert!(zone_at(off).is_none(), "{off}"); + } + } + + /// The zones' bands are their share of the plot's width, in order. + #[test] + fn a_zones_band_is_its_share_of_the_plot() { + let plot = Rect::from_min_max(pos2(100.0, 20.0), pos2(400.0, 200.0)); + let bands: Vec = ZONES.iter().map(|z| z.rect(plot)).collect(); + assert_eq!(bands.first().map(|b| b.left()), Some(plot.left())); + assert_eq!(bands.last().map(|b| b.right()), Some(plot.right())); + for band in &bands { + assert_eq!((band.top(), band.bottom()), (plot.top(), plot.bottom())); + } + for pair in bands.windows(2) { + assert_eq!(pair[0].right(), pair[1].left()); + } + let exposure = bands.get(2).copied().unwrap_or(plot); + assert!((exposure.width() - plot.width() * 0.30).abs() < 1e-3, "{exposure:?}"); + } + + /// A drag across the whole plot sweeps the whole range; Shift is a tenth; the ends clamp; the + /// unrounded value a slow drag accumulates keeps adding up. + #[test] + fn a_drag_sweeps_the_control_and_clamps_at_its_ends() { + let exposure = spec_of("light.exposure"); + let blacks = spec_of("light.blacks"); + assert!((dragged(exposure, 0.0, 20.0, 200.0, false) - 1.0).abs() < 1e-9, "a tenth of the plot is a tenth of ±5 EV"); + assert!((dragged(blacks, 0.0, 20.0, 200.0, false) - 20.0).abs() < 1e-9); + assert!((dragged(blacks, 0.0, 200.0, 200.0, false) - 100.0).abs() < 1e-9); + assert_eq!(dragged(blacks, 0.0, 400.0, 200.0, false), 100.0, "past the right end it stays"); + assert_eq!(dragged(blacks, 0.0, -400.0, 200.0, false), -100.0, "and past the left end"); + assert!((dragged(blacks, 0.0, 20.0, 200.0, true) - 2.0).abs() < 1e-9, "Shift is a tenth of the travel"); + assert!((dragged(blacks, 0.0, -20.0, 200.0, false) + 20.0).abs() < 1e-9, "left lowers"); + // a drag whose frames are each smaller than one step still adds up over the whole drag (the + // deltas are f32 points, so the sum carries their rounding) + let mut acc = 0.0; + for _ in 0..10 { + acc = dragged(blacks, acc, 0.4, 200.0, false); + } + assert!((acc - 4.0).abs() < 1e-6, "{acc}"); + // hostile input: no width, a hostile delta, a base that isn't a number + assert_eq!(dragged(blacks, 5.0, 20.0, 0.0, false), 5.0); + assert_eq!(dragged(blacks, 5.0, 20.0, -10.0, false), 5.0); + assert_eq!(dragged(blacks, 5.0, f32::NAN, 200.0, false), 5.0); + assert_eq!(dragged(blacks, 5.0, f32::INFINITY, 200.0, false), 5.0); + assert!(dragged(blacks, f64::NAN, 20.0, 200.0, false).is_nan(), "a base that isn't a number stays as it is"); + } + + /// Dispatched values land on the control's steps (as the sliders dispatch them) and inside its + /// range; a value that isn't a number gives the default rather than a NaN. + #[test] + fn a_dispatched_value_lands_on_the_controls_steps() { + let blacks = spec_of("light.blacks"); + let exposure = spec_of("light.exposure"); + assert_eq!(quantized(blacks, 20.4), 20.0); + assert_eq!(quantized(blacks, 20.6), 21.0); + assert_eq!(quantized(blacks, 1e9), 100.0); + assert_eq!(quantized(blacks, -1e9), -100.0); + assert_eq!(quantized(blacks, f64::NAN), 0.0); + assert!((quantized(exposure, 0.454) - 0.45).abs() < 1e-9, "{}", quantized(exposure, 0.454)); + for v in [-1e9, -3.7, 0.0, 2.2, 1e9] { + let q = quantized(exposure, v); + assert!((exposure.min..=exposure.max).contains(&q), "{v} → {q}"); + } + } + + /// The readout names the zone's control and shows its value: EV for the exposure, the plain + /// signed number otherwise, exactly as the sliders show them. + #[test] + fn the_readout_names_the_zone_and_its_value() { + let active = |control: &str, value: f64, dragging: bool| Active { + zone: ZONES.iter().copied().find(|z| z.control == control).unwrap_or(ZONES[0]), + value, + dragging, + }; + assert_eq!(active("light.exposure", 0.45, false).readout().as_deref(), Some("Exposure: +0.45 EV")); + assert_eq!(active("light.exposure", -2.0, true).readout().as_deref(), Some("Exposure: -2.00 EV")); + assert_eq!(active("light.shadows", 25.0, true).readout().as_deref(), Some("Shadows: +25")); + assert_eq!(active("light.blacks", 0.0, false).readout().as_deref(), Some("Blacks: 0")); + assert_eq!(active("light.highlights", -100.0, false).readout().as_deref(), Some("Highlights: -100")); + assert_eq!(active("light.whites", 13.0, false).readout().as_deref(), Some("Whites: +13")); + } + + /// The clipping rectangles sit in the plot's top corners, symmetric from both edges. + #[test] + fn clipping_rects_sit_in_the_plots_top_corners() { + let plot = Rect::from_min_max(pos2(100.0, 20.0), pos2(400.0, 200.0)); + let [low, high] = clipping_rects_in(plot, 10.0, 4.0); + assert!(plot.contains(low.center())); + assert!(plot.contains(high.center())); + assert_eq!(low.top(), plot.top() + 3.0); + assert_eq!(high.top(), plot.top() + 3.0); + assert_eq!(low.size(), egui::vec2(24.0, 22.0)); + assert_eq!(high.size(), egui::vec2(24.0, 22.0)); + assert!((low.left() - plot.left() - (plot.right() - high.right())).abs() < 1e-4); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7c432e50d..a07aee8b2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -13,6 +13,7 @@ pub mod empty_library; pub mod faces; pub mod filterbar; pub mod grid; +pub mod histogram; pub mod keymap; pub mod keyword_list; pub mod keywording; diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests_curve.rs index 4d3b45702..a658ee9a8 100644 --- a/crates/ui-egui/src/tests_curve.rs +++ b/crates/ui-egui/src/tests_curve.rs @@ -6,6 +6,7 @@ use serde_json::json; use crate::headless::Headless; use crate::{LightcraftApp, Services}; +use lightcraft_pipeline::ParametricCurve; const T: Duration = Duration::from_secs(20); @@ -221,3 +222,370 @@ fn curve_points_from_an_agent_are_stored_in_order() { let c = pts(&develop(&h).curve.green); assert!(c[1].0 < c[2].0, "dragged up to its neighbour, not past it: {c:?}"); } + +/// The settings the parametric graph is drawn from: two neighbouring regions pushed opposite ways, +/// where the raw sum of their bumps folds back on itself before the renderer's running maximum +/// straightens it out. +fn folded_regions() -> lightcraft_develop::ToneCurve { + lightcraft_develop::ToneCurve { shadows: 90.0, darks: -80.0, lights: -70.0, highlights: 60.0, ..Default::default() } +} + +/// The bare sum of the four region bumps is not monotone, so the drawn curve needs the renderer's +/// running maximum: this is the shape that made the graph disagree with the sliders. +#[test] +fn the_parametric_model_is_monotone_where_the_bare_sum_of_bumps_is_not() { + let c = folded_regions(); + let raw = |x: f64| { + let s = [0.0, c.split_shadows / 100.0, c.split_mid / 100.0, c.split_highlights / 100.0, 1.0]; + let amounts = [c.shadows, c.darks, c.lights, c.highlights]; + let mut d = 0.0; + for i in 0..4 { + let (a, b) = (s[i], s[i + 1]); + let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05); + let t = ((x - ctr) / half).clamp(-1.0, 1.0); + d += amounts[i] / 100.0 * 0.22 * (0.5 + 0.5 * (t * std::f64::consts::PI).cos()); + } + x + d * 4.0 * x * (1.0 - x) + }; + assert!((0..=64).any(|i| raw(i as f64 / 64.0) > raw((i + 1) as f64 / 64.0)), "the shape folds back, or it proves nothing"); + let model = ParametricCurve::new(&c); + let mut prev = f64::MIN; + for i in 0..=256 { + let y = model.at(i as f64 / 256.0); + assert!(y >= prev - 1e-12, "the model never folds back, at {i}: {y} < {prev}"); + prev = y; + } +} + +/// What the graph *paints* is the model the renderer computes from the sliders. They used to be two +/// copies of the formula and the panel's copy was missing the renderer's monotone pass, so the drawn +/// line could fold back while the renderer straightened it: the graph and the sliders below it +/// disagreed. This reads the painted line itself, not the function behind it. +#[test] +fn the_parametric_graph_paints_the_model_the_renderer_uses() { + let mut h = curve_open("parametric"); + let c = folded_regions(); + for (control, value) in [("curve.shadows", c.shadows), ("curve.darks", c.darks), ("curve.lights", c.lights), ("curve.highlights", c.highlights)] { + exec(&mut h, "develop.set", json!({"control": control, "value": value})); + } + h.step(); + h.step(); + let settings = develop(&h).curve; + assert_eq!(settings, c, "the sliders hold the folded shape"); + let graph = curve_graph_rect(&mut h); + let painted = painted_param_curve(&h, graph); + let model = ParametricCurve::new(&c); + let mut checked = 0; + for i in 0..=24 { + let x = i as f64 / 24.0; + let want = model.at(x); + let got = painted(x); + assert!((got - want).abs() < 0.02, "the painted curve is the renderer's at {x}: painted {got}, model {want}"); + checked += 1; + } + assert_eq!(checked, 25); + // and the shape really does fold back before the monotone pass, so this proves something + assert!(model.at(0.6) > model.at(0.3), "the region bumps still bend the curve"); +} + +/// The graph rect as `[left, top, width, height]` plus a reader for the painted curve's height at a +/// graph fraction (0 = the bottom of the graph, 1 = the top). +fn curve_graph_rect(h: &mut Headless) -> [f32; 4] { + let r = h.request("ui.widgets", json!({"filter": "curve"}), T); + let w = r["result"].as_array().unwrap().iter().find(|w| w["id"] == "curve").cloned().unwrap_or_else(|| panic!("curve widget on screen: {r}")); + let v = w["rect"].as_array().unwrap(); + std::array::from_fn(|i| v[i].as_f64().unwrap() as f32) +} + +/// A reader for the height of the painted parametric curve at a graph fraction. The curve is the +/// wide grey path the graph draws; the histogram is a mesh and the grid lines are single segments. +fn painted_param_curve(h: &Headless, r: [f32; 4]) -> impl Fn(f64) -> f64 { + let points = h + .view + .painted_shapes() + .into_iter() + .find_map(|s| match s { + egui::Shape::Path(p) if p.points.len() > 32 && (p.stroke.width - 2.0).abs() < 0.1 => Some(p.points), + _ => None, + }) + .unwrap_or_else(|| panic!("the parametric curve line is painted")); + move |x: f64| { + let want_x = r[0] + x as f32 * r[2]; + let q = points + .iter() + .min_by(|a, b| (a.x - want_x).abs().total_cmp(&(b.x - want_x).abs())) + .copied() + .unwrap_or_else(|| panic!("the curve has points")); + ((r[1] + r[3] - q.y) / r[3]).clamp(0.0, 1.0) as f64 + } +} + +/// The tone the parametric graph paints at `x`, read by value from the painted line. Stepped first +/// so the panel has drawn the state the command left behind. +fn painted_tone(h: &mut Headless, x: f64) -> f64 { + h.step(); + h.step(); + let r = curve_graph_rect(h); + painted_param_curve(h, r)(x) +} + +/// Where the midtones sit on a linear curve: a point well inside the Lights region, so lifting +/// Lights bends the line there. +const MID: f64 = 0.625; + +/// Reset restores the four sliders *and* puts the graph back to the straight diagonal, as one state. +#[test] +fn reset_clears_the_sliders_and_the_graph_together() { + let mut h = curve_open("parametric"); + let straight = painted_tone(&mut h, MID); + assert!((straight - MID).abs() < 0.02, "a fresh parametric curve is the diagonal: {straight}"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60})); + let bent = painted_tone(&mut h, MID); + assert!(bent > MID + 0.02, "the graph follows the slider up: {bent}"); + let undo_before = h.app.session.undo.len(); + click_widget(&mut h, "button:curveReset", 1); + let c = develop(&h).curve; + assert_eq!(c, lightcraft_develop::ToneCurve::default(), "Reset puts every slider and split back: {c:?}"); + let flat = painted_tone(&mut h, MID); + assert!((flat - MID).abs() < 0.02, "and the graph is the diagonal again: {flat}"); + assert_eq!(h.app.session.undo.len(), undo_before + 1, "Reset is one undo step"); +} + +/// Undo after a Reset brings back both the slider and the graph: the state the graph draws is the +/// state Undo restores, so the two can never be left showing different things. +#[test] +fn undo_restores_the_slider_and_the_graph_together() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60})); + let bent = painted_tone(&mut h, MID); + assert!(bent > MID + 0.02, "the graph is bent: {bent}"); + click_widget(&mut h, "button:curveReset", 1); + assert_eq!(develop(&h).curve, lightcraft_develop::ToneCurve::default(), "reset first"); + exec(&mut h, "edit.undo", json!({})); + let c = develop(&h).curve; + assert_eq!(c.lights, 60.0, "Undo brings the slider back: {c:?}"); + let again = painted_tone(&mut h, MID); + assert!(again > MID + 0.02, "and the graph is bent with it, not left flat: {again}"); + // redo puts the diagonal back, and the graph follows that too + exec(&mut h, "edit.redo", json!({})); + assert_eq!(develop(&h).curve, lightcraft_develop::ToneCurve::default(), "redo restores the reset"); + let flat = painted_tone(&mut h, MID); + assert!((flat - MID).abs() < 0.02, "the graph is flat with the sliders: {flat}"); +} + +/// One graph drag that bends several regions at once is one undo step: Ctrl+Z takes back the whole +/// gesture, not the last slider it happened to touch. +#[test] +fn one_graph_drag_that_moves_several_regions_is_one_undo_step() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 40})); + exec(&mut h, "develop.set", json!({"control": "curve.darks", "value": -40})); + let before = develop(&h).curve; + let undo_before = h.app.session.undo.len(); + // drag the Lights slider's own track, the way a graph handle writes it: begin, live updates, end + exec(&mut h, "develop.beginInteraction", json!({"label": "Parametric Curve"})); + for v in [45, 55, 70, 85] { + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": v})); + } + exec(&mut h, "develop.endInteraction", json!({})); + let after = develop(&h).curve; + assert_eq!(after.lights, 85.0, "the drag ended at its last value: {after:?}"); + assert_ne!(after, before, "the drag changed the state"); + assert_eq!(h.app.session.undo.len(), undo_before + 1, "the whole drag is one step, not one per frame"); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h).curve, before, "undo takes back the whole gesture at once"); +} + +/// The screen point where a drag should grab the curve: graph fraction `fx`, on the line itself. +fn on_curve(r: [f32; 4], c: &lightcraft_develop::ToneCurve, fx: f32) -> [f32; 2] { + let y = ParametricCurve::new(c).at(fx as f64); + [r[0] + fx * r[2], r[1] + (1.0 - y as f32) * r[3]] +} + +/// Dragging the curve itself bends the region under the pointer and moves that region's slider with +/// it, live. The graph draws no handles, so the only thing to grab is the line. +#[test] +fn dragging_the_curve_moves_the_region_slider() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60})); + h.step(); + let r = curve_graph_rect(&mut h); + let c = develop(&h).curve; + let undo_before = h.app.session.undo.len(); + // grab the line inside the Lights window (x = 0.625) and pull it down + let [x, y] = on_curve(r, &c, 0.625); + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y + 0.08 * r[3], "steps": 8}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let after = develop(&h).curve; + assert!(after.lights < 60.0, "the Lights slider follows the line down: {}", after.lights); + assert!(after.lights > 0.0, "and lands on a value, not at the bottom: {}", after.lights); + assert_eq!((after.shadows, after.darks, after.highlights), (0.0, 0.0, 0.0), "the other regions stay put: {after:?}"); + assert_eq!(after.split_shadows, c.split_shadows, "and the splits are untouched"); + assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step for the whole drag"); + // the graph really is redrawn below where the line was + let graph = curve_graph_rect(&mut h); + let painted = painted_param_curve(&h, graph)(0.625); + let before_level = ParametricCurve::new(&c).at(0.625); + assert!(painted < before_level - 0.02, "the painted line followed the drag down: {painted} vs before {before_level}"); + // and Undo takes the whole gesture back to where it started + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h).curve, c, "undo restores the value the drag started from"); +} + +/// A horizontal drag on the curve (zero vertical motion) changes no region amount, even when +/// neighbouring regions contribute heavily to the curve at the grabbed position (AlexChambers report: +/// Darks=60, Lights=0 grabbed at x=0.51 used to jump Lights to 45 with zero vertical motion). +#[test] +fn a_horizontal_drag_with_zero_vertical_motion_changes_nothing() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.darks", "value": 60})); + h.step(); + let before = develop(&h).curve; + assert_eq!(before.darks, 60.0); + assert_eq!(before.lights, 0.0); + let r = curve_graph_rect(&mut h); + let [x, y] = on_curve(r, &before, 0.51); + // Drag purely horizontally from x=0.51: dx = 20 px, dy = 0 + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 20.0, "toY": y, "steps": 6}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let after = develop(&h).curve; + assert_eq!(after.lights, 0.0, "horizontal motion preserves Lights=0: {}", after.lights); + assert_eq!(after.darks, 60.0, "Darks is preserved: {}", after.darks); + assert_eq!(after, before, "horizontal drag changes nothing: {after:?}"); +} + +/// Dragging up bends the region the other way, so the gesture is not just "downwards works". +#[test] +fn dragging_the_curve_up_raises_the_region_slider() { + let mut h = curve_open("parametric"); + let r = curve_graph_rect(&mut h); + let c = develop(&h).curve; + // x = 0.375 is inside the Darks window + let [x, y] = on_curve(r, &c, 0.375); + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y - 0.08 * r[3], "steps": 8}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let after = develop(&h).curve; + assert!(after.darks > 0.0, "the Darks slider follows the line up: {}", after.darks); + assert_eq!((after.shadows, after.lights, after.highlights), (0.0, 0.0, 0.0), "the other regions stay put: {after:?}"); + assert!(ParametricCurve::new(&after).at(0.375) > ParametricCurve::new(&c).at(0.375), "the curve is drawn bent up at the Darks region"); +} + +/// Which region a drag moves is decided by where on the line it is grabbed: the same drag gesture +/// four times over grabs four different sliders. +#[test] +fn the_region_dragged_is_the_one_under_the_pointer() { + for (fx, control, region) in + [(0.125_f32, "curve.shadows", 0usize), (0.375, "curve.darks", 1), (0.625, "curve.lights", 2), (0.875, "curve.highlights", 3)] + { + let mut h = curve_open("parametric"); + let r = curve_graph_rect(&mut h); + let c = develop(&h).curve; + assert_eq!(ParametricCurve::new(&c).region_at(fx as f64), region, "x = {fx} belongs to {control}"); + let [x, y] = on_curve(r, &c, fx); + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y - 0.06 * r[3], "steps": 6}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let after = develop(&h).curve; + let moved = after.shadows + after.darks + after.lights + after.highlights; + let want = match region { + 0 => after.shadows, + 1 => after.darks, + 2 => after.lights, + _ => after.highlights, + }; + assert!(want > 0.0, "grabbing at {fx} raises {control}: {after:?}"); + assert!((moved - want).abs() < 1e-9, "and nothing else moved: {after:?}"); + } +} + +/// A drag that starts away from the line is not a graph gesture at all: nothing moves and no undo +/// step is made (the graph is not a "click anywhere to bend" surface). +#[test] +fn a_parametric_drag_off_the_line_changes_nothing() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 40})); + h.step(); + let before = develop(&h).curve; + let undo = h.app.session.undo.len(); + let r = curve_graph_rect(&mut h); + // a corner of the graph, far above the line + let d = h.request( + "ui.drag", + json!({"x": r[0] + 0.06 * r[2], "y": r[1] + 0.06 * r[3], "toX": r[0] + 0.2 * r[2], "toY": r[1] + 0.3 * r[3], "steps": 6}), + T, + ); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + assert_eq!(develop(&h).curve, before, "no control moved"); + assert_eq!(h.app.session.undo.len(), undo, "and no undo step was made"); +} + +/// Hovering the line names the region a press there would move (and offers the grab cursor), so the +/// graph says which slider it is about to change before the drag starts. +#[test] +fn hovering_the_line_names_the_region_it_would_move() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60})); + h.step(); + let r = curve_graph_rect(&mut h); + let [x, y] = on_curve(r, &develop(&h).curve, 0.625); + let q = h.request("ui.move", json!({"x": x, "y": y}), T); + assert_eq!(q["ok"], true, "{q}"); + h.step(); + assert_eq!(h.last_cursor, egui::CursorIcon::Grab, "the line offers a grab: {:?}", h.last_cursor); + // the slider rows also paint "Lights", so compare with a frame away from the line + let lights = |h: &Headless| h.painted_text().iter().filter(|t| t.contains("Lights")).count(); + let hovered = lights(&h); + // well above the line there is no grab and no name + let q = h.request("ui.move", json!({"x": x, "y": r[1] + 0.06 * r[3]}), T); + assert_eq!(q["ok"], true, "{q}"); + h.step(); + assert_eq!(h.last_cursor, egui::CursorIcon::Default, "away from the line there is nothing to grab: {:?}", h.last_cursor); + assert!(hovered > lights(&h), "hovering names the slider it would move: {hovered} vs {} away", lights(&h)); +} + +/// A point's level on the region's curve and its slider amount are inverses, so a drag puts the line +/// exactly where the hand is, at whatever point on the graph it was grabbed. A level the region +/// cannot reach saturates rather than jumping. +#[test] +fn a_curve_level_and_its_amount_are_inverses() { + let c = lightcraft_develop::ToneCurve { shadows: 20.0, lights: 40.0, ..Default::default() }; + let model = ParametricCurve::new(&c); + for region in 0..4 { + let centre = model.region_x(region); + let (_, upper) = model.region_window(region); + // the region's window centre, and a point off-centre inside the same window + for x in [centre, centre + (upper - centre) * 0.5] { + for amount in [-100.0, -40.0, 0.0, 30.0, 100.0] { + let level = model.handle_level_at(region, amount, x); + let back = model.amount_at_level(region, level, x); + assert!((back - amount).abs() <= 1.0, "region {region} at {x}: {amount} → {level} → {back}"); + } + } + } + let centre = model.region_x(3); + assert_eq!(model.amount_at_level(3, 1.0, centre), 100.0, "a level above the region's reach saturates high"); + assert_eq!(model.amount_at_level(3, 0.0, centre), -100.0, "and one below saturates low"); +} + +/// The four regions tile the graph: every position belongs to exactly one of them, in order, so a +/// drag anywhere on the line moves exactly one slider and the one the pointer is over. +#[test] +fn the_four_regions_tile_the_graph() { + let model = ParametricCurve::new(&lightcraft_develop::ToneCurve::default()); + assert_eq!(model.region_at(0.0), 0, "black is the darkest region"); + assert_eq!(model.region_at(1.0), 3, "white is the lightest"); + assert_eq!(model.region_at(0.2), 0, "below the first split"); + assert_eq!(model.region_at(0.3), 1, "between the first two splits"); + assert_eq!(model.region_at(0.6), 2); + assert_eq!(model.region_at(0.8), 3); + let mut prev = model.region_at(0.0); + for i in 0..=100 { + let r = model.region_at(i as f64 / 100.0); + assert!(r >= prev, "the regions never go backwards: {r} after {prev}"); + prev = r; + } +} diff --git a/crates/ui-egui/src/tests_histogram_drag.rs b/crates/ui-egui/src/tests_histogram_drag.rs new file mode 100644 index 000000000..e6fd7138d --- /dev/null +++ b/crates/ui-egui/src/tests_histogram_drag.rs @@ -0,0 +1,295 @@ +//! The histogram's tonal zones, as in Lightroom Classic: dragging one sideways moves its Basic +//! control (one undo step for the whole drag), the zone locks at the press, a double-click resets +//! that control, and hovering highlights the zone and names it in the readout under the plot. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The zones' controls, left to right (`panels::histogram::ZONES`). +const CONTROLS: [&str; 5] = ["light.blacks", "light.shadows", "light.exposure", "light.highlights", "light.whites"]; + +/// The demo library in Detail with the Edit panel (the histogram sits first) on a selected photo. +fn edit_panel() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + let id = h.app.session.visible_cloned()[0].0; + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + widget(h, id).unwrap_or_else(|| panic!("no widget {id} on screen")) +} + +fn value(h: &Headless, control: &str) -> f64 { + let id = h.app.session.active().unwrap_or_else(|| panic!("no active photo")); + let spec = lightcraft_develop::controls::find(control).unwrap_or_else(|| panic!("no control {control}")); + lightcraft_develop::controls::get(&h.app.session.develop_of(id).unwrap_or_default(), control).unwrap_or(spec.default) +} + +/// Every zone's control, left to right. +fn values(h: &Headless) -> Vec { + CONTROLS.iter().map(|c| value(h, c)).collect() +} + +/// Set a control through the engine, as a slider would. +fn set(h: &mut Headless, control: &str, v: f64) { + h.app.run("develop.set", json!({"control": control, "value": v})).unwrap_or_else(|e| panic!("develop.set {control}: {e}")); +} + +/// Let the harness clock pass `seconds`, running frames. egui closes its double-click window when +/// the next click comes more than 0.3 s later *and* more than 6 points away; its triple-click +/// window is twice that, and two clicks in another zone right after two in one are close enough to +/// the second click to count as a triple click of it — which no longer reports `double_clicked`. +fn after(h: &mut Headless, seconds: f64) { + let until = h.time() + seconds; + while h.time() < until { + h.step(); + } +} + +/// Drag from a fraction `fx` across the histogram: `fy` 0.5 is inside the plot, `dx` in points. +fn drag(h: &mut Headless, fx: f32, dx: f32, shift: bool) { + let p = json!({"id": "histogram", "fx": fx, "fy": 0.5, "dx": dx, "steps": 8, "shift": shift}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); +} + +/// A double-click on the histogram at `fx` (the plot's own rows, `fy` 0.5). +fn double_click(h: &mut Headless, fx: f32) { + let r = h.request("ui.clickWidget", json!({"id": "histogram", "fx": fx, "fy": 0.5, "count": 2}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +/// Hover the histogram at `fx` and let the frame settle. +fn hover(h: &mut Headless, fx: f32) { + let r = h.request("ui.hoverWidget", json!({"id": "histogram", "fx": fx, "fy": 0.5}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +/// Dragging one zone moves that control: a sideways travel of the plot's width sweeps its whole +/// range, and the whole drag is one undo step. +#[test] +fn dragging_the_exposure_zone_moves_the_exposure_in_one_undo_step() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + let undo = h.app.session.undo.len(); + drag(&mut h, 0.5, w * 0.05, false); + // a twentieth of the plot: a twentieth of ±5 EV + let exposure = value(&h, "light.exposure"); + assert!((exposure - 0.5).abs() <= 0.02, "a twentieth of the plot is a twentieth of the range: {exposure}"); + assert_eq!(h.app.session.undo.len() - undo, 1, "one undo step for the whole drag"); + assert!(h.app.session.interaction.is_none(), "the interaction is closed on the release"); + assert!(h.app.ui.dragging_control.is_none(), "no control is left dragged"); + let mut others = values(&h); + others.remove(2); + assert_eq!(others, [0.0, 0.0, 0.0, 0.0], "the zones the drag crossed are untouched"); + // one undo brings the exposure back + let r = h.request("engine.execute", json!({"command": "edit.undo"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(value(&h, "light.exposure"), 0.0); +} + +/// The zone a press landed in stays locked for the whole drag: dragging from Exposure across the +/// Shadows and Blacks zones still only moves the Exposure. +#[test] +fn the_zone_locks_at_the_press_however_far_the_drag_travels() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + let undo = h.app.session.undo.len(); + drag(&mut h, 0.5, -w * 0.4, false); + let exposure = value(&h, "light.exposure"); + assert!((exposure + 4.0).abs() <= 0.02, "four tenths of the plot to the left is −4 EV: {exposure}"); + assert_eq!((value(&h, "light.shadows"), value(&h, "light.blacks")), (0.0, 0.0), "the zones crossed stay untouched"); + assert_eq!(h.app.session.undo.len() - undo, 1, "still one undo step"); +} + +/// Dragging left lowers the zone's control and dragging right raises it; a drag past either end +/// clamps at that control's range (the zones' own specs, ±100 for the four, ±5 EV for Exposure). +#[test] +fn a_drag_lowers_and_clamps_at_the_controls_range() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + drag(&mut h, 0.5, -w * 0.2, false); + assert!((value(&h, "light.exposure") + 2.0).abs() <= 0.02, "{}", value(&h, "light.exposure")); + // the Blacks zone, dragged right across the whole plot and beyond: clamped at +100 + drag(&mut h, 0.08, w * 0.9, false); + assert_eq!(value(&h, "light.blacks"), 100.0); + drag(&mut h, 0.92, -w * 2.0, false); + assert_eq!(value(&h, "light.whites"), -100.0); +} + +/// Shift is a tenth of the travel, as on the sliders. +#[test] +fn shift_drags_a_tenth_as_far() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + drag(&mut h, 0.5, w * 0.1, false); + let plain = value(&h, "light.exposure"); + assert!((plain - 1.0).abs() <= 0.02, "{plain}"); + set(&mut h, "light.exposure", 0.0); + drag(&mut h, 0.5, w * 0.1, true); + let fine = value(&h, "light.exposure"); + assert!((fine - 0.1).abs() <= 0.02, "a tenth of {plain}: {fine}"); +} + +/// A double-click in a zone puts that zone's control back to its default in one undo step, leaving +/// the other zones' controls alone. +#[test] +fn double_clicking_a_zone_resets_that_control_in_one_undo_step() { + let mut h = edit_panel(); + set(&mut h, "light.exposure", 1.25); + set(&mut h, "light.shadows", 40.0); + let undo = h.app.session.undo.len(); + double_click(&mut h, 0.5); + assert_eq!(value(&h, "light.exposure"), 0.0, "the Exposure zone is reset"); + assert_eq!(value(&h, "light.shadows"), 40.0, "only the zone that was clicked"); + assert_eq!(h.app.session.undo.len() - undo, 1, "one undo step"); + // the Shadows zone (0.15–0.35) resets its own control, once the double-click window has passed + after(&mut h, 0.7); + double_click(&mut h, 0.25); + assert_eq!(value(&h, "light.shadows"), 0.0); + // a click on the histogram toggles the clipping warnings (as before this feature): the two + // clicks of a double-click toggle them twice, so the state is where it started + assert!(!h.app.ui.show_clipping); +} + +/// The strip under the plot — the readout and the EXIF line — is not a zone: a drag there leaves +/// every control alone. +#[test] +fn a_drag_below_the_plot_leaves_the_tones_alone() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + let undo = h.app.session.undo.len(); + let p = json!({"id": "histogram", "fx": 0.5, "fy": 0.97, "dx": w * 0.2, "steps": 6}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); + assert_eq!(values(&h), [0.0; 5], "no zone was dragged"); + assert_eq!(h.app.session.undo.len(), undo, "and nothing was recorded"); + assert!(h.app.session.interaction.is_none(), "no interaction was opened"); +} + +/// A drag that starts on a clipping chevron (shadows or highlights) changes no develop tone: the +/// chevrons are clipping controls, not tonal zones, even though they sit inside the plot. +#[test] +fn a_drag_starting_on_a_clipping_chevron_changes_no_develop_value() { + let mut h = edit_panel(); + let undo = h.app.session.undo.len(); + // Drag starting on the shadow clipping indicator (which sits over the Blacks zone) + let p = json!({"id": "histogram:shadows", "dx": 40.0, "steps": 8}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); + assert_eq!(values(&h), [0.0; 5], "dragging from the shadow chevron moves no tone"); + assert_eq!(h.app.session.undo.len(), undo, "and leaves undo alone"); + assert!(h.app.session.interaction.is_none(), "no interaction was opened"); + + // Drag starting on the highlight clipping indicator (which sits over the Whites zone) + let p = json!({"id": "histogram:highlights", "dx": -40.0, "steps": 8}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); + assert_eq!(values(&h), [0.0; 5], "dragging from the highlight chevron moves no tone"); + assert_eq!(h.app.session.undo.len(), undo, "undo remains untouched"); + assert!(h.app.session.interaction.is_none(), "no interaction was opened"); +} + +/// Hovering a zone highlights it (the band covers that zone's share of the plot) and names it with +/// its value in the readout; off the histogram both are gone. +#[test] +fn hovering_a_zone_highlights_it_and_shows_its_name_and_value() { + let mut h = edit_panel(); + let hist = rect(&h, "histogram"); + set(&mut h, "light.shadows", 25.0); + hover(&mut h, 0.25); + let band = rect(&h, "histogram:zone"); + let share = |x: f32| hist.left() + hist.width() * x; + assert!(band.left() >= share(0.14) && band.right() <= share(0.36), "the Shadows band: {band:?} in {hist:?}"); + assert!(h.painted_text().iter().any(|t| t == "Shadows: +25"), "{:?}", h.painted_text()); + // the Exposure zone, with its EV unit + hover(&mut h, 0.5); + let band = rect(&h, "histogram:zone"); + assert!(band.left() >= share(0.34) && band.right() <= share(0.66), "the Exposure band: {band:?}"); + assert!(h.painted_text().iter().any(|t| t == "Exposure: 0.00 EV"), "{:?}", h.painted_text()); + // the pointer off the histogram: no band, no zone readout (the EXIF line is back) + let r = h.request("ui.move", json!({"x": 600.0, "y": 500.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(widget(&h, "histogram:zone").is_none(), "the highlight is gone"); + assert!(!h.painted_text().iter().any(|t| t.starts_with("Shadows: ")), "{:?}", h.painted_text()); +} + +/// The pointer over the histogram says the zones can be dragged sideways; elsewhere the canvas' +/// own cursor is back. +#[test] +fn the_cursor_over_the_histogram_offers_a_sideways_drag() { + let mut h = edit_panel(); + hover(&mut h, 0.5); + assert_eq!(h.last_cursor, egui::CursorIcon::ResizeHorizontal); + let r = h.request("ui.move", json!({"x": 600.0, "y": 500.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_ne!(h.last_cursor, egui::CursorIcon::ResizeHorizontal); +} + +/// A drag dispatches as it goes, with the interaction held open, so the draft render follows it +/// live; nothing is committed until the release, which lands the whole drag as one undo step. +#[test] +fn a_drag_dispatches_live_and_commits_on_the_release() { + let mut h = edit_panel(); + let before = h.app.session.undo.len(); + let hist = rect(&h, "histogram"); + let from = egui::pos2(hist.left() + hist.width() * 0.5, hist.top() + hist.height() * 0.5); + let to = from + egui::vec2(hist.width() * 0.1, 0.0); + // a press and its moves with no release: the drag is still going when the frames below run + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + for i in 1..=6 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + } + for _ in 0..12 { + h.step(); + } + let exposure = value(&h, "light.exposure"); + assert!(exposure > 0.4, "the exposure followed the drag while it was still going: {exposure}"); + assert!(h.app.session.interaction.is_some(), "the drag holds the interaction open"); + assert_eq!(h.app.ui.dragging_control.as_deref(), Some("light.exposure"), "the drag names the control it moves, as the sliders do"); + assert_eq!(h.app.session.undo.len(), before, "nothing is committed mid-drag"); + // the release: one undo step for the whole drag, and the interaction is closed + h.app.synthetic.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.settle(SETTLE); + assert!(h.app.session.interaction.is_none(), "the release commits the interaction"); + assert!(h.app.ui.dragging_control.is_none(), "and no control is left dragged"); + assert_eq!(h.app.session.undo.len() - before, 1, "the whole drag is one undo step"); +} diff --git a/docs/gaps.md b/docs/gaps.md index a8cdb5e5c..ceea3724f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -88,6 +88,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-22 | patch | Gap 28: View shows the current grid-cycle key beside Photo Grid and Square Grid (#2500); no new binding and no change to the remaining shortcut gap or hours | | 2026-10-22 | minor | Gap 15: the Print page setup and PDF gain the Japanese photo and postcard paper sizes (L判 89×127, 2L判 127×178, KG判 102×152, はがき 100×148, 往復はがき 148×200, 六切 203×254, 四切 254×305, 名刺 55×91 mm), by their customary names; gap not closed | | 2026-10-22 | minor | #2052: Thai glyphs added to shared export text; gap 21 retains Thai mark positioning, Arabic joining and bidi limits. No ranked gap closes; estimates unchanged | +| 2026-10-24 | minor | LR-LIB-SMARTALBUM: relative-date views and source totals expire on the session-clock minute, including nested smart albums; ranked gaps and readiness counts unchanged | | 2026-10-22 | patch | XMP namespace-prefix collisions no longer overwrite foreign metadata; ranked gaps and estimates unchanged | | 2026-10-22 | patch | Issue #2519: PSD/PSB EXIF resource 1058 and XMP resource 1060 now use the common metadata extractor, and `.psb` files are accepted by import; no ranked gap or readiness count changes | | 2026-10-22 | minor | Gap 30 added: multi-select metadata editing — Info panel and All Metadata dialog show one photo's values with no mixed-value indication | diff --git a/docs/localization-de.md b/docs/localization-de.md index aa058af9d..b31e9eff5 100644 --- a/docs/localization-de.md +++ b/docs/localization-de.md @@ -16,6 +16,32 @@ werden nicht übersetzt. Die vorhandenen Inter-Schriften enthalten die deutschen für Deutsch ist kein zusätzlicher Font-Download nötig. Beschreibungen für SAM 3 werden unverändert an das Modell übergeben; die Beispiele verwenden dessen englische Begriffe. +## Begriffe + +Eine Durchsicht aller Oberflächentexte und Meldungen (2026-10-24, mit einem Agenten erstellt und +von einem Muttersprachler stichprobenartig geprüft; die Versionshinweise nicht) hat diese Begriffe +festgelegt. Neue Texte verwenden sie ebenfalls: + +| Englisch | Deutsch | Anmerkung | +|---|---|---| +| Panel | Bedienfeld | wie in Lightroom; **Bereich** bleibt für Abschnitte (Sections) der Seitenleiste | +| Sidecar | Begleitdatei (XMP-Begleitdatei) | | +| Slide | Dia | passend zu „Diashow“ | +| Crop | Zuschnitt, zuschneiden | | +| Flag / Pick / Reject | Markierung / ausgewählt / abgelehnt | | +| Label (Color Label) | Farbmarkierung | nie nur „Markierung“, das ist die Ausgewählt/Abgelehnt-Kennzeichnung | +| Tint | Tönung | wie in Lightroom; „Tonung“ nur für Tonungsvorgaben wie Sepiatonung | +| Clipping | Beschneidung | wie Lightroom („Lichter-/Tiefenbeschneidung“); „Beschnitt“ ist ein Druckbegriff | +| Shutter Speed | Verschlusszeit | | +| Upright | Perspektivkorrektur | | +| Smart Album | Smart-Album | | +| Activity stack | Aktivitätsstapel | | + +Typografie: Gedankenstrich mit Leerzeichen als Halbgeviertstrich (` – `), Auslassungspunkte +ohne Leerzeichen direkt am Wort (`Importieren…`), deutsche Anführungszeichen („…“). „Ihre“ +nur, wo es eindeutig „von ihnen“ heißt; die Anrede ist *du*, ein Satz mit „Ihre Fotos“ am +Anfang liest sich sonst wie die Sie-Form. + ## Wartung und Prüfung Feste Texte: `crates/ui-egui/locales/de.json`. Meldungen mit Werten: diff --git a/docs/localization-parity.md b/docs/localization-parity.md index a2bb2833a..7813b6257 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-20 · **Change:** minor (German form of address: *du*; Indonesian and Finnish interface catalogs added; Italian: every interface string translated, native-speaker corrections; Vietnamese shipped; previously minor: de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-24 · **Change:** minor (German: interface strings and formats messages reviewed, terms fixed in localization-de.md; German form of address: *du*; Indonesian and Finnish interface catalogs added; Italian: every interface string translated, native-speaker corrections; Vietnamese shipped; previously minor: de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How far each interface language is from `full`. How to add or maintain a language is in [localization.md](localization.md); per-language notes are in `localization-.md` @@ -37,7 +37,7 @@ scripts `Latn`, `Cyrl`, `Hans`, `Hant`, `Jpan` only. The UI is egui, which has n and no complex-script shaping, so Arabic (RTL, joining) and Devanagari (conjuncts, reordering) cannot render correctly today. -Native-speaker review: no catalog has had a formal review. Several catalogs have edits from community +Native-speaker review: no catalog has had a full formal review; German's interface strings and formats messages have (2026-10-24, What's New not). Several catalogs have edits from community contributors whose commits only touch that language (ja, zh-hans, zh-hant, fr, de, ru, uk, pt-br); that is promising but is not recorded as review, so the column says `no (community edits)`. @@ -58,7 +58,7 @@ string follows that. | 7 | Portuguese (Brazil) | `pt-br` | 1,966 / 2,304 (85%) | as zh-hans | Latin | no (community edits) | partial | 6–10 h | | 8 | Indonesian | `id` | 2,555 / 2,555 (100%; 2026-10-19: every plain and formats message any catalog has, plus 69 command labels; What's New left in English) | as zh-hans | Latin (Inter covers it) | no (written with an agent; native-speaker pass pending) | partial (target: not shipped) | 2–4 h (native review, lower-layer errors) | | 9 | Japanese | `ja` | 2,383 / 2,398 (99%; 2026-10-12) | as zh-hans; vertical export watermarks | Jpan via craft-fonts (BIZ UDPGothic / UDMincho); IME through winit | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | -| 10 | German | `de` | 3,644 / 3,754 (97%; 2026-10-16: every interface message and every What's New line except 59: the distinct What's New lines whose English text has a ▸ menu path have no German entry yet and wait for #2016; the rest are words spelled the same) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | +| 10 | German | `de` | 3,644 / 3,754 (97%; 2026-10-16: every interface message and every What's New line except 59: the distinct What's New lines whose English text has a ▸ menu path have no German entry yet and wait for #2016; the rest are words spelled the same) | as zh-hans; What's New translated line by line | Latin | partly (2026-10-24: every interface string and formats message, reviewed with an agent and spot-checked by a native speaker; What's New lines not reviewed) | partial | 1–2 h (What's New review, lower-layer errors) | | 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | | 12 | Vietnamese | `vi` | 2,471 / 2,471 (100% key coverage of current union; intentional English like `ISO`/`OK` kept) | menus, panels, dialogs, presets, profiles, dates, formats; lower-layer errors English | Latin with stacked diacritics (Inter) | no (community contribution) | partial (catalog complete; lower-layer errors English) | 1–3 h | @@ -110,4 +110,5 @@ Target languages LightCraft lacks: Korean, Dutch, Norwegian Bokmål, Thai | 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates | | 2026-10-12 | minor | de, es, fr, ja and uk catalogs filled to every message another catalog knows (agent translations, native review welcome); those rows re-measured against the current 2,398 messages. Other rows keep their 2026-10-10 counts (open PRs for pt-BR, ru, zh) | | 2026-10-16 | minor | German (`de`): 217 interface strings and 862 What's New lines added (agent translations, native review welcome); re-measured 2,572 → 3,644 of 3,754 messages (80% → 97%). The denominator grew because German now translates What's New lines no other catalog has; other rows keep their earlier counts | +| 2026-10-24 | minor | German (`de`): interface strings and formats messages reviewed (agent-prepared, spot-checked by a native speaker); 108 messages corrected (Tönung, Beschneidung, Farbmarkierung vs. Markierung, Bedienfeld, Begleitdatei, Dia, en dash and ellipsis spacing, ambiguous *Ihre*); terms listed in localization-de.md; native review column `partly`, estimate 1–2 h | | 2026-10-20 | minor | German (`de`) speaks to the user with *du*; the 11 messages that used *Sie* changed, no count changes | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7fa952efb..d059494ce 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (LR-LIB-RATING, LR-LIB-DELETE: an open slider drag ends before the selection moves on; LRC-LIB-CATALOG-IMPORT: Import Lightroom Catalog… asks whether the catalog merges into the open library or goes into a new library, with an opt-out in Settings ▸ General, #2672; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; export regression integration; LRC-PRINT-LAYOUT: Japanese photo and postcard paper sizes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | -| Lightroom Classic extras | 40 | 25 | 21 | 8 | — | 24/26 (92%) | -| **Total** | 420 | 65 | 57 | 35 | 196/203 (97%) | 146/161 (91%) | +| Lightroom Classic extras | 41 | 25 | 21 | 8 | — | 24/26 (92%) | +| **Total** | 421 | 65 | 57 | 35 | 196/203 (97%) | 146/161 (91%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.5%** of 542 in-scope rows — P0 98.3% of 203 · P1 94.4% of 161 · P2 56.5% of 177. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.5%** of 543 in-scope rows — P0 98.3% of 203 · P1 94.4% of 161 · P2 56.7% of 178. ## Top gaps and shortcut notes @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/it/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/it/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant Nikon 12-bit small RAW (D810 / D4S): packed Y0/Y1/Cb/Cr rows decode to linear RGB; header probes retain geometry without decoding pixels, and truncated strips are refused. Chroma gains are approximate. | | 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 | @@ -96,7 +96,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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-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; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists; View ▸ Hold View Until Refreshed (`library.holdView`, off by default) keeps a photo that an edit made stop matching (a smart album, Picks, the filter bar) in the view until Refresh View, Reshuffle or another source, filter or 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 | @@ -139,7 +139,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | -| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | +| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; the plot's five tonal zones also drag to adjust their Basic control (LRC-DEV-HISTOGRAM-DRAG); it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | | LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | |, `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet; | ## D. Search & filter (FILT) @@ -184,7 +184,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632) | | LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | | LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | -| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | | +| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*`, `crates/pipeline/src/curve.rs` | the graph draws the renderer's own curve model; dragging the line moves the region under the pointer (one undo step) | | LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag; ⌥-drag moves it a tenth as far for fine adjustment, issue #1363); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON | | LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | | | LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | | @@ -707,7 +707,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | -| LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | +| LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (an album the filter held is replaced by the album you show; always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | @@ -733,6 +733,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-VIEWOPTIONS | Develop view options | P2 | 🟡 | `cmd:view.viewOptions`, `crates/ui-egui/src/panels/settings.rs` (`view_options`) | View ▸ View Options… (⌘J), the Loupe View part: Show Info Overlay with Info 1 / Info 2, and the three lines of each (none, file name, capture date, dimensions, exposure, camera, lens, camera + lens) with Use Defaults; applied as they change, saved with the app settings; `view.viewOptions {info1, info2}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), "show briefly when the photo changes", the loading-message and video-frame options | | LRC-DEV-LOUPEOVERLAY | Loupe overlay: layout image | P2 | 🟡 | `cmd:view.chooseLayoutImage`, `cmd:view.layoutImage`, `cmd:view.layoutImageOpacity`, `cmd:view.layoutImageMatte`, `crates/ui-egui/src/panels/layout_overlay.rs` | View ▸ Loupe Overlay: a PNG with transparency (a cover layout, a banner's safe areas) fitted and centred over the loupe photo, following zoom and pan; opacity and matte (the photo darkened outside the graphic's box) in steps; ⌥⌘O shows / hides it; read off the UI thread; saved with the UI state. Not yet: the Grid and Guides overlays, moving and resizing the layout with ⌘ held, free opacity / matte sliders | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | +| LRC-DEV-HISTOGRAM-DRAG | Drag on the histogram to adjust tone regions | P2 | ✅ | `crates/ui-egui/src/panels/histogram.rs`, `crates/ui-egui/src/panels/edit.rs`, `crates/develop/src/controls.rs`, `crates/ui-egui/src/tests_histogram_drag.rs`, `cmd:develop.set`, `cmd:develop.beginInteraction`, `cmd:develop.endInteraction`, `ctl:light.blacks`, `ctl:light.shadows`, `ctl:light.exposure`, `ctl:light.highlights`, `ctl:light.whites` | five zones across the plot's width, each moving the Basic control it names: Blacks 0.00–0.15, Shadows 0.15–0.35, Exposure 0.35–0.65, Highlights 0.65–0.85, Whites 0.85–1.00; every step is a `develop.set` between `develop.beginInteraction` and `develop.endInteraction` (the sliders' own path), so the draft render follows the drag live and the whole drag lands as one undo step; the zone the press landed in stays locked for the drag, so dragging across the others changes nothing; sideways travel of the plot's width sweeps that control's whole range (Shift a tenth, as on the sliders), clamped to its spec; hovering a zone tints it and shows its value in the strip under the plot ("Exposure: +0.45 EV"), with the sideways-resize cursor; a double-click resets that zone's control to its default in one undo step; the strip under the plot (readout / EXIF) and the clipping buttons in the plot's top corners are not zones | | LRC-MAP-VIEW | Map view | P2 | ⬜ | | | | LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | | | LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | | @@ -807,6 +808,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-22 | patch | KEY-GRID: both grid rows in View show the cycle shortcut and follow keymap changes; direct bindings and native accelerator ownership are preserved (#2500); status and counts unchanged | | 2026-10-22 | minor | LRC-PRINT-LAYOUT: the Japanese photo and postcard paper sizes (L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺) are selectable in the page setup and Print dialog and print to PDF at their metric sizes | | 2026-10-22 | minor | #2052 (partial): shared export text adds only Thai faces after Inter and the existing CJK chain. Thai horizontal mark positioning remains incomplete; Arabic fallback is deferred until joining and bidi reordering exist. LR-EXP-WATERMARK reassessed as partial for these layout and font limits; UI fallback unchanged | +| 2026-10-24 | patch | LR-LIB-SMARTALBUM: relative-date view and source-count caches expire on the session-clock minute, including nested smart albums; held views retain expired photos until refresh and admit new matches; status and feature counts unchanged | | 2026-10-22 | patch | LR-META-XMP: namespace URI determines ownership; foreign namespaces using recognized prefixes survive saves and cannot supply native settings or ratings; status and counts unchanged | | 2026-10-22 | patch | LR-EXP-PSD, LR-META-EXIF-FULL: PSD/PSB imports and image resources 1058/1060 now supply EXIF and XMP to metadata readers and All Metadata; Original with Remove Location Info refuses detected PSD/PSB GPS; statuses and feature counts unchanged | | 2026-10-21 | patch | LR-LIB-CAPTURETIME: a capture time that doesn't fully read is left as it is and counted (`unreadable`) instead of shifted into a wrong one; it can't anchor a set that moves other photos; status unchanged | @@ -953,3 +955,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-22 | minor | MENU-BAR: on macOS the native menu gives a key back to the window while a text field has focus or a shortcut is being recorded (muda's `set_accelerator(None)` does nothing there, so the menu item is now swapped for one without a key, and the key is put back on that item afterwards); status unchanged (✅) | | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | +| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | diff --git a/docs/whats-new.md b/docs/whats-new.md index c27c7e474..c6c259200 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,24 @@ # What's new in LightCraft +## 2026-10-24 + +### Relative-date filters and smart albums follow the clock +- A current view or source count using “in the last” rules now refreshes when the session clock moves into a new + minute, even when no catalog edit occurred. This fixes stale photos remaining in the grid and stale counts for + direct or nested smart albums. Held views keep photos that expired until Refresh View while admitting photos that + newly match; expiry itself does not create an undo step. + +### The histogram's tonal zones adjust the Basic tones +- The histogram is a control surface again, as in Lightroom Classic: hovering one of its five zones (Blacks, Shadows, + Exposure, Highlights, Whites) tints it and shows its name and value under the plot ("Exposure: +0.45 EV"). Dragging one + sideways moves that control — the zone you pressed stays locked while the pointer crosses the others, Shift drags a + tenth as far, and the whole drag is one undo step. Double-clicking a zone puts its control back to its default. + +### Drag the parametric Tone Curve +- In the Tone Curve's parametric mode the graph is now drawn from the renderer's own curve, so it always matches the + photo, and the line can be dragged: pressing it picks the region under the pointer (Highlights, Lights, Darks or + Shadows) and moving up or down changes that slider, as one undo step. Hovering the line names the region first. + ## 2026-10-23 ### Tab leaves the name field in New Album, New Smart Album and the name prompts