mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR: integration batch 44 (11 community PRs)
Integration batch 44: 11 community PRs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
Vendored
+253
-13
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user