Merge PR: integration batch 44 (11 community PRs)

Integration batch 44: 11 community PRs
This commit is contained in:
Brandon Thomas
2026-10-24 11:00:48 -04:00
committed by GitHub
40 changed files with 2630 additions and 231 deletions
+19 -2
View File
@@ -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<Op> {
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<Op> {
self.folder_records_carried(from, to, false)
}
fn folder_records_carried(&self, from: &str, to: &str, clear_destination: bool) -> Vec<Op> {
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<Op> = 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
+37 -5
View File
@@ -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<String, FolderRecord>,
/// 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<Op> {
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);
}
+39 -1
View File
@@ -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<PhotoId>,
/// 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<String> {
// `/` 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<String>) -> 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.
+11
View File
@@ -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<Vec<ColorLabel>> {
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)
+72 -4
View File
@@ -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");
}
@@ -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();
+49
View File
@@ -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<String> {
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());
}
+11 -4
View File
@@ -399,6 +399,9 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
"{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;
@@ -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));
}
+70 -2
View File
@@ -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<String> {
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<String> {
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<usize> {
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<u8> = (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
+62 -10
View File
@@ -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<String>,
/// 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<String>)>,
pub undo: Vec<UndoEntry>,
/// 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<PhotoId> {
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)
}
+1 -1
View File
@@ -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];
+14
View File
@@ -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();
+44 -1
View File
@@ -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<std::sync::Mutex<String>>, Vec<PhotoId>) {
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");
}
+23
View File
@@ -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<u64> = 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() {
+141 -1
View File
@@ -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<PhotoId>, album: u64) {
};
}
fn relative_clock_session(captures: &[&str]) -> (Session, std::sync::Arc<std::sync::Mutex<String>>, Vec<PhotoId>) {
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);
+11 -5
View File
@@ -161,11 +161,17 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
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<String> = 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::<Vec<_>>().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 {
+238
View File
@@ -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}");
}
}
}
}
}
+9 -20
View File
@@ -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::<Vec<_>>();
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.
+2
View File
@@ -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};
+253 -13
View File
@@ -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<RawImage> {
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<RawData> {
_ => 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<RawData> {
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<lightcraft_tiff::image::Chunk> {
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<RawImage> {
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<u8> {
let flat: Vec<u16> = 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<u8>, bits: u16, w: u32, h: u32) -> Result<RawImage> {
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(_))));
}
}
}
}
+29 -29
View File
@@ -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})",
+79 -79
View File
@@ -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",
+2
View File
@@ -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",
+24
View File
@@ -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() {
+14
View File
@@ -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()
+2
View File
@@ -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;
+10 -4
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>().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) {
+7
View File
@@ -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.
+139 -31
View File
@@ -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<String> = [
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::<Option<(usize, f64, f64, f32)>>(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::<Pos2>(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::<Pos2>(cursor_id);
d.remove::<Option<(usize, f64, f64, f32)>>(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<Pos2> = (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<Pos2> =
(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);
+2 -7
View File
@@ -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<ColorLabel> = 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<ColorLabel> = f.labels_shown().unwrap_or_default();
for l in ColorLabel::ALL {
let on = chosen.contains(&l);
let name = format!("{l:?}").to_lowercase();
+372
View File
@@ -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<Zone> {
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<String> {
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<Active> {
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<Rect> = 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);
}
}
+1
View File
@@ -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;
+368
View File
@@ -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;
}
}
+295
View File
@@ -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<egui::Rect> {
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<f64> {
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");
}
+1
View File
@@ -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 |
+26
View File
@@ -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:
+4 -3
View File
@@ -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-<code>.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 |
File diff suppressed because one or more lines are too long
+19
View File
@@ -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