mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 22:17:53 -06:00
LR-IMP-FORMATS: merge main; reconcile camera-TIFF detection with the Samsung/Pentax rules (#281)
Merge origin/main into #480 and apply the review: - the Samsung-with-a-Pentax-maker-note -> PEF rule now also needs raw evidence; its test fixture gets a CFA photometric, and the same RGB file without it is not a raw; - for the Pentax family the Exif CFAPattern (0xa302) counts as raw evidence, as main's PEF reader takes its layout from it (uncompressed / PackBits PEFs); - Samsung's private raw compressions 32769..=32773 count for SRW (NX1 / NX500 use 32773); - the engine fallback in files.rs is dropped (main's version kept): a corrupt raw without a preview stays an import error; the engine regression test for camera-authored TIFFs is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ const MAX_FILE: u64 = 4 << 20;
|
||||
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
|
||||
/// `(model, JSON)`.
|
||||
pub const BUNDLED: &[(&str, &str)] = &[
|
||||
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
|
||||
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
|
||||
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
|
||||
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
|
||||
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
rename_folder_on_disk(from, to)?;
|
||||
let ops: Vec<Op> = s
|
||||
let mut ops: Vec<Op> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
@@ -301,6 +301,8 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
})
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
// what the library knows about the folder (and those inside it) goes along, in the same step
|
||||
ops.extend(s.catalog.folder_records_follow(from, to));
|
||||
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let renamed = src.parent() == dst.parent();
|
||||
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
|
||||
|
||||
@@ -210,6 +210,25 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
|
||||
ops
|
||||
}
|
||||
|
||||
/// Ops that give a photo the lens data its file carries now (`info`, a fresh probe), or none when
|
||||
/// it already has them. A catalog from before the lens data was read at import has none, so such a
|
||||
/// photo's correction stayed off. When the photo gains lens data and its develop settings are still
|
||||
/// what import gave it, the lens correction is turned on as import would; an edited photo keeps
|
||||
/// its settings.
|
||||
pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
|
||||
if info.embedded_lens == ph.embedded_lens {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut ops = vec![Op::SetEmbeddedLens { id, lens: info.embedded_lens.map(Box::new) }];
|
||||
// (a photo with a default-preset look keeps it: that look is its import state)
|
||||
if ph.embedded_lens.is_none() && info.embedded_lens.is_some() && ph.import_look.is_none() && !ph.is_edited() && !ph.develop.optics.lens_profile {
|
||||
let mut d = (*ph.develop).clone();
|
||||
d.optics.lens_profile = true;
|
||||
ops.push(Op::SetDevelop { id, settings: std::sync::Arc::new(d), label: "Lens Corrections".into(), edited: ph.edited.clone() });
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -229,21 +248,19 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
|
||||
// nothing already set is overwritten
|
||||
let meta_ops = fill_missing_meta(id, ph, &info);
|
||||
let Some(op) = content_op(id, ph, info.clone()) else {
|
||||
if !meta_ops.is_empty() {
|
||||
ops.extend(meta_ops);
|
||||
reloaded.push(id);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
ops.push(op);
|
||||
ops.extend(meta_ops);
|
||||
let mut mine: Vec<Op> = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).collect();
|
||||
if !mine.is_empty() {
|
||||
ops.append(&mut mine);
|
||||
reloaded.push(id);
|
||||
}
|
||||
// virtual copies share the file
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
ops.extend(content_op(c.id, c, info.clone()));
|
||||
reloaded.push(c.id);
|
||||
let theirs: Vec<Op> = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect();
|
||||
if !theirs.is_empty() {
|
||||
ops.extend(theirs);
|
||||
reloaded.push(c.id);
|
||||
}
|
||||
}
|
||||
reloaded.push(id);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
@@ -316,7 +333,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Reload from Disk",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}",
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
|
||||
has_selection,
|
||||
reload
|
||||
),
|
||||
@@ -359,4 +376,102 @@ mod tests {
|
||||
ph.captured = Some("2026-10-04T08:16:11".into());
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
fn lens() -> lightcraft_develop::EmbeddedLens {
|
||||
lightcraft_develop::EmbeddedLens {
|
||||
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
|
||||
vignette: None,
|
||||
}
|
||||
}
|
||||
fn old_rw2() -> lightcraft_catalog::Photo {
|
||||
// as an older build catalogued it: no lens data, correction off
|
||||
lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(1),
|
||||
Source::File { path: "/x.rw2".into() },
|
||||
"x.rw2",
|
||||
"RW2",
|
||||
4592,
|
||||
3448,
|
||||
"2026-10-06T00:00:00",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_adds_lens_data_and_turns_the_correction_on_for_an_untouched_photo() {
|
||||
let ph = old_rw2();
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
let ops = lens_ops(ph.id, &ph, &info);
|
||||
assert!(matches!(ops.first(), Some(Op::SetEmbeddedLens { lens: Some(_), .. })), "{ops:?}");
|
||||
let Some(Op::SetDevelop { settings, .. }) = ops.get(1) else { panic!("{ops:?}") };
|
||||
assert!(settings.optics.lens_profile);
|
||||
// once applied, the photo is still "unedited" and has what import would have given it
|
||||
let mut c = lightcraft_catalog::Catalog::default();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();
|
||||
for op in ops {
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
let after = c.photo(lightcraft_catalog::PhotoId(1)).unwrap();
|
||||
assert_eq!(after.embedded_lens, Some(lens()));
|
||||
assert!(!after.is_edited());
|
||||
assert_eq!(*after.develop, after.camera_defaults());
|
||||
assert!(lens_ops(after.id, after, &info).is_empty(), "nothing more to do the second time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_adds_lens_data_but_keeps_an_edited_photos_settings() {
|
||||
let mut ph = old_rw2();
|
||||
let mut d = (*ph.develop).clone();
|
||||
d.light.exposure = 0.7;
|
||||
ph.develop = std::sync::Arc::new(d);
|
||||
assert!(ph.is_edited());
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
let ops = lens_ops(ph.id, &ph, &info);
|
||||
assert!(matches!(ops.as_slice(), [Op::SetEmbeddedLens { lens: Some(_), .. }]), "{ops:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_leaves_a_photo_whose_user_switched_the_correction_off_alone() {
|
||||
let mut ph = old_rw2();
|
||||
ph.embedded_lens = Some(lens());
|
||||
let mut d = ph.camera_defaults();
|
||||
d.optics.lens_profile = false;
|
||||
ph.develop = std::sync::Arc::new(d);
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
assert!(lens_ops(ph.id, &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
/// End to end on a public Panasonic raw (skipped without the corpus): a catalog from before the lens data was read
|
||||
/// gets it on Reload, the render changes to the corrected one, and Undo takes it all back.
|
||||
#[test]
|
||||
fn corpus_reload_corrects_an_rw2_catalogued_without_lens_data() {
|
||||
let root = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
|
||||
let f = root.join("raw/rw2-panasonic-gx80.rw2");
|
||||
if !f.exists() {
|
||||
eprintln!("skip: {} absent", f.display());
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let ph = s.catalog.photo(id).unwrap().clone();
|
||||
assert!(ph.embedded_lens.is_some() && ph.develop.optics.lens_profile, "import reads it");
|
||||
// back to how 0.4.0 catalogued it
|
||||
let mut old = (*ph.develop).clone();
|
||||
old.optics.lens_profile = false;
|
||||
s.catalog.apply(Op::SetEmbeddedLens { id, lens: None }).unwrap();
|
||||
s.catalog.apply(Op::SetDevelop { id, settings: std::sync::Arc::new(old), label: "x".into(), edited: None }).unwrap();
|
||||
s.media.forget(id);
|
||||
let before = s.render_now(id, 160, 120).unwrap().image.data;
|
||||
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(r["reloaded"], json!([id.0]));
|
||||
let now = s.catalog.photo(id).unwrap().clone();
|
||||
assert_eq!(now.embedded_lens, ph.embedded_lens);
|
||||
assert!(now.develop.optics.lens_profile && !now.is_edited());
|
||||
assert_ne!(s.render_now(id, 160, 120).unwrap().image.data, before, "the corrected render differs");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let undone = s.catalog.photo(id).unwrap();
|
||||
assert!(undone.embedded_lens.is_none() && !undone.develop.optics.lens_profile, "one undo step");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Version};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -74,6 +74,49 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)
|
||||
d.crop.geometry = crop_fit_angle(w, h, angle, a);
|
||||
}
|
||||
|
||||
/// Photo `id` is on an older rendering process than [`ProcessVersion::LATEST`].
|
||||
fn outdated(s: &Session, id: PhotoId) -> bool {
|
||||
s.catalog.photo(id).is_some_and(|p| p.develop.process.is_outdated())
|
||||
}
|
||||
|
||||
fn already_current() -> String {
|
||||
format!("already on the current process (version {})", ProcessVersion::LATEST.0)
|
||||
}
|
||||
|
||||
/// Update to Current Process applies to the selection (the selected photos, else the active
|
||||
/// photo): one of them is on an older rendering process. Calls that name their photos are
|
||||
/// checked by the command instead (`explicit_targets`).
|
||||
fn has_outdated_process(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let sel = &s.selection;
|
||||
let targets = if sel.ids.is_empty() { sel.active.as_slice() } else { sel.ids.as_slice() };
|
||||
if targets.iter().any(|id| outdated(s, *id)) { Ok(()) } else { Err(already_current()) }
|
||||
}
|
||||
|
||||
/// The photos a call names (`ids`, or `id`), each of which must be in the library; `None` when it
|
||||
/// names none (the selection applies).
|
||||
fn named_photos(s: &Session, p: &Value, c: &str) -> Result<Option<Vec<PhotoId>>> {
|
||||
if !super::names_photos(p) {
|
||||
return Ok(None);
|
||||
}
|
||||
let given = match p.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
Some(_) => return Err(bad(c, "`ids` must be a list of photo ids")),
|
||||
None => p.get("id").cloned().into_iter().collect(),
|
||||
};
|
||||
if given.is_empty() {
|
||||
return Err(bad(c, "`ids` names no photos"));
|
||||
}
|
||||
let ids = given
|
||||
.iter()
|
||||
.map(|v| match v.as_u64().map(PhotoId) {
|
||||
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
|
||||
_ => Err(bad(c, format!("no such photo {v}"))),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(Some(ids))
|
||||
}
|
||||
|
||||
/// The user preset named by `p.id` (built-ins can't be changed).
|
||||
fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> {
|
||||
let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?;
|
||||
@@ -203,12 +246,44 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
};
|
||||
s.develop_op(id, fresh, "Reset")
|
||||
// a fresh start renders with the current process (a section or slider reset doesn't change it)
|
||||
s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..fresh }, "Reset")
|
||||
})
|
||||
.collect();
|
||||
s.commit("Reset", Op::Batch { ops })?;
|
||||
ok()
|
||||
}),
|
||||
CommandSpec {
|
||||
explicit_targets: true,
|
||||
..cmd!(
|
||||
"develop.updateProcess",
|
||||
"Update to Current Process",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?}: moves the target photos (`ids`, else the selection) that are on an older rendering process to the current one, as one undo step (their look can change; the sliders keep their values; see docs/process-versions.md). Enabled while a selected photo is on an older process; named photos are checked themselves (each must exist, one must be on an older process) → {changed, process}",
|
||||
has_outdated_process,
|
||||
|s, p| {
|
||||
const C: &str = "develop.updateProcess";
|
||||
const LABEL: &str = "Update to Current Process";
|
||||
let targets = match named_photos(s, p, C)? {
|
||||
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
|
||||
Some(ids) => ids,
|
||||
None => s.targets(p),
|
||||
};
|
||||
let ops: Vec<Op> = targets
|
||||
.into_iter()
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter(|(_, d)| d.process.is_outdated())
|
||||
.filter_map(|(id, d)| s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..(*d).clone() }, LABEL))
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(LABEL, Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n, "process": ProcessVersion::LATEST}))
|
||||
}
|
||||
)
|
||||
},
|
||||
cmd!(
|
||||
"develop.resetSection",
|
||||
"Reset Section",
|
||||
@@ -572,6 +647,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
// ---- HDR editing
|
||||
cmd!(
|
||||
"develop.hdr",
|
||||
"HDR",
|
||||
[],
|
||||
None,
|
||||
"{enabled?: bool (default: toggle), maxEv?: number 0..5 (headroom limit, stops above SDR white)} — HDR editing: highlights above SDR white for HDR exports and displays; every SDR render uses the SDR rendition (the hdr.sdr* controls)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "develop.hdr")?;
|
||||
let cur = s.develop_of(id).unwrap_or_default().hdr;
|
||||
let enabled = bool_or(p, "enabled", !cur.enabled);
|
||||
let max_ev = match p.get("maxEv") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("develop.hdr", "`maxEv` must be a number"))?),
|
||||
};
|
||||
let label = if enabled { "HDR On" } else { "HDR Off" };
|
||||
edit(s, "develop.hdr", label, |d| {
|
||||
d.hdr.enabled = enabled;
|
||||
if let Some(m) = max_ev {
|
||||
d.hdr.max_ev = m.clamp(0.0, lightcraft_develop::Hdr::MAX_EV_LIMIT);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"enabled": enabled, "maxEv": s.develop_of(id).map(|d| d.hdr.max_ev)}))
|
||||
}
|
||||
),
|
||||
// ---- geometry: Upright
|
||||
cmd!(
|
||||
"geometry.upright",
|
||||
|
||||
@@ -80,6 +80,17 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
|
||||
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
|
||||
always, |s, p| {
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad("export.contactSheet", "missing path"))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
|
||||
let doc = crate::contact_sheet::prepare(s, p).and_then(|work| work.run(&mut |_, _| true)).map_err(|e| bad("export.contactSheet", e))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
|
||||
crate::export::write_file(path, &doc.bytes).map_err(|e| bad("export.contactSheet", e))?;
|
||||
Ok(json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"export.presets",
|
||||
"Export Presets",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
|
||||
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
|
||||
|
||||
use lightcraft_catalog::keywords::{clean, is_under};
|
||||
use lightcraft_catalog::keywords::{KeywordInfo, clean, closest, is_under, reparent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
@@ -15,20 +15,54 @@ fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a keyword batch; returns how many photos changed. The keyword filter follows a renamed
|
||||
/// keyword and is cleared when its keyword is deleted.
|
||||
/// Commit a keyword batch; returns `{changed: photos changed, listed: keyword list changes}`. The
|
||||
/// keyword filter follows a renamed keyword and is cleared when its keyword is deleted.
|
||||
fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option<String>) -> Result<Value> {
|
||||
let n = match &op {
|
||||
lightcraft_catalog::Op::Batch { ops } => ops.len(),
|
||||
_ => 1,
|
||||
use lightcraft_catalog::Op;
|
||||
let (photos, listed) = match &op {
|
||||
Op::Batch { ops } => {
|
||||
(ops.iter().filter(|o| matches!(o, Op::SetMeta { .. })).count(), ops.iter().filter(|o| matches!(o, Op::SetKeyword { .. })).count())
|
||||
}
|
||||
Op::SetMeta { .. } => (1, 0),
|
||||
_ => (0, 1),
|
||||
};
|
||||
if n > 0 {
|
||||
if photos + listed > 0 {
|
||||
s.commit(label, op)?;
|
||||
}
|
||||
if let Some(k) = s.filter.keyword.clone() {
|
||||
s.filter.keyword = follow(&k);
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
Ok(json!({"changed": photos, "listed": listed}))
|
||||
}
|
||||
|
||||
/// A keyword's attributes from command params over `base`: those not given keep their value.
|
||||
fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo {
|
||||
KeywordInfo {
|
||||
synonyms: if p.get("synonyms").is_some() {
|
||||
strs(p, "synonyms").into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||
} else {
|
||||
base.synonyms
|
||||
},
|
||||
include_on_export: bool_or(p, "includeOnExport", base.include_on_export),
|
||||
export_containing: bool_or(p, "exportContaining", base.export_containing),
|
||||
export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms),
|
||||
person: bool_or(p, "person", base.person),
|
||||
new_keywords_inside: base.new_keywords_inside,
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyword a command names (`keyword`), as the library writes it.
|
||||
fn named_keyword(s: &Session, p: &Value, command: &str) -> Result<String> {
|
||||
let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?;
|
||||
s.catalog.keyword_path(k).ok_or_else(|| bad(command, format!("no keyword “{}”", clean(k))))
|
||||
}
|
||||
|
||||
/// A catalog refusal as a command error; a taken name says how to merge.
|
||||
fn refused(command: &str, e: lightcraft_catalog::CatalogError, how_to_merge: &str) -> crate::EngineError {
|
||||
match e {
|
||||
lightcraft_catalog::CatalogError::KeywordExists(k) => bad(command, format!("there is a keyword “{k}” already{how_to_merge}")),
|
||||
e => bad(command, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of up to nine keywords.
|
||||
@@ -38,6 +72,12 @@ pub struct KeywordSet {
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
/// Two set names (or recent keywords) are the same whatever the case of any of their letters, as
|
||||
/// keywords are.
|
||||
fn same_set(a: &str, b: &str) -> bool {
|
||||
lightcraft_catalog::keywords::same(a.trim(), b.trim())
|
||||
}
|
||||
|
||||
/// The set name meaning "the nine most recently added keywords".
|
||||
pub const RECENT: &str = "Recent Keywords";
|
||||
|
||||
@@ -48,16 +88,54 @@ pub fn note_recent(s: &mut Session, added: &[String]) {
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
|
||||
s.recent_keywords.retain(|x| !same_set(x, &k));
|
||||
s.recent_keywords.insert(0, k);
|
||||
}
|
||||
s.recent_keywords.truncate(9);
|
||||
let _ = s.save_prefs();
|
||||
}
|
||||
|
||||
/// The largest keyword list file read: a file is input (a list of 100,000 keywords is ~2 MB).
|
||||
const MAX_LIST_BYTES: u64 = 16 << 20;
|
||||
|
||||
/// The characters Capture One's keyword importer refuses in a list (`|` can't be in a name here).
|
||||
const CAPTURE_ONE_REFUSES: [char; 4] = [';', ',', '<', '>'];
|
||||
|
||||
/// A keyword list file's text: at most `MAX_LIST_BYTES`, UTF-8 (with or without a byte-order mark).
|
||||
fn read_list(path: &str) -> Result<String> {
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_LIST_BYTES + 1).read_to_end(&mut bytes).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
|
||||
if bytes.len() as u64 > MAX_LIST_BYTES {
|
||||
return Err(bad("keyword.import", format!("{path}: larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
|
||||
}
|
||||
// Notepad and some exporters save UTF-16: say how to get the file in, not just that it isn't
|
||||
if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) {
|
||||
return Err(bad("keyword.import", format!("{path}: UTF-16 text — save it as UTF-8 and import it again")));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| bad("keyword.import", format!("{path}: not UTF-8 text")))
|
||||
}
|
||||
|
||||
/// A set's slots as typed: each cleaned in its place (an empty one is an empty slot, so the others
|
||||
/// keep their ⌥ keys), each keyword once whatever its case (a second one leaves its slot empty),
|
||||
/// nine at most, no empty ones at the end.
|
||||
pub fn slots(typed: &[String]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for k in typed.iter().take(9) {
|
||||
let k = clean(k);
|
||||
let twice = !k.is_empty() && out.iter().any(|x| lightcraft_catalog::keywords::same(x, &k));
|
||||
out.push(if twice { String::new() } else { k });
|
||||
}
|
||||
while out.last().is_some_and(String::is_empty) {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
|
||||
pub fn current_keywords(s: &Session) -> Vec<String> {
|
||||
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
|
||||
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| same_set(&x.name, n)));
|
||||
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
|
||||
v.truncate(9);
|
||||
v
|
||||
@@ -75,13 +153,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
|
||||
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
|
||||
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
|
||||
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
|
||||
s.keyword_set = if same_set(name, RECENT) || name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
s.keyword_sets
|
||||
.iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||||
.find(|x| same_set(&x.name, name))
|
||||
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
|
||||
.name
|
||||
.clone(),
|
||||
@@ -95,24 +173,53 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Keyword Set",
|
||||
[],
|
||||
None,
|
||||
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
|
||||
"{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames, new?: refuse a name another set has} — replaces a set of that name (or `replace`, in its place) and makes it current",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
|
||||
.filter(|n| !n.is_empty() && !same_set(n, RECENT))
|
||||
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
|
||||
.to_string();
|
||||
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
|
||||
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
|
||||
let same_name = |x: &KeywordSet, n: &str| same_set(&x.name, n);
|
||||
// the set it renames, if any (an empty `replace` is none)
|
||||
let renames = match str_param(p, "replace").map(str::trim).filter(|o| !o.is_empty()) {
|
||||
Some(old) => Some(
|
||||
s.keyword_sets
|
||||
.iter()
|
||||
.position(|x| same_name(x, old))
|
||||
.ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// keywords not given: the renamed set's own, else the current nine
|
||||
let keywords = if p.get("keywords").is_some() {
|
||||
slots(&strs(p, "keywords"))
|
||||
} else {
|
||||
current_keywords(s)
|
||||
match renames.and_then(|at| s.keyword_sets.get(at)) {
|
||||
Some(x) => x.keywords.clone(),
|
||||
None => current_keywords(s),
|
||||
}
|
||||
};
|
||||
keywords.truncate(9);
|
||||
let set = KeywordSet { name: name.clone(), keywords };
|
||||
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.keyword_sets.push(set),
|
||||
match renames {
|
||||
// rename: in its place, onto a name no other set has
|
||||
Some(at) => {
|
||||
if s.keyword_sets.iter().enumerate().any(|(i, x)| i != at && same_name(x, &name)) {
|
||||
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
|
||||
}
|
||||
if let Some(x) = s.keyword_sets.get_mut(at) {
|
||||
*x = set;
|
||||
}
|
||||
}
|
||||
// a new set (`new`) never takes another set's name
|
||||
None if bool_or(p, "new", false) && s.keyword_sets.iter().any(|x| same_name(x, &name)) => {
|
||||
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
|
||||
}
|
||||
None => match s.keyword_sets.iter_mut().find(|x| same_name(x, &name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.keyword_sets.push(set),
|
||||
},
|
||||
}
|
||||
s.keyword_set = Some(name);
|
||||
s.save_prefs()?;
|
||||
@@ -122,11 +229,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
|
||||
let before = s.keyword_sets.len();
|
||||
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
|
||||
s.keyword_sets.retain(|x| !same_set(&x.name, name));
|
||||
if s.keyword_sets.len() == before {
|
||||
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
|
||||
}
|
||||
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
|
||||
if s.keyword_set.as_deref().is_some_and(|c| same_set(c, name)) {
|
||||
s.keyword_set = None;
|
||||
}
|
||||
s.save_prefs()?;
|
||||
@@ -145,12 +252,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|i| (1..=9).contains(i))
|
||||
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
|
||||
// (an empty slot does nothing)
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
|
||||
return Ok(json!({"changed": 0}));
|
||||
};
|
||||
let ids = s.targets(p);
|
||||
let all = !ids.is_empty()
|
||||
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
|
||||
&& ids.iter().all(|id| {
|
||||
s.catalog
|
||||
.photo(*id)
|
||||
.is_some_and(|ph| ph.meta.keywords.iter().any(|x| lightcraft_catalog::keywords::same(&clean(x), &clean(&k))))
|
||||
});
|
||||
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
|
||||
let key = if all { "removeKeywords" } else { "addKeywords" };
|
||||
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
|
||||
@@ -185,6 +297,184 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!(s.catalog.keyword_suggestions(¤t, str_param(p, "prefix").unwrap_or(""), n)))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "keyword.info",
|
||||
"Keyword Info",
|
||||
[],
|
||||
None,
|
||||
"{keyword} → {path, listed, count, synonyms, includeOnExport, exportContaining, exportSynonyms, person}",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = named_keyword(s, p, "keyword.info")?;
|
||||
let info = s.catalog.keyword_info(&path).cloned();
|
||||
let count = s.catalog.photos().filter(|ph| ph.in_library() && ph.meta.keywords.iter().any(|k| is_under(k, &path))).count();
|
||||
let mut v = serde_json::to_value(info.clone().unwrap_or_default()).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
o.insert("listed".into(), json!(info.is_some()));
|
||||
o.insert("count".into(), json!(count));
|
||||
o.entry("synonyms").or_insert(json!([]));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.create",
|
||||
"Create Keyword",
|
||||
[],
|
||||
None,
|
||||
"{name, parent?: keyword | null (default: the default parent; null = the top level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?, addToSelected?: bool, ids?} → {keyword}",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").map(clean).filter(|n| !n.is_empty()).ok_or_else(|| bad("keyword.create", "missing `name`"))?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))),
|
||||
Some(_) => None,
|
||||
None => s.catalog.default_keyword_parent(),
|
||||
};
|
||||
let path = clean(&match parent {
|
||||
Some(parent) => format!("{parent}|{name}"),
|
||||
None => name,
|
||||
});
|
||||
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
|
||||
let op = s
|
||||
.catalog
|
||||
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
|
||||
.map_err(|e| refused("keyword.create", e, ""))?;
|
||||
s.commit("Create Keyword", op)?;
|
||||
if !photos.is_empty() {
|
||||
note_recent(s, std::slice::from_ref(&path));
|
||||
}
|
||||
Ok(json!({"keyword": path}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.edit",
|
||||
"Edit Keyword",
|
||||
[],
|
||||
None,
|
||||
"{keyword, name?: its new name (one level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?} — what isn't given keeps its value",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = named_keyword(s, p, "keyword.edit")?;
|
||||
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
|
||||
let name = str_param(p, "name").unwrap_or(&leaf).to_string();
|
||||
let info = info_from(p, s.catalog.keyword_info(&from).cloned().unwrap_or_default());
|
||||
let op =
|
||||
s.catalog.edit_keyword_ops(&from, &name, info).map_err(|e| refused("keyword.edit", e, " (to merge them, use keyword.merge)"))?;
|
||||
let to = match from.rsplit_once('|') {
|
||||
Some((parent, _)) => format!("{parent}|{}", name.trim()),
|
||||
None => name.trim().to_string(),
|
||||
};
|
||||
commit_keywords(s, "Edit Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.move",
|
||||
"Move Keyword",
|
||||
[],
|
||||
None,
|
||||
"{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = named_keyword(s, p, "keyword.move")?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::String(k)) => Some(k.clone()),
|
||||
Some(Value::Null) => None,
|
||||
_ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")),
|
||||
};
|
||||
// where it lands, as the catalog spells it: a parent that doesn't exist yet is made
|
||||
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
|
||||
let to = match parent.as_deref().map(|k| s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))).filter(|k| !k.is_empty()) {
|
||||
Some(parent) => format!("{parent}|{leaf}"),
|
||||
None => leaf,
|
||||
};
|
||||
let op = s
|
||||
.catalog
|
||||
.move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false))
|
||||
.map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?;
|
||||
commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.purgeUnused",
|
||||
"Purge Unused Keywords",
|
||||
[],
|
||||
None,
|
||||
"{} — takes off the keyword list the keywords no photo has → {purged}",
|
||||
always,
|
||||
|s, _| {
|
||||
let op = s.catalog.purge_unused_keywords_ops();
|
||||
let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"purged": r["listed"]}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.setDefaultParent",
|
||||
"Put New Keywords Inside",
|
||||
[],
|
||||
None,
|
||||
"{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)",
|
||||
always,
|
||||
|s, p| {
|
||||
let keyword = match p.get("keyword") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?),
|
||||
};
|
||||
let op = s.catalog.set_default_parent_ops(keyword.as_deref()).map_err(|e| bad("keyword.setDefaultParent", e.to_string()))?;
|
||||
commit_keywords(s, "Put New Keywords Inside", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"keyword": s.catalog.default_keyword_parent()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.export",
|
||||
"Export Keywords",
|
||||
[],
|
||||
None,
|
||||
"{path?} — the keyword list as a file (Lightroom Classic's format, which Capture One, Photo Supreme and Bridge read) → {keywords, captureOneRefuses: keywords with ; , < > that Capture One's importer refuses, unwritable: keywords left out because the format can't hold them (a name in brackets or braces, a line break, deeper than 64 levels; a synonym as \"path {synonym}\"), text when there is no path}",
|
||||
always,
|
||||
|s, p| {
|
||||
let file = s.catalog.keyword_list_text();
|
||||
// Capture One's importer refuses ; , < > in a keyword's name
|
||||
let refuses: Vec<&str> = file
|
||||
.written
|
||||
.iter()
|
||||
.filter_map(|p| p.rsplit(lightcraft_catalog::keywords::SEP).next())
|
||||
.filter(|n| n.contains(CAPTURE_ONE_REFUSES))
|
||||
.collect();
|
||||
let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable});
|
||||
match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
r["path"] = json!(path);
|
||||
}
|
||||
None => r["text"] = json!(file.text),
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.import",
|
||||
"Import Keywords",
|
||||
[],
|
||||
None,
|
||||
"{path | text} — a keyword list (Lightroom Classic's format, also Capture One's and Photo Supreme's Formatted Vocabulary File): adds the keywords the library doesn't have, gives those it has the list's synonyms; one undo step → {added, updated}",
|
||||
always,
|
||||
|s, p| {
|
||||
let text = match (str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()), str_param(p, "text")) {
|
||||
(Some(path), _) => read_list(path)?,
|
||||
(None, Some(text)) if text.len() as u64 > MAX_LIST_BYTES => {
|
||||
return Err(bad("keyword.import", format!("larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
|
||||
}
|
||||
(None, Some(text)) => text.to_string(),
|
||||
(None, None) => return Err(bad("keyword.import", "give `path` or `text`")),
|
||||
};
|
||||
let entries = lightcraft_catalog::keywords::parse_keyword_list(&text).map_err(|e| bad("keyword.import", e.to_string()))?;
|
||||
let (op, added, updated) = s.catalog.import_keywords_ops(&entries);
|
||||
commit_keywords(s, "Import Keywords", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"added": added, "updated": updated}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.rename",
|
||||
"Rename Keyword",
|
||||
@@ -197,9 +487,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string();
|
||||
let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?;
|
||||
let (f, t) = (clean(&from), clean(&to));
|
||||
commit_keywords(s, "Rename Keyword", op, |k| {
|
||||
Some(if is_under(k, &f) { format!("{t}{}", &k[f.len().min(k.len())..]) } else { k.to_string() })
|
||||
})
|
||||
commit_keywords(s, "Rename Keyword", op, |k| Some(if is_under(k, &f) { reparent(k, &f, &t) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -229,8 +517,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let op = s.catalog.merge_keywords_ops(&from, &into).map_err(|e| bad("keyword.merge", e.to_string()))?;
|
||||
let (from, into) = (from.iter().map(|f| clean(f)).collect::<Vec<_>>(), clean(&into));
|
||||
commit_keywords(s, "Merge Keywords", op, |k| {
|
||||
Some(match from.iter().find(|f| is_under(k, f)) {
|
||||
Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]),
|
||||
Some(match closest(k, &from) {
|
||||
Some(f) => reparent(k, f, &into),
|
||||
None => k.to_string(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,20 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
use crate::{LibrarySource, Result, Selection, Session};
|
||||
|
||||
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
|
||||
let at = before.iter().position(|id| Some(*id) == s.selection.active);
|
||||
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
|
||||
s.selection.ids.retain(|id| remaining.contains(id));
|
||||
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
|
||||
if s.selection.active.is_none()
|
||||
&& let Some(at) = at
|
||||
{
|
||||
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
|
||||
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
|
||||
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
/// The auto-import folder's visible files with their sizes (the file-system half of
|
||||
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
|
||||
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
|
||||
@@ -146,6 +160,24 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
|
||||
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
|
||||
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
|
||||
if rating != old {
|
||||
ops.push(Op::SetRating { id, rating });
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(label, Op::Batch { ops })?;
|
||||
}
|
||||
advance_if(s, p);
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
fn advance_if(s: &mut Session, p: &Value) {
|
||||
if bool_or(p, "advance", false) {
|
||||
let _ = step(s, 1);
|
||||
@@ -330,6 +362,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder {
|
||||
return Err(bad("library.filter", "a folder is already shown (library.source): show another source first"));
|
||||
}
|
||||
// checked like a smart album's rules when this call sets them: an unknown field or a value
|
||||
// that can't match is an error, not an empty grid. Rules that stopped checking since
|
||||
// (their album was deleted) don't block other filter changes.
|
||||
if let Some(rules) = f.rule_set.as_mut() {
|
||||
rules.upgrade();
|
||||
}
|
||||
let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null());
|
||||
if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) {
|
||||
return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
|
||||
}
|
||||
s.filter = f;
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
}
|
||||
@@ -339,7 +381,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Library Folders",
|
||||
[],
|
||||
None,
|
||||
"{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
||||
"{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
||||
always,
|
||||
|s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default())
|
||||
),
|
||||
@@ -387,6 +429,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"removed": ids.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.label",
|
||||
"Set Folder Color Label",
|
||||
[],
|
||||
None,
|
||||
"{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "folder.label";
|
||||
let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? {
|
||||
"none" => None,
|
||||
x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?),
|
||||
};
|
||||
// taking a label off always works, also once the folder's photos are gone
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {
|
||||
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if label.is_none() && s.catalog.folder_record(path).is_none() {
|
||||
return Ok(json!({"path": path, "label": label}));
|
||||
}
|
||||
let op = s.catalog.folder_label_op(path, label);
|
||||
s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?;
|
||||
Ok(json!({"path": path, "label": label}))
|
||||
}
|
||||
),
|
||||
cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {
|
||||
s.filter = Default::default();
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
@@ -564,6 +633,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
advance_if(s, p);
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
|
||||
change_rating(s, p, -1, "Decrease Rating")
|
||||
}),
|
||||
cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
|
||||
change_rating(s, p, 1, "Increase Rating")
|
||||
}),
|
||||
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
|
||||
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
|
||||
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
|
||||
@@ -661,9 +736,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
|
||||
let vis = s.visible_cloned();
|
||||
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
||||
select_after_deletion(s, &before);
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
|
||||
@@ -689,10 +764,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let t = s.targets(p);
|
||||
let op = s.catalog.delete_photos_permanently_ops(&t);
|
||||
s.commit("Delete Permanently", op)?;
|
||||
s.selection = Selection::default();
|
||||
select_after_deletion(s, &before);
|
||||
Ok(json!({"deleted": t.len()}))
|
||||
}),
|
||||
// ---- metadata
|
||||
@@ -756,16 +832,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
*field = v.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(k) = strs("keywords") {
|
||||
m.keywords = k;
|
||||
}
|
||||
for k in strs("addKeywords").unwrap_or_default() {
|
||||
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
// keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case
|
||||
use lightcraft_catalog::keywords::{clean, same};
|
||||
let add = |m: &mut lightcraft_catalog::Meta, k: &str| {
|
||||
let k = clean(k);
|
||||
if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) {
|
||||
m.keywords.push(k);
|
||||
}
|
||||
};
|
||||
if let Some(k) = strs("keywords") {
|
||||
m.keywords.clear();
|
||||
for k in k {
|
||||
add(&mut m, &k);
|
||||
}
|
||||
}
|
||||
for k in strs("addKeywords").unwrap_or_default() {
|
||||
add(&mut m, &k);
|
||||
}
|
||||
for k in strs("removeKeywords").unwrap_or_default() {
|
||||
m.keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
|
||||
let k = clean(&k);
|
||||
m.keywords.retain(|x| !same(&clean(x), &k));
|
||||
}
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
||||
}
|
||||
@@ -809,7 +895,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad("album.createSmart", "empty name"));
|
||||
}
|
||||
let rules = match p.get("rules") {
|
||||
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?,
|
||||
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?,
|
||||
None => view_rules(s),
|
||||
};
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
@@ -819,8 +905,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for};
|
||||
Ok(json!(FIELDS
|
||||
.iter()
|
||||
.map(|(id, label, kind)| {
|
||||
@@ -828,13 +914,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Kind::Text => "text",
|
||||
Kind::Keywords => "keywords",
|
||||
Kind::Number => "number",
|
||||
Kind::Album => "album",
|
||||
Kind::Date => "date",
|
||||
Kind::Choice(_) => "choice",
|
||||
Kind::Bool => "bool",
|
||||
};
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
if let Kind::Choice(c) = kind {
|
||||
v["choices"] = json!(c);
|
||||
v["choices"] = json!(c.iter().map(|c| c.0).collect::<Vec<_>>());
|
||||
v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::<serde_json::Map<_, _>>().into();
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -845,18 +933,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Smart Album",
|
||||
[],
|
||||
None,
|
||||
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}",
|
||||
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.setRules")?;
|
||||
let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?;
|
||||
let rules = if bool_or(p, "fromView", false) {
|
||||
view_rules(s)
|
||||
// the view is saved as it is, but never as a loop (showing A, which tests this
|
||||
// album, and saving that view here would make it test itself)
|
||||
let view = view_rules(s);
|
||||
let loops: Vec<String> = view
|
||||
.rule_set
|
||||
.as_ref()
|
||||
.map(|r| r.check_for(&s.catalog, Some(id)))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(s.catalog.album_filter_problem(&view, Some(id)))
|
||||
.filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop)
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
if !loops.is_empty() {
|
||||
return Err(bad("album.setRules", loops.join("; ")));
|
||||
}
|
||||
view
|
||||
} else {
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let folder = cur.library_folder.clone();
|
||||
let base = if replace { Default::default() } else { cur };
|
||||
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?;
|
||||
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?;
|
||||
// the rules dialog has no folder field: replacing its rules keeps the folder
|
||||
// unless the call says (`libraryFolder: null`) to drop it
|
||||
if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() {
|
||||
@@ -864,7 +968,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
r
|
||||
};
|
||||
s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?;
|
||||
let set = Op::SetAlbumRules { id, rules: Box::new(rules) };
|
||||
// a new name goes with the rules: one step, applied (and undone) together
|
||||
let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n));
|
||||
match rename {
|
||||
Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?,
|
||||
None => s.commit("Edit Smart Album", set)?,
|
||||
};
|
||||
Ok(json!({"count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
@@ -874,14 +984,45 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.commit("Rename Album", Op::RenameAlbum { id, name })?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("album.delete", "Delete Album", [], None, "{id}", always, |s, p| {
|
||||
let id = album_param(p, "id", "album.delete")?;
|
||||
s.commit("Delete Album", Op::RemoveAlbum { id })?;
|
||||
if s.source == LibrarySource::Album(id) {
|
||||
s.source = LibrarySource::All;
|
||||
cmd!(
|
||||
"album.delete",
|
||||
"Delete Album",
|
||||
[],
|
||||
None,
|
||||
"{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.delete")?;
|
||||
let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder;
|
||||
let mut children = std::collections::BTreeMap::<AlbumId, Vec<AlbumId>>::new();
|
||||
for album in s.catalog.albums() {
|
||||
if let Some(parent) = album.parent {
|
||||
children.entry(parent).or_default().push(album.id);
|
||||
}
|
||||
}
|
||||
// Iterative traversal: even a malformed cycle is rejected before any change.
|
||||
// Reverse parent-first order removes children first; the batch inverse restores
|
||||
// parents first, including memberships, smart rules, covers and manual ordering.
|
||||
let mut pending = vec![id];
|
||||
let mut order = Vec::new();
|
||||
let mut removed = std::collections::BTreeSet::new();
|
||||
while let Some(next) = pending.pop() {
|
||||
if !removed.insert(next) {
|
||||
return Err(bad("album.delete", "cycle in album folders"));
|
||||
}
|
||||
order.push(next);
|
||||
if let Some(nested) = children.remove(&next) {
|
||||
pending.extend(nested);
|
||||
}
|
||||
}
|
||||
let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect();
|
||||
s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?;
|
||||
if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) {
|
||||
s.source = LibrarySource::All;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
),
|
||||
cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| {
|
||||
let id = album_param(p, "id", "album.move")?;
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
@@ -1273,12 +1414,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// `base` with a partial Filter (JSON) merged on top.
|
||||
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see
|
||||
/// [`merge_rules`]).
|
||||
fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
let mut v = serde_json::to_value(base).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, patch);
|
||||
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
|
||||
return Err(bad(c, problem));
|
||||
let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if let Some(rules) = f.rule_set.as_mut() {
|
||||
rules.upgrade();
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`;
|
||||
/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a
|
||||
/// problem too).
|
||||
fn merge_rules(
|
||||
base: &lightcraft_catalog::Filter,
|
||||
patch: &Value,
|
||||
c: &str,
|
||||
cat: &lightcraft_catalog::Catalog,
|
||||
owner: Option<AlbumId>,
|
||||
) -> Result<lightcraft_catalog::Filter> {
|
||||
let f = merge_filter(base, patch, c)?;
|
||||
// only rules this change sets are checked: one that stopped checking since (its album was
|
||||
// deleted) doesn't block editing the album's other settings
|
||||
let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null());
|
||||
let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default();
|
||||
// and a loop through the album field the change sets
|
||||
if patch.get("album").is_some_and(|a| !a.is_null()) {
|
||||
problems.extend(cat.album_filter_problem(&f, owner));
|
||||
}
|
||||
if !problems.is_empty() {
|
||||
return Err(bad(c, problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
@@ -1286,7 +1454,7 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res
|
||||
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
|
||||
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
|
||||
/// one disk but a way to reach several.
|
||||
fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
ids.iter()
|
||||
.filter_map(|id| s.catalog.photo(*id))
|
||||
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
|
||||
@@ -1314,7 +1482,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
|
||||
}
|
||||
}
|
||||
}
|
||||
merge_rules(&base, &Value::Object(patch), "").unwrap_or(base)
|
||||
// the view as it is: a rule that no longer checks (its album was deleted) just matches
|
||||
// nothing there, and mustn't cost the filter bar's settings
|
||||
merge_filter(&base, &Value::Object(patch), "").unwrap_or(base)
|
||||
}
|
||||
None => {
|
||||
let mut f = s.source.to_filter(&s.filter, &s.catalog);
|
||||
@@ -1382,3 +1552,7 @@ mod gps_tests {
|
||||
assert_eq!(p("hello"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library/tests_relative_ratings.rs"]
|
||||
mod relative_rating_tests;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Relative ratings dispatch through the real engine, including history and AutoWrite.
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
fn session() -> Result<Session> {
|
||||
let mut s = Session::new();
|
||||
for (id, rating) in [(1, 0), (2, 2), (3, 5), (4, 1)] {
|
||||
let mut photo = Photo::new(
|
||||
PhotoId(id),
|
||||
Source::File { path: format!("/lightcraft-relative-ratings/{id}.jpg") },
|
||||
&format!("{id}.jpg"),
|
||||
"JPEG",
|
||||
40,
|
||||
30,
|
||||
"2026-10-09",
|
||||
);
|
||||
photo.rating = rating;
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
|
||||
}
|
||||
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
|
||||
s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn ratings(s: &Session) -> Result<Vec<u8>> {
|
||||
(1..=4)
|
||||
.map(|id| s.catalog.photo(PhotoId(id)).map(|p| p.rating).ok_or_else(|| lightcraft_catalog::CatalogError::NoPhoto(PhotoId(id)).into()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_ratings_clamp_each_photo_and_form_one_history_step() {
|
||||
let mut s = session().unwrap();
|
||||
let undo = s.undo.len();
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 2);
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.active(), Some(PhotoId(2)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 2, 5, 1]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({})).unwrap()["changed"], 3);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 2, 4, 1]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_active_fallback_explicit_targets_and_errors_preserve_history() {
|
||||
let mut s = session().unwrap();
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [1]})).unwrap()["changed"], 0);
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({"ids": [3]})).unwrap()["changed"], 0);
|
||||
assert!(s.undo.is_empty());
|
||||
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 1);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 1]);
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [4]})).unwrap()["changed"], 1);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
|
||||
let undo = s.undo.len();
|
||||
assert!(s.execute("photo.increaseRating", &json!({"ids": [1, 999]})).is_err());
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
s.execute("photo.increaseRating", &json!({"advance": true})).unwrap();
|
||||
assert_eq!(s.active(), Some(PhotoId(3)));
|
||||
s.selection = Selection::default();
|
||||
assert!(s.execute("photo.decreaseRating", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_ratings_and_history_use_auto_write() {
|
||||
let mut s = session().unwrap();
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("lc-relative-ratings-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Metadata-only fixtures: no import, image decoding or model/service dependency.
|
||||
for id in 1..=4 {
|
||||
s.catalog
|
||||
.apply(Op::SetFile {
|
||||
id: PhotoId(id),
|
||||
file_name: format!("{id}.jpg"),
|
||||
source: Source::File { path: dir.join(format!("{id}.jpg")).to_string_lossy().into_owned() },
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
s.execute("photo.increaseRating", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>1</xmp:Rating>"));
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
|
||||
assert!(!dir.join("3.xmp").exists(), "unchanged upper bound must not write");
|
||||
assert!(!dir.join("4.xmp").exists(), "unselected photo must not write");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>0</xmp:Rating>"));
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>2</xmp:Rating>"));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
|
||||
s.execute("photo.decreaseRating", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("3.xmp")).unwrap().contains("<xmp:Rating>4</xmp:Rating>"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -189,7 +189,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
// photos without a capture time start from their import time
|
||||
let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default();
|
||||
let mut delta = (f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0).round() as i64;
|
||||
// seconds; a shift beyond 10,000 years is a mistake (and would overflow), not a date
|
||||
const MAX_SHIFT: f64 = 10_000.0 * 365.25 * 86_400.0;
|
||||
let shift = f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0;
|
||||
if shift.is_nan() || shift.abs() > MAX_SHIFT {
|
||||
return Err(bad(c, "the shift is more than 10,000 years"));
|
||||
}
|
||||
let mut delta = shift.round() as i64;
|
||||
let mut each: Option<String> = None;
|
||||
if let Some(t) = str_param(p, "time") {
|
||||
let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?;
|
||||
@@ -197,23 +203,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
each = Some(t);
|
||||
} else {
|
||||
let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]);
|
||||
let from = iso_seconds(&base(s, anchor)).ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
|
||||
let anchor_date = base(s, anchor);
|
||||
// An impossible calendar date cannot anchor changes to the other photos.
|
||||
let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or(""))
|
||||
.and_then(|_| iso_seconds(&anchor_date))
|
||||
.ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
|
||||
delta += iso_seconds(&t).unwrap_or(from) - from;
|
||||
}
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
let mut out_of_range = 0usize;
|
||||
for id in &targets {
|
||||
let new = match &each {
|
||||
Some(t) => shift_iso(t, delta),
|
||||
None => shift_iso(&base(s, *id), delta),
|
||||
let from = match &each {
|
||||
Some(t) => t.clone(),
|
||||
None => base(s, *id),
|
||||
};
|
||||
let Some(new) = shift_iso(&from, delta) else {
|
||||
// a date that reads but would leave years 0000–9999 is refused below; one
|
||||
// that doesn't read is skipped, as before
|
||||
if iso_seconds(&from).is_some() {
|
||||
out_of_range += 1;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(new) = new else { continue };
|
||||
out.push(json!(new));
|
||||
if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) {
|
||||
ops.push(Op::SetCaptured { id: *id, captured: Some(new) });
|
||||
}
|
||||
}
|
||||
if out_of_range > 0 {
|
||||
return Err(bad(c, format!("the shift would take {out_of_range} photo(s) outside the years 0000–9999; nothing was changed")));
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit("Edit Capture Time", Op::Batch { ops })?;
|
||||
|
||||
@@ -255,7 +255,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let m = &mut masks[i];
|
||||
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
|
||||
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
|
||||
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
|
||||
{
|
||||
strokes.push(stroke);
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id:
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ mod prefs;
|
||||
mod preset_files;
|
||||
pub mod previews;
|
||||
mod query;
|
||||
mod sync;
|
||||
mod xmp;
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -54,6 +55,10 @@ pub struct CommandSpec {
|
||||
pub run: Run,
|
||||
/// Record in the journal (false for queries).
|
||||
pub journal: bool,
|
||||
/// `enabled` judges the selection (menus, shortcuts); a call that names its photos (`ids` /
|
||||
/// `id`, see [`names_photos`]) skips it, and the command validates those photos itself.
|
||||
/// Off unless a command opts in (`CommandSpec { explicit_targets: true, ..cmd!(…) }`).
|
||||
pub explicit_targets: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -107,10 +112,10 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
macro_rules! cmd {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, explicit_targets: false }
|
||||
};
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, explicit_targets: false }
|
||||
};
|
||||
}
|
||||
pub(crate) use cmd;
|
||||
@@ -142,6 +147,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(before::specs());
|
||||
v.extend(browse::specs());
|
||||
v.extend(missing::specs());
|
||||
v.extend(sync::specs());
|
||||
v.extend(metadata::specs());
|
||||
v.extend(filters::specs());
|
||||
v.extend(denoise::specs());
|
||||
@@ -158,6 +164,11 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
|
||||
// ---------- param helpers
|
||||
|
||||
/// The call names its target photos (`ids` or `id`, not null) instead of using the selection.
|
||||
pub(crate) fn names_photos(p: &Value) -> bool {
|
||||
["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null()))
|
||||
}
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va
|
||||
if let Some(rules) = &a.smart {
|
||||
v["smart"] = json!(true);
|
||||
v["rules"] = serde_json::to_value(rules).unwrap_or_default();
|
||||
v["rulesText"] = json!(rules.describe());
|
||||
v["rulesText"] = json!(rules.describe_with(cat));
|
||||
// rules that no longer check (an album they test was deleted…), as `rule 1: …`
|
||||
v["problems"] = json!(cat.smart_album_problems(a.id).iter().map(ToString::to_string).collect::<Vec<_>>());
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -149,9 +151,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| {
|
||||
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?} → the photo's develop settings; `process` is the rendering process they are interpreted by (see develop.updateProcess)", always, |s, p| {
|
||||
let id = photo_arg(s, p, "develop.get")?;
|
||||
Ok(s.develop_of(id).map(|d| d.to_json()).unwrap_or(Value::Null))
|
||||
Ok(s.develop_of(id)
|
||||
.map(|d| {
|
||||
let mut v = d.to_json();
|
||||
// saved settings leave V1 out (`ProcessVersion::is_legacy`); here it is always stated
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("process".into(), json!(d.process));
|
||||
}
|
||||
v
|
||||
})
|
||||
.unwrap_or(Value::Null))
|
||||
}),
|
||||
cmd!(query "develop.controls", "List Develop Controls", [], None, "{section?} — every slider with range, default and current value", always, |s, p| {
|
||||
let d = s.active().and_then(|id| s.develop_of(id)).unwrap_or_default();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Synchronize Folder commands (see [`crate::sync`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::sync::{SyncChoice, scan, synchronize};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn path(p: &Value, c: &str) -> Result<String> {
|
||||
str_param(p, "path").filter(|d| !d.trim().is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `path`"))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "folder.scanChanges",
|
||||
"Scan Folder for Changes",
|
||||
[],
|
||||
None,
|
||||
"{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, moved: [{id, from, to}] photos whose file is elsewhere in the folder (same content), metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "folder.scanChanges";
|
||||
let path = path(p, C)?;
|
||||
let changes = scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?;
|
||||
let v = serde_json::to_value(&changes).unwrap_or_default();
|
||||
s.folder_changes = Some(changes);
|
||||
Ok(v)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.synchronize",
|
||||
"Synchronize Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, disk?: bool, scanned?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while it is current (the folder's photos unchanged), else scans again — or, with `scanned: true`, refuses; one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}",
|
||||
always,
|
||||
sync
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn sync(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "folder.synchronize";
|
||||
let path = path(p, C)?;
|
||||
let d = SyncChoice::default();
|
||||
let choice = SyncChoice {
|
||||
import_new: bool_or(p, "importNew", d.import_new),
|
||||
relink_moved: bool_or(p, "relinkMoved", d.relink_moved),
|
||||
remove_missing: bool_or(p, "removeMissing", d.remove_missing),
|
||||
read_metadata: bool_or(p, "readMetadata", d.read_metadata),
|
||||
};
|
||||
let changes = match s.take_folder_changes(&path) {
|
||||
Some(c) => c,
|
||||
// the person agreed to what a scan showed: never act on something else
|
||||
None if bool_or(p, "scanned", false) => return Err(bad(C, format!("{path} changed since it was scanned: scan again"))),
|
||||
None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?,
|
||||
};
|
||||
let r = synchronize(s, changes, choice)?;
|
||||
Ok(json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed}))
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF.
|
||||
use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image};
|
||||
use crate::{Session, media::RenderJob};
|
||||
use lightcraft_pipeline::{OutputDepth, OutputSpace};
|
||||
use lightcraft_raster::Rgba8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct Options {
|
||||
pub paper: String,
|
||||
pub landscape: bool,
|
||||
pub columns: usize,
|
||||
pub rows: usize,
|
||||
pub captions: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self { paper: "a4".into(), landscape: false, columns: 3, rows: 4, captions: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn parse(p: &Value) -> Result<Self, String> {
|
||||
let mut settings = p.as_object().cloned().ok_or("params must be an object")?;
|
||||
settings.remove("path");
|
||||
settings.remove("ids");
|
||||
settings.remove("id");
|
||||
let o: Self = serde_json::from_value(Value::Object(settings)).map_err(|e| e.to_string())?;
|
||||
o.layout()?;
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn layout(&self) -> Result<Layout, String> {
|
||||
if !(1..=8).contains(&self.columns) || !(1..=10).contains(&self.rows) {
|
||||
return Err("columns must be 1–8 and rows 1–10".into());
|
||||
}
|
||||
let (mut w, mut h) = match self.paper.as_str() {
|
||||
"a4" => (595.28, 841.89),
|
||||
"letter" => (612.0, 792.0),
|
||||
_ => return Err("paper must be a4 or letter".into()),
|
||||
};
|
||||
if self.landscape {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
// Fixed 150 dpi, bounded paper sizes and grid dimensions keep allocations predictable.
|
||||
let width = (w * 150.0 / 72.0_f64).round() as usize;
|
||||
let height = (h * 150.0 / 72.0_f64).round() as usize;
|
||||
Ok(Layout { points: (w, h), width, height, cell_w: (width - 120) / self.columns, cell_h: (height - 120) / self.rows })
|
||||
}
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
points: (f64, f64),
|
||||
width: usize,
|
||||
height: usize,
|
||||
cell_w: usize,
|
||||
cell_h: usize,
|
||||
}
|
||||
|
||||
pub struct Prepared {
|
||||
options: Options,
|
||||
items: Vec<(String, RenderJob)>,
|
||||
}
|
||||
|
||||
pub struct Document {
|
||||
pub bytes: Vec<u8>,
|
||||
pub pages: usize,
|
||||
pub photos: usize,
|
||||
}
|
||||
|
||||
pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> {
|
||||
let options = Options::parse(p)?;
|
||||
if let Some(ids) = p.get("ids") {
|
||||
let a = ids.as_array().ok_or("ids must be an array of photo ids")?;
|
||||
if a.iter().any(|v| v.as_u64().is_none()) {
|
||||
return Err("ids must contain only photo ids".into());
|
||||
}
|
||||
}
|
||||
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
|
||||
return Err("id must be a photo id".into());
|
||||
}
|
||||
let ids = session.targets(p);
|
||||
if ids.is_empty() || ids.len() > 1000 {
|
||||
return Err("select between 1 and 1000 photos".into());
|
||||
}
|
||||
let l = options.layout()?;
|
||||
if ids.len().div_ceil(options.columns * options.rows) > 100 {
|
||||
return Err("contact sheets are limited to 100 pages; increase the grid size or select fewer photos".into());
|
||||
}
|
||||
let mut items = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let name = session.catalog.photo(id).ok_or("no such photo")?.file_name.clone();
|
||||
let job = session.export_job(id, l.cell_w - 16, l.cell_h - 48, OutputSpace::Srgb, OutputDepth::U8)?;
|
||||
items.push((name, job));
|
||||
}
|
||||
Ok(Prepared { options, items })
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// Cancellation produces no partial document; callers write only after this succeeds.
|
||||
pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result<Document, String> {
|
||||
let l = self.options.layout()?;
|
||||
let capacity = self.options.columns * self.options.rows;
|
||||
let photos = self.items.len();
|
||||
let pages = photos.div_ceil(capacity);
|
||||
let mut pdf = Pdf::new(pages, l.points);
|
||||
let mut page = Rgba8::filled(l.width, l.height, [255; 4]);
|
||||
for (i, (name, job)) in self.items.into_iter().enumerate() {
|
||||
if !progress(i, &name) {
|
||||
return Err("Contact sheet cancelled".into());
|
||||
}
|
||||
let image = job.run().rendered?.image;
|
||||
let slot = i % capacity;
|
||||
let x = 60 + slot % self.options.columns * l.cell_w;
|
||||
let y = 60 + slot / self.options.columns * l.cell_h;
|
||||
// The renderer fits within the requested rectangle and applies crop/orientation.
|
||||
if image.width > l.cell_w - 16 || image.height > l.cell_h - 48 {
|
||||
return Err("render exceeded contact sheet cell".into());
|
||||
}
|
||||
blit(&mut page, &image, x + (l.cell_w - image.width) / 2, y + (l.cell_h - 40 - image.height) / 2)?;
|
||||
if self.options.captions {
|
||||
let mut caption = Rgba8::filled(l.cell_w - 16, 28, [255; 4]);
|
||||
draw_watermark(
|
||||
&mut caption,
|
||||
&Watermark {
|
||||
text: caption_text(&name, l.cell_w - 16),
|
||||
size: 0.5,
|
||||
opacity: 1.0,
|
||||
anchor: Anchor::Center,
|
||||
inset: 0.0,
|
||||
color: [32; 3],
|
||||
shadow: false,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
blit(&mut page, &caption, x + 8, y + l.cell_h - 36)?;
|
||||
}
|
||||
if slot + 1 == capacity || i + 1 == photos {
|
||||
let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?;
|
||||
if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 {
|
||||
return Err("contact sheet exceeds 256 MiB; select fewer photos".into());
|
||||
}
|
||||
pdf.page(i / capacity, &jpg, l.width, l.height);
|
||||
page.data.fill([255; 4]);
|
||||
}
|
||||
}
|
||||
if !progress(photos, "PDF") {
|
||||
return Err("Contact sheet cancelled".into());
|
||||
}
|
||||
Ok(Document { bytes: pdf.finish(), pages, photos })
|
||||
}
|
||||
}
|
||||
|
||||
fn caption_text(name: &str, width: usize) -> String {
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
let clean: String = name.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
let cells: Vec<_> = clean.graphemes(true).collect();
|
||||
// A conservative one-em budget at 14 px also accommodates the optional CJK faces.
|
||||
let limit = (width / 14).max(3);
|
||||
if cells.len() <= limit {
|
||||
return clean;
|
||||
}
|
||||
let left = limit / 2;
|
||||
let right = limit - left - 1;
|
||||
format!("{}…{}", cells.iter().take(left).copied().collect::<String>(), cells.iter().skip(cells.len() - right).copied().collect::<String>())
|
||||
}
|
||||
|
||||
fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> {
|
||||
if x.checked_add(src.width).is_none_or(|w| w > dst.width) || y.checked_add(src.height).is_none_or(|h| h > dst.height) {
|
||||
return Err("contact sheet image outside page".into());
|
||||
}
|
||||
for row in 0..src.height {
|
||||
let start = (y + row) * dst.width + x;
|
||||
let a = dst.data.get_mut(start..start + src.width).ok_or("invalid page buffer")?;
|
||||
let b = src.data.get(row * src.width..(row + 1) * src.width).ok_or("invalid render buffer")?;
|
||||
for (dst, src) in a.iter_mut().zip(b) {
|
||||
let alpha = u32::from(src[3]);
|
||||
for channel in 0..3 {
|
||||
dst[channel] = ((u32::from(src[channel]) * alpha + u32::from(dst[channel]) * (255 - alpha) + 127) / 255) as u8;
|
||||
}
|
||||
dst[3] = 255;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets.
|
||||
struct Pdf {
|
||||
bytes: Vec<u8>,
|
||||
offsets: Vec<usize>,
|
||||
points: (f64, f64),
|
||||
}
|
||||
|
||||
impl Pdf {
|
||||
fn new(pages: usize, points: (f64, f64)) -> Self {
|
||||
let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points };
|
||||
p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" ");
|
||||
p.object(2, format!("<< /Type /Pages /Count {pages} /Kids [{kids}] >>").as_bytes());
|
||||
p
|
||||
}
|
||||
|
||||
fn object(&mut self, id: usize, body: &[u8]) {
|
||||
self.offsets.push(self.bytes.len());
|
||||
self.bytes.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
self.bytes.extend_from_slice(body);
|
||||
self.bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
|
||||
fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) {
|
||||
let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes();
|
||||
body.extend_from_slice(bytes);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
self.object(id, &body);
|
||||
}
|
||||
|
||||
fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) {
|
||||
let id = 3 + index * 3;
|
||||
let (w, h) = self.points;
|
||||
self.object(
|
||||
id,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R >>",
|
||||
id + 2,
|
||||
id + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes());
|
||||
self.stream(
|
||||
id + 2,
|
||||
&format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"),
|
||||
jpg,
|
||||
);
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
let start = self.bytes.len();
|
||||
self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes());
|
||||
for offset in self.offsets.iter().skip(1) {
|
||||
self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes());
|
||||
self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn validates_settings_and_selection() {
|
||||
let mut s = Session::with_demo();
|
||||
for p in [
|
||||
json!({"columns": 0}),
|
||||
json!({"rows": 11}),
|
||||
json!({"paper": "poster"}),
|
||||
json!({"landscape": "yes"}),
|
||||
json!({"typo": 2}),
|
||||
json!({"ids": ["bad"]}),
|
||||
json!({"ids": []}),
|
||||
] {
|
||||
assert!(prepare(&mut s, &p).is_err(), "{p}");
|
||||
}
|
||||
let a = Options::default().layout().unwrap();
|
||||
let b = Options { landscape: true, ..Default::default() }.layout().unwrap();
|
||||
assert_eq!((a.width, a.height), (b.height, b.width));
|
||||
let id = s.catalog.photos().next().unwrap().id.0;
|
||||
assert!(prepare(&mut s, &json!({"ids": [id, u64::MAX]})).is_err());
|
||||
assert!(prepare(&mut s, &json!({"ids": vec![id; 1001]})).is_err());
|
||||
assert!(prepare(&mut s, &json!({"ids": vec![id; 101], "columns": 1, "rows": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_pixels_composite_over_white() {
|
||||
let mut dst = Rgba8::filled(3, 1, [255; 4]);
|
||||
let src = Rgba8 { width: 3, height: 1, data: vec![[0, 0, 0, 0], [255, 0, 0, 128], [10, 20, 30, 255]] };
|
||||
blit(&mut dst, &src, 0, 0).unwrap();
|
||||
assert_eq!(dst.data, vec![[255; 4], [255, 127, 127, 255], [10, 20, 30, 255]]);
|
||||
assert!(blit(&mut dst, &src, usize::MAX, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_captions_are_rasterized() {
|
||||
assert_eq!(caption_text("very-long-éééé-filename.jpg", 140), "very-….jpg");
|
||||
assert_eq!(caption_text("café\n.jpg", 300), "café .jpg");
|
||||
let mut img = Rgba8::filled(360, 28, [255; 4]);
|
||||
draw_watermark(
|
||||
&mut img,
|
||||
&Watermark {
|
||||
text: "Été – Straße café.jpg".into(),
|
||||
size: 0.5,
|
||||
opacity: 1.0,
|
||||
anchor: Anchor::Center,
|
||||
inset: 0.0,
|
||||
color: [32; 3],
|
||||
shadow: false,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(img.data.iter().filter(|p| p[0] < 128).count() > 100);
|
||||
let mut ascii = Rgba8::filled(360, 28, [255; 4]);
|
||||
draw_watermark(
|
||||
&mut ascii,
|
||||
&Watermark {
|
||||
text: "Ete - Strasse cafe.jpg".into(),
|
||||
size: 0.5,
|
||||
opacity: 1.0,
|
||||
anchor: Anchor::Center,
|
||||
inset: 0.0,
|
||||
color: [32; 3],
|
||||
shadow: false,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_ne!(img, ascii);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_xref_and_cancellation() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
let p = json!({"ids": ids, "columns": 1, "rows": 2, "captions": false});
|
||||
let mut seen = Vec::new();
|
||||
let doc = prepare(&mut s, &p)
|
||||
.unwrap()
|
||||
.run(&mut |i, _| {
|
||||
seen.push(i);
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((doc.pages, doc.photos), (2, 3));
|
||||
assert_eq!(seen, vec![0, 1, 2, 3]);
|
||||
let text = String::from_utf8_lossy(&doc.bytes);
|
||||
assert!(text.contains("/Count 2 /Kids [3 0 R 6 0 R]"));
|
||||
let xref = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse::<usize>().unwrap();
|
||||
assert!(doc.bytes[xref..].starts_with(b"xref\n0 9\n"));
|
||||
let xref_text = std::str::from_utf8(&doc.bytes[xref..]).unwrap();
|
||||
for (index, line) in xref_text.lines().skip(3).take(8).enumerate() {
|
||||
let offset = line.split_whitespace().next().unwrap().parse::<usize>().unwrap();
|
||||
assert!(doc.bytes[offset..].starts_with(format!("{} 0 obj\n", index + 1).as_bytes()));
|
||||
}
|
||||
assert!(prepare(&mut s, &p).unwrap().run(&mut |_, _| false).is_err());
|
||||
}
|
||||
|
||||
fn first_page(bytes: &[u8]) -> Rgba8 {
|
||||
let start = bytes.windows(2).position(|v| v == [0xff, 0xd8]).unwrap();
|
||||
let end = bytes[start..].windows(2).position(|v| v == [0xff, 0xd9]).unwrap() + start + 2;
|
||||
lightcraft_codecs::decode(&bytes[start..end], Default::default()).unwrap().to_srgb8()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_uses_current_edits_and_captions() {
|
||||
let mut s = Session::with_demo();
|
||||
let p = json!({"columns": 1, "rows": 2, "captions": false});
|
||||
let plain = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes);
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap();
|
||||
let bright = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes);
|
||||
let sum = |img: &Rgba8| img.data.iter().map(|p| u64::from(p[0]) + u64::from(p[1]) + u64::from(p[2])).sum::<u64>();
|
||||
assert!(sum(&bright) > sum(&plain), "sheet ignored develop settings");
|
||||
let captions = first_page(&prepare(&mut s, &json!({"columns": 1, "rows": 2})).unwrap().run(&mut |_, _| true).unwrap().bytes);
|
||||
assert!(sum(&captions) < sum(&bright), "captions absent from PDF pixels");
|
||||
assert_eq!((plain.width, plain.height), (1240, 1754));
|
||||
}
|
||||
}
|
||||
+181
-36
@@ -18,6 +18,43 @@ use serde_json::{Map, Value, json};
|
||||
/// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]).
|
||||
pub type Props = BTreeMap<String, Vec<String>>;
|
||||
|
||||
/// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw
|
||||
/// with a measured illuminant takes `Temperature` as Kelvin; everything developed relative to its
|
||||
/// as-shot look (rendered files, and raws whose readers have no illuminant,
|
||||
/// [`lightcraft_catalog::relative_wb_format`]) needs a shift from the other app's as-shot white
|
||||
/// instead, because on that scale 6500 K / 0 means *as shot* (issue #510).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale.
|
||||
Any,
|
||||
/// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our
|
||||
/// relative scale.
|
||||
Rendered,
|
||||
/// A raw developed with an absolute white balance (DNG and other files with a measured
|
||||
/// illuminant): Kelvin as written.
|
||||
RawAbsolute,
|
||||
/// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]).
|
||||
RawRelative,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// The target for a file of this media kind and format (its extension or Lightroom's
|
||||
/// `fileFormat` name); `preview_only` for a raw that can't be decoded yet.
|
||||
pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target {
|
||||
if kind != lightcraft_catalog::MediaKind::Raw || preview_only {
|
||||
Target::Rendered
|
||||
} else if lightcraft_catalog::relative_wb_format(format) {
|
||||
Target::RawRelative
|
||||
} else {
|
||||
Target::RawAbsolute
|
||||
}
|
||||
}
|
||||
/// The target for a photo in the catalog.
|
||||
pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target {
|
||||
Target::for_file(p.kind, &p.format, p.preview_only.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// Band names as they appear in `crs:` field names, in our mixer order.
|
||||
const CRS_BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
|
||||
|
||||
@@ -69,6 +106,8 @@ pub fn has_adjustments(props: &Props) -> bool {
|
||||
thread_local! {
|
||||
/// Keys read by [`to_partial`] while [`to_partial_report`] runs.
|
||||
static READ: std::cell::RefCell<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
|
||||
/// Adjustments [`to_partial`] read but deliberately left out, for the report.
|
||||
static SKIPPED: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn note(k: &str) {
|
||||
@@ -79,6 +118,14 @@ fn note(k: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
fn skipped(what: &str) {
|
||||
SKIPPED.with(|r| {
|
||||
if let Some(s) = r.borrow_mut().as_mut() {
|
||||
s.push(what.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
|
||||
note(k);
|
||||
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
|
||||
@@ -138,10 +185,8 @@ fn curve(props: &Props, k: &str) -> Option<Value> {
|
||||
|
||||
/// Map the `crs:` fields of an XMP packet to a partial develop-settings JSON object.
|
||||
///
|
||||
/// `raw`: the target is a raw file (absolute Kelvin white balance) — `Some(false)` prefers the
|
||||
/// relative `Incremental*` white balance used for rendered files, `None` (presets) takes whichever
|
||||
/// is present, absolute first.
|
||||
pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
/// `target` decides how the white balance is read (see [`Target`]).
|
||||
pub fn to_partial(props: &Props, target: Target) -> Value {
|
||||
let mut out = Value::Object(Map::new());
|
||||
let o = &mut out;
|
||||
let n = |o: &mut Value, crs: &str, path: &str| {
|
||||
@@ -200,10 +245,24 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
let rel = num(props, "crs:IncrementalTemperature")
|
||||
.or(num(props, "crs:IncrementalTint").map(|_| 0.0))
|
||||
.map(|t| (rel_to_kelvin(t), num(props, "crs:IncrementalTint")));
|
||||
let wb = match raw {
|
||||
Some(true) => abs,
|
||||
Some(false) => rel.or(abs),
|
||||
None => abs.or(rel),
|
||||
// A Kelvin value for something we develop relative to its as-shot look: the other app's
|
||||
// as-shot white (`AsShotTemperature` / `AsShotTint`, written next to the edit) is the
|
||||
// reference, so the edit is the same mired shift from ours (issue #510).
|
||||
let shifted = || {
|
||||
let (t, tint) = abs?;
|
||||
let as_shot = num(props, "crs:AsShotTemperature").filter(|k| *k > 0.0)?;
|
||||
if t <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let r = (1e6 / as_shot - 1e6 / t) / 0.8;
|
||||
let tint = tint.map(|v| v - num(props, "crs:AsShotTint").unwrap_or(0.0));
|
||||
Some((rel_to_kelvin(r), tint))
|
||||
};
|
||||
let wb = match target {
|
||||
Target::RawAbsolute => abs,
|
||||
Target::Any => abs.or(rel),
|
||||
Target::Rendered => rel.or_else(shifted).or(abs),
|
||||
Target::RawRelative => rel.or_else(shifted),
|
||||
};
|
||||
match (mode, wb) {
|
||||
(Some(m), _) if m != "custom" => put(o, "wb.mode", json!(m)),
|
||||
@@ -214,6 +273,13 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
put(o, "wb.tint", json!(tint));
|
||||
}
|
||||
}
|
||||
// a custom Kelvin with nothing to shift it from: read on the relative scale it would be a
|
||||
// large colour shift (ΔE 22 against Lightroom's export on the photos of issue #510), so
|
||||
// the photo stays As Shot (ΔE 12 there) and the report says so
|
||||
(_, None) if target == Target::RawRelative && abs.is_some() => {
|
||||
put(o, "wb.mode", json!("asShot"));
|
||||
skipped("Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)");
|
||||
}
|
||||
(Some(m), None) => put(o, "wb.mode", json!(m)),
|
||||
(None, None) => {}
|
||||
}
|
||||
@@ -244,13 +310,19 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
put(o, "curve.master", c);
|
||||
}
|
||||
}
|
||||
// Lightroom Classic reads the red / green / blue curves only together with the master 2012
|
||||
// curve: a channel curve without `ToneCurvePV2012` leaves the photo unchanged
|
||||
// Lightroom Classic reads the red / green / blue curves only when the packet holds the master
|
||||
// 2012 curve and all three channel curves (as its own packets always do): a packet missing any
|
||||
// of them imports without channel curves (master + red, master + red + green, or red / green /
|
||||
// blue without the master all leave the photo's channels unchanged there)
|
||||
if let Some(c) = curve(props, "crs:ToneCurvePV2012") {
|
||||
put(o, "curve.master", c);
|
||||
for (crs, ch) in [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] {
|
||||
if let Some(c) = curve(props, &format!("crs:{crs}")) {
|
||||
put(o, &format!("curve.{ch}"), c);
|
||||
let channels = [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
|
||||
.map(|(crs, ch)| (curve(props, &format!("crs:{crs}")), ch));
|
||||
if channels.iter().all(|(c, _)| c.is_some()) {
|
||||
for (c, ch) in channels {
|
||||
if let Some(c) = c {
|
||||
put(o, &format!("curve.{ch}"), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,11 +447,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
/// With `values` (the packet's structured properties), local corrections become masks too
|
||||
/// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that
|
||||
/// can't be carried over are reported as `Mask: <kind>`.
|
||||
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option<bool>, aspect: f64) -> (Value, Vec<String>) {
|
||||
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, target: Target, aspect: f64) -> (Value, Vec<String>) {
|
||||
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
|
||||
let mut out = to_partial(props, raw);
|
||||
SKIPPED.with(|r| *r.borrow_mut() = Some(Vec::new()));
|
||||
let mut out = to_partial(props, target);
|
||||
let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
|
||||
let mut mask_skips = Vec::new();
|
||||
let mut mask_skips = SKIPPED.with(|r| r.borrow_mut().take()).unwrap_or_default();
|
||||
if let Some(values) = values {
|
||||
let (masks, skipped) = crate::crs_masks::masks(values, aspect);
|
||||
if !masks.is_empty() {
|
||||
@@ -387,7 +460,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
}
|
||||
if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) {
|
||||
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
|
||||
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
|
||||
mask_skips.extend(skipped.into_iter().map(|k| format!("Mask: {k}")));
|
||||
}
|
||||
}
|
||||
// fields that only switch a panel on/off or name things: not adjustments by themselves; an
|
||||
@@ -421,7 +494,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
|
||||
let d = lightcraft_meta::parse_xmp(xmp).ok()?;
|
||||
let props = &d.properties;
|
||||
let settings = to_partial(props, None);
|
||||
let settings = to_partial(props, Target::Any);
|
||||
if settings.as_object().is_none_or(Map::is_empty) {
|
||||
return None;
|
||||
}
|
||||
@@ -474,7 +547,7 @@ mod tests {
|
||||
fn maps_common_fields() {
|
||||
let p = props(SIDECAR);
|
||||
assert!(has_adjustments(&p));
|
||||
let partial = to_partial(&p, Some(true));
|
||||
let partial = to_partial(&p, Target::RawAbsolute);
|
||||
let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
|
||||
assert_eq!(s.wb.mode, WbMode::Custom);
|
||||
assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0));
|
||||
@@ -516,7 +589,7 @@ mod tests {
|
||||
fn only_present_fields_are_in_the_partial() {
|
||||
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
|
||||
xmlns:c="http://ns.adobe.com/camera-raw-settings/1.0/" c:Vibrance="+30" c:GrainAmount="10"/></rdf:RDF>"#;
|
||||
let partial = to_partial(&props(x), None);
|
||||
let partial = to_partial(&props(x), Target::Any);
|
||||
assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}}));
|
||||
}
|
||||
|
||||
@@ -531,12 +604,12 @@ mod tests {
|
||||
<crs:Temperature>4800</crs:Temperature>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let p = props(x);
|
||||
let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(false)), 1.0);
|
||||
let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0);
|
||||
assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw);
|
||||
assert_eq!(rendered.bw_mix.blue, -35.0);
|
||||
assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0);
|
||||
assert_eq!(rendered.wb.tint, -6.0);
|
||||
let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(true)), 1.0);
|
||||
let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0);
|
||||
assert_eq!(raw.wb.temp, 4800.0);
|
||||
}
|
||||
|
||||
@@ -545,13 +618,72 @@ mod tests {
|
||||
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:WhiteBalance="Daylight" crs:Temperature="5500" crs:Tint="10" crs:AlreadyApplied="True"/>"#;
|
||||
let p = props(x);
|
||||
assert_eq!(to_partial(&p, Some(true)), json!({"wb": {"mode": "daylight"}}));
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}}));
|
||||
assert!(!has_adjustments(&p));
|
||||
let bookkeeping = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Version="1" crs:HasSettings="True"/>"#;
|
||||
assert!(!has_adjustments(&props(bookkeeping)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() {
|
||||
// Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white
|
||||
// of 3650 K / −2 (written for this test from the numbers in the issue).
|
||||
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:WhiteBalance="Custom" crs:Temperature="3578" crs:Tint="-5" crs:AsShotTemperature="3650" crs:AsShotTint="-2"/>"#;
|
||||
let p = props(x);
|
||||
let rel = to_partial(&p, Target::RawRelative);
|
||||
// 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale
|
||||
let t = rel["wb"]["temp"].as_f64().unwrap();
|
||||
assert!((t - 6275.0).abs() < 5.0, "{t}");
|
||||
assert_eq!(rel["wb"]["tint"], -3.0);
|
||||
assert_eq!(rel["wb"]["mode"], "custom");
|
||||
// a rendered file without `Incremental*` fields shifts the same way; a raw with a measured
|
||||
// illuminant (and a preset) keeps the Kelvin value
|
||||
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]);
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
|
||||
assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0);
|
||||
// the as-shot fields are a reference, not an adjustment: nothing to report
|
||||
assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty());
|
||||
// no tint next to the as-shot tint: the custom tint is taken as the shift itself
|
||||
let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, ""));
|
||||
assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() {
|
||||
// The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos:
|
||||
// 3578 K read on the relative scale would be a large blue shift (issue #510)
|
||||
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:WhiteBalance="Custom" crs:Temperature="3578" crs:Tint="-5" crs:Exposure2012="+0.3"/>"#;
|
||||
let p = props(x);
|
||||
let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5);
|
||||
assert_eq!(partial["wb"], json!({"mode": "asShot"}));
|
||||
assert_eq!(partial["light"]["exposure"], 0.3);
|
||||
assert_eq!(unmapped.len(), 1, "{unmapped:?}");
|
||||
assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}");
|
||||
// a raw with a measured illuminant and a rendered file read it as before, and report nothing
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
|
||||
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0);
|
||||
assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty());
|
||||
// an unusable reference (zero) counts as none
|
||||
let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#));
|
||||
assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_follows_the_file() {
|
||||
use lightcraft_catalog::MediaKind;
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "nef", false), Target::RawRelative);
|
||||
// Lightroom's `fileFormat` name for proprietary raws
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false), Target::RawAbsolute);
|
||||
// a raw shown from its embedded preview develops like a rendered file
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true), Target::Rendered);
|
||||
assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), Target::Rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xmp_preset_with_name_and_group() {
|
||||
// Written for this test: a minimal preset-shaped packet with localized name/group.
|
||||
@@ -584,25 +716,38 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_curves_need_the_master_curve() {
|
||||
// Lightroom ignores a red / green / blue curve without `ToneCurvePV2012`
|
||||
let packet = |master: &str| {
|
||||
fn channel_curves_need_the_master_and_all_channel_curves() {
|
||||
// Lightroom applies red / green / blue curves only when the master and all three are present
|
||||
let seq = |name: &str, pts: &str| format!("<crs:{name}><rdf:Seq>{pts}</rdf:Seq></crs:{name}>");
|
||||
let identity = "<rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li>";
|
||||
let bent = "<rdf:li>0, 0</rdf:li><rdf:li>64, 40</rdf:li><rdf:li>255, 255</rdf:li>";
|
||||
let packet = |curves: &[String]| {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:ToneCurveName2012="Custom">{master}
|
||||
<crs:ToneCurvePV2012Green><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>64, 40</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012Green>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:ToneCurveName2012="Custom">{}
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#,
|
||||
curves.concat()
|
||||
)
|
||||
};
|
||||
let alone = to_partial(&props(&packet("")), Some(true));
|
||||
let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute);
|
||||
let (master, red, green, blue) = (
|
||||
seq("ToneCurvePV2012", identity),
|
||||
seq("ToneCurvePV2012Red", identity),
|
||||
seq("ToneCurvePV2012Green", bent),
|
||||
seq("ToneCurvePV2012Blue", identity),
|
||||
);
|
||||
// a channel curve alone, or with the master but without its two siblings: ignored
|
||||
let alone = partial(std::slice::from_ref(&green));
|
||||
assert!(alone.pointer("/curve/green").is_none(), "{alone}");
|
||||
let master = "<crs:ToneCurvePV2012><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012>";
|
||||
let with = to_partial(&props(&packet(master)), Some(true));
|
||||
assert!(with.pointer("/curve/green").is_some() && with.pointer("/curve/master").is_some(), "{with}");
|
||||
let master_and_green = partial(&[master.clone(), green.clone()]);
|
||||
assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}");
|
||||
// all four: read
|
||||
let all = partial(&[master, red, green, blue]);
|
||||
assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}");
|
||||
}
|
||||
|
||||
fn unmapped(x: &str) -> Vec<String> {
|
||||
to_partial_report(&props(x), None, None, 1.5).1
|
||||
to_partial_report(&props(x), None, Target::Any, 1.5).1
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -622,8 +767,8 @@ mod tests {
|
||||
let p = props(&preset_packet("", ""));
|
||||
let mut d = DevelopSettings::default();
|
||||
d.optics.lens_profile = true;
|
||||
assert!(apply_partial(&d, &to_partial(&p, None), 1.0).optics.lens_profile);
|
||||
assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile);
|
||||
let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#));
|
||||
assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, None), 1.0).optics.lens_profile);
|
||||
assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ mod tests {
|
||||
|
||||
fn settings() -> (DevelopSettings, Vec<String>) {
|
||||
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5);
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
|
||||
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
|
||||
}
|
||||
|
||||
|
||||
+463
-17
@@ -101,14 +101,15 @@ pub fn load(s: &mut Session) {
|
||||
/// functions, so a 24 MP demo original takes minutes of CPU time on a small machine (a 4-core CI
|
||||
/// VM). Each scene at each size is therefore generated once per process and shared:
|
||||
///
|
||||
/// - concurrent requests wait for one generation (Before and After at 1:1 both load the
|
||||
/// original; sessions side by side, e.g. parallel tests, open the same photo);
|
||||
/// - one generation per scene and size, which concurrent requests wait for (Before and After at
|
||||
/// 1:1 both load the original; sessions side by side, e.g. parallel tests, open the same photo;
|
||||
/// how it renders: [`generate`]);
|
||||
/// - the most recently used results are kept, up to [`scene_cache_limit`] bytes. They are the same
|
||||
/// allocations the sessions' source caches hold, so they cost extra memory only once no session
|
||||
/// holds them any more.
|
||||
///
|
||||
/// Only demo photos use this; real files are decoded by their loader as before.
|
||||
pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Arc<lightcraft_raster::Rgb32f> {
|
||||
pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Result<Arc<lightcraft_raster::Rgb32f>, String> {
|
||||
let key = SceneKey { kind: scene.kind, seed: scene.seed, width: scene.width, height: scene.height, max_edge };
|
||||
let slot = {
|
||||
let mut c = scene_cache();
|
||||
@@ -120,18 +121,152 @@ pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) ->
|
||||
e.slot.clone()
|
||||
}
|
||||
None => {
|
||||
let slot = Arc::new(std::sync::OnceLock::new());
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
c.entries.push(SceneEntry { key, slot: slot.clone(), used: tick });
|
||||
slot
|
||||
}
|
||||
}
|
||||
};
|
||||
// (no lock held while generating: other scenes and sizes are served meanwhile)
|
||||
let pixels = slot.get_or_init(|| Arc::new(scene.render_fit(max_edge))).clone();
|
||||
let pixels = slot.get_or_generate(|how| match how {
|
||||
Render::Parallel => scene.render_fit(max_edge),
|
||||
Render::Serial => scene.render_fit_serial(max_edge),
|
||||
});
|
||||
trim_scene_cache(&mut scene_cache(), scene_cache_limit());
|
||||
pixels
|
||||
}
|
||||
|
||||
/// How a generation renders (see [`generate`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Render {
|
||||
/// On rayon threads that do nothing else.
|
||||
Parallel,
|
||||
/// On the calling thread alone, without rayon.
|
||||
Serial,
|
||||
}
|
||||
|
||||
const FAILED: &str = "the demo scene could not be generated";
|
||||
|
||||
/// Render a scene for [`SceneSlot::get_or_generate`], so that it never needs a thread that waits
|
||||
/// for a generation. A rayon worker renders on its own thread alone: rendering in parallel, it
|
||||
/// would run queued jobs while it waits for rows other workers took, and one of them could ask for
|
||||
/// this very scene and wait for the generation further down its own stack (the faces scan's pool
|
||||
/// threads hung that way). That also keeps the work within the caller's pool and its limits (the
|
||||
/// scan's pace, `LIGHTCRAFT_FACE_THREADS`). Any other caller (the render workers, which are plain
|
||||
/// threads) renders in parallel on [`render_pool`], whose threads only ever render, and blocks
|
||||
/// without running other work meanwhile; where that pool can't be started it renders alone too.
|
||||
fn generate(render: impl FnOnce(Render) -> lightcraft_raster::Rgb32f + Send) -> lightcraft_raster::Rgb32f {
|
||||
if rayon::current_thread_index().is_some() {
|
||||
return render(Render::Serial);
|
||||
}
|
||||
match render_pool() {
|
||||
Some(pool) => pool.install(|| render(Render::Parallel)),
|
||||
None => render(Render::Serial),
|
||||
}
|
||||
}
|
||||
|
||||
/// The threads non-rayon callers' generations render on: rayon's default count (`RAYON_NUM_THREADS`,
|
||||
/// else the available parallelism), like the global pool they used before, without touching that
|
||||
/// pool (asking it for its size starts it, which panics where its threads can't start); `None`
|
||||
/// where this pool's threads can't start.
|
||||
fn render_pool() -> Option<&'static rayon::ThreadPool> {
|
||||
static POOL: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();
|
||||
POOL.get_or_init(|| rayon::ThreadPoolBuilder::new().thread_name(|i| format!("lc-demo-scene-{i}")).build().ok()).as_ref()
|
||||
}
|
||||
|
||||
/// One scene at one size: generated once, then shared.
|
||||
#[derive(Default)]
|
||||
struct SceneSlot {
|
||||
state: std::sync::Mutex<SlotState>,
|
||||
/// Signalled when a generation ends.
|
||||
ended: std::sync::Condvar,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SlotState {
|
||||
generation: Generation,
|
||||
/// Generations started so far; the one running or last ended is number `started`.
|
||||
started: u64,
|
||||
/// Callers waiting for the generation running (for the tests).
|
||||
waiting: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum Generation {
|
||||
#[default]
|
||||
Empty,
|
||||
Running,
|
||||
Ready(Arc<lightcraft_raster::Rgb32f>),
|
||||
/// The last generation failed (its render panicked); the next request tries again.
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl SlotState {
|
||||
/// Start a generation; its number.
|
||||
fn start(&mut self) -> u64 {
|
||||
self.started += 1;
|
||||
self.generation = Generation::Running;
|
||||
self.started
|
||||
}
|
||||
|
||||
/// What came of generation `n`, once it has ended: its pixels, or an error when it failed
|
||||
/// (even if a retry has started or finished since: callers get the outcome they waited for).
|
||||
fn outcome(&self, n: u64) -> Option<Result<Arc<lightcraft_raster::Rgb32f>, String>> {
|
||||
match &self.generation {
|
||||
Generation::Running if self.started == n => None,
|
||||
// pixels are kept for good once made, so only generation `n` itself can have made them
|
||||
Generation::Ready(p) if self.started == n => Some(Ok(p.clone())),
|
||||
_ => Some(Err(FAILED.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SceneSlot {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, SlotState> {
|
||||
self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn ready(&self) -> Option<Arc<lightcraft_raster::Rgb32f>> {
|
||||
match &self.lock().generation {
|
||||
Generation::Ready(p) => Some(p.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels `render` makes ([`generate`]), generated once: the first caller renders, with no
|
||||
/// lock held, and later ones wait for its outcome. A render that panics is an error for the
|
||||
/// callers of its generation, and the next request tries again.
|
||||
fn get_or_generate(&self, render: impl FnOnce(Render) -> lightcraft_raster::Rgb32f + Send) -> Result<Arc<lightcraft_raster::Rgb32f>, String> {
|
||||
let mut g = self.lock();
|
||||
let n = match &g.generation {
|
||||
Generation::Ready(p) => return Ok(p.clone()),
|
||||
Generation::Running => {
|
||||
let n = g.started;
|
||||
g.waiting += 1;
|
||||
let outcome = loop {
|
||||
if let Some(o) = g.outcome(n) {
|
||||
break o;
|
||||
}
|
||||
g = self.ended.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
};
|
||||
g.waiting -= 1;
|
||||
return outcome;
|
||||
}
|
||||
Generation::Empty | Generation::Failed => g.start(),
|
||||
};
|
||||
drop(g);
|
||||
let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| generate(render)));
|
||||
let mut g = self.lock();
|
||||
g.generation = match made {
|
||||
Ok(pixels) => Generation::Ready(Arc::new(pixels)),
|
||||
Err(_) => Generation::Failed,
|
||||
};
|
||||
let outcome = g.outcome(n).unwrap_or_else(|| Err(FAILED.into()));
|
||||
drop(g);
|
||||
self.ended.notify_all();
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of generated scenes kept for reuse: half the memory budget (768 MB by default; enough
|
||||
/// for one 24 MP original and the previews around it).
|
||||
fn scene_cache_limit() -> usize {
|
||||
@@ -147,11 +282,9 @@ struct SceneKey {
|
||||
max_edge: usize,
|
||||
}
|
||||
|
||||
type SceneSlot = Arc<std::sync::OnceLock<Arc<lightcraft_raster::Rgb32f>>>;
|
||||
|
||||
struct SceneEntry {
|
||||
key: SceneKey,
|
||||
slot: SceneSlot,
|
||||
slot: Arc<SceneSlot>,
|
||||
used: u64,
|
||||
}
|
||||
|
||||
@@ -167,7 +300,7 @@ fn scene_cache() -> std::sync::MutexGuard<'static, SceneCache> {
|
||||
}
|
||||
|
||||
fn scene_bytes(slot: &SceneSlot) -> usize {
|
||||
slot.get().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>()))
|
||||
slot.ready().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>()))
|
||||
}
|
||||
|
||||
/// Drop the least recently used generated scenes until the rest fit in `limit` bytes. Scenes still
|
||||
@@ -178,7 +311,7 @@ fn trim_scene_cache(c: &mut SceneCache, limit: usize) {
|
||||
if total <= limit {
|
||||
return;
|
||||
}
|
||||
let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.get().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else {
|
||||
let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.ready().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else {
|
||||
return;
|
||||
};
|
||||
c.entries.swap_remove(i);
|
||||
@@ -196,12 +329,12 @@ mod tests {
|
||||
#[test]
|
||||
fn a_scene_is_generated_once_per_size_and_shared() {
|
||||
let s = small_scene();
|
||||
let a = scene_pixels(&s, 48);
|
||||
let b = scene_pixels(&s, 48);
|
||||
let a = scene_pixels(&s, 48).unwrap();
|
||||
let b = scene_pixels(&s, 48).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation");
|
||||
assert_eq!(a.width.max(a.height), 48);
|
||||
assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly");
|
||||
let c = scene_pixels(&s, 40);
|
||||
let c = scene_pixels(&s, 40).unwrap();
|
||||
assert_eq!(c.width.max(c.height), 40, "another size is its own generation");
|
||||
}
|
||||
|
||||
@@ -210,19 +343,332 @@ mod tests {
|
||||
let s = small_scene();
|
||||
let edge = 37; // a size no other test asks for
|
||||
let got: Vec<_> = std::thread::scope(|sc| {
|
||||
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge))).collect();
|
||||
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge).unwrap())).collect();
|
||||
hs.into_iter().map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels");
|
||||
}
|
||||
|
||||
/// Run `f` on a thread of its own and wait for it at most `secs`: a deadlock fails the test instead of hanging it.
|
||||
fn within<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(secs)).expect("deadlocked")
|
||||
}
|
||||
|
||||
/// Wait (at most ten seconds) until `n` callers wait for `slot`'s generation.
|
||||
fn until_waiting(slot: &SceneSlot, n: usize) {
|
||||
let started = std::time::Instant::now();
|
||||
while slot.lock().waiting < n {
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "the callers never got to wait");
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn pixels(n: usize) -> lightcraft_raster::Rgb32f {
|
||||
lightcraft_raster::Rgb32f::new(n, 1)
|
||||
}
|
||||
|
||||
/// Rayon workers asking for a scene while it is generated never deadlock. The render is parallel, and a rayon worker
|
||||
/// waiting for rows another worker took runs queued jobs on top of itself: generating on the caller's thread, the
|
||||
/// faces scan's pool threads ended up waiting on the generation further down their own stack and hung. Requests
|
||||
/// keep arriving on a four-thread pool while the scene is generated, as when several photos are queued; every one
|
||||
/// of them must come back, at least four of them must have overlapped the generation, and the test waits with a
|
||||
/// timeout, so a deadlock fails it instead of hanging.
|
||||
#[test]
|
||||
fn rayon_workers_asking_for_a_scene_being_generated_never_deadlock() {
|
||||
let scene = Arc::new(small_scene());
|
||||
let edge = 601; // a size no other test asks for
|
||||
const ASKED: usize = 24;
|
||||
let (overlap, sizes) = within(120, move || {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
|
||||
let issued = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||
for _ in 0..ASKED {
|
||||
let (scene, done, issued) = (scene.clone(), done_tx.clone(), issued.clone());
|
||||
issued.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
pool.spawn(move || {
|
||||
let got = scene_pixels(&scene, edge).unwrap();
|
||||
// how many requests had been made when this one came back
|
||||
let _ = done.send((issued.load(std::sync::atomic::Ordering::SeqCst), got.data.len()));
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
let replies: Vec<(usize, usize)> = (0..ASKED).map(|_| done_rx.recv().unwrap()).collect();
|
||||
(replies.iter().map(|r| r.0).min().unwrap(), replies.iter().map(|r| r.1).collect::<Vec<_>>())
|
||||
});
|
||||
assert_eq!(sizes.len(), ASKED, "every request came back");
|
||||
assert!(sizes.iter().all(|n| *n == sizes[0] && *n > 0));
|
||||
assert!(overlap >= 4, "requests overlapped the generation (only {overlap} had been made when the first came back)");
|
||||
}
|
||||
|
||||
/// However many requests (from rayon workers and other threads) arrive while a scene is generated, it is rendered
|
||||
/// once, and never twice at the same time: duplicates would each hold a whole image (288 MB for a 24 MP original).
|
||||
#[test]
|
||||
fn a_scene_is_rendered_once_however_many_ask() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let (renders, peak) = within(60, || {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
|
||||
let render = {
|
||||
let (running, peak, renders) = (running.clone(), peak.clone(), renders.clone());
|
||||
move |how: Render| {
|
||||
let now = running.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
peak.fetch_max(now, Ordering::SeqCst);
|
||||
renders.fetch_add(1, Ordering::SeqCst);
|
||||
// work like the real render's: in parallel, the rendering thread waits for rows other threads took
|
||||
use rayon::prelude::*;
|
||||
let sum: u64 = match how {
|
||||
Render::Parallel => (0..2_000_000u64).into_par_iter().map(|i| i % 7).sum(),
|
||||
Render::Serial => (0..2_000_000u64).map(|i| i % 7).sum(),
|
||||
};
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
running.fetch_sub(1, Ordering::SeqCst);
|
||||
pixels(sum as usize % 5 + 1)
|
||||
}
|
||||
};
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
for _ in 0..16 {
|
||||
let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone());
|
||||
pool.spawn(move || {
|
||||
let _ = tx.send(slot.get_or_generate(render).unwrap());
|
||||
});
|
||||
}
|
||||
for _ in 0..4 {
|
||||
let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone());
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(slot.get_or_generate(render).unwrap());
|
||||
});
|
||||
}
|
||||
let got: Vec<_> = (0..20).map(|_| rx.recv().unwrap()).collect();
|
||||
assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels");
|
||||
(renders.load(Ordering::SeqCst), peak.load(Ordering::SeqCst))
|
||||
});
|
||||
assert_eq!((renders, peak), (1, 1));
|
||||
}
|
||||
|
||||
/// A rayon worker (the faces scan's) renders a scene on its own thread alone, so the work stays within its pool
|
||||
/// and that pool's limits; other threads render in parallel on the scene pool.
|
||||
#[test]
|
||||
fn a_rayon_worker_renders_alone_on_its_own_thread() {
|
||||
let ((how, caller, renderer), (other_how, other_name)) = within(30, || {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap();
|
||||
let slot = SceneSlot::default();
|
||||
let mut seen = None;
|
||||
pool.install(|| {
|
||||
let caller = std::thread::current().id();
|
||||
slot.get_or_generate(|how| {
|
||||
seen = Some((how, caller, std::thread::current().id()));
|
||||
pixels(1)
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
let other = SceneSlot::default();
|
||||
let mut other_seen = None;
|
||||
other
|
||||
.get_or_generate(|how| {
|
||||
other_seen = Some((how, std::thread::current().name().unwrap_or_default().to_string()));
|
||||
pixels(1)
|
||||
})
|
||||
.unwrap();
|
||||
(seen.unwrap(), other_seen.unwrap())
|
||||
});
|
||||
assert_eq!((how, renderer), (Render::Serial, caller));
|
||||
assert_eq!(other_how, Render::Parallel);
|
||||
assert!(other_name.starts_with("lc-demo-scene-"), "{other_name}");
|
||||
}
|
||||
|
||||
/// A caller finding a generation running waits for it instead of rendering its own (one 24 MP original costs
|
||||
/// minutes of CPU time on a small machine).
|
||||
#[test]
|
||||
fn callers_wait_for_the_generation_running() {
|
||||
let (a, b, renders) = within(30, || {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let (started_tx, started) = std::sync::mpsc::channel::<()>();
|
||||
let (go, go_rx) = std::sync::mpsc::channel::<()>();
|
||||
let (started_tx, go_rx) = (Arc::new(std::sync::Mutex::new(started_tx)), Arc::new(std::sync::Mutex::new(go_rx)));
|
||||
let ask = |slot: &Arc<SceneSlot>| {
|
||||
let (slot, renders, started_tx, go_rx) = (slot.clone(), renders.clone(), started_tx.clone(), go_rx.clone());
|
||||
std::thread::spawn(move || {
|
||||
slot.get_or_generate(move |_| {
|
||||
renders.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let _ = started_tx.lock().unwrap().send(());
|
||||
let _ = go_rx.lock().unwrap().recv();
|
||||
pixels(1)
|
||||
})
|
||||
})
|
||||
};
|
||||
let first = ask(&slot);
|
||||
started.recv().unwrap();
|
||||
let second = ask(&slot);
|
||||
until_waiting(&slot, 1);
|
||||
go.send(()).unwrap();
|
||||
let (a, b) = (first.join().unwrap().unwrap(), second.join().unwrap().unwrap());
|
||||
(a, b, renders.load(std::sync::atomic::Ordering::SeqCst))
|
||||
});
|
||||
assert!(Arc::ptr_eq(&a, &b) && a.width == 1, "one generation, shared");
|
||||
assert_eq!(renders, 1);
|
||||
}
|
||||
|
||||
/// A generation that fails (its render panicked) is an error for its callers, never a hang, and the next request
|
||||
/// generates the scene again.
|
||||
#[test]
|
||||
fn a_failed_generation_is_an_error_and_the_next_request_tries_again() {
|
||||
let (first, waiters, again) = within(30, || {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let (started_tx, started) = std::sync::mpsc::channel::<()>();
|
||||
let (go, go_rx) = std::sync::mpsc::channel::<()>();
|
||||
let failing = {
|
||||
let slot = slot.clone();
|
||||
std::thread::spawn(move || {
|
||||
slot.get_or_generate(move |_| {
|
||||
let _ = started_tx.send(());
|
||||
let _ = go_rx.recv();
|
||||
panic!("render failed");
|
||||
})
|
||||
})
|
||||
};
|
||||
started.recv().unwrap();
|
||||
let waiters: Vec<_> = (0..2)
|
||||
.map(|_| {
|
||||
let slot = slot.clone();
|
||||
std::thread::spawn(move || slot.get_or_generate(|_| pixels(2)))
|
||||
})
|
||||
.collect();
|
||||
until_waiting(&slot, 2);
|
||||
drop(go);
|
||||
let first = failing.join().unwrap();
|
||||
let waiters: Vec<_> = waiters.into_iter().map(|w| w.join().unwrap()).collect();
|
||||
(first, waiters, slot.get_or_generate(|_| pixels(3)))
|
||||
});
|
||||
assert!(first.is_err() && waiters.iter().all(Result::is_err), "the callers of the failed generation get its error");
|
||||
assert_eq!(again.unwrap().width, 3);
|
||||
}
|
||||
|
||||
/// Callers get the outcome of the generation they waited for, even when a retry starts (or finishes) before they
|
||||
/// look: a failed generation followed by a retry is still an error for them.
|
||||
#[test]
|
||||
fn a_retry_never_changes_the_outcome_waited_for() {
|
||||
let mut st = SlotState::default();
|
||||
let n = st.start();
|
||||
assert!(st.outcome(n).is_none(), "running");
|
||||
st.generation = Generation::Failed;
|
||||
let m = st.start();
|
||||
assert!(matches!(st.outcome(n), Some(Err(_))));
|
||||
assert!(st.outcome(m).is_none());
|
||||
st.generation = Generation::Ready(Arc::new(pixels(1)));
|
||||
assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded");
|
||||
assert!(matches!(st.outcome(m), Some(Ok(_))));
|
||||
// and with threads: a retry racing the waiters of a failed generation never hands them its result
|
||||
let wrong = within(60, || {
|
||||
let mut wrong = 0;
|
||||
for _ in 0..20 {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let (started_tx, started) = std::sync::mpsc::channel::<()>();
|
||||
let (go, go_rx) = std::sync::mpsc::channel::<()>();
|
||||
let failing = {
|
||||
let slot = slot.clone();
|
||||
std::thread::spawn(move || {
|
||||
slot.get_or_generate(move |_| {
|
||||
let _ = started_tx.send(());
|
||||
let _ = go_rx.recv();
|
||||
panic!("render failed");
|
||||
})
|
||||
})
|
||||
};
|
||||
started.recv().unwrap();
|
||||
let waiters: Vec<_> = (0..3)
|
||||
.map(|_| {
|
||||
let slot = slot.clone();
|
||||
std::thread::spawn(move || slot.get_or_generate(|_| pixels(2)))
|
||||
})
|
||||
.collect();
|
||||
until_waiting(&slot, 3);
|
||||
let retry = {
|
||||
let slot = slot.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
if matches!(slot.lock().generation, Generation::Failed) {
|
||||
return slot.get_or_generate(|_| pixels(4));
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
})
|
||||
};
|
||||
drop(go);
|
||||
let _ = failing.join().unwrap();
|
||||
wrong += waiters.into_iter().map(|w| w.join().unwrap()).filter(Result::is_ok).count();
|
||||
assert!(retry.join().unwrap().is_ok());
|
||||
}
|
||||
wrong
|
||||
});
|
||||
assert_eq!(wrong, 0, "waiters of a failed generation got a retry's pixels");
|
||||
}
|
||||
|
||||
/// Generating a scene never touches the global rayon pool, so a process whose global pool couldn't start (asking it
|
||||
/// anything then panics) still gets its demo scenes. Run in a process of its own: the global pool is the process's.
|
||||
#[test]
|
||||
fn scenes_are_generated_when_the_global_pool_is_broken() {
|
||||
use std::io::Read;
|
||||
use std::process::Stdio;
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "demo::tests::with_a_broken_global_pool", "--ignored", "--test-threads=1"])
|
||||
.env("LC_DEMO_BROKEN_GLOBAL_POOL", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
// Drain both pipes on threads so a chatty child can't block on a full pipe.
|
||||
let drain = |mut r: Box<dyn Read + Send>| {
|
||||
std::thread::spawn(move || {
|
||||
let mut s = String::new();
|
||||
let _ = r.read_to_string(&mut s);
|
||||
s
|
||||
})
|
||||
};
|
||||
let (stdout, stderr) = (drain(Box::new(child.stdout.take().unwrap())), drain(Box::new(child.stderr.take().unwrap())));
|
||||
// A deadlocked child fails the test instead of hanging CI.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
panic!("the child process did not finish within 120 s");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
};
|
||||
let (stdout, stderr) = (stdout.join().unwrap(), stderr.join().unwrap());
|
||||
assert!(status.success() && stdout.contains("1 passed"), "{stdout}\n{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run in a process of its own by scenes_are_generated_when_the_global_pool_is_broken"]
|
||||
fn with_a_broken_global_pool() {
|
||||
if std::env::var_os("LC_DEMO_BROKEN_GLOBAL_POOL").is_none() {
|
||||
return;
|
||||
}
|
||||
let broken = rayon::ThreadPoolBuilder::new().spawn_handler(|_| Err(std::io::Error::other("no threads"))).build_global();
|
||||
assert!(broken.is_err());
|
||||
assert!(std::panic::catch_unwind(rayon::current_num_threads).is_err(), "the global pool is broken");
|
||||
let s = small_scene();
|
||||
let got = scene_pixels(&s, 33).unwrap();
|
||||
assert_eq!(got.data, s.render_fit_serial(33).data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cache_keeps_the_most_recent_scenes_within_its_limit() {
|
||||
let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge };
|
||||
let entry = |max_edge, used, px: Option<usize>| {
|
||||
let slot: SceneSlot = Arc::new(std::sync::OnceLock::new());
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
if let Some(n) = px {
|
||||
let _ = slot.set(Arc::new(lightcraft_raster::Rgb32f::new(n, 1)));
|
||||
slot.lock().generation = Generation::Ready(Arc::new(lightcraft_raster::Rgb32f::new(n, 1)));
|
||||
}
|
||||
SceneEntry { key: key(max_edge), slot, used }
|
||||
};
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! The monitor profile (`app.displayProfile`): previews shown on a wide-gamut or calibrated
|
||||
//! display through its ICC profile.
|
||||
//!
|
||||
//! The frontend holds the profile in use (Settings ▸ Display, the UI command `app.displayProfile`).
|
||||
//! With a profile, the loupe renders into the display's own primaries
|
||||
//! ([`RenderJob::with_display`](crate::media::RenderJob::with_display)) and everything else made
|
||||
//! for sRGB (thumbnails, cached previews, embedded camera JPEGs) is converted to it
|
||||
//! ([`present`]). Histograms and the preview caches stay sRGB, and exports never use it.
|
||||
|
||||
pub use lightcraft_codecs::display::DisplayKind;
|
||||
use lightcraft_codecs::display::{DisplayProfile, MAX_PROFILE_BYTES};
|
||||
use lightcraft_pipeline::DisplaySpace;
|
||||
use lightcraft_raster::{Histogram, Rgba8};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::media::RenderResult;
|
||||
|
||||
/// A loaded display profile: its transforms and the pipeline target.
|
||||
#[derive(Debug)]
|
||||
pub struct Display {
|
||||
/// The file it came from.
|
||||
pub path: String,
|
||||
pub profile: DisplayProfile,
|
||||
pub space: DisplaySpace,
|
||||
}
|
||||
|
||||
impl Display {
|
||||
/// Load the ICC profile at `path`.
|
||||
pub fn load(path: &str) -> Result<Display, String> {
|
||||
let path = path.trim();
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if meta.len() > MAX_PROFILE_BYTES as u64 {
|
||||
return Err(format!("{path}: too large for an ICC profile"));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
Display::from_bytes(path, &bytes)
|
||||
}
|
||||
|
||||
pub fn from_bytes(path: &str, bytes: &[u8]) -> Result<Display, String> {
|
||||
let profile = DisplayProfile::from_icc(bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let space = DisplaySpace::new(profile.to_rec2020, profile.id).ok_or_else(|| format!("{path}: the profile's primaries are degenerate"))?;
|
||||
Ok(Display { path: path.to_string(), profile, space })
|
||||
}
|
||||
|
||||
pub fn id(&self) -> u64 {
|
||||
self.profile.id
|
||||
}
|
||||
|
||||
/// Load the profile at `path` (`""`: none, the display is treated as sRGB).
|
||||
pub fn load_opt(path: &str) -> Result<Option<Arc<Display>>, String> {
|
||||
if path.trim().is_empty() { Ok(None) } else { Display::load(path).map(|d| Some(Arc::new(d))) }
|
||||
}
|
||||
|
||||
/// For `app.displayProfile` and Settings: path, description, kind, primaries (xy).
|
||||
pub fn describe(&self) -> Value {
|
||||
let to_xyz = lightcraft_color::REC2020.to_xyz().mul(&self.profile.to_rec2020).0;
|
||||
let xy = |c: usize| {
|
||||
let (x, y, z) = (to_xyz[0][c], to_xyz[1][c], to_xyz[2][c]);
|
||||
let s = x + y + z;
|
||||
if s.abs() < 1e-12 { json!([0.0, 0.0]) } else { json!([round4(x / s), round4(y / s)]) }
|
||||
};
|
||||
json!({
|
||||
"path": self.path,
|
||||
"description": self.profile.description,
|
||||
"kind": self.profile.kind.label(),
|
||||
"primaries": {"red": xy(0), "green": xy(1), "blue": xy(2)},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn round4(v: f64) -> f64 {
|
||||
(v * 1e4).round() / 1e4
|
||||
}
|
||||
|
||||
/// Make `r` ready for `display`: an sRGB image (thumbnail, cached preview, camera JPEG) is
|
||||
/// converted to the display's values. A render made for the display (see
|
||||
/// [`RenderJob::with_display`](crate::media::RenderJob::with_display)) is already.
|
||||
pub fn present(r: &mut RenderResult, display: &Display) {
|
||||
if r.display.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(rendered) = &mut r.rendered {
|
||||
match display.profile.from_srgb(&mut rendered.image) {
|
||||
Ok(()) => r.display = Some(display.id()),
|
||||
Err(e) => log::warn!("display profile: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A render into `display`'s primaries (sRGB curve) → in place, the display's device values; and
|
||||
/// its sRGB histogram, plus (`full_srgb`: for the cached view preview) its whole sRGB version.
|
||||
/// The histogram samples the same pixels [`Histogram::of_srgb8`] would, so only those are
|
||||
/// converted when the sRGB image itself isn't needed (slider drags).
|
||||
pub(crate) fn finish_render(image: &mut Rgba8, display: &Display, full_srgb: bool) -> (Option<Rgba8>, Option<Histogram>) {
|
||||
let warn = |e: String| log::warn!("display profile: {e}");
|
||||
let (srgb, histogram) = if full_srgb {
|
||||
let srgb = display.profile.to_srgb(image).map_err(warn).ok();
|
||||
let h = srgb.as_ref().map(Histogram::of_srgb8);
|
||||
(srgb, h)
|
||||
} else {
|
||||
let sample = histogram_sample(image);
|
||||
(None, display.profile.to_srgb(&sample).map_err(warn).ok().map(|s| Histogram::of_srgb8(&s)))
|
||||
};
|
||||
if let Err(e) = display.profile.correct(image) {
|
||||
warn(e);
|
||||
}
|
||||
(srgb, histogram)
|
||||
}
|
||||
|
||||
/// The pixels [`Histogram::of_srgb8`] reads (at most ~1 MP, a regular grid), as an image of
|
||||
/// their own (which it then reads whole).
|
||||
fn histogram_sample(img: &Rgba8) -> Rgba8 {
|
||||
let step = ((img.len() as f64 / 1_000_000.0).sqrt().ceil() as usize).max(1);
|
||||
if step == 1 {
|
||||
return img.clone();
|
||||
}
|
||||
let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step));
|
||||
let mut data = Vec::with_capacity(w * h);
|
||||
for y in (0..img.height).step_by(step) {
|
||||
for x in (0..img.width).step_by(step) {
|
||||
data.push(img.get(x, y));
|
||||
}
|
||||
}
|
||||
Rgba8 { width: w, height: h, data }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p3() -> Display {
|
||||
Display::from_bytes("p3.icc", &lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap()
|
||||
}
|
||||
|
||||
fn picture(w: usize, h: usize) -> Rgba8 {
|
||||
Rgba8 { width: w, height: h, data: (0..w * h).map(|i| [(i * 7 % 256) as u8, (i * 13 % 256) as u8, (i * 29 % 256) as u8, 255]).collect() }
|
||||
}
|
||||
|
||||
/// The sampled histogram of a drag render is exactly the histogram of the whole sRGB image.
|
||||
#[test]
|
||||
fn sampled_histogram_matches_the_full_one() {
|
||||
let d = p3();
|
||||
for (w, h) in [(40, 30), (1500, 1100), (2561, 1709)] {
|
||||
let img = picture(w, h);
|
||||
let (mut a, mut b) = (img.clone(), img.clone());
|
||||
let (srgb, full) = finish_render(&mut a, &d, true);
|
||||
let (none, sampled) = finish_render(&mut b, &d, false);
|
||||
assert!(srgb.is_some() && none.is_none());
|
||||
assert_eq!(full.unwrap(), sampled.unwrap(), "{w}x{h}");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reports_and_rejects() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let good = dir.join("p3.icc");
|
||||
std::fs::write(&good, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap();
|
||||
let bad = dir.join("bad.icc");
|
||||
std::fs::write(&bad, b"not a profile").unwrap();
|
||||
|
||||
let d = Display::load_opt(&good.to_string_lossy()).unwrap().unwrap();
|
||||
let r = d.describe();
|
||||
assert_eq!(r["kind"], "matrix");
|
||||
assert_eq!(r["description"], "Display P3");
|
||||
let red = r["primaries"]["red"].as_array().unwrap();
|
||||
assert!((red[0].as_f64().unwrap() - 0.68).abs() < 2e-3 && (red[1].as_f64().unwrap() - 0.32).abs() < 2e-3, "{r}");
|
||||
assert!(Display::load_opt(" ").unwrap().is_none());
|
||||
let e = Display::load(&bad.to_string_lossy()).unwrap_err();
|
||||
assert!(e.contains("not a valid ICC profile"), "{e}");
|
||||
assert!(Display::load(&dir.join("missing.icc").to_string_lossy()).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+486
-72
@@ -600,6 +600,11 @@ pub struct ExportOptions {
|
||||
/// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the
|
||||
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
|
||||
pub bit_depth: Option<u8>,
|
||||
/// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR
|
||||
/// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb`
|
||||
/// not applied), AVIF as 10-bit Rec. 2020 PQ, 32-bit float TIFF with the highlights above SDR
|
||||
/// white kept. Other formats, and photos without an HDR edit, are SDR.
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
@@ -623,6 +628,7 @@ impl Default for ExportOptions {
|
||||
watermark: None,
|
||||
color_space: OutputSpace::Srgb,
|
||||
bit_depth: None,
|
||||
hdr: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -658,6 +664,7 @@ pub const OPTION_PARAMS: &[&str] = &[
|
||||
"watermark",
|
||||
"colorSpace",
|
||||
"bitDepth",
|
||||
"hdr",
|
||||
];
|
||||
|
||||
/// The keys of a `watermark` object ([`Watermark`], camelCase).
|
||||
@@ -789,7 +796,7 @@ impl ExportOptions {
|
||||
}
|
||||
serde_json::from_value::<Resize>(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?;
|
||||
}
|
||||
"dontEnlarge" | "removeLocation" | "background" => {
|
||||
"dontEnlarge" | "removeLocation" | "background" | "hdr" => {
|
||||
boolean(k, v)?;
|
||||
}
|
||||
"naming" | "subfolder" | "path" | "dir" | "preset" => {
|
||||
@@ -922,6 +929,7 @@ impl ExportOptions {
|
||||
.filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()),
|
||||
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
|
||||
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
|
||||
hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,8 +998,10 @@ impl ExportOptions {
|
||||
(ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16,
|
||||
(ExportFormat::Png, _) => OutputDepth::U8,
|
||||
(ExportFormat::Tiff, Some(8)) => OutputDepth::U8,
|
||||
(ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear,
|
||||
(ExportFormat::Tiff, _) => OutputDepth::U16,
|
||||
(ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16,
|
||||
(ExportFormat::Avif, _) => OutputDepth::U8,
|
||||
}
|
||||
@@ -1007,9 +1017,19 @@ impl ExportOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour space the file is actually written in (AVIF: sRGB).
|
||||
/// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF
|
||||
/// (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`].
|
||||
pub fn hdr_output(&self) -> bool {
|
||||
self.hdr && matches!((self.format, self.bit_depth), (ExportFormat::Jpeg | ExportFormat::Avif, _) | (ExportFormat::Tiff, Some(32)))
|
||||
}
|
||||
|
||||
/// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR).
|
||||
pub fn effective_space(&self) -> OutputSpace {
|
||||
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
|
||||
match self.format {
|
||||
ExportFormat::Avif if self.hdr => OutputSpace::Rec2020,
|
||||
ExportFormat::Avif => OutputSpace::Srgb,
|
||||
_ => self.color_space,
|
||||
}
|
||||
}
|
||||
|
||||
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
|
||||
@@ -1119,13 +1139,8 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
|
||||
|
||||
/// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP.
|
||||
pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
let mut img = img.clone();
|
||||
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
|
||||
let img = finish_8bit(img, o);
|
||||
let space = o.effective_space();
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
|
||||
draw_watermark(&mut img, &wm);
|
||||
}
|
||||
let profile = icc::write_named(named_space(space));
|
||||
let exif = meta.map(lightcraft_meta::write_exif);
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
@@ -1168,6 +1183,56 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`.
|
||||
fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 {
|
||||
let mut img = img.clone();
|
||||
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
|
||||
let space = o.effective_space();
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
|
||||
draw_watermark(&mut img, &wm);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// Output sharpening and the watermark on a high-bit-depth render.
|
||||
fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage {
|
||||
let mut img = img.clone();
|
||||
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
|
||||
draw_watermark_deep(&mut img, &wm);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space)
|
||||
/// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured
|
||||
/// against the base as written, so its 8-bit rounding is handed back by the map.
|
||||
pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
use lightcraft_codecs::gainmap;
|
||||
let base = finish_8bit(sdr, o);
|
||||
let hdr = finish_deep(hdr, o);
|
||||
let DeepSamples::F32(hv) = &hdr.samples else {
|
||||
return Err("HDR export needs a float HDR render".into());
|
||||
};
|
||||
let (w, h) = (base.width, base.height);
|
||||
if (hdr.width, hdr.height) != (w, h) || hv.len() < w * h * 3 {
|
||||
return Err("HDR and SDR renders differ in size".into());
|
||||
}
|
||||
let space = o.effective_space();
|
||||
let trc = space.trc();
|
||||
let sdr_lin: Vec<[f32; 3]> = base.data.iter().map(|p| [0, 1, 2].map(|c| trc.decode(p[c] as f32 / 255.0))).collect();
|
||||
let hdr_lin: &[[f32; 3]] = hv.as_chunks::<3>().0;
|
||||
let (map, gm) = gainmap::compute(&sdr_lin, hdr_lin, w, h, space.luma(), &gainmap::GainMapOptions::default()).map_err(|e| e.to_string())?;
|
||||
let profile = icc::write_named(named_space(space));
|
||||
let exif = meta.map(lightcraft_meta::write_exif);
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
|
||||
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
|
||||
gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The codecs' name of an output space (for its ICC profile).
|
||||
pub fn named_space(s: OutputSpace) -> NamedSpace {
|
||||
match s {
|
||||
@@ -1204,12 +1269,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met
|
||||
|
||||
/// Encode a high-bit-depth image (see [`encode_rendered`]).
|
||||
pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
let mut img = img.clone();
|
||||
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
|
||||
draw_watermark_deep(&mut img, &wm);
|
||||
}
|
||||
let img = finish_deep(img, o);
|
||||
let profile = match img.samples {
|
||||
DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear),
|
||||
DeepSamples::U16(_) => icc::write_named(named_space(img.space)),
|
||||
@@ -1222,27 +1282,21 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
|
||||
DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)),
|
||||
DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)),
|
||||
};
|
||||
let r = match o.format {
|
||||
ExportFormat::Png => encode::encode_png(&e, &meta),
|
||||
ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta),
|
||||
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
f => return Err(format!("{f:?} export is 8-bit only")),
|
||||
let r = match (o.format, &img.samples) {
|
||||
(ExportFormat::Png, _) => encode::encode_png(&e, &meta),
|
||||
(ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta),
|
||||
(ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => {
|
||||
lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta)
|
||||
}
|
||||
(ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
(f, _) => return Err(format!("{f:?} export is 8-bit only")),
|
||||
};
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds.
|
||||
fn parse_shutter(s: &str) -> Option<f64> {
|
||||
let s = s.trim().trim_end_matches(['s', '"']).trim();
|
||||
match s.split_once('/') {
|
||||
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().ok()?),
|
||||
None => s.parse().ok(),
|
||||
}
|
||||
.filter(|v: &f64| v.is_finite() && *v > 0.0)
|
||||
}
|
||||
|
||||
/// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing.
|
||||
pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option<Metadata> {
|
||||
/// Keywords follow the catalog's keyword list (`Catalog::export_keywords`).
|
||||
pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option<Metadata> {
|
||||
let m = &photo.meta;
|
||||
let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
|
||||
// copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL
|
||||
@@ -1270,7 +1324,9 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) ->
|
||||
out.state = text(&m.state);
|
||||
out.country = text(&m.country);
|
||||
}
|
||||
out.keywords = m.keywords.clone();
|
||||
let keywords = catalog.export_keywords(&m.keywords);
|
||||
out.keywords = keywords.flat;
|
||||
out.hierarchical_keywords = keywords.hierarchical;
|
||||
out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso);
|
||||
out.rating = (photo.rating > 0).then_some(photo.rating as i8);
|
||||
// Pixels are exported upright: orientation is baked in.
|
||||
@@ -1283,7 +1339,7 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) ->
|
||||
out.lens_model = text(&m.lens);
|
||||
out.focal_length = m.focal_mm.map(f64::from);
|
||||
out.f_number = m.aperture.map(f64::from);
|
||||
out.exposure_time = parse_shutter(&m.shutter);
|
||||
out.exposure_time = lightcraft_catalog::parse_shutter_seconds(&m.shutter);
|
||||
out.iso = m.iso;
|
||||
}
|
||||
Some(out)
|
||||
@@ -1324,10 +1380,15 @@ pub struct PreparedExport {
|
||||
work: Work,
|
||||
/// The library's originals, which [`run_batch`] never writes over (shared by a batch).
|
||||
guard: std::sync::Arc<crate::originals::OriginalGuard>,
|
||||
/// Estimated working memory of [`Self::run`] (bytes), held from [`crate::memory::export_gate`]
|
||||
/// while it runs beside other photos of a batch.
|
||||
weight: usize,
|
||||
}
|
||||
|
||||
struct RenderWork {
|
||||
job: crate::media::RenderJob,
|
||||
/// The HDR rendition of a gain map JPEG (`job` renders its SDR base).
|
||||
hdr_job: Option<crate::media::RenderJob>,
|
||||
meta: Option<Metadata>,
|
||||
opts: ExportOptions,
|
||||
}
|
||||
@@ -1373,25 +1434,52 @@ fn prepare_guarded(
|
||||
) -> Result<PreparedExport, String> {
|
||||
let p = session.catalog.photo(id).ok_or("no such photo")?;
|
||||
let file_name = o.file_name_for(p, seq);
|
||||
let work = if o.format.is_rendered() {
|
||||
// HDR output only for photos edited in HDR; the rest of the batch exports as usual
|
||||
let sdr_opts;
|
||||
let o = if o.hdr && !p.develop.hdr.enabled {
|
||||
sdr_opts = ExportOptions { hdr: false, ..o.clone() };
|
||||
&sdr_opts
|
||||
} else {
|
||||
o
|
||||
};
|
||||
let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize);
|
||||
let (work, weight) = if o.format.is_rendered() {
|
||||
let (w, h) = output_size(p, o);
|
||||
let meta = export_metadata(p, o);
|
||||
let meta = export_metadata(p, &session.catalog, o);
|
||||
let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg;
|
||||
let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?;
|
||||
Work::Render(Box::new(RenderWork { job, meta, opts: o.clone() }))
|
||||
let weight = render_weight(full_px, long, job.level, w.saturating_mul(h));
|
||||
let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None };
|
||||
(Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight)
|
||||
} else {
|
||||
let lightcraft_catalog::Source::File { path } = &p.source else {
|
||||
return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name));
|
||||
};
|
||||
Work::File {
|
||||
let dng = (o.format == ExportFormat::Dng).then_some(o.dng_compression);
|
||||
// the file's bytes; a DNG also decodes the raw data (16-bit samples) and writes it again
|
||||
let weight = (p.file_size as usize).saturating_add(if dng.is_some() { full_px.saturating_mul(6) } else { 0 });
|
||||
let work = Work::File {
|
||||
path: path.clone(),
|
||||
read: session.media.file_bytes.clone(),
|
||||
packet: crate::sidecar::sidecar_packet(p, &session.catalog),
|
||||
dng: (o.format == ExportFormat::Dng).then_some(o.dng_compression),
|
||||
dng,
|
||||
label: p.file_name.clone(),
|
||||
size: (p.width as usize, p.height as usize),
|
||||
}
|
||||
};
|
||||
(work, weight)
|
||||
};
|
||||
Ok(PreparedExport { photo: id, file_name, work, guard })
|
||||
Ok(PreparedExport { photo: id, file_name, work, guard, weight })
|
||||
}
|
||||
|
||||
/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level
|
||||
/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the
|
||||
/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export
|
||||
/// peaks at ~1.2 GB, one at 2048 px from the 2560 px preview at ~150 MB.
|
||||
fn render_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize {
|
||||
let edge = level.max_edge().min(long.max(1));
|
||||
let scale = edge as f64 / long.max(1) as f64;
|
||||
let src_px = (full_px as f64 * scale * scale).min(usize::MAX as f64 / 64.0) as usize;
|
||||
src_px.saturating_mul(12).saturating_add(out_px.saturating_mul(36))
|
||||
}
|
||||
|
||||
impl PreparedExport {
|
||||
@@ -1399,9 +1487,16 @@ impl PreparedExport {
|
||||
let file_name = self.file_name;
|
||||
match self.work {
|
||||
Work::Render(w) => {
|
||||
let RenderWork { job, meta, opts } = *w;
|
||||
let RenderWork { job, hdr_job, meta, opts } = *w;
|
||||
let r = job.run().rendered?;
|
||||
let bytes = encode_rendered(&r, &opts, meta.as_ref())?;
|
||||
let bytes = match hdr_job {
|
||||
Some(hj) => {
|
||||
let hr = hj.run().rendered?;
|
||||
let hdr = hr.deep.ok_or("the HDR render has no float samples")?;
|
||||
encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())?
|
||||
}
|
||||
None => encode_rendered(&r, &opts, meta.as_ref())?,
|
||||
};
|
||||
Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() })
|
||||
}
|
||||
Work::File { path, read, packet, dng, label, size } => {
|
||||
@@ -1501,6 +1596,11 @@ fn sidecar_path(main: &str, ext: &str) -> String {
|
||||
/// policy. `progress(done, next file)` is called before each photo; returning false cancels the
|
||||
/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`,
|
||||
/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`.
|
||||
///
|
||||
/// Several photos render side by side ([`export_parallelism`], within the memory of
|
||||
/// [`crate::memory::export_gate`]), so one photo's decode, encode or GPU wait overlaps another's
|
||||
/// render (issue #496). Paths, writes, results and `progress` still follow the batch order, and the
|
||||
/// files are the same as one at a time.
|
||||
pub fn run_batch(
|
||||
items: Vec<PreparedExport>,
|
||||
o: &ExportOptions,
|
||||
@@ -1510,31 +1610,226 @@ pub fn run_batch(
|
||||
stop_on_error: bool,
|
||||
progress: &mut dyn FnMut(usize, &str) -> bool,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
use serde_json::json;
|
||||
let join = |a: &str, b: &str| if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) };
|
||||
let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join(&to.dir, &o.subfolder) };
|
||||
let single = items.len() == 1;
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for (i, item) in items.into_iter().enumerate() {
|
||||
if !progress(i, &item.file_name) {
|
||||
break;
|
||||
let lanes = export_parallelism(items.len());
|
||||
run_batch_with(items, &mut Placer::new(o, to, write, exists, stop_on_error), progress, lanes)
|
||||
}
|
||||
|
||||
/// How many photos of a batch of `n` render side by side. Each render already spreads its rows
|
||||
/// over every core; the others fill the cores while it decodes, encodes or waits for the GPU.
|
||||
/// One on wasm32 (no threads).
|
||||
pub fn export_parallelism(n: usize) -> usize {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return 1;
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
(cores / 4).clamp(1, 4).min(n.max(1))
|
||||
}
|
||||
|
||||
/// [`run_batch`] with `lanes` photos in flight (the calling thread is one of them).
|
||||
fn run_batch_with(
|
||||
items: Vec<PreparedExport>,
|
||||
placer: &mut Placer<'_>,
|
||||
progress: &mut dyn FnMut(usize, &str) -> bool,
|
||||
lanes: usize,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
placer.single = items.len() == 1;
|
||||
if lanes <= 1 || items.len() <= 1 {
|
||||
for (i, item) in items.into_iter().enumerate() {
|
||||
if !progress(i, &item.file_name) {
|
||||
break;
|
||||
}
|
||||
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
|
||||
placer.place(photo, name, &guard, run_gated(item))?;
|
||||
}
|
||||
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
|
||||
let e = match item.run() {
|
||||
Ok(e) => e,
|
||||
Err(err) if stop_on_error => return Err(err),
|
||||
Err(err) => {
|
||||
out.push(json!({"photo": photo.0, "file": name, "error": err}));
|
||||
return Ok(std::mem::take(&mut placer.out));
|
||||
}
|
||||
let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone())).collect();
|
||||
let queue = Queue {
|
||||
lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }),
|
||||
cv: std::sync::Condvar::new(),
|
||||
ahead: lanes,
|
||||
};
|
||||
std::thread::scope(|sc| {
|
||||
for k in 1..lanes {
|
||||
let q = &queue;
|
||||
// a lane that can't start leaves its photos to the others (and to this thread)
|
||||
if std::thread::Builder::new().name(format!("export-{k}")).spawn_scoped(sc, move || q.work()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let placed = || {
|
||||
for (i, (photo, name, guard)) in heads.into_iter().enumerate() {
|
||||
if !progress(i, &name) {
|
||||
break;
|
||||
}
|
||||
let e = queue.result(i);
|
||||
placer.place(photo, name, &guard, e)?;
|
||||
}
|
||||
Ok::<_, String>(())
|
||||
};
|
||||
let r = placed();
|
||||
queue.stop();
|
||||
r
|
||||
})?;
|
||||
Ok(std::mem::take(&mut placer.out))
|
||||
}
|
||||
|
||||
/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic
|
||||
/// becomes that photo's error, not the batch's end.
|
||||
fn run_gated(item: PreparedExport) -> Result<Exported, String> {
|
||||
let _held = crate::memory::export_gate().acquire(item.weight);
|
||||
let what = format!("exporting {}", item.file_name);
|
||||
crate::guard::catch(&what, || item.run()).and_then(|r| r)
|
||||
}
|
||||
|
||||
/// The photos of a batch shared by its lanes: each lane takes the next one, at most
|
||||
/// [`Queue::ahead`] past the last placed. That bounds the finished files waiting in memory for
|
||||
/// their turn too (their working memory is given back to the gate as soon as they are encoded).
|
||||
struct Queue {
|
||||
lane: std::sync::Mutex<Lane>,
|
||||
cv: std::sync::Condvar,
|
||||
ahead: usize,
|
||||
}
|
||||
|
||||
struct Lane {
|
||||
items: Vec<Option<PreparedExport>>,
|
||||
/// The next photo to start.
|
||||
next: usize,
|
||||
/// Photos handed to the placer so far.
|
||||
placed: usize,
|
||||
stop: bool,
|
||||
done: std::collections::HashMap<usize, Result<Exported, String>>,
|
||||
}
|
||||
|
||||
impl Lane {
|
||||
fn take_next(&mut self) -> Option<(usize, PreparedExport)> {
|
||||
let i = self.next;
|
||||
let item = self.items.get_mut(i)?.take()?;
|
||||
self.next += 1;
|
||||
Some((i, item))
|
||||
}
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Lane> {
|
||||
self.lane.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn wait<'a>(&self, g: std::sync::MutexGuard<'a, Lane>) -> std::sync::MutexGuard<'a, Lane> {
|
||||
self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn finish(&self, i: usize, r: Result<Exported, String>) {
|
||||
self.lock().done.insert(i, r);
|
||||
self.cv.notify_all();
|
||||
}
|
||||
|
||||
/// A lane thread: run photos until none is left or the batch stops.
|
||||
fn work(&self) {
|
||||
loop {
|
||||
let mut l = self.lock();
|
||||
let (i, item) = loop {
|
||||
if l.stop || l.next >= l.items.len() {
|
||||
return;
|
||||
}
|
||||
if l.next < l.placed.saturating_add(self.ahead) {
|
||||
match l.take_next() {
|
||||
Some(t) => break t,
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
l = self.wait(l);
|
||||
};
|
||||
drop(l);
|
||||
self.finish(i, run_gated(item));
|
||||
}
|
||||
}
|
||||
|
||||
/// Photo `i`'s result, waiting for it; run here when no lane has started it yet.
|
||||
fn result(&self, i: usize) -> Result<Exported, String> {
|
||||
let mut l = self.lock();
|
||||
loop {
|
||||
if let Some(r) = l.done.remove(&i) {
|
||||
l.placed = i + 1;
|
||||
drop(l);
|
||||
self.cv.notify_all();
|
||||
return r;
|
||||
}
|
||||
if l.next == i {
|
||||
let Some((_, item)) = l.take_next() else {
|
||||
return Err("export: photo missing from the batch".into());
|
||||
};
|
||||
drop(l);
|
||||
self.finish(i, run_gated(item));
|
||||
l = self.lock();
|
||||
continue;
|
||||
}
|
||||
l = self.wait(l);
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
self.lock().stop = true;
|
||||
self.cv.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a batch's files go: each photo's path (subfolder, conflict policy, sidecars, never an
|
||||
/// original) and its write, in batch order.
|
||||
struct Placer<'a> {
|
||||
o: &'a ExportOptions,
|
||||
to: &'a Destination,
|
||||
dir: String,
|
||||
single: bool,
|
||||
taken: std::collections::HashSet<String>,
|
||||
out: Vec<serde_json::Value>,
|
||||
write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>,
|
||||
exists: &'a dyn Fn(&str) -> bool,
|
||||
stop_on_error: bool,
|
||||
}
|
||||
|
||||
fn join_path(a: &str, b: &str) -> String {
|
||||
if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) }
|
||||
}
|
||||
|
||||
impl<'a> Placer<'a> {
|
||||
fn new(
|
||||
o: &'a ExportOptions,
|
||||
to: &'a Destination,
|
||||
write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>,
|
||||
exists: &'a dyn Fn(&str) -> bool,
|
||||
stop_on_error: bool,
|
||||
) -> Placer<'a> {
|
||||
let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join_path(&to.dir, &o.subfolder) };
|
||||
Placer { o, to, dir, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error }
|
||||
}
|
||||
|
||||
/// Place photo `photo`'s export result (file `name`): write it and record the outcome. `Err`
|
||||
/// only when it failed and `stop_on_error` is set.
|
||||
fn place(
|
||||
&mut self,
|
||||
photo: lightcraft_catalog::PhotoId,
|
||||
name: String,
|
||||
guard: &crate::originals::OriginalGuard,
|
||||
e: Result<Exported, String>,
|
||||
) -> Result<(), String> {
|
||||
use serde_json::json;
|
||||
let (o, dir, exists) = (self.o, &self.dir, self.exists);
|
||||
let e = match e {
|
||||
Ok(e) => e,
|
||||
Err(err) if self.stop_on_error => return Err(err),
|
||||
Err(err) => {
|
||||
self.out.push(json!({"photo": photo.0, "file": name, "error": err}));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// the exported file and its sidecars
|
||||
let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::<Vec<_>>();
|
||||
let path = match to.exact.as_deref().filter(|_| single) {
|
||||
let path = match self.to.exact.as_deref().filter(|_| self.single) {
|
||||
Some(p) => Ok(p.to_string()),
|
||||
None => {
|
||||
let path = join(&dir, &e.file_name);
|
||||
let path = join_path(dir, &e.file_name);
|
||||
let taken = &self.taken;
|
||||
let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p));
|
||||
if !busy(&path) {
|
||||
Ok(path)
|
||||
@@ -1542,12 +1837,12 @@ pub fn run_batch(
|
||||
match o.conflict {
|
||||
Conflict::Overwrite => Ok(path),
|
||||
Conflict::Skip => {
|
||||
out.push(json!({"skipped": path}));
|
||||
continue;
|
||||
self.out.push(json!({"skipped": path}));
|
||||
return Ok(());
|
||||
}
|
||||
Conflict::Unique => {
|
||||
let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b)));
|
||||
let name = |n: usize| join(&dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}")));
|
||||
let name = |n: usize| join_path(dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}")));
|
||||
(2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name"))
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1850,7 @@ pub fn run_batch(
|
||||
}
|
||||
};
|
||||
let file = path.clone().unwrap_or_else(|_| name.clone());
|
||||
let write = &mut *self.write;
|
||||
let written = path.and_then(|path| {
|
||||
let files = group(&path);
|
||||
// never over an original, whatever the conflict policy or the exact path said
|
||||
@@ -1571,14 +1867,14 @@ pub fn run_batch(
|
||||
});
|
||||
match written {
|
||||
Ok((path, files, sidecars)) => {
|
||||
taken.extend(files);
|
||||
out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}));
|
||||
self.taken.extend(files);
|
||||
self.out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}));
|
||||
}
|
||||
Err(err) if stop_on_error => return Err(err),
|
||||
Err(err) => out.push(json!({"photo": photo.0, "file": file, "error": err})),
|
||||
Err(err) if self.stop_on_error => return Err(err),
|
||||
Err(err) => self.out.push(json!({"photo": photo.0, "file": file, "error": err})),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1626,9 +1922,28 @@ mod tests {
|
||||
assert!(img.data[3][0] < 80 && img.data[4][0] > 170);
|
||||
}
|
||||
|
||||
/// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject`
|
||||
/// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of
|
||||
/// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`.
|
||||
#[test]
|
||||
fn exported_keywords_follow_their_options() {
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source, keywords::KeywordInfo};
|
||||
let mut c = Catalog::new();
|
||||
c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap();
|
||||
c.apply(Op::SetKeyword { path: "draft".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap();
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
|
||||
p.meta.keywords = vec!["Places|Lisbon".into(), "travel|Italy".into(), "draft".into()];
|
||||
let m = export_metadata(&p, &c, &ExportOptions::default()).unwrap();
|
||||
assert_eq!(m.keywords, ["Lisbon", "Italy", "travel"]);
|
||||
assert_eq!(m.hierarchical_keywords, ["Places|Lisbon", "travel|Italy"]);
|
||||
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&m)).unwrap();
|
||||
let back = lightcraft_meta::extract(&jpg);
|
||||
assert_eq!((back.keywords, back.hierarchical_keywords), (m.keywords.clone(), m.hierarchical_keywords.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_policies() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_catalog::{Catalog, Photo, PhotoId, Source};
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
|
||||
p.meta.camera = "Synthetic X2".into();
|
||||
p.meta.copyright = "(c) Me".into();
|
||||
@@ -1637,21 +1952,21 @@ mod tests {
|
||||
p.meta.copyright_url = "https://example.com/rights".into();
|
||||
p.meta.shutter = "1/250".into();
|
||||
p.meta.gps = Some((43.0, -110.0));
|
||||
let all = export_metadata(&p, &ExportOptions::default()).unwrap();
|
||||
let all = export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap();
|
||||
assert_eq!(all.model.as_deref(), Some("Synthetic X2"));
|
||||
assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9);
|
||||
assert!(all.gps.is_some());
|
||||
let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() };
|
||||
let m = export_metadata(&p, &o).unwrap();
|
||||
let m = export_metadata(&p, &Catalog::new(), &o).unwrap();
|
||||
assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some());
|
||||
let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap();
|
||||
let c = export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap();
|
||||
assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me"));
|
||||
// "copyright only" keeps all the copyright info: status, usage terms, info URL
|
||||
assert_eq!(
|
||||
(c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()),
|
||||
(Some(true), Some("Editorial use only"), Some("https://example.com/rights"))
|
||||
);
|
||||
assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none());
|
||||
assert!(export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none());
|
||||
// embedded and readable back from the JPEG
|
||||
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap();
|
||||
let back = lightcraft_meta::extract(&jpg);
|
||||
@@ -2225,4 +2540,103 @@ mod tests {
|
||||
assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15);
|
||||
assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0);
|
||||
}
|
||||
|
||||
/// A batch with `lanes` photos in flight into a fake folder `out` (nothing exists there):
|
||||
/// results, writes (path, bytes) and progress calls; `progress` returns false at `cancel_at`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn lanes_batch(
|
||||
s: &mut crate::Session,
|
||||
ids: &[lightcraft_catalog::PhotoId],
|
||||
o: &ExportOptions,
|
||||
lanes: usize,
|
||||
stop_on_error: bool,
|
||||
cancel_at: usize,
|
||||
) -> (Result<Vec<serde_json::Value>, String>, Vec<(String, Vec<u8>)>, Vec<(usize, String)>) {
|
||||
let items = prepare_batch(s, ids, o).unwrap();
|
||||
let to = Destination { dir: "out".into(), exact: None };
|
||||
let mut written = Vec::new();
|
||||
let mut write = |p: &str, b: &[u8]| {
|
||||
written.push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
};
|
||||
let mut seen = Vec::new();
|
||||
let mut progress = |i: usize, name: &str| {
|
||||
seen.push((i, name.to_string()));
|
||||
i < cancel_at
|
||||
};
|
||||
let files = run_batch_with(items, &mut Placer::new(o, &to, &mut write, &|_| false, stop_on_error), &mut progress, lanes);
|
||||
(files, written, seen)
|
||||
}
|
||||
|
||||
/// The files of a batch without their byte counts: a GPU render (when another test turns the
|
||||
/// GPU off mid-run) may differ from a CPU one by an LSB, never in name, size or order.
|
||||
fn shape(files: &[serde_json::Value]) -> Vec<serde_json::Value> {
|
||||
files.iter().map(|f| json!({"path": f["path"], "width": f["width"], "height": f["height"], "error": f["error"]})).collect()
|
||||
}
|
||||
|
||||
// Issue #496: photos rendered side by side give the same files, names, order and progress as
|
||||
// one at a time, and a cancel stops at the same photo
|
||||
#[test]
|
||||
fn side_by_side_batches_match_one_at_a_time() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(5).collect();
|
||||
// one name for all: the Unique numbering follows the order the files are placed in
|
||||
let o = ExportOptions::from_json(&json!({"format": "png", "width": 40, "naming": "same"}));
|
||||
let (files, written, seen) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX);
|
||||
let files = files.unwrap();
|
||||
let paths: Vec<_> = written.iter().map(|(p, _)| p.clone()).collect();
|
||||
assert_eq!(paths, ["out/same.png", "out/same-2.png", "out/same-3.png", "out/same-4.png", "out/same-5.png"]);
|
||||
let heights: std::collections::HashSet<_> = files.iter().map(|f| f["height"].as_u64()).collect();
|
||||
assert!(heights.len() > 1, "photos of different shapes show the order: {files:?}");
|
||||
for lanes in [2, 3, 8] {
|
||||
let (f, w, sn) = lanes_batch(&mut s, &ids, &o, lanes, true, usize::MAX);
|
||||
assert_eq!(shape(&f.unwrap()), shape(&files), "{lanes} lanes");
|
||||
assert_eq!(w.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>(), paths, "{lanes} lanes");
|
||||
assert_eq!(sn, seen, "{lanes} lanes");
|
||||
}
|
||||
let (f, w, sn) = lanes_batch(&mut s, &ids, &o, 3, true, 2);
|
||||
assert_eq!(shape(&f.unwrap()), shape(&files[..2]));
|
||||
assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written");
|
||||
}
|
||||
|
||||
// A photo that fails is reported in its place while the others are exported; with
|
||||
// stop_on_error the batch ends there and nothing after it is written
|
||||
#[test]
|
||||
fn side_by_side_batches_report_failures_in_order() {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
let mut s = crate::Session::with_demo();
|
||||
let mut ids: Vec<_> = s.visible().iter().copied().take(3).collect();
|
||||
let gone =
|
||||
Photo::new(PhotoId(9_496), Source::File { path: "no/such/dir/gone.jpg".into() }, "gone.jpg", "JPEG", 600, 400, "2026-10-09T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(gone) }).unwrap();
|
||||
ids.insert(1, PhotoId(9_496));
|
||||
let o = ExportOptions::from_json(&json!({"format": "jpeg", "width": 40}));
|
||||
let (files, written, _) = lanes_batch(&mut s, &ids, &o, 3, false, usize::MAX);
|
||||
let files = files.unwrap();
|
||||
assert_eq!(files.len(), 4);
|
||||
assert_eq!((files[1]["photo"].as_u64(), files[1]["file"].as_str()), (Some(9_496), Some("gone.jpg")), "{files:?}");
|
||||
assert!(files[1]["error"].is_string());
|
||||
assert!([0, 2, 3].iter().all(|&i| files[i]["path"].is_string()), "{files:?}");
|
||||
assert_eq!(written.len(), 3);
|
||||
let one = lanes_batch(&mut s, &ids, &o, 1, false, usize::MAX).0.unwrap();
|
||||
assert_eq!(shape(&files), shape(&one));
|
||||
|
||||
let (r, written, _) = lanes_batch(&mut s, &ids, &o, 3, true, usize::MAX);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(written.len(), 1, "only the photo before the failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_weights_follow_the_source_and_output_sizes() {
|
||||
use crate::media::SourceLevel;
|
||||
let mb = |b: usize| b >> 20;
|
||||
// a full-size 24 MP export (measured peak ~1.2 GB)
|
||||
assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Full, 6000 * 4000)), 1098);
|
||||
// 2048 px from the 2560 px preview (measured ~150 MB)
|
||||
assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Preview, 2048 * 1365)), 145);
|
||||
// a photo smaller than the preview level is its own source
|
||||
assert_eq!(render_weight(1000 * 500, 1000, SourceLevel::Preview, 0), 1000 * 500 * 12);
|
||||
assert_eq!(render_weight(0, 0, SourceLevel::Full, 0), 0);
|
||||
assert!(render_weight(usize::MAX, 1, SourceLevel::Full, usize::MAX) > 0, "no overflow");
|
||||
}
|
||||
}
|
||||
|
||||
+562
-164
@@ -37,7 +37,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
usage_terms: m.usage_terms.clone().unwrap_or_default(),
|
||||
copyright_url: m.copyright_url.clone().unwrap_or_default(),
|
||||
creator: m.artist.clone().unwrap_or_default(),
|
||||
keywords: m.keywords.clone(),
|
||||
// paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels
|
||||
keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords),
|
||||
regions: m.regions.clone(),
|
||||
};
|
||||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||||
@@ -102,69 +103,58 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
let (meta, captured) = meta_of(&m);
|
||||
if lightcraft_raw::probe(bytes).is_some() {
|
||||
let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) {
|
||||
Ok(r) => Some(r),
|
||||
Ok(r) => r,
|
||||
Err(Ok(why)) => {
|
||||
// a raw variant we can't decode yet: describe it from its embedded preview
|
||||
if let Some((w, h)) = embedded_preview_size(bytes) {
|
||||
return Ok(ProbeInfo {
|
||||
width: w,
|
||||
height: h,
|
||||
format: ext_upper(name),
|
||||
kind: MediaKind::Raw,
|
||||
file_size: bytes.len() as u64,
|
||||
captured,
|
||||
meta,
|
||||
as_shot_wb: None,
|
||||
content_hash,
|
||||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||||
preview_only: Some(why),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if !(lightcraft_codecs::sniff(bytes).is_some_and(|f| f.can_decode()) && lightcraft_codecs::read_header(bytes).is_ok()) {
|
||||
return Err(format!("unsupported raw ({why}) without an embedded preview"));
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(Err(e)) => {
|
||||
if !(lightcraft_codecs::sniff(bytes).is_some_and(|f| f.can_decode()) && lightcraft_codecs::read_header(bytes).is_ok()) {
|
||||
return Err(e);
|
||||
}
|
||||
None
|
||||
let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?;
|
||||
return Ok(ProbeInfo {
|
||||
width: w,
|
||||
height: h,
|
||||
format: ext_upper(name),
|
||||
kind: MediaKind::Raw,
|
||||
file_size: bytes.len() as u64,
|
||||
captured,
|
||||
meta,
|
||||
as_shot_wb: None,
|
||||
content_hash,
|
||||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||||
preview_only: Some(why),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(Err(e)) => return Err(e),
|
||||
};
|
||||
if let Some(raw) = raw {
|
||||
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
|
||||
if w <= 1 || h <= 1 {
|
||||
(w, h) = (raw.active_area.width as u32, raw.active_area.height as u32);
|
||||
}
|
||||
if raw.orientation.swaps_axes() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
|
||||
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
|
||||
let embedded_lens = embedded_lens(&raw);
|
||||
return Ok(ProbeInfo {
|
||||
embedded_lens,
|
||||
width: w,
|
||||
height: h,
|
||||
format: ext_upper(name),
|
||||
kind: MediaKind::Raw,
|
||||
file_size: bytes.len() as u64,
|
||||
captured,
|
||||
meta,
|
||||
as_shot_wb,
|
||||
content_hash,
|
||||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||||
preview_only: None,
|
||||
});
|
||||
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
|
||||
if w <= 1 || h <= 1 {
|
||||
(w, h) = (raw.active_area.width as u32, raw.active_area.height as u32);
|
||||
}
|
||||
if raw.orientation.swaps_axes() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
|
||||
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
|
||||
let embedded_lens = embedded_lens(&raw);
|
||||
return Ok(ProbeInfo {
|
||||
embedded_lens,
|
||||
width: w,
|
||||
height: h,
|
||||
format: ext_upper(name),
|
||||
kind: MediaKind::Raw,
|
||||
file_size: bytes.len() as u64,
|
||||
captured,
|
||||
meta,
|
||||
as_shot_wb,
|
||||
content_hash,
|
||||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||||
preview_only: None,
|
||||
});
|
||||
}
|
||||
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
|
||||
if !fmt.can_decode() {
|
||||
return Err(format!("{fmt:?} files are not supported yet"));
|
||||
if let Some(why) = fmt.not_decodable() {
|
||||
// e.g. "Heif files can't be opened: HEIC/HEIF support isn't included in this build of LightCraft"
|
||||
return Err(format!("{fmt:?} files can't be opened: {why}"));
|
||||
}
|
||||
// headers only (issue #367: decoding the pixels was nearly all of an import's CPU time)
|
||||
let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?;
|
||||
@@ -212,6 +202,86 @@ fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result<String, S
|
||||
/// Sensor clip level (normalised) for highlight reconstruction.
|
||||
const HIGHLIGHT_CLIP: f32 = 0.99;
|
||||
|
||||
/// How a raw's camera RGB becomes the linear Rec.2020 the pipeline takes.
|
||||
pub(crate) struct CameraColourModel<'a> {
|
||||
/// White-balance multipliers (as shot).
|
||||
pub wb: [f32; 3],
|
||||
/// White-balanced camera RGB → linear Rec.2020: the camera matrix, or a look fitted to the camera's JPEG on top.
|
||||
pub matrix: lightcraft_color::Mat3,
|
||||
/// Baseline exposure, as a gain.
|
||||
pub gain: f32,
|
||||
/// A DNG profile's hue/saturation map and look table.
|
||||
pub tables: Option<&'a lightcraft_raw::profile::ProfileTables>,
|
||||
/// A fitted camera look's hue/saturation table.
|
||||
pub hue_sat: Option<&'a crate::camera_preview::HueSat<'a>>,
|
||||
}
|
||||
|
||||
impl CameraColourModel<'_> {
|
||||
/// Rebuild sensor-clipped channels; fully clipped highlights become [`clipped_white`] of the matrix, so they
|
||||
/// come out of [`Self::apply`] neutral.
|
||||
pub fn rebuild_highlights(&self, img: &mut Rgb32f) {
|
||||
lightcraft_raw::highlight::reconstruct_with(img, self.wb, HIGHLIGHT_CLIP, clipped_white(&self.matrix));
|
||||
}
|
||||
|
||||
/// White balance, matrix, tables and baseline exposure, in place.
|
||||
pub fn apply(&self, img: &mut Rgb32f) {
|
||||
let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain);
|
||||
img.map_in_place(|p| {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = match self.tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The white-balanced camera RGB that `model` (white-balanced camera RGB → linear Rec.2020) renders
|
||||
/// neutral, as bright as it renders `[1, 1, 1]`: what fully clipped highlights are rebuilt to, so they come out
|
||||
/// neutral whatever the colour model does to the camera's own neutral (a look fitted to the camera's JPEG or a
|
||||
/// camera profile often tints it by a few per cent, which any darkening of the highlights would show).
|
||||
/// `[1, 1, 1]` (the camera's own neutral, as before this existed) when the model can't be trusted with it: not
|
||||
/// invertible or nearly so, an answer far from the camera's neutral (a sane colour model moves white by a few per
|
||||
/// cent), or one that isn't neutral through the `f32` matrix the decode applies.
|
||||
pub(crate) fn clipped_white(model: &lightcraft_color::Mat3) -> [f32; 3] {
|
||||
const CAMERA: [f32; 3] = [1.0; 3];
|
||||
/// Largest condition number (max row sum norm) of a model trusted for this.
|
||||
const CONDITION: f64 = 1e3;
|
||||
/// Largest factor between the answer and the camera's neutral, per channel.
|
||||
const SPREAD: f64 = 4.0;
|
||||
let norm = |m: &lightcraft_color::Mat3| m.0.iter().map(|row| row.iter().map(|v| v.abs()).sum::<f64>()).fold(0.0, f64::max);
|
||||
let Some(inverse) = model.inverse() else { return CAMERA };
|
||||
let condition = norm(model) * norm(&inverse);
|
||||
if !condition.is_finite() || condition > CONDITION {
|
||||
return CAMERA;
|
||||
}
|
||||
let luma = |v: [f64; 3]| {
|
||||
let c = model.apply(v);
|
||||
(0..3).map(|i| c[i] * f64::from(lightcraft_color::LUMA_2020[i])).sum::<f64>()
|
||||
};
|
||||
let n = inverse.apply([1.0; 3]);
|
||||
let (target, own) = (luma([1.0; 3]), luma(n));
|
||||
if !(target > 0.0 && own > 0.0 && target.is_finite() && own.is_finite()) {
|
||||
return CAMERA;
|
||||
}
|
||||
let white = n.map(|v| v * target / own);
|
||||
if !white.iter().all(|v| v.is_finite() && (1.0 / SPREAD..=SPREAD).contains(v)) {
|
||||
return CAMERA;
|
||||
}
|
||||
let white = white.map(|v| v as f32);
|
||||
// neutral through the matrix as the decode applies it
|
||||
let m = model.to_f32();
|
||||
let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * white[0] + m[i][1] * white[1] + m[i][2] * white[2]);
|
||||
let (lo, hi) = (out[0].min(out[1]).min(out[2]), out[0].max(out[1]).max(out[2]));
|
||||
if lo > 0.0 && hi.is_finite() && hi - lo <= 1e-3 * hi { white } else { CAMERA }
|
||||
}
|
||||
|
||||
/// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels:
|
||||
/// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the
|
||||
/// 2560 px preview). X-Trans can only bin 3× (its 6×6 pattern), and its full demosaic is ~5× the
|
||||
@@ -275,118 +345,110 @@ fn load_bytes_now(
|
||||
denoise: Option<&DenoiseSpec>,
|
||||
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
|
||||
if lightcraft_raw::probe(&bytes).is_some() {
|
||||
let raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
|
||||
Ok(r) => Some(r),
|
||||
let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
|
||||
Ok(r) => r,
|
||||
Err(Ok(why)) => {
|
||||
// show the camera's embedded JPEG (rendered, not raw) until the variant is supported
|
||||
if let Some((img, info)) = load_embedded_preview(&bytes, max_edge) {
|
||||
return Ok((img, None, info));
|
||||
}
|
||||
if !lightcraft_codecs::sniff(&bytes).is_some_and(|f| f.can_decode()) {
|
||||
return Err(format!("unsupported raw ({why}) without an embedded preview"));
|
||||
}
|
||||
None
|
||||
return load_embedded_preview(&bytes, max_edge)
|
||||
.map(|(img, info)| (img, None, info))
|
||||
.ok_or(format!("unsupported raw ({why}) without an embedded preview"));
|
||||
}
|
||||
Err(Err(e)) => {
|
||||
if !lightcraft_codecs::sniff(&bytes).is_some_and(|f| f.can_decode()) {
|
||||
return Err(e);
|
||||
}
|
||||
None
|
||||
Err(Err(e)) => return Err(e),
|
||||
};
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
let lens = embedded_lens(&raw.info());
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
// the source's segmentation mattes (DNG semantic masks), read while the preview is fitted
|
||||
let (camera_look, mattes) = rayon::join(|| crate::camera_preview::fit_preview(&raw, &bytes, &t), || dng_mattes(&bytes, &raw));
|
||||
drop(bytes);
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
let t0 = web_time::Instant::now();
|
||||
let bin = bin_factor(&raw, max_edge);
|
||||
let binned = match bin {
|
||||
Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
|
||||
None => None,
|
||||
};
|
||||
let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 };
|
||||
let img = match binned {
|
||||
Some(img) => img,
|
||||
None => {
|
||||
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
|
||||
raw.develop(method).map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
if let Some(mut raw) = raw {
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
let lens = embedded_lens(&raw.info());
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
// the source's segmentation mattes (DNG semantic masks), read while the preview is fitted
|
||||
let (camera_look, mattes) = rayon::join(|| crate::camera_preview::fit_preview(&raw, &bytes, &t), || dng_mattes(&bytes, &raw));
|
||||
drop(bytes);
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
let t0 = web_time::Instant::now();
|
||||
let bin = bin_factor(&raw, max_edge);
|
||||
let binned = match bin {
|
||||
Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
|
||||
None => None,
|
||||
};
|
||||
let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 };
|
||||
let img = match binned {
|
||||
Some(img) => img,
|
||||
None => {
|
||||
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
|
||||
raw.develop(method).map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
// the same development from the denoised mosaic, when the photo has been denoised
|
||||
let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height);
|
||||
// the samples aren't needed any more (the colour model below reads only the tags)
|
||||
raw.data = lightcraft_raw::RawData::U16(Vec::new());
|
||||
let wb = t.wb;
|
||||
let m = camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
|
||||
let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new);
|
||||
let gain = 2f32.powf(t.baseline_exposure as f32);
|
||||
// A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6.
|
||||
let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy));
|
||||
// camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied,
|
||||
// fitted to `max_edge` and upright (the same for the plain and the denoised picture)
|
||||
let finish = |mut img: Rgb32f| {
|
||||
let mut stages = vec![("develop", t0.elapsed())];
|
||||
stages.push(("transform", t0.elapsed()));
|
||||
lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP);
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
img.map_in_place(|p| {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = match &tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
});
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
stages.push(("fit", t0.elapsed()));
|
||||
let img = img.into_oriented(raw.orientation);
|
||||
stages.push(("orient", t0.elapsed()));
|
||||
(img, stages)
|
||||
};
|
||||
let (img, stages) = finish(img);
|
||||
if lightcraft_pipeline::profiling() {
|
||||
let mut prev = std::time::Duration::ZERO;
|
||||
let parts: Vec<String> = stages
|
||||
.iter()
|
||||
.map(|(n, t)| {
|
||||
let d = *t - prev;
|
||||
prev = *t;
|
||||
format!("{n} {:.1}", d.as_secs_f64() * 1e3)
|
||||
})
|
||||
.collect();
|
||||
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
|
||||
}
|
||||
let twin = twin.map(|picture| finish(picture).0);
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback;
|
||||
// White balance re-evaluates the file's own colour model (when it has one and no
|
||||
// file-local look matrix sits on top of it)
|
||||
let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| {
|
||||
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||||
});
|
||||
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
|
||||
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
|
||||
return Ok((
|
||||
img,
|
||||
twin,
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes },
|
||||
));
|
||||
// the same development from the denoised mosaic, when the photo has been denoised
|
||||
let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height);
|
||||
// the samples aren't needed any more (the colour model below reads only the tags)
|
||||
raw.data = lightcraft_raw::RawData::U16(Vec::new());
|
||||
let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new);
|
||||
// A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6.
|
||||
let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy));
|
||||
let colour = CameraColourModel {
|
||||
wb: t.wb,
|
||||
matrix: camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix),
|
||||
gain: 2f32.powf(t.baseline_exposure as f32),
|
||||
tables: tables.as_ref(),
|
||||
hue_sat: hue_sat.as_ref(),
|
||||
};
|
||||
// camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied,
|
||||
// fitted to `max_edge` and upright (the same for the plain and the denoised picture)
|
||||
let finish = |mut img: Rgb32f| {
|
||||
let mut stages = vec![("develop", t0.elapsed())];
|
||||
stages.push(("transform", t0.elapsed()));
|
||||
colour.rebuild_highlights(&mut img);
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
colour.apply(&mut img);
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
stages.push(("fit", t0.elapsed()));
|
||||
let img = img.into_oriented(raw.orientation);
|
||||
stages.push(("orient", t0.elapsed()));
|
||||
(img, stages)
|
||||
};
|
||||
let (img, stages) = finish(img);
|
||||
if lightcraft_pipeline::profiling() {
|
||||
let mut prev = std::time::Duration::ZERO;
|
||||
let parts: Vec<String> = stages
|
||||
.iter()
|
||||
.map(|(n, t)| {
|
||||
let d = *t - prev;
|
||||
prev = *t;
|
||||
format!("{n} {:.1}", d.as_secs_f64() * 1e3)
|
||||
})
|
||||
.collect();
|
||||
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
|
||||
}
|
||||
let twin = twin.map(|picture| finish(picture).0);
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback;
|
||||
// White balance re-evaluates the file's own colour model (when it has one and no
|
||||
// file-local look matrix sits on top of it)
|
||||
let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| {
|
||||
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||||
});
|
||||
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
|
||||
let local_tone = local_tone(&raw);
|
||||
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
|
||||
return Ok((
|
||||
img,
|
||||
twin,
|
||||
SourceInfo {
|
||||
raw: true,
|
||||
as_shot_temp: temp,
|
||||
as_shot_tint: tint,
|
||||
lens,
|
||||
relative_wb: relative,
|
||||
camera_color,
|
||||
camera_tone,
|
||||
mattes,
|
||||
local_tone,
|
||||
},
|
||||
));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?;
|
||||
drop(bytes);
|
||||
@@ -395,6 +457,18 @@ fn load_bytes_now(
|
||||
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
|
||||
}
|
||||
|
||||
/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in
|
||||
/// the active area: rendered when the photo's "Camera local tone mapping" option is on.
|
||||
fn local_tone(raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::local_tone::LocalTone>> {
|
||||
let map = raw.color.profile.gain_table_map.clone()?;
|
||||
let a = raw.active_area;
|
||||
let (aw, ah) = (a.width.max(1) as f64, a.height.max(1) as f64);
|
||||
let c = raw.develop_crop(a.width, a.height);
|
||||
let rect = [c.x as f64 / aw, c.y as f64 / ah, c.width as f64 / aw, c.height as f64 / ah];
|
||||
let placement = lightcraft_raw::gaintable::SourcePlacement { rect, orientation: raw.orientation };
|
||||
Some(Arc::new(lightcraft_pipeline::local_tone::LocalTone { map, placement }))
|
||||
}
|
||||
|
||||
/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop,
|
||||
/// oriented like it). Person mattes without a confidently selected pixel are left out (the
|
||||
/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky).
|
||||
@@ -587,6 +661,19 @@ impl crate::Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the
|
||||
/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides
|
||||
/// them; an exported `travel|Italy|Rome` comes back as itself.
|
||||
#[test]
|
||||
fn imported_keywords_keep_their_hierarchy() {
|
||||
let m = lightcraft_meta::Metadata {
|
||||
keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()],
|
||||
hierarchical_keywords: vec!["travel|Italy|Rome".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
|
||||
@@ -868,6 +955,240 @@ mod tests {
|
||||
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
|
||||
}
|
||||
|
||||
/// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's
|
||||
/// own white-balanced neutral comes out yellow (blue 12 % low).
|
||||
fn yellowing_look() -> lightcraft_color::Mat3 {
|
||||
lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_white_renders_neutral_through_the_colour_model() {
|
||||
use lightcraft_color::{LUMA_2020, Mat3};
|
||||
let look = yellowing_look();
|
||||
let white = clipped_white(&look);
|
||||
let out = look.apply(white.map(f64::from));
|
||||
assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}");
|
||||
let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::<f64>();
|
||||
assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral");
|
||||
assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]);
|
||||
// a model that can't be inverted, or only just, keeps the camera's neutral
|
||||
assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||||
assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||||
// so does one whose white would overflow the rebuilt highlights (2.5e38 in red here)
|
||||
assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||||
// or move the camera's neutral implausibly far
|
||||
assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||||
// what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite
|
||||
for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] {
|
||||
let w = clipped_white(&model);
|
||||
let m = model.to_f32();
|
||||
let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]);
|
||||
assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}");
|
||||
let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None };
|
||||
let mut img = Rgb32f::filled(4, 4, [1.0; 3]);
|
||||
colour.rebuild_highlights(&mut img);
|
||||
colour.apply(&mut img);
|
||||
assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that
|
||||
/// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral;
|
||||
/// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it.
|
||||
#[test]
|
||||
fn clipped_highlights_stay_neutral_when_darkened() {
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
let wb = [2.4f32, 1.0, 1.6];
|
||||
let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None };
|
||||
// a grey wall (camera neutral) with a fully clipped light and a light clipped in green only
|
||||
let mut img = Rgb32f::from_fn(96, 64, |x, y| {
|
||||
let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt();
|
||||
if d(28.0) < 14.0 {
|
||||
[1.0; 3]
|
||||
} else if d(70.0) < 12.0 {
|
||||
[0.6 / wb[0], 1.0, 0.7 / wb[2]]
|
||||
} else {
|
||||
[0.3 / wb[0], 0.3, 0.3 / wb[2]]
|
||||
}
|
||||
});
|
||||
colour.rebuild_highlights(&mut img);
|
||||
colour.apply(&mut img);
|
||||
let info = SourceInfo { raw: true, ..Default::default() };
|
||||
let mut issue = lightcraft_develop::DevelopSettings::default();
|
||||
let l = &mut issue.light;
|
||||
(l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0);
|
||||
let mut h100 = lightcraft_develop::DevelopSettings::default();
|
||||
h100.light.highlights = -100.0;
|
||||
for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] {
|
||||
let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image;
|
||||
let p = out.get(28, 32);
|
||||
assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})");
|
||||
let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]);
|
||||
assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral");
|
||||
}
|
||||
}
|
||||
|
||||
/// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`)
|
||||
/// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise
|
||||
/// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last
|
||||
/// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row.
|
||||
fn darkened_ramp(rows: std::ops::Range<usize>, height: usize) -> ([[u8; 4]; 3], u8) {
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
let wb = [2.5f32, 1.0, 1.6];
|
||||
let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None };
|
||||
let w = 1600usize;
|
||||
let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32;
|
||||
let sensor =
|
||||
Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] });
|
||||
let y = (rows.start + rows.end) / 2;
|
||||
let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap();
|
||||
let mut img = sensor.clone();
|
||||
colour.rebuild_highlights(&mut img);
|
||||
colour.apply(&mut img);
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
s.light.exposure = -2.0;
|
||||
let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image;
|
||||
let step = |x: usize| {
|
||||
let (a, b) = (out.get(x - 1, y), out.get(x, y));
|
||||
(0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0)
|
||||
};
|
||||
([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards
|
||||
/// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels
|
||||
/// there.
|
||||
#[test]
|
||||
fn rendered_colour_is_continuous_where_the_first_channel_clips() {
|
||||
let ([a, b, c], worst) = darkened_ramp(0..16, 16);
|
||||
let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0);
|
||||
assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}");
|
||||
assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels");
|
||||
}
|
||||
|
||||
/// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there
|
||||
/// is no reliable colour around it. It still carries on across the first clip, however thin the line and however
|
||||
/// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163];
|
||||
/// on 512 rows, from [225, 126, 153] to [218, 145, 151]).
|
||||
#[test]
|
||||
fn rendered_thin_line_is_continuous_where_the_first_channel_clips() {
|
||||
let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0);
|
||||
for height in [32usize, 128, 512, 1024] {
|
||||
for width in [1usize, 2, 4, 16] {
|
||||
let top = (height - width) / 2;
|
||||
let ([a, b, c], worst) = darkened_ramp(top..top + width, height);
|
||||
let case = format!("{width} rows of {height}");
|
||||
assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}");
|
||||
assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1,
|
||||
/// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale
|
||||
/// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows
|
||||
/// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the
|
||||
/// rendered colour on either side of where red clips on the band's middle row, and the largest step between
|
||||
/// neighbouring pixels along that row.
|
||||
fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) {
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
let wb = [2.5f32, 1.0, 1.6];
|
||||
let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None };
|
||||
let (w, h) = (320usize, top + strip + band);
|
||||
let sensor = Rgb32f::from_fn(w, h, |x, y| {
|
||||
if y < top {
|
||||
[0.8, 0.35, 0.3]
|
||||
} else if y < top + strip {
|
||||
[0.4, 0.95, 0.8]
|
||||
} else {
|
||||
[0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8]
|
||||
}
|
||||
});
|
||||
let y = top + strip + band / 2;
|
||||
let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap();
|
||||
let mut img = sensor.clone();
|
||||
colour.rebuild_highlights(&mut img);
|
||||
colour.apply(&mut img);
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
s.light.exposure = -2.0;
|
||||
let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image;
|
||||
let step = |x: usize| {
|
||||
let (a, b) = (out.get(x - 1, y), out.get(x, y));
|
||||
(0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0)
|
||||
};
|
||||
([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The
|
||||
/// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly
|
||||
/// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then
|
||||
/// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped
|
||||
/// (without the coloured surface: [198, 195, 178] then [205, 181, 168]).
|
||||
#[test]
|
||||
fn rendered_colour_is_continuous_where_a_second_channel_clips() {
|
||||
for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] {
|
||||
let ([a, b], worst) = second_clip(top, strip, band);
|
||||
let case = format!("{top} rows of colour, {strip} of rim, {band} of band");
|
||||
let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0);
|
||||
assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips");
|
||||
assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels");
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in
|
||||
/// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo
|
||||
/// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them).
|
||||
#[test]
|
||||
fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
let (img, _) = load_bytes(&bytes, 1600).unwrap();
|
||||
let (w, h) = (img.width, img.height);
|
||||
let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p);
|
||||
let chroma = |p: [f32; 3]| {
|
||||
let s = (p[0] + p[1] + p[2]).max(1e-6);
|
||||
[p[0] / s, p[2] / s]
|
||||
};
|
||||
// the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame
|
||||
let px = |f: f32, n: usize| (f * n as f32) as usize;
|
||||
let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]);
|
||||
let mut rows = 0;
|
||||
for y in px(0.24, h)..px(0.34, h) {
|
||||
let row = &img.data[y * w..(y + 1) * w];
|
||||
let sky = lum(row[px(0.2, w)]);
|
||||
let dark: Vec<usize> = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect();
|
||||
let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue };
|
||||
let k = w as f32 / 6000.0;
|
||||
let mean = |a: usize, b: usize| {
|
||||
let n = (b - a) as f64;
|
||||
(a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n])
|
||||
};
|
||||
let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize);
|
||||
for (side, (n, f)) in
|
||||
[(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
for c in 0..2 {
|
||||
near[side][c] += n[c];
|
||||
far[side][c] += f[c];
|
||||
}
|
||||
}
|
||||
rows += 1;
|
||||
}
|
||||
assert!(rows > 50, "the tree was not found ({rows} rows)");
|
||||
for side in 0..2 {
|
||||
let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max);
|
||||
assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]);
|
||||
}
|
||||
}
|
||||
|
||||
/// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right
|
||||
/// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky
|
||||
/// heuristic would never look) is used; a matte with an unknown name is ignored.
|
||||
@@ -981,6 +1302,59 @@ mod tests {
|
||||
assert_eq!(before.data, after.data);
|
||||
}
|
||||
|
||||
/// The Profile option "Camera local tone mapping" renders the map by position (in the raw's
|
||||
/// active area, whatever the photo's orientation), and only when it's on.
|
||||
#[test]
|
||||
fn dng_gain_table_map_renders_when_asked() {
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
use lightcraft_raw::gaintable::GainTableMap;
|
||||
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
let mut raw = lightcraft_raw::decode(&plain).unwrap();
|
||||
// ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour
|
||||
raw.color.profile.gain_table_map = Some(GainTableMap {
|
||||
points_v: 1,
|
||||
points_h: 2,
|
||||
points_n: 1,
|
||||
spacing_v: 1.0,
|
||||
spacing_h: 1.0,
|
||||
origin_v: 0.0,
|
||||
origin_h: 0.0,
|
||||
weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0],
|
||||
gamma: 1.0,
|
||||
gains: vec![1.0, 4.0],
|
||||
});
|
||||
let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
let (img, info) = load_bytes(&with_map, 64).unwrap();
|
||||
let (_, plain_info) = load_bytes(&plain, 64).unwrap();
|
||||
assert!(info.local_tone.is_some() && plain_info.local_tone.is_none());
|
||||
let req = RenderRequest::fit(64, 64);
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
// (darker, so that ×4 never reaches white)
|
||||
s.light.exposure = -3.0;
|
||||
let off = render(&img, &info, &s, &req).image;
|
||||
assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing");
|
||||
s.profile.camera_local_tone = true;
|
||||
assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)");
|
||||
let on = render(&img, &info, &s, &req).image;
|
||||
// brightness gained, per column: none at the left edge, most at the right
|
||||
let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::<f32>();
|
||||
let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0);
|
||||
let last = on.width - 1;
|
||||
assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0));
|
||||
assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last));
|
||||
// the photo turned upside down: the map follows the raw, so the brightened side swaps
|
||||
s.orientation = lightcraft_geom::Orientation::Rotate180;
|
||||
s.profile.camera_local_tone = false;
|
||||
let off = render(&img, &info, &s, &req).image;
|
||||
s.profile.camera_local_tone = true;
|
||||
let on = render(&img, &info, &s, &req).image;
|
||||
let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0);
|
||||
assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last));
|
||||
// a rendered (non-raw) source never gets it
|
||||
let jpeg = SourceInfo { raw: false, ..info.clone() };
|
||||
assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_raw_falls_back_to_embedded_preview() {
|
||||
let b = cr3_with_preview(48, 32);
|
||||
@@ -1091,7 +1465,7 @@ mod tests {
|
||||
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16));
|
||||
assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none());
|
||||
|
||||
// TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws
|
||||
// TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws (#281)
|
||||
for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
@@ -1133,6 +1507,30 @@ mod tests {
|
||||
assert!(e.contains("without an embedded preview"), "{e}");
|
||||
}
|
||||
|
||||
/// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its
|
||||
/// headers; loading it says what is wrong instead of naming a bare TIFF compression number.
|
||||
#[test]
|
||||
fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let mut ifd = IfdBuilder::new();
|
||||
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16]));
|
||||
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
|
||||
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![9]));
|
||||
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
|
||||
ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
|
||||
ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
|
||||
ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] });
|
||||
let f = TiffWriter::default().write(&[ifd]).unwrap();
|
||||
let p = probe_bytes("GOPR0001.GPR", &f).unwrap();
|
||||
assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16));
|
||||
let e = load_bytes(&f, 16).unwrap_err();
|
||||
assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}");
|
||||
}
|
||||
|
||||
/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview;
|
||||
/// CR3 keeps its own wording.
|
||||
#[test]
|
||||
|
||||
@@ -32,9 +32,12 @@ use crate::media::ProbeInfo;
|
||||
|
||||
/// File extensions LightCraft imports (lower case).
|
||||
pub const EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl",
|
||||
"gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd",
|
||||
"jxl", "gif", "bmp",
|
||||
// Decoded when built with the codecs' `heif` feature; otherwise the import reports them as
|
||||
// failed with the reason ("HEIC/HEIF support isn't included in this build").
|
||||
"heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -850,8 +853,17 @@ impl ImportJob {
|
||||
}
|
||||
}
|
||||
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| self.cache.remove(f)).collect();
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read),
|
||||
// as long as they are still there, as they were: a file gone or changed since is probed again
|
||||
let cached: Vec<Option<ProbeInfo>> = todo
|
||||
.iter()
|
||||
.map(|f| {
|
||||
// (the web's files are not std::fs files: kept as probed)
|
||||
self.cache
|
||||
.remove(f)
|
||||
.filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size))
|
||||
})
|
||||
.collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let progress = ScanProgress::default();
|
||||
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
|
||||
@@ -899,7 +911,8 @@ impl ImportJob {
|
||||
let packet = crate::sidecar::find_sidecar(&path, self.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw) {
|
||||
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some());
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) {
|
||||
Ok(sc) => Some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
@@ -1044,7 +1057,7 @@ fn name_family(name: &str) -> String {
|
||||
|
||||
/// Threads for `jobs` file reads (probes, copies) at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn workers(jobs: usize) -> usize {
|
||||
pub(crate) fn workers(jobs: usize) -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1))
|
||||
}
|
||||
|
||||
@@ -1371,6 +1384,7 @@ impl Session {
|
||||
cache: None,
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
};
|
||||
let embedded = match (&self.media.preview_loader, c.kind) {
|
||||
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
|
||||
|
||||
@@ -19,11 +19,13 @@ mod camera_preview;
|
||||
pub mod camera_profiles;
|
||||
pub mod cmd;
|
||||
pub mod config;
|
||||
pub mod contact_sheet;
|
||||
pub mod crs;
|
||||
pub mod crs_masks;
|
||||
pub mod demo;
|
||||
pub mod denoise;
|
||||
pub mod devices;
|
||||
pub mod display;
|
||||
pub mod export;
|
||||
pub mod face_download;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -53,6 +55,7 @@ pub mod rename;
|
||||
pub mod segment;
|
||||
pub mod sidecar;
|
||||
pub mod smart;
|
||||
pub mod sync;
|
||||
mod view;
|
||||
pub mod walk;
|
||||
|
||||
@@ -166,6 +169,9 @@ pub struct Session {
|
||||
/// Photos in the current source with no filter on (cached like `visible`).
|
||||
total: Option<((u64, String), usize)>,
|
||||
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`]).
|
||||
commits: u64,
|
||||
pub redo: Vec<UndoEntry>,
|
||||
pub interaction: Option<Interaction>,
|
||||
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
|
||||
@@ -252,6 +258,9 @@ pub struct Session {
|
||||
pub before: std::collections::HashMap<PhotoId, Arc<DevelopSettings>>,
|
||||
/// File probes from the last import review (`library.importPreview`), reused by the import.
|
||||
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
|
||||
/// The last Synchronize Folder scan, which `folder.synchronize` acts on while it is current
|
||||
/// (see [`Session::take_folder_changes`]).
|
||||
pub folder_changes: Option<sync::FolderChanges>,
|
||||
/// The last (or running) Build Previews.
|
||||
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
|
||||
/// Develop defaults applied on import (persisted in prefs.json).
|
||||
@@ -290,6 +299,7 @@ impl Session {
|
||||
visible_gen: 0,
|
||||
total: None,
|
||||
undo: Vec::new(),
|
||||
commits: 0,
|
||||
redo: Vec::new(),
|
||||
interaction: None,
|
||||
skip_auto_write: false,
|
||||
@@ -334,6 +344,7 @@ impl Session {
|
||||
recent_keywords: Vec::new(),
|
||||
before: Default::default(),
|
||||
import_probes: Default::default(),
|
||||
folder_changes: None,
|
||||
preview_build: None,
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
@@ -352,7 +363,10 @@ impl Session {
|
||||
/// Run a command by id. THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
|
||||
// a command that validates the photos a call names isn't held back by the selection
|
||||
if !(spec.explicit_targets && cmd::names_photos(params)) {
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
|
||||
}
|
||||
let empty = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
|
||||
@@ -422,6 +436,7 @@ impl Session {
|
||||
let inv = self.catalog.apply(op)?;
|
||||
self.pending_log.push(fwd);
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None });
|
||||
self.commits = self.commits.wrapping_add(1);
|
||||
if self.undo.len() > 1000 {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
@@ -486,6 +501,12 @@ impl Session {
|
||||
|
||||
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
|
||||
/// depends on the state the previous one left).
|
||||
/// How many undo steps were committed in this session so far: what a command that commits
|
||||
/// several steps compares before and after to merge them ([`Session::merge_undo`]).
|
||||
pub fn commits(&self) -> u64 {
|
||||
self.commits
|
||||
}
|
||||
|
||||
pub fn merge_undo(&mut self, n: usize, label: &str) {
|
||||
if n < 2 || n > self.undo.len() {
|
||||
return;
|
||||
@@ -626,7 +647,8 @@ impl Session {
|
||||
|
||||
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
|
||||
/// other selected photos: only the settings that changed; never spot removal or red eye (they
|
||||
/// belong to one photo's pixels), nor history / snapshot restores.
|
||||
/// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor
|
||||
/// history / snapshot restores.
|
||||
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
|
||||
if !self.auto_sync
|
||||
|| self.active() != Some(id)
|
||||
@@ -639,7 +661,7 @@ impl Session {
|
||||
let Some(old) = self.develop_of(id) else { return Vec::new() };
|
||||
let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() };
|
||||
if let Some(o) = delta.as_object_mut() {
|
||||
for k in ["spots", "red_eye", "version"] {
|
||||
for k in ["spots", "red_eye", "version", "process"] {
|
||||
o.remove(k);
|
||||
}
|
||||
if o.is_empty() {
|
||||
@@ -859,6 +881,8 @@ mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_import_move;
|
||||
#[cfg(test)]
|
||||
mod tests_keyword_list;
|
||||
#[cfg(test)]
|
||||
mod tests_libops;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
@@ -871,10 +895,14 @@ mod tests_persist;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_process;
|
||||
#[cfg(test)]
|
||||
mod tests_segment;
|
||||
#[cfg(test)]
|
||||
mod tests_settings_files;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
#[cfg(test)]
|
||||
mod tests_sync;
|
||||
#[cfg(test)]
|
||||
mod tests_xmp;
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::sync::{
|
||||
|
||||
use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue};
|
||||
use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source};
|
||||
use lightcraft_geom::{Affine, Orientation, Point, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -477,17 +478,83 @@ fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
|
||||
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn mapped_settings(text: &str, raw: bool, aspect: f64) -> Result<(Value, Vec<String>), String> {
|
||||
fn mapped_settings(
|
||||
text: &str,
|
||||
target: crate::crs::Target,
|
||||
aspect: f64,
|
||||
orientation: Orientation,
|
||||
stored_aspect: f64,
|
||||
) -> Result<(Value, Vec<String>), String> {
|
||||
let mut root = crate::preset_import::parse_lua(text)?;
|
||||
let deferred = strip_lua_sentinels(&mut root);
|
||||
let (props, values) = crate::preset_import::lua_settings_props(&root)?;
|
||||
let (partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect);
|
||||
let (mut props, values) = crate::preset_import::lua_settings_props(&root)?;
|
||||
// A catalog's develop settings carry the crop edges and angle but no `HasCrop` (XMP has it):
|
||||
// any crop field means the photo is cropped.
|
||||
const CROP: [&str; 5] = ["crs:CropLeft", "crs:CropTop", "crs:CropRight", "crs:CropBottom", "crs:CropAngle"];
|
||||
if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) {
|
||||
props.insert("crs:HasCrop".into(), vec!["True".into()]);
|
||||
}
|
||||
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect);
|
||||
catalog_crop(&mut partial, orientation, stored_aspect);
|
||||
if deferred > 0 {
|
||||
unknown.push("Deferred Lightroom adjustments (-999999)".into());
|
||||
}
|
||||
Ok((partial, unknown))
|
||||
}
|
||||
|
||||
/// Lightroom's `Adobe_images.orientation`: where the stored image's top-left and top-right corners
|
||||
/// land in the displayed photo, naming the displayed corners A, B, C, D clockwise from the top left
|
||||
/// (`AB` upright, `BC` a quarter turn clockwise, `DA` a quarter turn counter-clockwise, `BA` mirrored).
|
||||
fn lightroom_orientation(code: &str) -> Option<Orientation> {
|
||||
const ALL: [Orientation; 8] = [
|
||||
Orientation::Normal,
|
||||
Orientation::FlipH,
|
||||
Orientation::Rotate180,
|
||||
Orientation::FlipV,
|
||||
Orientation::Transpose,
|
||||
Orientation::Rotate90,
|
||||
Orientation::Transverse,
|
||||
Orientation::Rotate270,
|
||||
];
|
||||
let corner = |o: Orientation, x: f64, y: f64| match o.map(x, y, 1.0, 1.0) {
|
||||
(u, v) if u < 0.5 && v < 0.5 => 'A',
|
||||
(_, v) if v < 0.5 => 'B',
|
||||
(u, _) if u >= 0.5 => 'C',
|
||||
_ => 'D',
|
||||
};
|
||||
ALL.into_iter().find(|&o| code.chars().eq([corner(o, 0.0, 0.0), corner(o, 1.0, 0.0)]))
|
||||
}
|
||||
|
||||
/// Lightroom's crop in LightCraft's frame. `crs` gives the crop's top-left (`CropLeft`, `CropTop`)
|
||||
/// and bottom-right (`CropRight`, `CropBottom`) corners on the stored image (before orientation and
|
||||
/// straightening), normalized to its size, plus a straighten angle that turns the other way to
|
||||
/// LightCraft's. LightCraft's crop is a rectangle in the oriented image rotated by `angle` about its
|
||||
/// centre, normalized to the oriented image's size, so the corners go through the orientation and
|
||||
/// then the rotation; `stored_aspect` (width / height of the stored image) matters once the
|
||||
/// rectangle turns. On a real catalog this reproduced the frame aspect of all 223 straightened crops
|
||||
/// in Lightroom's exports within 1%.
|
||||
fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f64) {
|
||||
let Some(geometry) = partial.pointer_mut("/crop/geometry").and_then(Value::as_object_mut) else { return };
|
||||
let edge = |k: &str| geometry.get("rect").and_then(|r| r.get(k)).and_then(Value::as_f64);
|
||||
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (edge("x0"), edge("y0"), edge("x1"), edge("y1")) else { return };
|
||||
let lightroom_angle = geometry.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
if orientation == Orientation::Normal && lightroom_angle == 0.0 {
|
||||
return;
|
||||
}
|
||||
let w = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { 1.0 };
|
||||
let (ow, oh) = if orientation.swaps_axes() { (1.0, w) } else { (w, 1.0) };
|
||||
// a mirror turns the straighten the other way too
|
||||
let angle = if orientation.to_parts().0 { lightroom_angle } else { -lightroom_angle };
|
||||
let to_rotated = Affine::rotate_about(angle.to_radians(), Point::new(ow / 2.0, oh / 2.0));
|
||||
let corner = |x: f64, y: f64| {
|
||||
let (u, v) = orientation.map(x * w, y, w, 1.0);
|
||||
to_rotated.apply(Point::new(u, v))
|
||||
};
|
||||
let r = Rect::from_points(corner(x0, y0), corner(x1, y1));
|
||||
geometry.insert("rect".into(), json!({"x0": r.x0 / ow, "y0": r.y0 / oh, "x1": r.x1 / ow, "y1": r.y1 / oh}));
|
||||
geometry.insert("angle".into(), json!(angle));
|
||||
}
|
||||
|
||||
fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize {
|
||||
use crate::preset_import::Lua;
|
||||
match value {
|
||||
@@ -656,7 +723,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
preserved += 1;
|
||||
}
|
||||
if !src.xmp.is_empty() {
|
||||
match crate::sidecar::parse_sidecar(&src.xmp, p.kind == lightcraft_catalog::MediaKind::Raw) {
|
||||
match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) {
|
||||
Ok(mut sc) => {
|
||||
if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop {
|
||||
strip_xmp_sentinels(partial);
|
||||
@@ -680,7 +747,15 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
p.meta.keywords = src.keywords.clone();
|
||||
}
|
||||
if !src.settings.is_empty() && !keep_develop {
|
||||
match mapped_settings(&src.settings, p.kind == lightcraft_catalog::MediaKind::Raw, p.width.max(1) as f64 / p.height.max(1) as f64) {
|
||||
let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default();
|
||||
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
|
||||
match mapped_settings(
|
||||
&src.settings,
|
||||
crate::crs::Target::for_photo(&p),
|
||||
p.width.max(1) as f64 / p.height.max(1) as f64,
|
||||
orientation,
|
||||
stored_aspect,
|
||||
) {
|
||||
Ok((partial, unknown)) => {
|
||||
p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0));
|
||||
p.edited = Some(now.clone());
|
||||
@@ -788,6 +863,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_geom::CropGeometry;
|
||||
fn row(v: Value) -> Row {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
@@ -866,7 +942,10 @@ mod tests {
|
||||
cyclic.collections[0].insert("parent".into(), json!(10));
|
||||
assert!(apply(&mut s, cyclic, false).is_err());
|
||||
assert_eq!(s.catalog.to_snapshot(), before);
|
||||
assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0).is_err());
|
||||
assert!(
|
||||
mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compressed_xmp_is_bounded_and_settings_are_data_only() {
|
||||
@@ -876,10 +955,180 @@ mod tests {
|
||||
assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet));
|
||||
encoded[0] = 0xff;
|
||||
assert!(xmp(Some(&json!(encoded))).is_err());
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings(
|
||||
"s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }",
|
||||
crate::crs::Target::RawAbsolute,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], 0.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() {
|
||||
// Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed
|
||||
// relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin
|
||||
let settings =
|
||||
"s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }";
|
||||
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}");
|
||||
assert_eq!(partial["light"]["exposure"], 0.5);
|
||||
assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}");
|
||||
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(
|
||||
(partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()),
|
||||
(Some("custom"), Some(3578.0), Some(-5.0))
|
||||
);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_crops_without_has_crop_are_imported() {
|
||||
// Lightroom Classic catalogs store crop edges and angle but never `HasCrop`
|
||||
let (partial, unknown) = mapped_settings(
|
||||
"s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }",
|
||||
crate::crs::Target::RawAbsolute,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
)
|
||||
.unwrap();
|
||||
let crop = &partial["crop"]["geometry"];
|
||||
assert_eq!(crop["rect"]["x0"], 0.042702);
|
||||
assert_eq!(crop["rect"]["y0"], 0.1);
|
||||
assert_eq!(crop["rect"]["x1"], 0.957298);
|
||||
assert_eq!(crop["rect"]["y1"], 1.0);
|
||||
assert_eq!(crop["angle"], 0.0);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}");
|
||||
// an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's)
|
||||
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["crop"]["geometry"]["angle"], 1.5);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}");
|
||||
// no crop fields: the crop stays untouched
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightroom_orientation_codes() {
|
||||
// the three seen in a real catalog, checked against each file's EXIF orientation (1, 6, 8)
|
||||
assert_eq!(lightroom_orientation("AB"), Some(Orientation::Normal));
|
||||
assert_eq!(lightroom_orientation("BC"), Some(Orientation::Rotate90));
|
||||
assert_eq!(lightroom_orientation("DA"), Some(Orientation::Rotate270));
|
||||
assert_eq!(lightroom_orientation("CD"), Some(Orientation::Rotate180));
|
||||
assert_eq!(lightroom_orientation("BA"), Some(Orientation::FlipH));
|
||||
assert_eq!(lightroom_orientation("DC"), Some(Orientation::FlipV));
|
||||
assert_eq!(lightroom_orientation("AD"), Some(Orientation::Transpose));
|
||||
assert_eq!(lightroom_orientation("CB"), Some(Orientation::Transverse));
|
||||
for code in ["", "A", "ABC", "AC", "ab", "XY"] {
|
||||
assert_eq!(lightroom_orientation(code), None, "{code:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_crops_follow_the_photo_orientation() {
|
||||
let crop = |o: Orientation| {
|
||||
let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }";
|
||||
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
let g = &partial["crop"]["geometry"];
|
||||
let r = &g["rect"];
|
||||
[r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9)
|
||||
};
|
||||
// (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise
|
||||
assert_eq!(crop(Orientation::Normal), [0.1, 0.2, 0.6, 0.9, 0.0]);
|
||||
assert_eq!(crop(Orientation::Rotate90), [0.1, 0.1, 0.8, 0.6, 0.0]);
|
||||
assert_eq!(crop(Orientation::Rotate270), [0.2, 0.4, 0.9, 0.9, 0.0]);
|
||||
assert_eq!(crop(Orientation::Rotate180), [0.4, 0.1, 0.9, 0.8, 0.0]);
|
||||
assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]);
|
||||
// a mirror turns the straighten the other way
|
||||
let angle = |o: Orientation| {
|
||||
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
partial["crop"]["geometry"]["angle"].as_f64().unwrap()
|
||||
};
|
||||
assert_eq!(angle(Orientation::Rotate90), -2.0);
|
||||
assert_eq!(angle(Orientation::FlipH), 2.0);
|
||||
// no crop: nothing to orient
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
/// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image
|
||||
/// of aspect `w` (height 1) shown with orientation `o`.
|
||||
fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String {
|
||||
let (ow, oh) = if o.swaps_axes() { (1.0, w) } else { (w, 1.0) };
|
||||
let to_source = Affine::rotate_about(-g.angle.to_radians(), Point::new(ow / 2.0, oh / 2.0));
|
||||
let stored = |x: f64, y: f64| {
|
||||
let p = to_source.apply(Point::new(x * ow, y * oh));
|
||||
o.inverse().map(p.x, p.y, ow, oh)
|
||||
};
|
||||
// the rectangle's corners that land top-left and bottom-right on the stored image
|
||||
let corners = [stored(g.rect.x0, g.rect.y0), stored(g.rect.x1, g.rect.y0), stored(g.rect.x1, g.rect.y1), stored(g.rect.x0, g.rect.y1)];
|
||||
let (tl, br) = (0..4).map(|i| (corners[i], corners[(i + 2) % 4])).find(|(a, b)| a.0 < b.0 && a.1 < b.1).unwrap();
|
||||
let angle = if o.to_parts().0 { g.angle } else { -g.angle };
|
||||
format!("s = {{ CropLeft = {}, CropTop = {}, CropRight = {}, CropBottom = {}, CropAngle = {angle} }}", tl.0 / w, tl.1, br.0 / w, br.1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straightened_catalog_crops_round_trip() {
|
||||
let g = CropGeometry { rect: Rect::new(0.2, 0.15, 0.7, 0.8), angle: 6.5 };
|
||||
for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] {
|
||||
for angle in [6.5, -6.5] {
|
||||
let g = CropGeometry { angle, ..g };
|
||||
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap();
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
assert!(
|
||||
close(got.angle, g.angle)
|
||||
&& [(got.rect.x0, g.rect.x0), (got.rect.y0, g.rect.y0), (got.rect.x1, g.rect.x1), (got.rect.y1, g.rect.y1)]
|
||||
.iter()
|
||||
.all(|&(a, b)| close(a, b)),
|
||||
"{o:?} {angle}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straightened_catalog_crops_match_lightroom_exports() {
|
||||
// Settings and pixel sizes from a real Lightroom Classic catalog (9504 × 6336 stored) and the
|
||||
// JPEGs Lightroom exported from it at full size.
|
||||
for (settings, o, export) in [
|
||||
(
|
||||
"s = { CropAngle = -9.34444, CropBottom = 0.802034, CropLeft = 0.05499, CropRight = 0.94501, CropTop = 0.197966 }",
|
||||
Orientation::Normal,
|
||||
(7725.0, 5150.0),
|
||||
),
|
||||
(
|
||||
"s = { CropAngle = -1.48964, CropBottom = 0.962457, CropLeft = 0.010429, CropRight = 0.989571, CropTop = 0.037543 }",
|
||||
Orientation::Rotate90,
|
||||
(6100.0, 9150.0),
|
||||
),
|
||||
] {
|
||||
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap();
|
||||
let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap();
|
||||
let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) };
|
||||
let (w, h) = (r.width() * ow, r.height() * oh);
|
||||
assert!((w - export.0).abs() <= 3.0 && (h - export.1).abs() <= 3.0, "{o:?}: {w:.1} × {h:.1}, Lightroom exported {export:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_crops_use_the_catalog_orientation() {
|
||||
let mut data = sample();
|
||||
data.photos.truncate(1);
|
||||
data.photos[0].image.remove("masterImage");
|
||||
data.photos[0].image.insert("orientation".into(), json!("BC"));
|
||||
data.photos[0].settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }".into();
|
||||
data.photos[0].xmp = String::new();
|
||||
let mut s = crate::Session::new();
|
||||
apply(&mut s, data, false).unwrap();
|
||||
let g = s.catalog.photos().next().unwrap().develop.crop.geometry;
|
||||
let r = [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1].map(|v| (v * 1e9).round() / 1e9);
|
||||
assert_eq!(r, [0.1, 0.1, 0.8, 0.6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_auto_tone_sentinels_never_become_real_slider_values() {
|
||||
let mut data = sample();
|
||||
@@ -896,7 +1145,9 @@ mod tests {
|
||||
assert_eq!(p.develop.light.shadows, 65.0);
|
||||
assert_eq!(p.develop.light.blacks, -22.0);
|
||||
assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred"))));
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0).unwrap();
|
||||
let (partial, _) =
|
||||
mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
|
||||
.unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], -5.0);
|
||||
assert_eq!(partial["light"]["contrast"], -100.0);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 16;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 37;
|
||||
|
||||
/// 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];
|
||||
@@ -209,6 +209,7 @@ impl DecodedSource {
|
||||
/// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix.
|
||||
pub fn bytes(&self) -> usize {
|
||||
let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes());
|
||||
let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4);
|
||||
self.image.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes())
|
||||
}
|
||||
|
||||
@@ -250,7 +251,7 @@ impl SourceRef {
|
||||
pub fn load_source(&self) -> Result<DecodedSource, String> {
|
||||
let image = match self {
|
||||
SourceRef::Loaded(a) => return Ok((**a).clone()),
|
||||
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge),
|
||||
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
|
||||
SourceRef::File { path, max_edge, loader, fallback, denoise } => {
|
||||
let plain = || match loader {
|
||||
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
|
||||
@@ -673,6 +674,8 @@ pub struct RenderJob {
|
||||
pub stages: Option<Arc<StageCache>>,
|
||||
/// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]).
|
||||
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
|
||||
/// Render for this monitor profile ([`Self::with_display`]).
|
||||
pub display: Option<Arc<crate::display::Display>>,
|
||||
}
|
||||
|
||||
pub struct RenderResult {
|
||||
@@ -686,6 +689,9 @@ pub struct RenderResult {
|
||||
pub loaded: Option<DecodedSource>,
|
||||
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
|
||||
pub quick: Option<QuickSource>,
|
||||
/// The display profile (id) the image is encoded for; `None`: sRGB (see
|
||||
/// [`crate::display::present`]).
|
||||
pub display: Option<u64>,
|
||||
}
|
||||
|
||||
impl RenderJob {
|
||||
@@ -733,6 +739,21 @@ impl RenderJob {
|
||||
self
|
||||
}
|
||||
|
||||
/// Render for a monitor profile: into the display's primaries (its gamut, not sRGB's), ready
|
||||
/// to show ([`RenderResult::display`]). The histogram and the cached view preview stay sRGB.
|
||||
/// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are:
|
||||
/// [`crate::display::present`] converts their sRGB results.
|
||||
pub fn with_display(mut self, display: Option<Arc<crate::display::Display>>) -> Self {
|
||||
if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 {
|
||||
return self;
|
||||
}
|
||||
let k = |d: &Option<Arc<crate::display::Display>>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93);
|
||||
self.key ^= k(&self.display) ^ k(&display);
|
||||
self.request.display = display.as_ref().map(|d| d.space);
|
||||
self.display = display;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn run(self) -> RenderResult {
|
||||
if let Some((cache, key)) = &self.cache
|
||||
&& let Some(img) = cache.get_at(self.cache_generation, *key)
|
||||
@@ -748,10 +769,18 @@ impl RenderJob {
|
||||
rendered: Ok(Rendered { image, histogram, deep: None }),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
};
|
||||
}
|
||||
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
|
||||
match self.source.load_source() {
|
||||
// The app is closing (issue #620): nobody is left to show this, and quitting waits for the
|
||||
// jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU.
|
||||
let closing = || lightcraft_gpu::shutting_down().then(|| "LightCraft is closing".to_string());
|
||||
let source = match closing() {
|
||||
Some(e) => Err(e),
|
||||
None => self.source.load_source().and_then(|s| closing().map_or(Ok(s), Err)),
|
||||
};
|
||||
match source {
|
||||
Ok(source) => {
|
||||
// the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for
|
||||
let src = &source.image_for(self.settings.denoise_amount());
|
||||
@@ -759,14 +788,30 @@ impl RenderJob {
|
||||
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
|
||||
// the GPU when there is one.
|
||||
let gpu = self.cache.is_none();
|
||||
let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
let mut rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
// for a display profile: the image goes to the display's values, the histogram
|
||||
// and the cached view preview stay sRGB
|
||||
let mut srgb = None;
|
||||
let display = match (&self.display, self.request.display) {
|
||||
(Some(d), Some(_)) => {
|
||||
let keep = self.view_cache.is_some() && self.request.quality == Quality::Full;
|
||||
let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep);
|
||||
if let Some(h) = h {
|
||||
rendered.histogram = h;
|
||||
}
|
||||
srgb = s;
|
||||
Some(d.id())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((cache, key)) = &self.cache {
|
||||
cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone()));
|
||||
}
|
||||
if let Some((cache, key)) = &self.view_cache
|
||||
&& self.request.quality == Quality::Full
|
||||
&& (display.is_none() || srgb.is_some())
|
||||
{
|
||||
cache.put_deferred_at(self.cache_generation, *key, Arc::new(rendered.image.clone()));
|
||||
cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone())));
|
||||
}
|
||||
RenderResult {
|
||||
request_id: self.request_id,
|
||||
@@ -777,6 +822,7 @@ impl RenderJob {
|
||||
rendered: Ok(rendered),
|
||||
loaded: (!was_loaded).then_some(source),
|
||||
quick: None,
|
||||
display,
|
||||
}
|
||||
}
|
||||
Err(e) => RenderResult {
|
||||
@@ -788,6 +834,7 @@ impl RenderJob {
|
||||
rendered: Err(e),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -852,6 +899,7 @@ impl QuickJob {
|
||||
rendered,
|
||||
loaded: None,
|
||||
quick,
|
||||
display: None,
|
||||
};
|
||||
let rendered = |image: Rgba8| {
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
@@ -971,6 +1019,7 @@ impl crate::Session {
|
||||
cache,
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1021,6 +1070,7 @@ impl crate::Session {
|
||||
cache: Some((self.media.rendered.clone(), ck)),
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1066,6 +1116,7 @@ impl crate::Session {
|
||||
cache: Some((self.media.rendered.clone(), ck)),
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ use serde::Serialize;
|
||||
/// - a quarter for the working memory of decodes in flight ([`work_gate`]);
|
||||
/// - an eighth for the GPU renderer's pool of recycled buffers (trimmed when the app is idle).
|
||||
///
|
||||
/// The rest is headroom for what isn't cached (renders in progress, textures, the UI). Default:
|
||||
/// The rest is headroom for what isn't cached (renders in progress, textures, the UI). An export
|
||||
/// batch may, in addition, hold up to twice the budget in photos rendering side by side
|
||||
/// ([`export_gate`]): transient memory, given back when the batch ends. Default:
|
||||
/// a quarter of the machine's RAM, at most 1.5 GiB (`LIGHTCRAFT_MEMORY_MB` overrides it).
|
||||
pub fn default_budget() -> usize {
|
||||
if let Some(mb) = std::env::var("LIGHTCRAFT_MEMORY_MB").ok().and_then(|v| v.trim().parse::<usize>().ok()).filter(|m| *m >= 64) {
|
||||
@@ -83,6 +85,9 @@ fn apply(b: usize) {
|
||||
if let Some(g) = GATE.get() {
|
||||
g.set_limit(b / 4);
|
||||
}
|
||||
if let Some(g) = EXPORT_GATE.get() {
|
||||
g.set_limit(export_share(b));
|
||||
}
|
||||
lightcraft_gpu::set_pool_limit((b / 8) as u64);
|
||||
}
|
||||
|
||||
@@ -174,6 +179,19 @@ pub fn work_gate() -> &'static WorkGate {
|
||||
|
||||
static GATE: OnceLock<WorkGate> = OnceLock::new();
|
||||
|
||||
/// The process-wide gate for the photos of an export batch rendering side by side
|
||||
/// ([`crate::export::run_batch`]; limit: twice the budget). Separate from [`work_gate`], which the
|
||||
/// decodes inside each export take: one gate held across the other's acquire could deadlock.
|
||||
pub fn export_gate() -> &'static WorkGate {
|
||||
EXPORT_GATE.get_or_init(|| WorkGate::new(export_share(budget())))
|
||||
}
|
||||
|
||||
fn export_share(budget: usize) -> usize {
|
||||
budget.saturating_mul(2)
|
||||
}
|
||||
|
||||
static EXPORT_GATE: OnceLock<WorkGate> = OnceLock::new();
|
||||
|
||||
/// Entries and bytes held by one cache.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct Usage {
|
||||
|
||||
@@ -464,7 +464,7 @@ pub fn group_from_dir(dir: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
|
||||
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT);
|
||||
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT);
|
||||
if from_photo && let Some(o) = settings.as_object_mut() {
|
||||
// a photo's own framing and absolute white balance don't belong in a look
|
||||
o.remove("crop");
|
||||
|
||||
@@ -114,8 +114,10 @@ pub enum DevelopPatch {
|
||||
Partial(Value),
|
||||
}
|
||||
|
||||
/// Parse a sidecar / XMP packet. `raw` selects absolute (raw) vs relative white balance for `crs:`.
|
||||
pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, String> {
|
||||
/// Parse a sidecar / XMP packet. `target` selects how a `crs:` white balance is read
|
||||
/// ([`crate::crs::Target`]: absolute Kelvin for raws with a measured illuminant, a shift from the
|
||||
/// as-shot white for everything developed relative to it).
|
||||
pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Result<SidecarData, String> {
|
||||
let d = lightcraft_meta::parse_xmp(xmp).map_err(|e| e.to_string())?;
|
||||
let m = &d.metadata;
|
||||
let lc = |k: &str| d.properties.get(&format!("lc:{k}")).and_then(|v| v.first()).cloned();
|
||||
@@ -133,7 +135,8 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
country: m.country.clone(),
|
||||
alt_text: m.alt_text.clone(),
|
||||
extended_description: m.extended_description.clone(),
|
||||
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
|
||||
keywords: (!m.keywords.is_empty() || !m.hierarchical_keywords.is_empty())
|
||||
.then(|| lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords)),
|
||||
// A sidecar that has `mwg-rs:Regions` at all (even an empty list) was written by an app that
|
||||
// knows about regions, so it's authoritative: its list, empty or not, replaces the catalog's.
|
||||
// One without it (most writers, LightCraft's own included, which keeps another app's
|
||||
@@ -162,7 +165,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
out.develop = match full {
|
||||
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
|
||||
None if crate::crs::has_adjustments(&d.properties) => {
|
||||
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), Some(raw), crate::crs_masks::DEFAULT_ASPECT).0))
|
||||
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT).0))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -445,6 +448,18 @@ impl Session {
|
||||
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
|
||||
/// sidecar — a raw first, else the first by file name. The others use Full naming
|
||||
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
|
||||
/// [`Session::sidecar_naming`] for many photos, the catalog's sidecar owners worked out once
|
||||
/// (per photo that is a pass over the whole catalog).
|
||||
pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
let owners = (self.xmp.naming == SidecarNaming::Stem).then(|| StemOwners::of(&self.catalog));
|
||||
ids.iter()
|
||||
.map(|id| match (self.catalog.photo(*id), &owners) {
|
||||
(Some(p), Some(o)) => o.naming(p, self.xmp.naming),
|
||||
_ => self.xmp.naming,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
|
||||
match self.catalog.photo(id) {
|
||||
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
|
||||
@@ -475,9 +490,15 @@ impl Session {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let Some(orig) = file_path(p) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
|
||||
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw)
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?
|
||||
.resolve_label(&self.catalog);
|
||||
let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
|
||||
Ok(Some((self.sidecar_op(id, sc)?, from)))
|
||||
}
|
||||
|
||||
/// The op that applies what a sidecar says (already read and parsed, e.g. on a worker
|
||||
/// thread) to photo `id`: the sidecar wins. No file is read.
|
||||
pub fn sidecar_op(&self, id: PhotoId, sc: SidecarData) -> Result<Op> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let sc = sc.resolve_label(&self.catalog);
|
||||
let mut q = (**p).clone();
|
||||
let develop_changed = merge_into(&mut q, &sc, &(self.clock)());
|
||||
let mut ops = vec![
|
||||
@@ -492,7 +513,7 @@ impl Session {
|
||||
if develop_changed {
|
||||
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
|
||||
}
|
||||
Ok(Some((Op::Batch { ops }, from)))
|
||||
Ok(Op::Batch { ops })
|
||||
}
|
||||
|
||||
/// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned).
|
||||
@@ -515,6 +536,19 @@ impl Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`)
|
||||
/// gives the photo its keywords as paths.
|
||||
#[test]
|
||||
fn sidecar_keywords_keep_their_hierarchy() {
|
||||
let m = lightcraft_meta::Metadata {
|
||||
keywords: vec!["Lisbon".into(), "Places".into()],
|
||||
hierarchical_keywords: vec!["Places|Lisbon".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let sc = super::parse_sidecar(&lightcraft_meta::write_xmp(&m, None), crate::crs::Target::Rendered).unwrap();
|
||||
assert_eq!(sc.keywords, Some(vec!["Places|Lisbon".to_string()]));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use lightcraft_catalog::Meta;
|
||||
|
||||
@@ -553,7 +587,7 @@ mod tests {
|
||||
fn packet_roundtrip_restores_everything() {
|
||||
let p = photo();
|
||||
let x = sidecar_packet(&p, &lightcraft_catalog::Catalog::new());
|
||||
let sc = parse_sidecar(&x, false).unwrap();
|
||||
let sc = parse_sidecar(&x, crate::crs::Target::Rendered).unwrap();
|
||||
assert_eq!(sc.rating, Some(4));
|
||||
assert_eq!(sc.flag, Some(Flag::Pick));
|
||||
assert_eq!(sc.label, Some(Some(ColorLabel::Purple)));
|
||||
@@ -572,7 +606,7 @@ mod tests {
|
||||
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
xmp:Rating="-1" xmp:Label="Green" crs:Exposure2012="-0.40" crs:Vibrance="+12"/></rdf:RDF></x:xmpmeta>"#;
|
||||
let sc = parse_sidecar(x, true).unwrap();
|
||||
let sc = parse_sidecar(x, crate::crs::Target::RawAbsolute).unwrap();
|
||||
assert_eq!((sc.rating, sc.flag, sc.label), (Some(0), Some(Flag::Reject), Some(Some(ColorLabel::Green))));
|
||||
let mut p = photo();
|
||||
let before = p.develop.clone();
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
//! Synchronize Folder: bring a folder of the library up to date with what is on disk, as
|
||||
//! Lightroom Classic's Synchronize Folder does.
|
||||
//!
|
||||
//! A [scan](scan_with) compares the folder (and the folders inside it) with the library and finds
|
||||
//! three kinds of change, touching nothing:
|
||||
//!
|
||||
//! - **new** files: supported files the library doesn't have, by path or by content (the import
|
||||
//! scan's duplicate rules, [`crate::import::scan_with`]);
|
||||
//! - **missing** photos: library photos in the folder whose file is gone (the Missing Photos rule,
|
||||
//! [`crate::cmd::missing::checked_path`]);
|
||||
//! - **metadata** updates: photos whose XMP sidecar file was saved after the photo came into the
|
||||
//! library or was last edited here, and says something the library doesn't (rating, flag,
|
||||
//! label, metadata, capture time or develop settings). Reading one makes the sidecar win, as
|
||||
//! Read Metadata from File does. Embedded XMP in a raw is not a sidecar and is not looked at.
|
||||
//!
|
||||
//! [`synchronize`] then imports the new files (in place), relinks the moved photos, moves the
|
||||
//! missing photos to Recently Deleted and reads the metadata updates — each only when chosen — as
|
||||
//! one undo step. It runs in halves ([`SyncJob`]) so that, in the app, every file it reads is
|
||||
//! read on a worker thread and only the catalog changes happen on the UI thread — with what every
|
||||
//! command does after them there: the library's durable save and, with XMP auto-write on, the
|
||||
//! sidecars of the photos whose metadata was read.
|
||||
//!
|
||||
//! The scan runs without the session ([`SyncInput`] + [`scan_with`]), so the app can run it on a
|
||||
//! worker thread: a folder on a sleeping network share must never stall a frame.
|
||||
//!
|
||||
//! Limits of telling an update from a sidecar that is merely older:
|
||||
//! - a sidecar the library itself wrote (auto-write on) agrees with it and is never an update;
|
||||
//! - the library records when a photo came in and when its develop settings last changed, not
|
||||
//! when its rating, flag, label or metadata did: a sidecar another app saved, followed by such
|
||||
//! a change made here (with auto-write off), is listed as an update, and reading it puts the
|
||||
//! older value back. Reading updates is therefore never the default;
|
||||
//! - times are compared as the clock the session runs with; headless sessions (CLI, MCP) start
|
||||
//! with a fixed clock, so their own times say little;
|
||||
//! - a sidecar is found as Read Metadata from File finds it, including the other naming
|
||||
//! scheme's file (`IMG_1.xmp` for `IMG_1.JPG` beside `IMG_1.CR3`).
|
||||
//!
|
||||
//! The walk for new files stops at the import's limits on depth and entries (logged), and is
|
||||
//! not cancelled midway.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::import::{ImportCandidate, ImportMode, ImportOptions, ScanInput, ScanProgress};
|
||||
use crate::media::ProbeInfo;
|
||||
use crate::sidecar::SidecarNaming;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped.
|
||||
const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// How far a scan is: the folder's files probed ([`ScanProgress`], whose `cancel` stops the
|
||||
/// whole scan) and the library's photos checked.
|
||||
#[derive(Default)]
|
||||
pub struct SyncProgress {
|
||||
pub files: ScanProgress,
|
||||
pub checked: std::sync::atomic::AtomicUsize,
|
||||
pub to_check: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl SyncProgress {
|
||||
/// (done, of how many), both halves together.
|
||||
pub fn counts(&self) -> (usize, usize) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let done = self.files.done.load(Relaxed).saturating_add(self.checked.load(Relaxed));
|
||||
(done, self.files.total.load(Relaxed).saturating_add(self.to_check.load(Relaxed)))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a scan of a folder needs, taken from the session so it can run on a worker thread.
|
||||
pub struct SyncInput {
|
||||
folder: String,
|
||||
scan: ScanInput,
|
||||
/// The library's photos in the folder, each with how its sidecar is named.
|
||||
photos: Vec<(Arc<Photo>, SidecarNaming)>,
|
||||
/// For the colour label names a sidecar may use.
|
||||
labels: Catalog,
|
||||
}
|
||||
|
||||
/// What changed in a folder since it came into the library.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct FolderChanges {
|
||||
/// The folder, as asked for.
|
||||
pub path: String,
|
||||
/// Files to import.
|
||||
pub new: Vec<ImportCandidate>,
|
||||
/// Files whose content the library already has (under another path).
|
||||
pub duplicates: usize,
|
||||
/// Supported files that couldn't be read.
|
||||
pub unreadable: Vec<ImportCandidate>,
|
||||
/// Photos whose file is gone.
|
||||
pub missing: Vec<FolderPhoto>,
|
||||
/// Photos whose sidecar has news.
|
||||
pub metadata: Vec<FolderPhoto>,
|
||||
/// Photos whose file was renamed or moved within the folder (found by its content).
|
||||
pub moved: Vec<FolderMoved>,
|
||||
/// The folder itself is not there (moved or renamed whole, or on a disk that isn't
|
||||
/// connected): nothing else is reported, rather than every photo as missing.
|
||||
pub offline: bool,
|
||||
/// The probes of the new files, kept for the import that follows.
|
||||
#[serde(skip)]
|
||||
probes: HashMap<String, ProbeInfo>,
|
||||
/// The folder's photos as the scan saw them: the scan is current while they are the same
|
||||
/// records (any change to one replaces it in the catalog).
|
||||
#[serde(skip)]
|
||||
seen: Vec<Arc<Photo>>,
|
||||
}
|
||||
|
||||
/// A photo of the folder and its file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct FolderPhoto {
|
||||
pub id: u64,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// A photo whose file is now somewhere else in the folder.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct FolderMoved {
|
||||
pub id: u64,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// What [`synchronize`] does with the changes found.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SyncChoice {
|
||||
pub import_new: bool,
|
||||
pub relink_moved: bool,
|
||||
pub remove_missing: bool,
|
||||
pub read_metadata: bool,
|
||||
}
|
||||
|
||||
impl Default for SyncChoice {
|
||||
/// Lightroom Classic's defaults: import the new files, leave the rest to be looked at; and
|
||||
/// photos whose file moved follow it (nothing is lost, and undo puts them back).
|
||||
fn default() -> Self {
|
||||
SyncChoice { import_new: true, relink_moved: true, remove_missing: false, read_metadata: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`synchronize`] did.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct SyncReport {
|
||||
pub imported: usize,
|
||||
pub relinked: usize,
|
||||
pub removed: usize,
|
||||
pub read: usize,
|
||||
/// (path or photo, why) for what couldn't be done.
|
||||
pub failed: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn invalid(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(msg.into())
|
||||
}
|
||||
|
||||
impl SyncInput {
|
||||
/// The scan of folder `path`: a folder the library holds photos in, not the startup disk. A
|
||||
/// whole disk or share, or a folder that holds disks (`/Volumes`, `/mnt`…), only with `disk`
|
||||
/// (as for Remove Disk from Library: its missing photos can be a whole disk's).
|
||||
pub fn new(s: &mut Session, path: &str, disk: bool) -> Result<SyncInput> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return Err(invalid("missing `path`"));
|
||||
}
|
||||
if lightcraft_catalog::folders::is_startup_disk(path) {
|
||||
return Err(invalid("choose a folder, not the whole startup disk"));
|
||||
}
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
let ids = s.catalog.query(&f, &Sort::default());
|
||||
if ids.is_empty() {
|
||||
return Err(invalid(format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) {
|
||||
return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it")));
|
||||
}
|
||||
let namings = s.sidecar_namings(&ids);
|
||||
let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
|
||||
let (scan, _) = ScanInput::new(s, &[path.to_string()]);
|
||||
Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Find what changed in the folder (see the module docs). Reads the disk, never the session.
|
||||
///
|
||||
/// Both halves wait on the disk (a network share answers each file slowly), so they run at once:
|
||||
/// the look for new files (which probes them on its own threads) beside the checks of the
|
||||
/// library's photos, which run on several threads too. Stops early when `progress.files.cancel`
|
||||
/// is set.
|
||||
pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
|
||||
let SyncInput { folder, scan, photos, labels } = input;
|
||||
let seen: Vec<Arc<Photo>> = photos.iter().map(|(p, _)| Arc::clone(p)).collect();
|
||||
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
|
||||
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
|
||||
}
|
||||
// (no threads on the web: one after the other there)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (out, checks) = std::thread::scope(|sc| {
|
||||
let photos = &photos;
|
||||
let labels = &labels;
|
||||
let checks = sc.spawn(move || check_photos(photos, labels, progress));
|
||||
let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files);
|
||||
// (a check that panicked counts as nothing found; the scan still reports the rest)
|
||||
(out, checks.join().unwrap_or_default())
|
||||
});
|
||||
let mut changes = FolderChanges { path: folder, seen, ..Default::default() };
|
||||
for ((p, _), check) in photos.iter().zip(checks) {
|
||||
let Some(file) = crate::cmd::missing::checked_path(p) else { continue };
|
||||
let found = FolderPhoto { id: p.id.0, path: file.to_string() };
|
||||
match check {
|
||||
Check::Missing => changes.missing.push(found),
|
||||
Check::News => changes.metadata.push(found),
|
||||
Check::Nothing => {}
|
||||
}
|
||||
}
|
||||
let moved_from = match_moved(&changes.missing, &out.candidates, &out.probes, &labels);
|
||||
for c in out.candidates {
|
||||
if moved_from.values().any(|to| *to == c.path) {
|
||||
continue;
|
||||
}
|
||||
let browsed = c.existing.and_then(|id| labels.photo(PhotoId(id))).is_some_and(|p| p.local && !p.deleted);
|
||||
match (c.duplicate.as_deref(), &c.error) {
|
||||
// a file only looked at in Local is not in the library yet: it is new
|
||||
(Some("path"), _) if browsed => changes.new.push(ImportCandidate { duplicate: None, existing: None, ..c }),
|
||||
(Some("path"), _) => {}
|
||||
(Some(_), _) => changes.duplicates += 1,
|
||||
(None, Some(_)) => changes.unreadable.push(c),
|
||||
(None, None) => changes.new.push(c),
|
||||
}
|
||||
}
|
||||
let missing = std::mem::take(&mut changes.missing);
|
||||
for m in missing {
|
||||
match moved_from.get(&m.path) {
|
||||
Some(to) => changes.moved.push(FolderMoved { id: m.id, from: m.path, to: to.clone() }),
|
||||
None => changes.missing.push(m),
|
||||
}
|
||||
}
|
||||
changes.probes = out.probes;
|
||||
changes
|
||||
}
|
||||
|
||||
/// Whether a photo is the same as far as synchronizing goes: its file, its place in the library
|
||||
/// and what a sidecar is compared with. Face and pet regions found in the background meanwhile
|
||||
/// don't make a scan stale.
|
||||
fn same_for_sync(a: &Arc<Photo>, b: &Arc<Photo>) -> bool {
|
||||
if Arc::ptr_eq(a, b) {
|
||||
return true;
|
||||
}
|
||||
let meta = |p: &Photo| lightcraft_catalog::Meta { regions: Vec::new(), ..p.meta.clone() };
|
||||
(&a.source, a.deleted, a.local, a.copy_of, &a.content_hash, a.file_size, a.rating, a.flag, a.label, &a.captured, &a.edited, &a.develop)
|
||||
== (&b.source, b.deleted, b.local, b.copy_of, &b.content_hash, b.file_size, b.rating, b.flag, b.label, &b.captured, &b.edited, &b.develop)
|
||||
&& meta(a) == meta(b)
|
||||
}
|
||||
|
||||
/// The content a hash stands for: a Duplicate's own hash is the original's plus `:dup<id>`.
|
||||
fn base_hash(h: &str) -> &str {
|
||||
h.split(':').next().unwrap_or(h)
|
||||
}
|
||||
|
||||
/// Missing photos' files found elsewhere in the folder (old path → new path): the file was
|
||||
/// renamed or moved, and the photo (with its edits) follows it rather than going missing.
|
||||
///
|
||||
/// By content first: each missing file's hash claims one file with that content (two missing
|
||||
/// files with the same content claim two). A photo with no hash on record (taken over from a
|
||||
/// Lightroom catalog) is matched by file size and dimensions, and only when exactly one missing
|
||||
/// file and exactly one unclaimed file of the folder agree on them.
|
||||
fn match_moved(
|
||||
missing: &[FolderPhoto],
|
||||
candidates: &[ImportCandidate],
|
||||
probes: &HashMap<String, ProbeInfo>,
|
||||
labels: &Catalog,
|
||||
) -> HashMap<String, String> {
|
||||
// distinct missing files (a photo's virtual copies share its file), with what is known of them
|
||||
let mut files: Vec<(&str, Option<&Photo>)> = Vec::new();
|
||||
for m in missing {
|
||||
if !files.iter().any(|(p, _)| *p == m.path) {
|
||||
files.push((m.path.as_str(), labels.photo(PhotoId(m.id)).map(|p| &**p)));
|
||||
}
|
||||
}
|
||||
let mut by_hash: HashMap<&str, std::collections::VecDeque<&str>> = HashMap::new();
|
||||
for (path, p) in &files {
|
||||
if let Some(h) = p.and_then(|p| p.content_hash.as_deref()) {
|
||||
by_hash.entry(base_hash(h)).or_default().push_back(path);
|
||||
}
|
||||
}
|
||||
let usable = |c: &&ImportCandidate| c.error.is_none() && c.duplicate.as_deref() != Some("path");
|
||||
let mut moved: HashMap<String, String> = HashMap::new();
|
||||
for c in candidates.iter().filter(usable) {
|
||||
let hash = probes.get(&c.path).and_then(|i| i.content_hash.as_deref());
|
||||
if let Some(from) = hash.and_then(|h| by_hash.get_mut(base_hash(h))).and_then(std::collections::VecDeque::pop_front) {
|
||||
moved.insert(from.to_string(), c.path.clone());
|
||||
}
|
||||
}
|
||||
let key = |size: u64, w: u32, h: u32| (size > 0 && w > 0 && h > 0).then_some((size, w, h));
|
||||
let mut hashless: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new();
|
||||
for (path, p) in &files {
|
||||
if let Some(p) = p.filter(|p| p.content_hash.is_none())
|
||||
&& let Some(k) = key(p.file_size, p.width, p.height)
|
||||
{
|
||||
hashless.entry(k).or_default().push(path);
|
||||
}
|
||||
}
|
||||
let mut found: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new();
|
||||
for c in candidates.iter().filter(usable).filter(|c| !moved.values().any(|to| *to == c.path)) {
|
||||
if let Some(k) = key(c.file_size, c.width, c.height) {
|
||||
found.entry(k).or_default().push(c.path.as_str());
|
||||
}
|
||||
}
|
||||
for (k, from) in hashless {
|
||||
if let ([from], Some([to])) = (from.as_slice(), found.get(&k).map(Vec::as_slice)) {
|
||||
moved.insert(from.to_string(), to.to_string());
|
||||
}
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
/// What checking one photo of the folder found.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum Check {
|
||||
#[default]
|
||||
Nothing,
|
||||
Missing,
|
||||
News,
|
||||
}
|
||||
|
||||
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
|
||||
let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing };
|
||||
if !Path::new(file).exists() {
|
||||
Check::Missing
|
||||
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) {
|
||||
Check::News
|
||||
} else {
|
||||
Check::Nothing
|
||||
}
|
||||
}
|
||||
|
||||
/// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the
|
||||
/// disk, not work for the CPU). Photos not reached before a cancel count as nothing found.
|
||||
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
progress.to_check.store(photos.len(), Relaxed);
|
||||
let cancel = &progress.files.cancel;
|
||||
let check = |p: &Photo, naming: SidecarNaming| {
|
||||
let c = check_photo(p, naming, labels);
|
||||
progress.checked.fetch_add(1, Relaxed);
|
||||
c
|
||||
};
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return vec![Check::Nothing; photos.len()];
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = crate::import::workers(photos.len());
|
||||
if n > 1 {
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let found = std::sync::Mutex::new(vec![Check::Nothing; photos.len()]);
|
||||
std::thread::scope(|sc| {
|
||||
for _ in 0..n {
|
||||
sc.spawn(|| {
|
||||
while !cancel.load(Relaxed) {
|
||||
let i = next.fetch_add(1, Relaxed);
|
||||
let Some((p, naming)) = photos.get(i) else { break };
|
||||
let c = check(p, *naming);
|
||||
if let Some(slot) = found.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) {
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return found.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
}
|
||||
photos.iter().map(|(p, naming)| if cancel.load(Relaxed) { Check::Nothing } else { check(p, *naming) }).collect()
|
||||
}
|
||||
|
||||
/// [`scan_with`] on the calling thread.
|
||||
pub fn scan(s: &mut Session, path: &str, disk: bool) -> Result<FolderChanges> {
|
||||
let input = SyncInput::new(s, path, disk)?;
|
||||
let changes = scan_with(input, &SyncProgress::default());
|
||||
if changes.offline {
|
||||
return Err(not_there(&changes.path));
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
fn not_there(path: &str) -> EngineError {
|
||||
invalid(format!("{path} is not there (moved, renamed, or on a disk that isn't connected)"))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The last scan ([`Session::folder_changes`]) if it is of folder `path` and still current:
|
||||
/// the folder holds the same photos, none of them changed. What the person was shown is what
|
||||
/// gets done. Used once. (Changes elsewhere in the library don't matter.)
|
||||
pub fn take_folder_changes(&mut self, path: &str) -> Option<FolderChanges> {
|
||||
let c = self.folder_changes.take()?;
|
||||
if lightcraft_catalog::query::folder_key(&c.path) != lightcraft_catalog::query::folder_key(path) {
|
||||
return None;
|
||||
}
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
let mut now: Vec<PhotoId> = self.catalog.query(&f, &Sort::default());
|
||||
let mut then: Vec<PhotoId> = c.seen.iter().map(|p| p.id).collect();
|
||||
now.sort();
|
||||
then.sort();
|
||||
let unchanged = now == then && c.seen.iter().all(|p| self.catalog.photo(p.id).is_some_and(|q| same_for_sync(p, q)));
|
||||
unchanged.then_some(c)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo
|
||||
/// (imported, or edited here) and says something the library doesn't.
|
||||
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool {
|
||||
let Source::File { path } = &p.source else { return false };
|
||||
let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false };
|
||||
let Ok(meta) = std::fs::metadata(&file) else { return false };
|
||||
if meta.len() > MAX_SIDECAR_BYTES {
|
||||
return false;
|
||||
}
|
||||
let saved = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
|
||||
let Some(saved) = saved.and_then(|s| i64::try_from(s).ok()) else { return false };
|
||||
let seen = [Some(p.imported.as_str()), p.edited.as_deref()].into_iter().flatten().filter_map(lightcraft_catalog::stacks::iso_seconds).max();
|
||||
if seen.is_some_and(|t| saved <= t) {
|
||||
return false;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&file) else { return false };
|
||||
let Ok(sc) = crate::sidecar::parse_sidecar(&text, crate::crs::Target::for_photo(p)) else { return false };
|
||||
let sc = sc.resolve_label(labels);
|
||||
let mut q = p.clone();
|
||||
crate::sidecar::merge_into(&mut q, &sc, "");
|
||||
(q.rating, q.flag, q.label, &q.meta, &q.captured, &q.develop) != (p.rating, p.flag, p.label, &p.meta, &p.captured, &p.develop)
|
||||
}
|
||||
|
||||
/// Act on `changes` as `choice` says, as one undo step ("Synchronize Folder"). A missing photo
|
||||
/// whose file is back by now is left alone. Once the import is in, a part that fails is
|
||||
/// reported in `failed` and the rest is still done (and still one step).
|
||||
pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<SyncReport> {
|
||||
let (work, mut commit) = SyncJob::start(s, changes, choice)?;
|
||||
let mut steps = Vec::new();
|
||||
work.run(&SyncProgress::default(), |step| {
|
||||
steps.push(step);
|
||||
true
|
||||
});
|
||||
for step in steps {
|
||||
commit.apply(s, step);
|
||||
}
|
||||
Ok(commit.finish(s))
|
||||
}
|
||||
|
||||
/// Synchronizing in halves, so no file is touched on the UI thread: [`SyncJob::start`] takes what
|
||||
/// it needs from the session (no file-system call); the [`SyncWork`] does every file read (the
|
||||
/// import's probes, whether files are still where the scan found them, the sidecars) and hands
|
||||
/// over [`SyncStep`]s — on a worker thread in the app; [`SyncCommit::apply`] puts each into the
|
||||
/// catalog and [`SyncCommit::finish`] makes them one undo step.
|
||||
pub struct SyncJob;
|
||||
|
||||
/// The file-system half of a synchronize (see [`SyncJob`]).
|
||||
pub struct SyncWork {
|
||||
/// The import of the new files, and the files.
|
||||
import: Option<(crate::import::ImportJob, Vec<String>)>,
|
||||
moved: Vec<FolderMoved>,
|
||||
missing: Vec<FolderPhoto>,
|
||||
/// (photo, its file, how its sidecar is named)
|
||||
sidecars: Vec<(PhotoId, String, SidecarNaming, crate::crs::Target)>,
|
||||
}
|
||||
|
||||
/// The catalog half of a synchronize (see [`SyncJob`]).
|
||||
pub struct SyncCommit {
|
||||
opts: ImportOptions,
|
||||
now: String,
|
||||
/// The library the run was readied for: steps never land in another one (File › Open
|
||||
/// Library while the work runs), whose photo ids name other photos.
|
||||
library: Arc<()>,
|
||||
/// The run's own undo steps so far, and the history's length after the last of them: they
|
||||
/// are merged only while nothing else came in between (an Undo or an edit made meanwhile
|
||||
/// stays its own step, and the run's parts stay theirs).
|
||||
own: usize,
|
||||
after_last: Option<usize>,
|
||||
interleaved: bool,
|
||||
report: SyncReport,
|
||||
}
|
||||
|
||||
/// A piece of work done, for [`SyncCommit::apply`].
|
||||
pub enum SyncStep {
|
||||
/// A batch of new files, readied for the catalog.
|
||||
Import(crate::import::Prepared),
|
||||
/// Moved photos whose file is still at its new place (photo, new path).
|
||||
Relink(Vec<(PhotoId, String)>),
|
||||
/// Missing photos whose file is still gone.
|
||||
Remove(Vec<PhotoId>),
|
||||
/// Sidecars read (photo, its file, what the sidecar says or why it couldn't be read).
|
||||
Sidecars(Vec<(PhotoId, String, std::result::Result<crate::sidecar::SidecarData, String>)>),
|
||||
}
|
||||
|
||||
impl SyncJob {
|
||||
/// Ready a synchronize of `changes` as `choice` says. Reads no file.
|
||||
pub fn start(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<(SyncWork, SyncCommit)> {
|
||||
if changes.offline {
|
||||
return Err(not_there(&changes.path));
|
||||
}
|
||||
let FolderChanges { new, missing, metadata, moved, probes, .. } = changes;
|
||||
let opts = ImportOptions { mode: ImportMode::Add, ..Default::default() };
|
||||
let import = if choice.import_new && !new.is_empty() {
|
||||
s.import_probes = probes;
|
||||
let job = crate::import::ImportJob::new(s, opts.clone())?;
|
||||
Some((job, new.into_iter().map(|c| c.path).collect()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let now = import.as_ref().map_or_else(|| (s.clock)(), |(j, _)| j.now().to_string());
|
||||
let sidecars = if choice.read_metadata {
|
||||
let namings = s.sidecar_namings(&metadata.iter().map(|m| PhotoId(m.id)).collect::<Vec<_>>());
|
||||
metadata
|
||||
.into_iter()
|
||||
.zip(namings)
|
||||
.filter_map(|(m, naming)| {
|
||||
let p = s.catalog.photo(PhotoId(m.id))?;
|
||||
Some((p.id, m.path, naming, crate::crs::Target::for_photo(p)))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let work = SyncWork {
|
||||
import,
|
||||
moved: if choice.relink_moved { moved } else { Vec::new() },
|
||||
missing: if choice.remove_missing { missing } else { Vec::new() },
|
||||
sidecars,
|
||||
};
|
||||
let commit = SyncCommit {
|
||||
opts,
|
||||
now,
|
||||
library: Arc::clone(&s.library_identity),
|
||||
own: 0,
|
||||
after_last: None,
|
||||
interleaved: false,
|
||||
report: SyncReport::default(),
|
||||
};
|
||||
Ok((work, commit))
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncWork {
|
||||
/// Do the work, handing each step to `send` (`false`: nobody takes them any more — stop).
|
||||
/// Stops before the next piece once `progress.files.cancel` is set; what was handed over
|
||||
/// stays handed over.
|
||||
pub fn run(self, progress: &SyncProgress, mut send: impl FnMut(SyncStep) -> bool) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let cancel = &progress.files.cancel;
|
||||
let SyncWork { import, moved, missing, sidecars } = self;
|
||||
let pieces = import.as_ref().map_or(0, |(_, f)| f.len()) + moved.len() + missing.len() + sidecars.len();
|
||||
progress.to_check.store(pieces, Relaxed);
|
||||
progress.checked.store(0, Relaxed);
|
||||
let step = |n: usize| {
|
||||
progress.checked.fetch_add(n, Relaxed);
|
||||
};
|
||||
if let Some((mut job, files)) = import {
|
||||
for chunk in files.chunks(crate::import::batch_size()) {
|
||||
if cancel.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
let prepared = job.prepare_files(chunk.to_vec(), cancel);
|
||||
step(chunk.len());
|
||||
// (the files are added in place: a batch nobody takes leaves nothing behind on disk)
|
||||
if !send(SyncStep::Import(prepared)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
// still where the scan found it, and still gone from where the photo says
|
||||
let checked = moved.len();
|
||||
let relink: Vec<(PhotoId, String)> =
|
||||
moved.into_iter().filter(|m| Path::new(&m.to).is_file() && !Path::new(&m.from).exists()).map(|m| (PhotoId(m.id), m.to)).collect();
|
||||
step(checked);
|
||||
if !relink.is_empty() && !send(SyncStep::Relink(relink)) {
|
||||
return;
|
||||
}
|
||||
if cancel.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
let gone: Vec<PhotoId> = missing.iter().filter(|m| !Path::new(&m.path).exists()).map(|m| PhotoId(m.id)).collect();
|
||||
step(missing.len());
|
||||
if !gone.is_empty() && !send(SyncStep::Remove(gone)) {
|
||||
return;
|
||||
}
|
||||
let mut read = Vec::with_capacity(sidecars.len());
|
||||
for (id, path, naming, target) in sidecars {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
read.push((id, path.clone(), read_sidecar(&path, naming, target)));
|
||||
step(1);
|
||||
}
|
||||
if !read.is_empty() {
|
||||
send(SyncStep::Sidecars(read));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and parse photo file `path`'s XMP sidecar file.
|
||||
fn read_sidecar(path: &str, naming: SidecarNaming, target: crate::crs::Target) -> std::result::Result<crate::sidecar::SidecarData, String> {
|
||||
let file = crate::sidecar::find_sidecar(path, naming).ok_or_else(|| "no XMP sidecar".to_string())?;
|
||||
let len = std::fs::metadata(&file).map_err(|e| format!("{}: {e}", file.display()))?.len();
|
||||
if len > MAX_SIDECAR_BYTES {
|
||||
return Err(format!("{}: too large for an XMP sidecar", file.display()));
|
||||
}
|
||||
let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
crate::sidecar::parse_sidecar(&text, target).map_err(|e| format!("{}: {e}", file.display()))
|
||||
}
|
||||
|
||||
impl SyncCommit {
|
||||
/// Put a step into the catalog (no file is touched). A part that fails is reported in the
|
||||
/// end, and the rest is still done.
|
||||
pub fn apply(&mut self, s: &mut Session, step: SyncStep) {
|
||||
if !Arc::ptr_eq(&self.library, &s.library_identity) {
|
||||
self.report.failed.push((String::new(), "another library was opened: the rest of the synchronize was dropped".into()));
|
||||
return;
|
||||
}
|
||||
let commits = s.commits();
|
||||
if self.after_last.is_some_and(|n| n != s.undo.len()) {
|
||||
self.interleaved = true;
|
||||
}
|
||||
self.apply_step(s, step);
|
||||
if s.commits() != commits {
|
||||
self.own += usize::try_from(s.commits().wrapping_sub(commits)).unwrap_or(0);
|
||||
self.after_last = Some(s.undo.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_step(&mut self, s: &mut Session, step: SyncStep) {
|
||||
let report = &mut self.report;
|
||||
match step {
|
||||
SyncStep::Import(prepared) => match crate::import::commit_prepared(s, &self.opts, &self.now, prepared) {
|
||||
Ok(r) => {
|
||||
report.imported += r.imported.len();
|
||||
report.failed.extend(r.failed);
|
||||
}
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
},
|
||||
SyncStep::Relink(moved) => {
|
||||
let ops: Vec<Op> =
|
||||
moved.iter().filter(|(id, _)| s.catalog.photo(*id).is_some()).map(|(id, to)| crate::cmd::missing::relink_op(*id, to)).collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
match s.commit("Relink Moved Photos", Op::Batch { ops }) {
|
||||
Ok(()) => report.relinked += n,
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
SyncStep::Remove(gone) => {
|
||||
let ops: Vec<Op> = gone
|
||||
.iter()
|
||||
.filter(|id| s.catalog.photo(**id).is_some_and(|p| p.in_library()))
|
||||
.map(|id| Op::SetDeleted { id: *id, deleted: true })
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
match s.commit("Remove Missing Photos", Op::Batch { ops }) {
|
||||
Ok(()) => report.removed += n,
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
SyncStep::Sidecars(read) => {
|
||||
let mut ops = Vec::new();
|
||||
for (id, path, sc) in read {
|
||||
match sc.map_err(EngineError::Other).and_then(|sc| s.sidecar_op(id, sc)) {
|
||||
Ok(op) => ops.push(op),
|
||||
Err(e) => report.failed.push((path, e.to_string())),
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
match s.commit("Read Metadata from File", Op::Batch { ops }) {
|
||||
Ok(()) => report.read += n,
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What was done, as one undo step ("Synchronize Folder").
|
||||
pub fn finish(mut self, s: &mut Session) -> SyncReport {
|
||||
// a report line once, however many steps came after a library switch
|
||||
self.report.failed.dedup();
|
||||
let ours = Arc::ptr_eq(&self.library, &s.library_identity) && !self.interleaved && self.after_last == Some(s.undo.len());
|
||||
let steps = if ours { self.own.min(s.undo.len()) } else { 0 };
|
||||
s.merge_undo(steps, "Synchronize Folder");
|
||||
if let Some(last) = s.undo.last_mut().filter(|_| steps == 1) {
|
||||
last.label = "Synchronize Folder".into();
|
||||
}
|
||||
self.report
|
||||
}
|
||||
}
|
||||
+100
-4
@@ -1,6 +1,6 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Session, command_specs};
|
||||
use crate::{Selection, Session, command_specs};
|
||||
|
||||
fn demo() -> Session {
|
||||
Session::with_demo()
|
||||
@@ -14,7 +14,7 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
|
||||
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::{DevelopSettings, WbMode};
|
||||
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF"] {
|
||||
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF", "ORF"] {
|
||||
let mut s = demo();
|
||||
let id = PhotoId(100);
|
||||
let name = format!("synthetic.{format}");
|
||||
@@ -113,9 +113,10 @@ fn albums_crud() {
|
||||
s.execute("library.selectAll", &json!({})).unwrap();
|
||||
s.execute("album.addPhotos", &json!({"id": a})).unwrap();
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().photos.len(), 24);
|
||||
assert!(s.execute("album.delete", &json!({"id": f})).is_err(), "non-empty folder");
|
||||
s.execute("album.delete", &json!({"id": a})).unwrap();
|
||||
s.execute("album.delete", &json!({"id": f})).unwrap();
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(a)).is_none());
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(f)).is_none());
|
||||
assert_eq!(s.catalog.photos().count(), 24, "album deletion keeps the photos");
|
||||
}
|
||||
|
||||
/// Given an album and a folder, when something is created inside each, then only the folder
|
||||
@@ -157,6 +158,101 @@ fn filters_and_delete_restore() {
|
||||
assert_eq!(s.visible().len(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_photos_selects_a_neighbour_in_the_current_view() {
|
||||
for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] {
|
||||
for at_end in [false, true] {
|
||||
let mut s = demo();
|
||||
s.execute("library.sort", ¶ms).unwrap();
|
||||
s.execute("library.filter", &json!({"rating": 2})).unwrap();
|
||||
let visible = s.visible_cloned();
|
||||
assert!(visible.len() > 5);
|
||||
let at = if at_end { visible.len() - 2 } else { visible.len() / 2 };
|
||||
let deleted = &visible[at..at + 2];
|
||||
s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap();
|
||||
s.execute("photo.delete", &json!({})).unwrap();
|
||||
let expected = if at_end { visible[at - 1] } else { visible[at + 2] };
|
||||
assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}");
|
||||
assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(deleted.iter().all(|id| !s.catalog.photo(*id).unwrap().deleted));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted));
|
||||
}
|
||||
}
|
||||
let mut s = demo();
|
||||
s.execute("library.selectAll", &json!({})).unwrap();
|
||||
s.execute("photo.delete", &json!({})).unwrap();
|
||||
assert!(s.visible().is_empty());
|
||||
assert_eq!(s.selection, Selection::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_explicit_targets_keeps_the_surviving_selection() {
|
||||
let mut s = demo();
|
||||
let visible = s.visible_cloned();
|
||||
s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap();
|
||||
let before = s.selection.clone();
|
||||
s.execute("photo.delete", &json!({"ids": [visible[3]]})).unwrap();
|
||||
assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection");
|
||||
s.execute("photo.delete", &json!({"ids": [visible[9]]})).unwrap();
|
||||
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanently_deleting_photos_selects_a_neighbour_in_recently_deleted() {
|
||||
for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] {
|
||||
for at_end in [false, true] {
|
||||
let mut s = demo();
|
||||
s.execute("library.selectAll", &json!({})).unwrap();
|
||||
s.execute("photo.delete", &json!({})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "recentlyDeleted"})).unwrap();
|
||||
s.execute("library.sort", ¶ms).unwrap();
|
||||
s.execute("library.filter", &json!({"rating": 2})).unwrap();
|
||||
let visible = s.visible_cloned();
|
||||
assert!(visible.len() > 5);
|
||||
let at = if at_end { visible.len() - 2 } else { visible.len() / 2 };
|
||||
let deleted = &visible[at..at + 2];
|
||||
s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap();
|
||||
let snapshot = s.catalog.to_snapshot();
|
||||
let undo_len = s.undo.len();
|
||||
let r = s.execute("photo.deletePermanently", &json!({})).unwrap();
|
||||
assert_eq!(r["deleted"], 2);
|
||||
let expected = if at_end { visible[at - 1] } else { visible[at + 2] };
|
||||
assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}");
|
||||
assert!(deleted.iter().all(|id| s.catalog.photo(*id).is_none()));
|
||||
assert_eq!(s.undo.len(), undo_len + 1);
|
||||
let removed = s.catalog.to_snapshot();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), snapshot);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), removed);
|
||||
}
|
||||
}
|
||||
let mut s = demo();
|
||||
s.execute("library.selectAll", &json!({})).unwrap();
|
||||
s.execute("photo.deletePermanently", &json!({})).unwrap();
|
||||
assert!(s.visible().is_empty());
|
||||
assert_eq!(s.selection, Selection::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() {
|
||||
let mut s = demo();
|
||||
let visible = s.visible_cloned();
|
||||
s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap();
|
||||
let before = s.selection.clone();
|
||||
s.execute("photo.deletePermanently", &json!({"ids": [visible[3]]})).unwrap();
|
||||
assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection");
|
||||
s.execute("photo.deletePermanently", &json!({"ids": [visible[9]]})).unwrap();
|
||||
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
|
||||
let before = s.selection.clone();
|
||||
let snapshot = s.catalog.to_snapshot();
|
||||
assert!(s.execute("photo.deletePermanently", &json!({"ids": [999_999]})).is_err());
|
||||
assert_eq!(s.selection, before, "a refused deletion doesn't change selection");
|
||||
assert_eq!(s.catalog.to_snapshot(), snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_crop_and_render() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -370,3 +370,73 @@ fn exports_are_atomic_without_a_sync() {
|
||||
assert!(syncs_on_this_thread() > before, "the durable writer syncs");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The demo library's ocean sunset (a sun far above SDR white), selected and active.
|
||||
fn sunset(s: &mut Session) -> lightcraft_catalog::PhotoId {
|
||||
let id = s.catalog.photos().find(|p| p.meta.title == "Golden horizon").map(|p| p.id).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_jpeg_export_writes_a_gain_map_and_sdr_edits_stay_plain() {
|
||||
use lightcraft_codecs::gainmap;
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 320, "hdr": true}));
|
||||
assert!(o.hdr_output());
|
||||
// no HDR edit: an ordinary JPEG even with HDR output asked for
|
||||
let plain = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
assert!(!gainmap::is_gain_map_jpeg(&plain.bytes));
|
||||
|
||||
let r = s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap();
|
||||
assert_eq!(r["enabled"], true);
|
||||
let e = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
assert!(gainmap::is_gain_map_jpeg(&e.bytes));
|
||||
let gm = gainmap::read_jpeg(&e.bytes).unwrap();
|
||||
assert!(gm.meta.alternate_headroom > 0.5 && gm.meta.alternate_headroom <= 3.05, "headroom {}", gm.meta.alternate_headroom);
|
||||
// the base is the SDR rendition: the same pixels as the SDR export (the decoders agree)
|
||||
let a = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
|
||||
let b = lightcraft_codecs::decode(&plain.bytes, Default::default()).unwrap();
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
let diff = a.image.data.iter().zip(&b.image.data).map(|(p, q)| (p[1] - q[1]).abs()).fold(0.0f32, f32::max);
|
||||
assert!(diff < 0.02, "base differs from the SDR export by {diff}");
|
||||
// HDR off again: the gain map goes away
|
||||
s.execute("develop.hdr", &json!({"enabled": false})).unwrap();
|
||||
assert!(!gainmap::is_gain_map_jpeg(&export_photo(&mut s, id, &o, 1).unwrap().bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_float_tiff_keeps_highlights_above_white() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||||
let max_of = |s: &mut Session, hdr: bool| {
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 240, "format": "tiff", "bitDepth": 32, "hdr": hdr}));
|
||||
let e = export_photo(s, id, &o, 1).unwrap();
|
||||
let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
|
||||
d.image.data.iter().flat_map(|p| p.iter().copied()).fold(0.0f32, f32::max)
|
||||
};
|
||||
let hdr = max_of(&mut s, true);
|
||||
let sdr = max_of(&mut s, false);
|
||||
assert!(sdr <= 1.0 + 1e-3, "SDR float TIFF stays in 0..1 ({sdr})");
|
||||
assert!(hdr > 1.2 && hdr <= 4.0 + 1e-3, "HDR float TIFF max {hdr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_avif_export_is_pq_rec2020() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "quality": 70}));
|
||||
assert!(o.hdr_output());
|
||||
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
// the `colr` nclx box: BT.2020 primaries (9), PQ (16), BT.2020 NCL (9), full range
|
||||
let nclx = |b: &[u8]| b.windows(4).position(|w| w == b"nclx").map(|i| b[i + 4..i + 11].to_vec());
|
||||
let c = nclx(&hdr.bytes).expect("colr nclx box");
|
||||
assert_eq!(c, vec![0, 9, 0, 16, 0, 9, 0x80]);
|
||||
assert!(hdr.bytes.windows(4).any(|w| w == b"clli"), "content light level box");
|
||||
// a photo without an HDR edit exports as an ordinary (sRGB) AVIF
|
||||
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
//! * Removing a folder from the library moves its photos to Recently Deleted (one undo step) and
|
||||
//! leaves every file where it is.
|
||||
//! * Renaming or moving a folder on disk keeps the chosen folder chosen.
|
||||
//! * Given a folder of the library, an agent gives it a colour label with `folder.label`; the
|
||||
//! folder list shows it, undo takes it back, and only folders the library holds photos in can
|
||||
//! be labelled.
|
||||
//! * A labelled folder keeps its label when it is renamed or moved on disk, and so do the folders
|
||||
//! inside it; undo puts the labels back where they were.
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
@@ -463,3 +468,99 @@ fn a_blank_folder_filter_is_no_filter() {
|
||||
s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap();
|
||||
assert_eq!(s.filter, lightcraft_catalog::Filter::default(), "no hidden 'filters active' state");
|
||||
}
|
||||
|
||||
/// The label `library.folders` reports for the row at `path`.
|
||||
fn listed_label(s: &mut Session, path: &str) -> Option<String> {
|
||||
fn find(rows: &serde_json::Value, key: &str) -> Option<serde_json::Value> {
|
||||
rows.as_array()?.iter().find_map(|r| {
|
||||
if r["path"].as_str().map(lightcraft_catalog::query::folder_key).as_deref() == Some(key) {
|
||||
Some(r.clone())
|
||||
} else {
|
||||
find(&r["children"], key)
|
||||
}
|
||||
})
|
||||
}
|
||||
let rows = s.execute("library.folders", &json!({})).unwrap();
|
||||
let row = find(&rows, &lightcraft_catalog::query::folder_key(path)).unwrap_or_else(|| panic!("no row for {path}: {rows}"));
|
||||
row["label"].as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_labelled_folder_shows_its_label_in_the_folder_list_until_undone() {
|
||||
let mut s = session();
|
||||
s.execute("folder.label", &json!({"path": "/pics/trip", "label": "green"})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, "/pics/trip").as_deref(), Some("green"));
|
||||
assert_eq!(listed_label(&mut s, "/pics/home"), None, "its neighbour has none");
|
||||
s.execute("folder.label", &json!({"path": "/pics/trip", "label": "none"})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, "/pics/trip"), None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, "/pics/trip").as_deref(), Some("green"), "undo puts the label back");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, "/pics/trip"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_folder_of_the_library_takes_a_known_label() {
|
||||
let mut s = session();
|
||||
let before = s.catalog.to_snapshot();
|
||||
for (p, why) in [
|
||||
(json!({"path": "/pics/trip", "label": "mauve"}), "unknown label"),
|
||||
(json!({"path": "/pics/trip"}), "missing label"),
|
||||
(json!({"label": "red"}), "missing path"),
|
||||
(json!({"path": " ", "label": "red"}), "blank path"),
|
||||
(json!({"path": "/elsewhere", "label": "red"}), "no photo was imported from it"),
|
||||
(json!({"path": "pics/trip", "label": "red"}), "a relative path"),
|
||||
] {
|
||||
assert!(s.execute("folder.label", &p).is_err(), "{why}");
|
||||
}
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_follow_a_renamed_folder_and_undo_puts_them_back() {
|
||||
let dir = Scratch::new("label-rename");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
let (trip, day1) = (dir.path("trip"), dir.path("trip/day1"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{trip}/a.jpg"));
|
||||
add(&mut s, &format!("{day1}/b.jpg"));
|
||||
s.execute("folder.label", &json!({"path": trip, "label": "red"})).unwrap();
|
||||
s.execute("folder.label", &json!({"path": day1, "label": "blue"})).unwrap();
|
||||
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &dir.path("holiday")).as_deref(), Some("red"));
|
||||
assert_eq!(listed_label(&mut s, &dir.path("holiday/day1")).as_deref(), Some("blue"), "the folder inside goes along");
|
||||
assert_eq!(s.catalog.folder_record(&trip), None, "nothing stays behind");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &trip).as_deref(), Some("red"));
|
||||
assert_eq!(listed_label(&mut s, &day1).as_deref(), Some("blue"));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &dir.path("holiday")).as_deref(), Some("red"), "redo carries them again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_follow_a_moved_folder() {
|
||||
let dir = Scratch::new("label-move");
|
||||
std::fs::create_dir_all(dir.0.join("trip")).unwrap();
|
||||
std::fs::create_dir_all(dir.0.join("archive")).unwrap();
|
||||
let trip = dir.path("trip");
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{trip}/a.jpg"));
|
||||
s.execute("folder.label", &json!({"path": trip, "label": "yellow"})).unwrap();
|
||||
s.execute("folder.move", &json!({"path": trip, "into": dir.path("archive")})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &dir.path("archive/trip")).as_deref(), Some("yellow"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &trip).as_deref(), Some("yellow"), "undo moves it back with the folder");
|
||||
assert_eq!(s.catalog.folder_record(&dir.path("archive/trip")), None);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(listed_label(&mut s, &dir.path("archive/trip")).as_deref(), Some("yellow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_comes_off_a_folder_whose_photos_are_gone() {
|
||||
let mut s = session();
|
||||
s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).unwrap();
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
s.execute("folder.label", &json!({"path": "/pics/trip", "label": "none"})).unwrap();
|
||||
assert_eq!(s.catalog.folder_record("/pics/trip"), None);
|
||||
assert!(s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).is_err(), "but a new one needs photos");
|
||||
}
|
||||
|
||||
@@ -974,8 +974,27 @@ fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() {
|
||||
fn deleting_several_photos_permanently_leaves_nothing_dangling() {
|
||||
let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-perm-refs");
|
||||
s.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
let files = [src.join("a.png"), src.join("b.png")].map(|path| {
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
(path, bytes)
|
||||
});
|
||||
let snapshot = s.catalog.to_snapshot();
|
||||
s.drain_log();
|
||||
s.execute("photo.deletePermanently", &json!({"ids": ids})).unwrap();
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty());
|
||||
assert!(ids.iter().all(|i| s.catalog.photo(lightcraft_catalog::PhotoId(*i)).is_none()));
|
||||
let removed = s.catalog.to_snapshot();
|
||||
let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect();
|
||||
let mut reopened = lightcraft_catalog::Catalog::from_snapshot(&snapshot).unwrap();
|
||||
reopened.replay(&log).unwrap();
|
||||
assert_eq!(reopened.to_snapshot(), removed);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), snapshot, "one undo restores photos, album and stack");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), removed);
|
||||
for (path, bytes) in files {
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes, "permanent deletion only removes catalog records");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
@@ -1105,3 +1124,54 @@ fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() {
|
||||
assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30)));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_gone_since_the_import_review_is_not_imported() {
|
||||
let src = temp_dir("gone-since-review");
|
||||
write_png(&src.join("a.png"), 1);
|
||||
write_png(&src.join("b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
std::fs::remove_file(src.join("b.png")).unwrap();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap();
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 1), "{r}");
|
||||
assert_eq!(s.catalog.len(), 1, "no photo for a file that isn't there");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// A HEIC laid out like an iPhone's (a grid of HEVC pictures, a Display P3 profile, a quarter turn
|
||||
/// in the container, a thumbnail) imports as a normal photo that develops and exports upright like
|
||||
/// a JPEG. In a build without the codecs' `heif` feature it is reported as failed, saying why.
|
||||
#[test]
|
||||
fn heic_imports_as_a_normal_photo_or_says_why_not() {
|
||||
use lightcraft_heif::testdata::{Prop, Spec, build};
|
||||
let src = temp_dir("heic");
|
||||
let photo = |x: u32, y: u32| [((x * 7 + y * 13) % 200 + 30) as u16, 110, 150];
|
||||
let p3 = lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3);
|
||||
let bytes = build(&Spec { props: vec![Prop::Icc(p3), Prop::Irot(1)], thumbnail: Some(&photo), ..Spec::new(96, 64, &photo) });
|
||||
std::fs::write(src.join("IMG_0001.HEIC"), &bytes).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
if !lightcraft_codecs::Format::Heif.can_decode() {
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (0, 1), "{r}");
|
||||
assert!(r["failed"].to_string().contains("isn't included in this build"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
return;
|
||||
}
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}");
|
||||
let p = s.catalog.photos().next().unwrap();
|
||||
let id = p.id;
|
||||
// Upright (the container's turn applied) and not preview-only.
|
||||
assert_eq!((p.kind, p.preview_only.clone(), p.width, p.height), (lightcraft_catalog::MediaKind::Image, None, 64, 96));
|
||||
s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap();
|
||||
let plain = crate::export::export_photo(&mut s, id, &crate::export::ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap();
|
||||
assert_eq!((plain.width, plain.height), (64, 96));
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
let brighter = crate::export::export_photo(&mut s, id, &crate::export::ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap();
|
||||
let mean = |png: &[u8]| {
|
||||
let d = lightcraft_codecs::decode(png, Default::default()).unwrap();
|
||||
d.image.data.iter().map(|p| p[1]).sum::<f32>() / d.image.data.len() as f32
|
||||
};
|
||||
assert!(mean(&brighter.bytes) > mean(&plain.bytes) * 1.3, "exposure +1 brightens the HEIC");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
//! The keyword list's commands (Lightroom Classic's Keyword List): create, edit, move and purge
|
||||
//! keywords, their attributes, and the default parent for new keywords.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The keyword filter follows a renamed keyword whatever the case it was typed in, also where a
|
||||
/// letter's case changes its length (ẞ / ß): it used to be cut by bytes, which could land inside
|
||||
/// a letter and panic.
|
||||
#[test]
|
||||
fn the_filter_follows_a_rename_whatever_the_case() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["ßßa|é"]})).unwrap();
|
||||
s.execute("library.filter", &json!({"keyword": "ßßa|é"})).unwrap();
|
||||
s.execute("keyword.rename", &json!({"from": "ẞẞA", "to": "Road"})).unwrap();
|
||||
assert_eq!(s.filter.keyword.as_deref(), Some("Road|é"));
|
||||
s.execute("keyword.merge", &json!({"from": ["ROAD"], "into": "Weg"})).unwrap();
|
||||
assert_eq!(s.filter.keyword.as_deref(), Some("Weg|é"));
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-keyword-list-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn keywords_of(s: &Session, id: u64) -> Vec<String> {
|
||||
s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.clone()
|
||||
}
|
||||
|
||||
/// Create Keyword: a name inside a parent, with attributes, given to the selected photos at once,
|
||||
/// in one undo step; `keyword.info` reads it back. A keyword that exists isn't created again.
|
||||
#[test]
|
||||
fn creating_a_keyword_with_attributes_and_the_selection() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("library.select", &json!({"ids": [id]})).unwrap();
|
||||
let undo = s.undo.len();
|
||||
let r = s
|
||||
.execute("keyword.create", &json!({"name": "Weddings", "parent": "Events", "synonyms": ["marriage"], "person": false, "addToSelected": true}))
|
||||
.unwrap();
|
||||
assert_eq!(r["keyword"], "Events|Weddings", "{r}");
|
||||
assert_eq!(s.undo.len(), undo + 1, "one undo step");
|
||||
assert!(keywords_of(&s, id).contains(&"Events|Weddings".to_string()));
|
||||
let info = s.execute("keyword.info", &json!({"keyword": "events|weddings"})).unwrap();
|
||||
assert_eq!(info["path"], "Events|Weddings");
|
||||
assert_eq!(info["synonyms"], json!(["marriage"]));
|
||||
assert_eq!((info["includeOnExport"].as_bool(), info["listed"].as_bool(), info["count"].as_u64()), (Some(true), Some(true), Some(1)));
|
||||
let err = s.execute("keyword.create", &json!({"name": "weddings", "parent": "events"})).unwrap_err().to_string();
|
||||
assert!(err.contains("already"), "{err}");
|
||||
assert!(s.execute("keyword.info", &json!({"keyword": "lisbon"})).is_err(), "no such keyword");
|
||||
}
|
||||
|
||||
/// "Put new keywords inside this keyword": new keywords go inside the default parent unless the
|
||||
/// command says otherwise (`parent: null` = the top level). It belongs to its keyword in the
|
||||
/// library: it follows a rename, goes with a delete, and undo and redo bring it back with them.
|
||||
#[test]
|
||||
fn new_keywords_go_inside_the_default_parent() {
|
||||
let dir = temp_dir("default-parent");
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
s.execute("keyword.create", &json!({"name": "Events"})).unwrap();
|
||||
s.execute("keyword.setDefaultParent", &json!({"keyword": "events"})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Events"));
|
||||
assert_eq!(s.execute("keyword.create", &json!({"name": "Birthdays"})).unwrap()["keyword"], "Events|Birthdays");
|
||||
assert_eq!(s.execute("keyword.create", &json!({"name": "Travel", "parent": null})).unwrap()["keyword"], "Travel");
|
||||
s.execute("keyword.rename", &json!({"from": "Events", "to": "Occasions"})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "follows the rename");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Events"), "undo takes it back");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, false).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "kept with the library");
|
||||
s.execute("keyword.delete", &json!({"keyword": "occasions"})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent(), None, "gone with its keyword");
|
||||
assert_eq!(s.execute("keyword.create", &json!({"name": "Graduations"})).unwrap()["keyword"], "Graduations", "at the top level");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "undoing the delete brings it back");
|
||||
s.execute("keyword.setDefaultParent", &json!({"keyword": null})).unwrap();
|
||||
assert_eq!(s.catalog.default_keyword_parent(), None);
|
||||
assert!(s.execute("keyword.setDefaultParent", &json!({"keyword": "lisbon"})).is_err(), "no such keyword");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Edit Keyword: a new name and attributes in one step; attributes not given keep their values.
|
||||
#[test]
|
||||
fn editing_a_keyword_changes_what_it_is_given() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.create", &json!({"name": "Weddings", "synonyms": ["marriage"]})).unwrap();
|
||||
let undo = s.undo.len();
|
||||
s.execute("keyword.edit", &json!({"keyword": "weddings", "name": "Marriages", "includeOnExport": false})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let info = s.execute("keyword.info", &json!({"keyword": "Marriages"})).unwrap();
|
||||
assert_eq!((info["includeOnExport"].as_bool(), info["synonyms"].clone()), (Some(false), json!(["marriage"])));
|
||||
assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_err());
|
||||
}
|
||||
|
||||
/// Move Keyword: inside another or to the top level. Onto a name that is taken it is refused with
|
||||
/// a message saying it would merge, unless `merge: true`.
|
||||
#[test]
|
||||
fn moving_a_keyword_merges_only_when_asked() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[0]], "keywords": ["Rome"]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[1]], "keywords": ["Europe|Rome"]})).unwrap();
|
||||
let err = s.execute("keyword.move", &json!({"keyword": "rome", "parent": "europe"})).unwrap_err().to_string();
|
||||
assert!(err.contains("merge"), "{err}");
|
||||
s.execute("keyword.move", &json!({"keyword": "rome", "parent": "europe", "merge": true})).unwrap();
|
||||
assert_eq!(keywords_of(&s, ids[0]), ["Europe|Rome"]);
|
||||
s.execute("keyword.move", &json!({"keyword": "europe|rome", "parent": null})).unwrap();
|
||||
assert_eq!((keywords_of(&s, ids[0]), keywords_of(&s, ids[1])), (vec!["Rome".to_string()], vec!["Rome".to_string()]));
|
||||
assert!(s.execute("keyword.move", &json!({"keyword": "rome"})).is_err(), "`parent` must be said, null for the top level");
|
||||
}
|
||||
|
||||
/// Purge Unused Keywords takes off the list the keywords no photo has, in one undo step.
|
||||
#[test]
|
||||
fn purging_unused_keywords() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.create", &json!({"name": "Weddings"})).unwrap();
|
||||
let r = s.execute("keyword.purgeUnused", &json!({})).unwrap();
|
||||
assert_eq!(r["purged"], 1, "{r}");
|
||||
assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_ok());
|
||||
}
|
||||
|
||||
/// Moving a keyword into a parent that doesn't exist yet makes that parent, and the keyword filter
|
||||
/// follows the keyword there (the grid kept filtering by the old path, and showed nothing).
|
||||
#[test]
|
||||
fn the_filter_follows_a_keyword_moved_into_a_new_parent() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Rome"]})).unwrap();
|
||||
s.execute("library.filter", &json!({"keyword": "Rome"})).unwrap();
|
||||
s.execute("keyword.move", &json!({"keyword": "rome", "parent": "Italy"})).unwrap();
|
||||
assert_eq!(keywords_of(&s, id), ["Italy|Rome"]);
|
||||
assert_eq!(s.filter.keyword.as_deref(), Some("Italy|Rome"));
|
||||
}
|
||||
|
||||
/// Keywords given to photos are stored cleaned (" beach " is "beach", "Travel | Italy" is
|
||||
/// "Travel|Italy"), so the list's actions find them; one a photo has already, whatever the case of
|
||||
/// any letter, isn't added twice.
|
||||
#[test]
|
||||
fn keywords_given_to_photos_are_stored_cleaned() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "keywords": [" beach ", "Travel | Italy", "Ärzte"]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": [" sea ", "ÄRZTE", " "]})).unwrap();
|
||||
assert_eq!(keywords_of(&s, id), ["beach", "Travel|Italy", "Ärzte", "sea"]);
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "removeKeywords": ["travel | italy"]})).unwrap();
|
||||
assert_eq!(keywords_of(&s, id), ["beach", "Ärzte", "sea"]);
|
||||
assert!(s.execute("keyword.info", &json!({"keyword": "beach"})).is_ok());
|
||||
}
|
||||
|
||||
/// Rename, merge and delete say how many photos they changed, as before the keyword list: the
|
||||
/// list's own changes aren't photos.
|
||||
#[test]
|
||||
fn keyword_actions_count_the_photos_they_change() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("keyword.create", &json!({"name": "Weddings", "synonyms": ["marriage"]})).unwrap();
|
||||
assert_eq!(s.execute("keyword.rename", &json!({"from": "Weddings", "to": "Marriages"})).unwrap()["changed"], 0);
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": ["Marriages"]})).unwrap();
|
||||
assert_eq!(s.execute("keyword.rename", &json!({"from": "Marriages", "to": "Weddings"})).unwrap()["changed"], 1);
|
||||
assert_eq!(s.execute("keyword.delete", &json!({"keyword": "weddings"})).unwrap()["changed"], 1);
|
||||
}
|
||||
|
||||
/// ⌥1–⌥9 take a set's keyword off the selected photos when they all have it, whatever the case of
|
||||
/// any of its letters (the set has "ärzte", the photos "ÄRZTE" and "ärzte").
|
||||
#[test]
|
||||
fn a_set_keyword_toggles_whatever_its_case() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[0]], "keywords": ["ÄRZTE"]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[1]], "keywords": ["ärzte"]})).unwrap();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Clinic", "keywords": ["ärzte"]})).unwrap();
|
||||
s.execute("keyword.toggleFromSet", &json!({"index": 1, "ids": ids})).unwrap();
|
||||
assert!(ids.iter().all(|id| keywords_of(&s, *id).is_empty()), "taken off both");
|
||||
}
|
||||
|
||||
fn set_named(s: &mut Session, name: &str) -> serde_json::Value {
|
||||
let sets = s.execute("keyword.sets", &json!({})).unwrap();
|
||||
sets["sets"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned().unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Edit Keyword Set: a set's nine slots keep their places, so each keyword stays on its ⌥ key; an
|
||||
/// empty slot is allowed and does nothing.
|
||||
#[test]
|
||||
fn a_keyword_sets_slots_keep_their_places() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": [" ceremony ", "", "reception", ""]})).unwrap();
|
||||
assert_eq!(set_named(&mut s, "Weddings")["keywords"], json!(["ceremony", "", "reception"]), "trailing empty slots go");
|
||||
let before = keywords_of(&s, id);
|
||||
let r = s.execute("keyword.toggleFromSet", &json!({"index": 2, "ids": [id]})).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), keywords_of(&s, id)), (Some(0), before), "⌥2 is empty: nothing");
|
||||
s.execute("keyword.toggleFromSet", &json!({"index": 3, "ids": [id]})).unwrap();
|
||||
assert!(keywords_of(&s, id).contains(&"reception".to_string()), "⌥3 is reception");
|
||||
}
|
||||
|
||||
/// A set holds each keyword once, whatever its case: a second one leaves its slot empty.
|
||||
#[test]
|
||||
fn a_keyword_set_holds_each_keyword_once() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["Lisbon", "harbour", "LISBON"]})).unwrap();
|
||||
assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["Lisbon", "harbour"]));
|
||||
}
|
||||
|
||||
/// Renaming a set keeps its place among the sets and keeps it current; a name another set has is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn renaming_a_keyword_set_keeps_its_place() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap();
|
||||
s.execute("keyword.useSet", &json!({"name": "Weddings"})).unwrap();
|
||||
let r = s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "weddings", "keywords": ["ceremony", "rings"]})).unwrap();
|
||||
let names: Vec<String> = r["sets"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(names, ["Recent Keywords", "Ceremonies", "Travel"]);
|
||||
assert_eq!(r["current"], "Ceremonies");
|
||||
assert_eq!(r["keywords"], json!(["ceremony", "rings"]));
|
||||
let err = s.execute("keyword.saveSet", &json!({"name": "travel", "replace": "Ceremonies", "keywords": []})).unwrap_err().to_string();
|
||||
assert!(err.contains("already"), "{err}");
|
||||
assert!(s.execute("keyword.saveSet", &json!({"name": "Rings", "replace": "Lisbon", "keywords": []})).is_err(), "no such set");
|
||||
}
|
||||
|
||||
/// Renaming a set without giving its keywords keeps its keywords (not the current set's).
|
||||
#[test]
|
||||
fn renaming_a_set_alone_keeps_its_keywords() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "Weddings"})).unwrap();
|
||||
assert_eq!(set_named(&mut s, "Ceremonies")["keywords"], json!(["ceremony"]));
|
||||
}
|
||||
|
||||
/// A new set (`new: true`, what Edit Set… on Recent Keywords saves) never takes another set's
|
||||
/// name: saving over it silently is refused.
|
||||
#[test]
|
||||
fn a_new_set_doesnt_take_a_sets_name() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour", "lisbon"]})).unwrap();
|
||||
let err = s.execute("keyword.saveSet", &json!({"name": "travel", "new": true, "keywords": ["x"]})).unwrap_err().to_string();
|
||||
assert!(err.contains("already"), "{err}");
|
||||
assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["harbour", "lisbon"]), "left as it was");
|
||||
}
|
||||
|
||||
/// Set names match whatever the case of any of their letters, for every set command alike.
|
||||
#[test]
|
||||
fn set_names_match_whatever_their_case() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("keyword.saveSet", &json!({"name": "ÉTÉ", "keywords": ["sunset"]})).unwrap();
|
||||
s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap();
|
||||
s.execute("keyword.useSet", &json!({"name": "été"})).unwrap();
|
||||
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "ÉTÉ");
|
||||
s.execute("keyword.deleteSet", &json!({"name": "été"})).unwrap();
|
||||
assert!(set_named(&mut s, "ÉTÉ").is_null(), "deleted");
|
||||
}
|
||||
|
||||
/// Export Keywords writes the keyword list file, and names the keywords Capture One's importer
|
||||
/// refuses (it allows none of ; , < > in a list); without a path it gives the text.
|
||||
#[test]
|
||||
fn exporting_keywords_writes_the_list() {
|
||||
let dir = temp_dir("export-keywords");
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Places|Lisbon", "fish, chips"]})).unwrap();
|
||||
let path = dir.join("keywords.txt");
|
||||
let r = s.execute("keyword.export", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("Places\n\tLisbon\n"), "{text}");
|
||||
assert!(r["keywords"].as_u64().unwrap() >= 3, "{r}");
|
||||
assert_eq!(r["captureOneRefuses"], json!(["fish, chips"]), "{r}");
|
||||
assert_eq!(r["unwritable"], json!([]), "{r}");
|
||||
let r = s.execute("keyword.export", &json!({})).unwrap();
|
||||
assert_eq!(r["text"].as_str(), Some(text.as_str()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A keyword the format can't hold is left out of the file and named, so the file reads back.
|
||||
#[test]
|
||||
fn exporting_keywords_names_what_the_list_cant_hold() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0].0;
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Travel|[draft]", "Travel|Lisbon"]})).unwrap();
|
||||
let before = s.execute("keyword.export", &json!({})).unwrap()["keywords"].as_u64().unwrap();
|
||||
// a name starting with a brace is written, counted and checked for Capture One like any other
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": ["{draft, notes"]})).unwrap();
|
||||
let r = s.execute("keyword.export", &json!({})).unwrap();
|
||||
assert_eq!(r["keywords"].as_u64(), Some(before + 1), "{r}");
|
||||
assert_eq!(r["captureOneRefuses"], json!(["{draft, notes"]), "{r}");
|
||||
assert_eq!(r["unwritable"], json!(["Travel|[draft]"]), "{r}");
|
||||
let text = r["text"].as_str().unwrap();
|
||||
assert!(text.contains("Travel\n\tLisbon\n") && !text.contains("[draft]"), "{text}");
|
||||
s.execute("keyword.import", &json!({"text": text})).unwrap();
|
||||
}
|
||||
|
||||
/// A list saved as UTF-16 (as Windows' Notepad can) says to save it as UTF-8, and text too big to
|
||||
/// be a keyword list is refused, as a file that big is.
|
||||
#[test]
|
||||
fn importing_keywords_refuses_what_it_cant_read() {
|
||||
let dir = temp_dir("import-keywords-refused");
|
||||
let mut s = Session::with_demo();
|
||||
let utf16: Vec<u8> = [0xff, 0xfe].into_iter().chain("Travel\r\n".encode_utf16().flat_map(u16::to_le_bytes)).collect();
|
||||
std::fs::write(dir.join("travel.txt"), utf16).unwrap();
|
||||
let err = s.execute("keyword.import", &json!({"path": dir.join("travel.txt").to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(err.contains("UTF-16") && err.contains("UTF-8"), "{err}");
|
||||
let big = "Travel\n".repeat((17 << 20) / 7);
|
||||
let err = s.execute("keyword.import", &json!({"text": big})).unwrap_err().to_string();
|
||||
assert!(err.contains("16 MB"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Import Keywords reads a keyword list file (or text) as one undo step and says what it added;
|
||||
/// a file it can't read says which line, and nothing is taken.
|
||||
#[test]
|
||||
fn importing_keywords_reads_a_list() {
|
||||
let dir = temp_dir("import-keywords");
|
||||
let mut s = Session::with_demo();
|
||||
let path = dir.join("vocabulary.utf8");
|
||||
std::fs::write(&path, "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n").unwrap();
|
||||
let undo = s.undo.len();
|
||||
let r = s.execute("keyword.import", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!((r["added"].as_u64(), r["updated"].as_u64()), (Some(3), Some(0)), "{r}");
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert!(s.catalog.has_keyword("Places|Portugal|Lisbon"));
|
||||
let r = s.execute("keyword.import", &json!({"text": "Events\n\t{celebrations}\n"})).unwrap();
|
||||
assert_eq!(r["added"], 1);
|
||||
let err = s.execute("keyword.import", &json!({"text": "Events\n\t\tWeddings\n"})).unwrap_err().to_string();
|
||||
assert!(err.contains("line 2"), "{err}");
|
||||
std::fs::write(dir.join("bad.txt"), [0xff, 0xfe, 0x00, 0x41]).unwrap();
|
||||
assert!(s.execute("keyword.import", &json!({"path": dir.join("bad.txt").to_string_lossy()})).is_err(), "not UTF-8: refused");
|
||||
assert!(s.execute("keyword.import", &json!({})).is_err(), "a path or text");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -355,9 +355,9 @@ fn label_sets_and_xmp_label_names() {
|
||||
p.label = Some(ColorLabel::Green);
|
||||
let x = crate::sidecar::sidecar_packet(&p, &s.catalog);
|
||||
assert!(x.contains("Approved"), "{x}");
|
||||
let sc = crate::sidecar::parse_sidecar(&x, false).unwrap().resolve_label(&s.catalog);
|
||||
let sc = crate::sidecar::parse_sidecar(&x, crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog);
|
||||
assert_eq!(sc.label, Some(Some(ColorLabel::Green)));
|
||||
let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), false).unwrap().resolve_label(&s.catalog);
|
||||
let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog);
|
||||
assert_eq!(plain.label, Some(Some(ColorLabel::Blue)), "colour names still work");
|
||||
s.execute("label.deleteSet", &json!({"name": "Studio"})).unwrap();
|
||||
assert!(s.execute("label.deleteSet", &json!({"name": "Review"})).is_err(), "built-ins stay");
|
||||
@@ -551,3 +551,41 @@ fn choosing_random_starts_a_fresh_shuffle() {
|
||||
assert_eq!(s.sort.seed, 42, "an explicit seed is honoured");
|
||||
assert!(s.sort.seed < 1 << 53);
|
||||
}
|
||||
|
||||
fn capture_time_raw_metadata_session(raw: Option<&str>) -> Session {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
let mut s = Session::new();
|
||||
let id = PhotoId(1);
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 0 }, "one.jpg", "JPEG", 4, 3, "2026-04-01T00:00:00");
|
||||
p.captured = raw.map(str::to_string);
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.selection = crate::Selection::single(id);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_time_invalid_calendar_anchor_keeps_other_targets_and_redo() {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
for raw in ["2026-02-30T12:00:00", "2026x03x01T12:00:00"] {
|
||||
let mut s = capture_time_raw_metadata_session(Some(raw));
|
||||
let peer = PhotoId(2);
|
||||
let mut p = Photo::new(peer, Source::Demo { scene: 0 }, "peer.jpg", "JPEG", 4, 3, "2026-10-09T00:00:00");
|
||||
p.captured = Some("2026-03-01T12:00:00.25+02:00".into());
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.commit("Earlier rating", Op::SetRating { id: peer, rating: 4 }).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.selection.ids = vec![PhotoId(1), peer];
|
||||
s.selection.active = Some(PhotoId(1));
|
||||
let before = s.catalog.clone();
|
||||
let selection = s.selection.clone();
|
||||
let undo: Vec<_> = s.undo.iter().map(|e| (e.label.clone(), e.op.clone())).collect();
|
||||
let redo: Vec<_> = s.redo.iter().map(|e| (e.label.clone(), e.op.clone())).collect();
|
||||
let log = s.pending_log.clone();
|
||||
assert!(s.execute("photo.setCaptureTime", &json!({"time": "2026-03-03T12:00:00"})).is_err(), "{raw}");
|
||||
assert_eq!(s.catalog, before);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!(s.undo.iter().map(|e| (e.label.clone(), e.op.clone())).collect::<Vec<_>>(), undo);
|
||||
assert_eq!(s.redo.iter().map(|e| (e.label.clone(), e.op.clone())).collect::<Vec<_>>(), redo);
|
||||
assert_eq!(s.pending_log, log);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,11 +252,43 @@ fn smart_album_rule_sets() {
|
||||
s.clock = Box::new(|| "2026-10-02T12:00:00".to_string());
|
||||
let fields = s.execute("album.ruleFields", &serde_json::json!({})).unwrap();
|
||||
assert!(fields.as_array().unwrap().iter().any(|f| f["field"] == "keywords" && f["ops"].as_array().unwrap().len() > 3));
|
||||
// each field names its field-menu group (null at the top level)
|
||||
let group = |id: &str| fields.as_array().unwrap().iter().find(|f| f["field"] == id).map(|f| f["group"].clone());
|
||||
assert_eq!(group("filePath"), Some(serde_json::json!("File")));
|
||||
assert_eq!(group("rating"), Some(serde_json::Value::Null));
|
||||
// choices stay the ids rules store; choiceLabels maps them to what people read
|
||||
let flag = fields.as_array().unwrap().iter().find(|f| f["field"] == "copyrightStatus").unwrap();
|
||||
assert_eq!(flag["choices"], serde_json::json!(["copyrighted", "publicDomain", "unknown"]));
|
||||
assert_eq!(flag["choiceLabels"]["publicDomain"], "Public Domain");
|
||||
let album = fields.as_array().unwrap().iter().find(|f| f["field"] == "album").unwrap();
|
||||
assert_eq!((album["kind"].as_str(), album["ops"].as_array().map(Vec::len)), (Some("album"), Some(2)), "an album id, is / isn't");
|
||||
let bad = s.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}),
|
||||
);
|
||||
assert!(bad.is_err(), "operators are checked");
|
||||
// yes/no values: "false" means no, anything unreadable is an error rather than a silent yes
|
||||
let maybe = s.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Maybe", "rules": {"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "maybe"}]}}}),
|
||||
);
|
||||
assert!(maybe.is_err_and(|e| e.to_string().contains("yes or no")), "unreadable yes/no value");
|
||||
// the library filter checks a rule set the same way, instead of quietly showing nothing
|
||||
for rules in [serde_json::json!([{"field": "edited", "op": "is", "value": "maybe"}]), serde_json::json!([{"field": "nope", "op": "is"}])] {
|
||||
let r = s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": rules}}));
|
||||
assert!(r.is_err(), "{rules}");
|
||||
}
|
||||
assert!(s.filter.rule_set.is_none(), "a refused filter isn't applied");
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "no"}]}})).unwrap();
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": null})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Unedited", "rules": {"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "false"}]}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let unedited = s.catalog.photos().filter(|p| !p.deleted && !p.is_edited()).count();
|
||||
assert_eq!(r["count"].as_u64(), Some(unedited as u64));
|
||||
let r = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
@@ -440,3 +472,206 @@ fn a_library_open_elsewhere_is_refused() {
|
||||
assert_eq!(other.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A capture-time shift from an agent can't overflow or write a date no one can read back: a
|
||||
/// shift of more than 10,000 years is refused and nothing changes; an ordinary one still works.
|
||||
#[test]
|
||||
fn capture_time_shift_is_bounded() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
let before = s.catalog.photo(id).unwrap().captured.clone();
|
||||
for p in [
|
||||
serde_json::json!({"ids": [id.0], "shift": 1e30}),
|
||||
serde_json::json!({"ids": [id.0], "hours": -1e15}),
|
||||
serde_json::json!({"ids": [id.0], "shift": 9.3e18}),
|
||||
] {
|
||||
let r = s.execute("photo.setCaptureTime", &p);
|
||||
// a real refusal, not the panic guard catching an overflow ("failed unexpectedly")
|
||||
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("10,000 years")), "{p}: {r:?}");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().captured, before, "{p} changed nothing");
|
||||
}
|
||||
s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "hours": 1})).unwrap();
|
||||
assert_ne!(s.catalog.photo(id).unwrap().captured, before);
|
||||
// a shift that would take a photo past year 9999 says so, rather than skipping it quietly
|
||||
s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "time": "9999-12-31T12:00:00"})).unwrap();
|
||||
let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "hours": 24}));
|
||||
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("9999")), "{r:?}");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("9999-12-31T12:00:00"), "unchanged");
|
||||
}
|
||||
|
||||
/// Values are checked too: an agent's rule set with a date that isn't one, or a rating of 9, is
|
||||
/// refused with the rule's position, by album.createSmart and library.filter alike.
|
||||
#[test]
|
||||
fn smart_rule_values_are_checked_by_commands() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let r = s.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Odd", "rules": {"ruleSet": {"rules": [
|
||||
{"field": "rating", "op": "gte", "value": 3},
|
||||
{"field": "captureDate", "op": "is", "value": "banana"}
|
||||
]}}}),
|
||||
);
|
||||
let e = r.expect_err("a date that isn't one").to_string();
|
||||
assert!(e.contains("rule 2:") && e.contains("needs a date"), "{e}");
|
||||
assert!(s.catalog.albums().all(|a| a.name != "Odd"));
|
||||
let e = s
|
||||
.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 9}]}}))
|
||||
.expect_err("rating 9")
|
||||
.to_string();
|
||||
assert!(e.contains("rule 1: no rating 0–5 is ≥ 9"), "{e}");
|
||||
}
|
||||
|
||||
/// A smart album saved with an old album operator (≥, from when Album was a number field) can
|
||||
/// still be edited: its rule is read as the "isn't" it always meant.
|
||||
#[test]
|
||||
fn old_album_operator_rules_stay_editable() {
|
||||
use lightcraft_catalog::{Album, AlbumId, Op};
|
||||
let mut s = crate::Session::with_demo();
|
||||
let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
|
||||
let rules: lightcraft_catalog::Filter =
|
||||
serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "gte", "value": trip}]}})).unwrap();
|
||||
let id = s.catalog.alloc_album_id();
|
||||
s.catalog.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(rules)), ..Album::new(id, "Old") } }).unwrap();
|
||||
s.execute("album.setRules", &serde_json::json!({"id": id.0, "rules": {"rating": 2}})).unwrap();
|
||||
let saved = serde_json::to_string(&s.catalog.album(AlbumId(id.0)).unwrap().smart).unwrap();
|
||||
assert!(saved.contains(r#""op":"isNot""#), "{saved}");
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "lte", "value": trip}]}})).unwrap();
|
||||
}
|
||||
|
||||
/// Renaming and changing the rules is one step: both or neither, undone together.
|
||||
#[test]
|
||||
fn set_rules_with_a_name_is_one_step() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.execute("album.createSmart", &serde_json::json!({"name": "Old", "rules": {"rating": 3}})).unwrap()["id"].as_u64().unwrap();
|
||||
let album = |s: &crate::Session| s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().clone();
|
||||
let good = serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 4}]}});
|
||||
let bad = serde_json::json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "is", "value": "banana"}]}});
|
||||
// refused rules: the name stays too
|
||||
assert!(s.execute("album.setRules", &serde_json::json!({"id": id, "name": "New", "replace": true, "rules": bad})).is_err());
|
||||
assert_eq!(album(&s).name, "Old");
|
||||
let undo = s.undo.len();
|
||||
s.execute("album.setRules", &serde_json::json!({"id": id, "name": "New", "replace": true, "rules": good})).unwrap();
|
||||
assert_eq!((album(&s).name.as_str(), s.undo.len()), ("New", undo + 1), "one undo step");
|
||||
assert!(album(&s).smart.unwrap().rule_set.is_some());
|
||||
s.execute("edit.undo", &serde_json::json!({})).unwrap();
|
||||
assert_eq!(album(&s).name, "Old");
|
||||
assert!(album(&s).smart.unwrap().rule_set.is_none(), "undone together");
|
||||
// a blank name keeps the album's
|
||||
s.execute("album.setRules", &serde_json::json!({"id": id, "name": " ", "rules": {"rating": 5}})).unwrap();
|
||||
assert_eq!(album(&s).name, "Old");
|
||||
}
|
||||
|
||||
/// albums.list says which smart albums have rules that no longer check, and why.
|
||||
#[test]
|
||||
fn album_list_reports_problems() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
|
||||
let rules = serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": trip}]}});
|
||||
let id = s.execute("album.createSmart", &serde_json::json!({"name": "In Trip", "rules": rules})).unwrap()["id"].as_u64().unwrap();
|
||||
let find = |s: &mut crate::Session| {
|
||||
let list = s.execute("albums.list", &serde_json::json!({})).unwrap();
|
||||
list.as_array().unwrap().iter().find(|a| a["id"] == id).cloned().unwrap()
|
||||
};
|
||||
assert_eq!(find(&mut s)["problems"], serde_json::json!([]));
|
||||
s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap();
|
||||
assert_eq!(find(&mut s)["problems"], serde_json::json!([format!("rule 1: no album {trip}")]));
|
||||
}
|
||||
|
||||
/// "Travel" = keywords contain travel, but not in "Excluded Photos" (red or rejected): the counts
|
||||
/// follow, and Excluded Photos can't then test Travel back (a loop).
|
||||
#[test]
|
||||
fn smart_album_excluding_a_smart_album() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let excluded = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Excluded Photos", "rules": {"ruleSet": {"match": "any", "rules": [
|
||||
{"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}}}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let keyword = s.catalog.photos().flat_map(|p| p.meta.keywords.clone()).next().expect("a demo keyword");
|
||||
let travel = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Travel", "rules": {"ruleSet": {"rules": [
|
||||
{"field": "keywords", "op": "contains", "value": keyword}, {"field": "album", "op": "isNot", "value": excluded}]}}}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let count = |s: &crate::Session| s.catalog.album_count(lightcraft_catalog::AlbumId(travel));
|
||||
let want = |s: &crate::Session| {
|
||||
s.catalog
|
||||
.photos()
|
||||
.filter(|p| !p.deleted && p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&keyword.to_lowercase())))
|
||||
.filter(|p| p.label != Some(lightcraft_catalog::ColorLabel::Red) && p.flag != lightcraft_catalog::Flag::Reject)
|
||||
.count()
|
||||
};
|
||||
assert_eq!(count(&s), want(&s));
|
||||
// reject one of Travel's photos: it leaves Travel
|
||||
let before = count(&s);
|
||||
let id = s.catalog.photos().find(|p| s.catalog.album_contains(lightcraft_catalog::AlbumId(travel), p)).map(|p| p.id).expect("a travel photo");
|
||||
s.execute("photo.flag", &serde_json::json!({"ids": [id.0], "flag": "reject"})).unwrap();
|
||||
assert_eq!(count(&s), before - 1);
|
||||
// Excluded Photos testing Travel back would include itself
|
||||
let looped = s.execute(
|
||||
"album.setRules",
|
||||
&serde_json::json!({"id": excluded, "rules": {"ruleSet": {"rules": [{"field": "album", "op": "is", "value": travel}]}}, "replace": true}),
|
||||
);
|
||||
assert!(looped.is_err_and(|e| e.to_string().contains("would make this album include itself")), "a loop is refused");
|
||||
// agents read the album by name in the summary
|
||||
let list = s.execute("albums.list", &serde_json::json!({})).unwrap();
|
||||
let travel_json = list.as_array().unwrap().iter().find(|a| a["id"] == travel).cloned().unwrap();
|
||||
assert!(travel_json["rulesText"].as_str().unwrap_or("").contains("album isn't “Excluded Photos”"), "{travel_json}");
|
||||
}
|
||||
|
||||
/// "Update Rules from Current Filter" refuses a loop too: showing A ("Album isn't B") and
|
||||
/// updating B from that view would make B test itself.
|
||||
#[test]
|
||||
fn rules_from_the_view_cant_loop() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let b = s.execute("album.createSmart", &serde_json::json!({"name": "B", "rules": {"rating": 2}})).unwrap()["id"].as_u64().unwrap();
|
||||
let rules = serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": b}]}});
|
||||
let a = s.execute("album.createSmart", &serde_json::json!({"name": "A", "rules": rules})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("library.source", &serde_json::json!({"kind": "album", "id": a})).unwrap();
|
||||
let before = s.catalog.album(lightcraft_catalog::AlbumId(b)).unwrap().smart.clone();
|
||||
let r = s.execute("album.setRules", &serde_json::json!({"id": b, "fromView": true}));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("would make this album include itself")), "refused");
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(b)).unwrap().smart, before, "B unchanged");
|
||||
assert!(s.catalog.smart_album_problems(lightcraft_catalog::AlbumId(b)).is_empty());
|
||||
}
|
||||
|
||||
/// A rule that stops checking later (the album it tests is deleted) doesn't lock up what doesn't
|
||||
/// touch it: the filter bar keeps working, and an album's other settings can still be edited. A
|
||||
/// change to the rules themselves is still checked.
|
||||
#[test]
|
||||
fn a_stale_rule_doesnt_block_other_changes() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
|
||||
let rules = serde_json::json!({"rules": [{"field": "album", "op": "isNot", "value": trip}]});
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": rules})).unwrap();
|
||||
let smart =
|
||||
s.execute("album.createSmart", &serde_json::json!({"name": "Not Trip", "rules": {"ruleSet": rules}})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap();
|
||||
// the filter bar: a rating still applies on top of the stale rule
|
||||
s.execute("library.filter", &serde_json::json!({"rating": 3})).unwrap();
|
||||
assert_eq!(s.filter.rating, 3);
|
||||
// the album: a partial edit that leaves its rules alone
|
||||
s.execute("album.setRules", &serde_json::json!({"id": smart, "rules": {"rating": 2}})).unwrap();
|
||||
// changing the rules is still checked
|
||||
let bad = serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 9}]}});
|
||||
assert!(s.execute("library.filter", &bad).is_err());
|
||||
assert!(s.execute("album.setRules", &serde_json::json!({"id": smart, "rules": bad})).is_err());
|
||||
}
|
||||
|
||||
/// album.setRules refuses a loop through the album filter as well as through the rules.
|
||||
#[test]
|
||||
fn an_album_filter_loop_is_refused() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let a = s.execute("album.createSmart", &serde_json::json!({"name": "A", "rules": {"rating": 2}})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("album.setRules", &serde_json::json!({"id": a, "rules": {"album": a}}));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("include itself")), "refused");
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().smart.as_ref().and_then(|f| f.album), None);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,22 @@ fn smart_album_from_smart_album_view_keeps_its_rules() {
|
||||
assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
|
||||
}
|
||||
|
||||
/// Saving the view of a smart album keeps the filter bar's settings even when the album holds a
|
||||
/// rule that no longer checks (it tests an album that has since been deleted).
|
||||
#[test]
|
||||
fn smart_album_view_keeps_the_filter_bar_over_a_stale_rule() {
|
||||
let mut s = Session::with_demo();
|
||||
let gone = s.execute("album.create", &json!({"name": "Gone", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
|
||||
let rules = json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": gone}]}});
|
||||
let id = s.execute("album.createSmart", &json!({"name": "Not in Gone", "rules": rules})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.delete", &json!({"id": gone})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap();
|
||||
s.execute("library.filter", &json!({"rating": 3})).unwrap();
|
||||
let r = s.view_rules();
|
||||
assert_eq!(r.rating, 3, "the filter bar's rating survives");
|
||||
assert!(r.rule_set.is_some(), "and so do the album's own rules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_copies_are_independent_and_persist() {
|
||||
let dir = temp_dir("vc");
|
||||
@@ -256,3 +272,44 @@ fn random_sort_persists_with_the_view() {
|
||||
assert_eq!(s2.sort.seed, 123_456_789);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #501: deleting a populated folder is one reversible, durable catalog edit.
|
||||
#[test]
|
||||
fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() {
|
||||
use lightcraft_catalog::AlbumId;
|
||||
let dir = temp_dir("delete-folder");
|
||||
let mut s = open(&dir);
|
||||
let make = |s: &mut Session, p| s.execute("album.create", &p).unwrap()["id"].as_u64().unwrap();
|
||||
let outer = make(&mut s, json!({"name": "Trips", "folder": true}));
|
||||
let nested = make(&mut s, json!({"name": "Europe", "folder": true, "parent": outer}));
|
||||
let album = make(&mut s, json!({"name": "Best", "parent": nested, "addSelected": true}));
|
||||
let sibling = make(&mut s, json!({"name": "Kept", "addSelected": true}));
|
||||
let smart = s.execute("album.createSmart", &json!({"name": "Rated", "parent": outer, "rules": {"rating": 4}})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.reorder", &json!({"id": album, "parent": nested})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
|
||||
let before = s.catalog.to_snapshot();
|
||||
let photos = serde_json::to_value(s.catalog.photos().collect::<Vec<_>>()).unwrap();
|
||||
let undo_before = s.undo.len();
|
||||
s.execute("album.delete", &json!({"id": outer})).unwrap();
|
||||
for id in [outer, nested, album, smart] {
|
||||
assert!(s.catalog.album(AlbumId(id)).is_none());
|
||||
}
|
||||
assert!(s.catalog.album(AlbumId(sibling)).is_some());
|
||||
assert_eq!(serde_json::to_value(s.catalog.photos().collect::<Vec<_>>()).unwrap(), photos);
|
||||
assert_eq!(s.source, LibrarySource::All, "a deleted descendant cannot remain the source");
|
||||
assert_eq!(s.undo.len(), undo_before + 1);
|
||||
let deleted = s.catalog.to_snapshot();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "all album fields and memberships restored");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), deleted);
|
||||
let undo_after = s.undo.len();
|
||||
assert!(s.execute("album.delete", &json!({"id": outer})).is_err());
|
||||
assert_eq!(s.catalog.to_snapshot(), deleted, "missing id leaves the catalog unchanged");
|
||||
assert_eq!(s.undo.len(), undo_after);
|
||||
drop(s);
|
||||
let reopened = open(&dir);
|
||||
assert_eq!(reopened.catalog.to_snapshot(), deleted, "journal replay preserves the deletion");
|
||||
drop(reopened);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Process versions through the commands (`docs/process-versions.md`): new photos and Reset get
|
||||
//! the latest process, Update to Current Process is one undo step, copies inherit, copy / paste /
|
||||
//! sync / presets / Auto Sync keep each photo's own, and numbers this build doesn't know survive a
|
||||
//! save and a sidecar round trip.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::{DevelopSettings, ProcessVersion};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// No process is older than V1 yet: 0 (which no LightCraft writes) stands in for one.
|
||||
const OLDER: ProcessVersion = ProcessVersion(0);
|
||||
/// A number from a newer LightCraft.
|
||||
const NEWER: ProcessVersion = ProcessVersion(ProcessVersion::LATEST.0 + 6);
|
||||
|
||||
fn process(s: &Session, id: PhotoId) -> ProcessVersion {
|
||||
s.develop_of(id).unwrap().process
|
||||
}
|
||||
|
||||
/// Put photo `id` on process `p` (with exposure `ev`) without going through a command.
|
||||
fn put(s: &mut Session, id: PhotoId, p: ProcessVersion, ev: f64) {
|
||||
let mut d = (*s.develop_of(id).unwrap()).clone();
|
||||
d.process = p;
|
||||
d.light.exposure = ev;
|
||||
s.set_develop(id, d, "test").unwrap();
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[PhotoId]) {
|
||||
s.execute("library.select", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn update_info(s: &Session) -> crate::CommandInfo {
|
||||
s.commands().into_iter().find(|c| c.id == "develop.updateProcess").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_photos_and_reset_get_the_latest_process() {
|
||||
let mut s = Session::with_demo();
|
||||
assert!(s.catalog.photos().all(|p| p.develop.process == ProcessVersion::LATEST));
|
||||
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(2).collect();
|
||||
put(&mut s, ids[0], OLDER, 0.7);
|
||||
put(&mut s, ids[1], NEWER, -0.3);
|
||||
// a section or a slider reset leaves the process alone
|
||||
select(&mut s, &ids[1..]);
|
||||
s.execute("develop.resetSection", &json!({"section": "light"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.2})).unwrap();
|
||||
s.execute("develop.resetControl", &json!({"control": "light.exposure"})).unwrap();
|
||||
let d = s.develop_of(ids[1]).unwrap();
|
||||
assert_eq!((d.process, d.light.exposure), (NEWER, 0.0));
|
||||
// a full reset is a fresh start on the latest process, also from a newer one
|
||||
select(&mut s, &ids);
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
for id in &ids {
|
||||
assert_eq!(process(&s, *id), ProcessVersion::LATEST);
|
||||
assert!(!s.catalog.photo(*id).unwrap().is_edited());
|
||||
}
|
||||
// a photo whose default preset's look was recorded on an older process
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(process(&s, ids[0]), OLDER);
|
||||
let mut p = (**s.catalog.photo(ids[0]).unwrap()).clone();
|
||||
let mut look = p.camera_defaults();
|
||||
look.light.contrast = 20.0;
|
||||
look.process = OLDER;
|
||||
p.import_look = Some(Arc::new(look));
|
||||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: p.id }).unwrap();
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.execute("develop.reset", &json!({"ids": [ids[0].0]})).unwrap();
|
||||
let d = s.develop_of(ids[0]).unwrap();
|
||||
assert_eq!((d.process, d.light.contrast), (ProcessVersion::LATEST, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_to_current_process_is_one_undo_step_and_only_when_needed() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(3).collect();
|
||||
select(&mut s, &ids);
|
||||
// everything is current: disabled, with the reason
|
||||
let info = update_info(&s);
|
||||
assert!(!info.enabled && info.disabled_reason.as_deref().is_some_and(|r| r.contains("already on the current process")), "{info:?}");
|
||||
assert!(matches!(s.execute("develop.updateProcess", &json!({})), Err(EngineError::Disabled(..))));
|
||||
// in the Photo menu
|
||||
assert_eq!(info.menu, vec!["Photo"]);
|
||||
|
||||
put(&mut s, ids[0], OLDER, 0.5);
|
||||
put(&mut s, ids[1], OLDER, -0.5);
|
||||
put(&mut s, ids[2], NEWER, 0.25);
|
||||
select(&mut s, &ids);
|
||||
assert!(update_info(&s).enabled);
|
||||
let undo = s.undo.len();
|
||||
let r = s.execute("develop.updateProcess", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 2, "process": ProcessVersion::LATEST.0}));
|
||||
assert_eq!(s.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(s.undo.last().unwrap().label, "Update to Current Process");
|
||||
assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [ProcessVersion::LATEST; 2]);
|
||||
assert_eq!(process(&s, ids[2]), NEWER, "a newer process is never downgraded");
|
||||
assert_eq!(s.develop_of(ids[0]).unwrap().light.exposure, 0.5, "the sliders keep their values");
|
||||
assert_eq!(s.catalog.photo(ids[1]).unwrap().history.last().unwrap().label, "Update to Current Process");
|
||||
assert!(!update_info(&s).enabled, "nothing left to update");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [OLDER; 2]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [ProcessVersion::LATEST; 2]);
|
||||
// a photo on a newer process alone: nothing to update
|
||||
select(&mut s, &ids[2..]);
|
||||
assert!(!update_info(&s).enabled);
|
||||
// the active photo when nothing else is selected
|
||||
put(&mut s, ids[0], OLDER, 0.5);
|
||||
s.selection = crate::Selection { ids: Vec::new(), active: Some(ids[0]) };
|
||||
assert!(update_info(&s).enabled);
|
||||
assert_eq!(s.execute("develop.updateProcess", &json!({})).unwrap()["changed"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_copies_inherit_the_process() {
|
||||
let mut s = Session::with_demo();
|
||||
let src = s.visible_cloned()[0];
|
||||
put(&mut s, src, NEWER, 0.4);
|
||||
select(&mut s, &[src]);
|
||||
let r = s.execute("photo.virtualCopy", &json!({})).unwrap();
|
||||
let copy = PhotoId(r["ids"][0].as_u64().unwrap());
|
||||
assert_eq!(process(&s, copy), NEWER);
|
||||
assert!(s.develop_of(copy).unwrap() == s.develop_of(src).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_sync_presets_and_auto_sync_keep_each_photos_process() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(3).collect();
|
||||
let (src, a, b) = (ids[0], ids[1], ids[2]);
|
||||
put(&mut s, src, NEWER, 1.0);
|
||||
put(&mut s, b, OLDER, 0.0);
|
||||
select(&mut s, &[src]);
|
||||
s.execute("develop.copy", &json!({"groups": lightcraft_develop::SettingsGroup::ALL})).unwrap();
|
||||
select(&mut s, &[a, b]);
|
||||
s.execute("develop.paste", &json!({})).unwrap();
|
||||
assert_eq!([process(&s, a), process(&s, b)], [ProcessVersion::LATEST, OLDER]);
|
||||
assert_eq!(s.develop_of(b).unwrap().light.exposure, 1.0, "the values are pasted");
|
||||
// sync from the active photo
|
||||
put(&mut s, src, NEWER, -1.0);
|
||||
s.selection = crate::Selection { ids: vec![src, a, b], active: Some(src) };
|
||||
s.execute("develop.sync", &json!({})).unwrap();
|
||||
assert_eq!([process(&s, a), process(&s, b)], [ProcessVersion::LATEST, OLDER]);
|
||||
assert_eq!(s.develop_of(a).unwrap().light.exposure, -1.0);
|
||||
// a preset made from the photo
|
||||
select(&mut s, &[src]);
|
||||
// (not Grain: its integer seed doesn't survive scaling, a separate issue)
|
||||
let groups: Vec<_> = lightcraft_develop::SettingsGroup::ALL.into_iter().filter(|g| *g != lightcraft_develop::SettingsGroup::Grain).collect();
|
||||
let pid = s.execute("preset.create", &json!({"name": "Everything", "groups": groups})).unwrap()["id"].clone();
|
||||
put(&mut s, b, OLDER, 0.0);
|
||||
select(&mut s, &[b]);
|
||||
s.execute("preset.apply", &json!({"id": pid, "amount": 50})).unwrap();
|
||||
assert_eq!(process(&s, b), OLDER);
|
||||
assert_eq!(s.develop_of(b).unwrap().light.exposure, -0.5, "the preset applied (at 50 %)");
|
||||
// Auto Sync carries an edit of the active photo, not its process
|
||||
s.selection = crate::Selection { ids: vec![src, a], active: Some(src) };
|
||||
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
|
||||
s.execute("develop.merge", &json!({"settings": {"process": OLDER.0, "light": {"contrast": 15}}})).unwrap();
|
||||
assert_eq!(process(&s, src), OLDER, "Apply Settings JSON sets what it names");
|
||||
let d = s.develop_of(a).unwrap();
|
||||
assert_eq!((d.process, d.light.contrast), (ProcessVersion::LATEST, 15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_numbers_survive_a_reopen_and_develop_get_reports_them() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-process-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(2).collect();
|
||||
select(&mut s, &ids[..1]);
|
||||
s.execute("develop.merge", &json!({"settings": {"process": NEWER.0, "light": {"exposure": 0.5}}})).unwrap();
|
||||
assert_eq!(s.execute("develop.get", &json!({"id": ids[0].0})).unwrap()["process"], json!(NEWER.0));
|
||||
assert_eq!(s.execute("develop.get", &json!({"id": ids[1].0})).unwrap()["process"], json!(ProcessVersion::LATEST.0));
|
||||
// it renders (with the newest process this build has)
|
||||
assert!(s.render_now(ids[0], 48, 48).is_ok());
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, false).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), expect);
|
||||
assert_eq!(process(&s, ids[0]), NEWER);
|
||||
assert_eq!(process(&s, ids[1]), ProcessVersion::LATEST);
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecars_keep_the_process_and_interchange_edits_get_the_latest() {
|
||||
use crate::sidecar::{DevelopPatch, merge_into, parse_sidecar, sidecar_packet};
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0];
|
||||
// our own settings round-trip with their process; V1 settings say nothing (as before process
|
||||
// versions, so sidecars written then read as V1)
|
||||
put(&mut s, id, NEWER, 0.3);
|
||||
let p = s.catalog.photo(id).unwrap().clone();
|
||||
let back = parse_sidecar(&sidecar_packet(&p, &s.catalog), crate::crs::Target::Rendered).unwrap();
|
||||
let Some(DevelopPatch::Full(d)) = &back.develop else { panic!("no lc:settings") };
|
||||
assert_eq!((d.process, d.light.exposure), (NEWER, 0.3));
|
||||
put(&mut s, id, ProcessVersion::V1, 0.3);
|
||||
let packet = sidecar_packet(s.catalog.photo(id).unwrap(), &s.catalog);
|
||||
assert!(!packet.contains("process"), "{packet}");
|
||||
let Some(DevelopPatch::Full(d)) = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().develop else { panic!("no lc:settings") };
|
||||
assert_eq!(d.process, ProcessVersion::V1);
|
||||
// a damaged process value reads as V1 and costs nothing else: the sidecar's edits still come back
|
||||
for bad in ["-1", "1.5", "\"two\"", "null", "4294967296"] {
|
||||
let json = format!(r#"{{"process": {bad}, "light": {{"exposure": 0.5}}}}"#);
|
||||
let packet = lightcraft_meta::write_xmp(&lightcraft_meta::Metadata::default(), Some(&json));
|
||||
let Some(DevelopPatch::Full(d)) = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().develop else {
|
||||
panic!("{bad}: the edit was dropped")
|
||||
};
|
||||
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5), "{bad}");
|
||||
}
|
||||
// `crs:` edits (other raw developers, Lightroom): their ProcessVersion numbers another
|
||||
// renderer and is ignored; mapped like a preset, they leave the photo's process as it is,
|
||||
// which for a newly imported photo is the latest
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:ProcessVersion="11.0" crs:Exposure2012="+1.10" crs:Contrast2012="+20"/></rdf:RDF></x:xmpmeta>"#;
|
||||
let sc = parse_sidecar(xmp, crate::crs::Target::RawAbsolute).unwrap();
|
||||
let Some(DevelopPatch::Partial(partial)) = &sc.develop else { panic!("no crs: edit") };
|
||||
assert!(partial.get("process").is_none(), "{partial}");
|
||||
let mut fresh = Photo::new(PhotoId(999), Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00");
|
||||
assert!(merge_into(&mut fresh, &sc, "2026-10-09T00:00:00"));
|
||||
assert_eq!((fresh.develop.process, fresh.develop.light.exposure), (ProcessVersion::LATEST, 1.1));
|
||||
let mut older = fresh.clone();
|
||||
older.develop = Arc::new(DevelopSettings { process: OLDER, ..Default::default() });
|
||||
assert!(merge_into(&mut older, &sc, "2026-10-09T00:00:00"));
|
||||
assert_eq!(older.develop.process, OLDER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_to_current_process_checks_named_photos_not_the_selection() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(3).collect();
|
||||
let (old, current, other) = (ids[0], ids[1], ids[2]);
|
||||
put(&mut s, old, OLDER, 0.5);
|
||||
// a different, current photo is selected: the menu item is off, but naming the photo works
|
||||
select(&mut s, &[current]);
|
||||
assert!(!update_info(&s).enabled);
|
||||
let undo = s.undo.len();
|
||||
assert_eq!(s.execute("develop.updateProcess", &json!({"ids": [old.0]})).unwrap()["changed"], 1);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(process(&s, old), ProcessVersion::LATEST);
|
||||
assert_eq!(s.develop_of(old).unwrap().light.exposure, 0.5);
|
||||
// with nothing selected at all, by `ids` or `id`
|
||||
put(&mut s, old, OLDER, 0.5);
|
||||
put(&mut s, other, OLDER, 0.0);
|
||||
s.selection = crate::Selection { ids: Vec::new(), active: None };
|
||||
assert!(!update_info(&s).enabled, "no selection");
|
||||
assert_eq!(s.execute("develop.updateProcess", &json!({"ids": [old.0, current.0]})).unwrap()["changed"], 1);
|
||||
assert_eq!(s.execute("develop.updateProcess", &json!({"id": other.0})).unwrap()["changed"], 1);
|
||||
assert_eq!([process(&s, old), process(&s, other)], [ProcessVersion::LATEST; 2]);
|
||||
// named photos are validated: all current, unknown, malformed or none
|
||||
let err = s.execute("develop.updateProcess", &json!({"ids": [current.0]})).unwrap_err();
|
||||
assert!(matches!(&err, EngineError::Disabled(_, why) if why.contains("already on the current process")), "{err}");
|
||||
for bad in [json!({"ids": [99_999]}), json!({"ids": ["x"]}), json!({"ids": "x"}), json!({"ids": []}), json!({"id": -1})] {
|
||||
assert!(matches!(s.execute("develop.updateProcess", &bad), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
// `ids: null` names nothing: the selection decides (and there is none)
|
||||
assert!(matches!(s.execute("develop.updateProcess", &json!({"ids": null})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
//! Synchronize Folder: bring a library folder up to date with what is on disk (see
|
||||
//! [`crate::sync`]).
|
||||
//!
|
||||
//! Scenarios, in the words of someone whose folder changed outside LightCraft:
|
||||
//!
|
||||
//! * Given a folder of the library, when files were added to it (or to a folder inside it), the
|
||||
//! scan lists them as new; files the library already has, by path or by content, are not new.
|
||||
//! * When a photo's file was deleted or moved away, the scan lists the photo as missing.
|
||||
//! * When another app saved a photo's XMP sidecar after the photo came into the library, and the
|
||||
//! sidecar says something the library doesn't, the scan lists a metadata update; a sidecar older
|
||||
//! than that, or one that agrees with the library, is not an update.
|
||||
//! * Scanning changes nothing.
|
||||
//! * Synchronizing imports the new files; it removes missing photos (to Recently Deleted) and
|
||||
//! reads metadata updates only when asked. Whatever it does is one undo step.
|
||||
//! * Only a folder of the library can be synchronized, never the whole startup disk.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A scratch folder that goes away with the test, however it ends.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("lc-sync-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small procedural PNG (distinct per seed).
|
||||
fn write_png(path: &str, seed: u8) {
|
||||
let (w, h) = (24usize, 16usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(Path::new(path).parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn sidecar(rating: u8) -> String {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/>
|
||||
</rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Write `a.xmp` beside `a.png`, dated `age` before now.
|
||||
fn write_sidecar(png: &str, rating: u8, age: Duration) {
|
||||
let xmp = Path::new(png).with_extension("xmp");
|
||||
std::fs::write(&xmp, sidecar(rating)).unwrap();
|
||||
let f = std::fs::File::options().write(true).open(&xmp).unwrap();
|
||||
f.set_modified(SystemTime::now() - age).unwrap();
|
||||
}
|
||||
|
||||
/// A library holding `trip/a.png` and `trip/day1/b.png`, the session clock at a fixed past time.
|
||||
fn library(dir: &Scratch) -> Session {
|
||||
write_png(&dir.path("trip/a.png"), 1);
|
||||
write_png(&dir.path("trip/day1/b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap();
|
||||
assert_eq!(s.catalog.len(), 2);
|
||||
s
|
||||
}
|
||||
|
||||
fn paths(v: &Value, key: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = v[key].as_array().unwrap().iter().map(|c| c["path"].as_str().unwrap().to_string()).collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// What the library holds: each photo's file, whether it is in the library, and its rating.
|
||||
fn holdings(s: &Session) -> Vec<(String, bool, u8)> {
|
||||
let mut v: Vec<(String, bool, u8)> = s.catalog.photos().map(|p| (p.file_name.clone(), p.in_library(), p.rating)).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
fn scan(s: &mut Session, path: &str) -> Value {
|
||||
s.execute("folder.scanChanges", &json!({"path": path})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_folder_has_no_changes() {
|
||||
let dir = Scratch::new("none");
|
||||
let mut s = library(&dir);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_added_to_the_folder_or_a_folder_inside_it_are_new() {
|
||||
let dir = Scratch::new("new");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
write_png(&dir.path("trip/day2/d.png"), 4);
|
||||
// the same bytes as a photo the library has: not new
|
||||
std::fs::copy(dir.path("trip/a.png"), dir.path("trip/a-copy.png")).unwrap();
|
||||
let before = s.catalog.to_snapshot();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png"), dir.path("trip/day2/d.png")], "{r}");
|
||||
assert_eq!(r["duplicates"], 1, "{r}");
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "scanning changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_whose_file_is_gone_is_missing() {
|
||||
let dir = Scratch::new("missing");
|
||||
let mut s = library(&dir);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "missing"), vec![dir.path("trip/day1/b.png")], "{r}");
|
||||
assert!(r["missing"][0]["id"].is_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sidecar_saved_by_another_app_is_a_metadata_update() {
|
||||
let dir = Scratch::new("meta");
|
||||
let mut s = library(&dir);
|
||||
// saved now: after the photo came in (the session clock says 2026-09-30)
|
||||
write_sidecar(&dir.path("trip/a.png"), 4, Duration::ZERO);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "metadata"), vec![dir.path("trip/a.png")], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_sidecar_or_one_that_agrees_is_no_update() {
|
||||
let dir = Scratch::new("meta-old");
|
||||
let mut s = library(&dir);
|
||||
// older than the import: whatever it says, the library has had its say since
|
||||
write_sidecar(&dir.path("trip/a.png"), 4, Duration::from_secs(400 * 24 * 3600));
|
||||
// new, but says what the library already knows
|
||||
write_sidecar(&dir.path("trip/day1/b.png"), 0, Duration::ZERO);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_imports_new_files_and_leaves_the_rest_unless_asked() {
|
||||
let dir = Scratch::new("sync");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(0), Some(0)), "{r}");
|
||||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 3, "the missing photo stays");
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap();
|
||||
assert_eq!(a.rating, 0, "metadata is read only when asked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_everything_is_one_undo_step() {
|
||||
let dir = Scratch::new("sync-all");
|
||||
let mut s = library(&dir);
|
||||
let before = holdings(&s);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||||
let r =
|
||||
s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "importNew": true, "removeMissing": true, "readMetadata": true})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(1), Some(1)), "{r}");
|
||||
let library: Vec<String> = s.catalog.photos().filter(|p| p.in_library()).map(|p| p.file_name.clone()).collect();
|
||||
assert_eq!(library.len(), 2, "b went to Recently Deleted: {library:?}");
|
||||
assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5, "the sidecar was read");
|
||||
let undo = s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(undo["undone"], "Synchronize Folder");
|
||||
assert_eq!(holdings(&s), before, "one undo step puts everything back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_with_no_changes_synchronizes_without_an_undo_step() {
|
||||
let dir = Scratch::new("sync-none");
|
||||
let mut s = library(&dir);
|
||||
let steps = s.undo.len();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true, "readMetadata": true})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(0), Some(0), Some(0)), "{r}");
|
||||
assert_eq!(s.undo.len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_folder_of_the_library_is_synchronized() {
|
||||
let dir = Scratch::new("refused");
|
||||
let mut s = library(&dir);
|
||||
std::fs::create_dir_all(dir.0.join("elsewhere")).unwrap();
|
||||
for (cmd, p, why) in [
|
||||
("folder.scanChanges", json!({}), "missing path"),
|
||||
("folder.scanChanges", json!({"path": " "}), "blank path"),
|
||||
("folder.scanChanges", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"),
|
||||
("folder.scanChanges", json!({"path": "/"}), "the startup disk"),
|
||||
("folder.synchronize", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"),
|
||||
("folder.synchronize", json!({"path": "/"}), "the startup disk"),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_acts_on_the_changes_just_scanned() {
|
||||
let dir = Scratch::new("cached");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "new").len(), 1);
|
||||
// arrived after the scan the person was shown: not part of what they agreed to
|
||||
write_png(&dir.path("trip/d.png"), 4);
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
// the scan is used once; the next synchronize looks again
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "d.png now: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_is_stale_once_the_library_changed() {
|
||||
let dir = Scratch::new("stale");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
// c.png comes in some other way between the scan and the synchronize
|
||||
s.execute("library.import", &json!({"paths": [dir.path("trip/c.png")]})).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 0, "the library changed: it looked again: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_scan_stops_checking_files() {
|
||||
let dir = Scratch::new("cancel");
|
||||
let mut s = library(&dir);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let c = crate::sync::scan_with(input, &progress);
|
||||
assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_photos_are_checked_in_parallel_with_the_same_answer() {
|
||||
let dir = Scratch::new("many");
|
||||
for i in 0..40u8 {
|
||||
write_png(&dir.path(&format!("big/{i:02}.png")), i);
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.path("big")]})).unwrap();
|
||||
for i in (0..40u8).step_by(3) {
|
||||
std::fs::remove_file(dir.path(&format!("big/{i:02}.png"))).unwrap();
|
||||
}
|
||||
for i in (1..40u8).step_by(5) {
|
||||
write_sidecar(&dir.path(&format!("big/{i:02}.png")), 2, Duration::ZERO);
|
||||
}
|
||||
let r = scan(&mut s, &dir.path("big"));
|
||||
let expect_missing: Vec<String> = (0..40u8).step_by(3).map(|i| dir.path(&format!("big/{i:02}.png"))).collect();
|
||||
let expect_meta: Vec<String> = (1..40u8).step_by(5).filter(|i| i % 3 != 0).map(|i| dir.path(&format!("big/{i:02}.png"))).collect();
|
||||
assert_eq!(paths(&r, "missing"), expect_missing);
|
||||
assert_eq!(paths(&r, "metadata"), expect_meta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_is_not_there_is_not_scanned_as_if_everything_went_missing() {
|
||||
let dir = Scratch::new("offline");
|
||||
let mut s = library(&dir);
|
||||
// an unplugged disk, or a folder moved away whole
|
||||
std::fs::rename(dir.0.join("trip"), dir.0.join("trip-elsewhere")).unwrap();
|
||||
for cmd in ["folder.scanChanges", "folder.synchronize"] {
|
||||
let e = s.execute(cmd, &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("not there"), "{cmd}: {e}");
|
||||
}
|
||||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_disk_or_a_folder_holding_disks_is_synchronized_only_on_request() {
|
||||
let dir = Scratch::new("disks");
|
||||
let mut s = library(&dir);
|
||||
// a library photo on another disk makes the scratch folder's root look like it holds disks
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = lightcraft_catalog::Photo::new(
|
||||
id,
|
||||
lightcraft_catalog::Source::File { path: "/Volumes/nas/x.jpg".into() },
|
||||
"x.jpg",
|
||||
"JPEG",
|
||||
6,
|
||||
4,
|
||||
"2026-01-01",
|
||||
);
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
if std::path::Path::new("/Volumes").is_dir() {
|
||||
let e = s.execute("folder.scanChanges", &json!({"path": "/Volumes"})).unwrap_err().to_string();
|
||||
assert!(e.contains("disk: true"), "{e}");
|
||||
}
|
||||
// a folder inside one disk needs nothing more
|
||||
assert!(s.execute("folder.scanChanges", &json!({"path": dir.path("trip")})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_only_browsed_in_local_is_new_and_joins_the_library() {
|
||||
let dir = Scratch::new("local");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
// looking at the folder in Local catalogues c.png as a browse record, not a library photo
|
||||
s.execute("library.browse", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.local));
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png")], "{r}");
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.in_library()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_renamed_or_moved_inside_the_folder_is_relinked_not_lost() {
|
||||
let dir = Scratch::new("moved");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap();
|
||||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/day1/a-renamed.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}");
|
||||
assert_eq!(r["moved"][0]["id"], a.0, "{r}");
|
||||
assert_eq!(r["moved"][0]["to"], dir.path("trip/day1/a-renamed.png"), "{r}");
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap();
|
||||
assert_eq!((r["relinked"].as_u64(), r["removed"].as_u64(), r["imported"].as_u64()), (Some(1), Some(0), Some(0)), "{r}");
|
||||
let p = s.catalog.photo(a).unwrap();
|
||||
assert!(p.in_library() && p.rating == 4, "the same photo, edits and all");
|
||||
assert_eq!(p.source, lightcraft_catalog::Source::File { path: dir.path("trip/day1/a-renamed.png") });
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(a).unwrap().source, lightcraft_catalog::Source::File { path: dir.path("trip/a.png") });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_is_one_undo_step_even_with_a_full_undo_history() {
|
||||
let dir = Scratch::new("full-undo");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
// a long session: the history is at its limit and drops its oldest steps
|
||||
for i in 0..1005u32 {
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": i % 5})).unwrap();
|
||||
}
|
||||
let before = holdings(&s);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
let undo = s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(undo["undone"], "Synchronize Folder");
|
||||
assert_eq!(holdings(&s), before, "one step undoes the import and the removal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_stays_current_while_only_other_folders_change() {
|
||||
let dir = Scratch::new("current");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("other/x.png"), 9);
|
||||
s.execute("library.import", &json!({"paths": [dir.path("other")]})).unwrap();
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
// something elsewhere in the library changes while the dialog is open (a background import…)
|
||||
let x = s.catalog.photos().find(|p| p.file_name == "x.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [x.0], "rating": 3})).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_to_the_folders_photos_makes_the_scan_stale() {
|
||||
let dir = Scratch::new("stale-folder");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 2})).unwrap();
|
||||
// the dialog asks for exactly what it showed: refused, never redone on the spot
|
||||
let e = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("scan again"), "{e}");
|
||||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was done");
|
||||
// an agent that just asks to synchronize gets a fresh scan
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_progress_counts_the_photos_checked_too() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let dir = Scratch::new("progress");
|
||||
let mut s = library(&dir);
|
||||
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
crate::sync::scan_with(input, &progress);
|
||||
assert_eq!((progress.checked.load(Relaxed), progress.to_check.load(Relaxed)), (2, 2));
|
||||
let (done, total) = progress.counts();
|
||||
assert!(done == total && total >= 2, "{done}/{total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sidecar_the_library_wrote_itself_is_no_update() {
|
||||
let dir = Scratch::new("autowrite");
|
||||
let mut s = library(&dir);
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 3})).unwrap();
|
||||
s.execute("photo.label", &json!({"ids": [a.0], "label": "green"})).unwrap();
|
||||
assert!(Path::new(&dir.path("trip/a.xmp")).exists(), "auto-write wrote the sidecar");
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_photo_with_no_content_hash_is_relinked_when_its_file_is_unmistakable() {
|
||||
// as photos taken over from a Lightroom catalog are: no hash on record
|
||||
let dir = Scratch::new("nohash");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
let mut p = (**s.catalog.photo(a).unwrap()).clone();
|
||||
p.content_hash = None;
|
||||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: a }).unwrap();
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/a-renamed.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(r["moved"][0]["to"], dir.path("trip/a-renamed.png"), "{r}");
|
||||
assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_duplicates_are_each_relinked() {
|
||||
let dir = Scratch::new("dups");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.duplicate", &json!({"ids": [a.0]})).unwrap();
|
||||
assert!(Path::new(&dir.path("trip/a-copy.png")).exists());
|
||||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/one.png")).unwrap();
|
||||
std::fs::rename(dir.path("trip/a-copy.png"), dir.path("trip/two.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(r["moved"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
assert!(paths(&r, "missing").is_empty(), "{r}");
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["relinked"], 2, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_regions_found_meanwhile_leave_the_scan_current() {
|
||||
let dir = Scratch::new("faces");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
// background face search adds a region to a photo of the folder
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
let mut meta = s.catalog.photo(a).unwrap().meta.clone();
|
||||
meta.regions.push(lightcraft_meta::Region { rect: Default::default(), kind: lightcraft_meta::RegionKind::Face, name: None, description: None });
|
||||
s.catalog.apply(lightcraft_catalog::Op::SetMeta { id: a, meta: Box::new(meta) }).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
}
|
||||
|
||||
/// Synchronizing in halves, as the app does it: readied on the UI thread without touching the
|
||||
/// disk, the work (every file read) on a worker, each step committed back on the UI thread.
|
||||
fn sync_in_halves(s: &mut Session, path: &str, choice: crate::sync::SyncChoice) -> crate::sync::SyncReport {
|
||||
let changes = crate::sync::scan(s, path, false).unwrap();
|
||||
let (work, mut commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || work.run(&progress, |step| tx.send(step).is_ok())).join().unwrap();
|
||||
for step in rx {
|
||||
commit.apply(s, step);
|
||||
}
|
||||
commit.finish(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_of_synchronizing_runs_apart_from_its_commits() {
|
||||
let dir = Scratch::new("halves");
|
||||
let mut s = library(&dir);
|
||||
let before = holdings(&s);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||||
let choice = crate::sync::SyncChoice { import_new: true, relink_moved: true, remove_missing: true, read_metadata: true };
|
||||
let r = sync_in_halves(&mut s, &dir.path("trip"), choice);
|
||||
assert_eq!((r.imported, r.removed, r.read), (1, 1, 1), "{r:?}");
|
||||
assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(holdings(&s), before, "still one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_checks_again_that_a_new_file_is_still_there() {
|
||||
let dir = Scratch::new("ready");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap();
|
||||
// the file goes away between readying (UI thread) and the work (worker): the work finds out
|
||||
let (work, mut commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap();
|
||||
std::fs::remove_file(dir.path("trip/c.png")).unwrap();
|
||||
let mut steps = Vec::new();
|
||||
work.run(&crate::sync::SyncProgress::default(), |step| {
|
||||
steps.push(step);
|
||||
true
|
||||
});
|
||||
for step in steps {
|
||||
commit.apply(&mut s, step);
|
||||
}
|
||||
let r = commit.finish(&mut s);
|
||||
assert_eq!(r.imported, 0, "{r:?}");
|
||||
assert_eq!(r.failed.len(), 1, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_synchronize_does_nothing_more() {
|
||||
let dir = Scratch::new("cancel-run");
|
||||
let mut s = library(&dir);
|
||||
let steps_before = s.undo.len();
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let (work, commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut sent = 0;
|
||||
work.run(&progress, |_| {
|
||||
sent += 1;
|
||||
true
|
||||
});
|
||||
assert_eq!(sent, 0);
|
||||
let r = commit.finish(&mut s);
|
||||
assert_eq!((r.imported, s.undo.len()), (0, steps_before));
|
||||
}
|
||||
|
||||
/// Ready a synchronize of the trip folder and do its work, keeping the steps for the test to
|
||||
/// commit when it likes.
|
||||
fn readied(s: &mut Session, dir: &Scratch, choice: crate::sync::SyncChoice) -> (Vec<crate::sync::SyncStep>, crate::sync::SyncCommit) {
|
||||
let changes = crate::sync::scan(s, &dir.path("trip"), false).unwrap();
|
||||
let (work, commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap();
|
||||
let mut steps = Vec::new();
|
||||
work.run(&crate::sync::SyncProgress::default(), |step| {
|
||||
steps.push(step);
|
||||
true
|
||||
});
|
||||
(steps, commit)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_for_a_library_that_was_closed_meanwhile_are_not_applied() {
|
||||
let dir = Scratch::new("switched");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() };
|
||||
let (steps, mut commit) = readied(&mut s, &dir, choice);
|
||||
// File › Open Library while the work runs: another catalog, whose photo ids mean other photos
|
||||
s.library_identity = std::sync::Arc::new(());
|
||||
let before = holdings(&s);
|
||||
let undo = s.undo.len();
|
||||
for step in steps {
|
||||
commit.apply(&mut s, step);
|
||||
}
|
||||
let r = commit.finish(&mut s);
|
||||
assert_eq!(holdings(&s), before, "nothing landed in the other library");
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!((r.imported, r.removed), (0, 0), "{r:?}");
|
||||
assert!(!r.failed.is_empty(), "and the report says why: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undo_pressed_while_synchronizing_is_not_folded_into_it() {
|
||||
let dir = Scratch::new("undo-mid");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap();
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() };
|
||||
let (mut steps, mut commit) = readied(&mut s, &dir, choice);
|
||||
assert!(steps.len() >= 2, "an import step and a remove step");
|
||||
let first = steps.remove(0);
|
||||
commit.apply(&mut s, first);
|
||||
// the person presses Undo between two steps of the run
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
for step in steps {
|
||||
commit.apply(&mut s, step);
|
||||
}
|
||||
commit.finish(&mut s);
|
||||
// undoing the run's last step never takes the rating with it
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(a).unwrap().rating, 4, "the earlier, unrelated step stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_namings_for_many_photos_agree_with_one_at_a_time() {
|
||||
let dir = Scratch::new("namings");
|
||||
let mut s = library(&dir);
|
||||
// a raw beside its JPEG: the raw owns IMG_1.xmp, the JPEG's is IMG_1.JPG.xmp
|
||||
for name in ["IMG_1.CR3", "IMG_1.JPG", "IMG_2.JPG"] {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = lightcraft_catalog::Photo::new(
|
||||
id,
|
||||
lightcraft_catalog::Source::File { path: dir.path(&format!("trip/{name}")) },
|
||||
name,
|
||||
"X",
|
||||
6,
|
||||
4,
|
||||
"2026-01-01",
|
||||
);
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
let ids: Vec<lightcraft_catalog::PhotoId> = s.catalog.photos().map(|p| p.id).collect();
|
||||
let one_by_one: Vec<_> = ids.iter().map(|id| s.sidecar_naming(*id)).collect();
|
||||
assert_eq!(s.sidecar_namings(&ids), one_by_one);
|
||||
assert!(one_by_one.contains(&crate::sidecar::SidecarNaming::Full), "the case that matters is there");
|
||||
}
|
||||
@@ -94,7 +94,8 @@ fn auto_write_and_naming_preference() {
|
||||
// a slider drag writes once, at the end
|
||||
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap();
|
||||
let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), false).unwrap().develop {
|
||||
let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().develop
|
||||
{
|
||||
Some(crate::sidecar::DevelopPatch::Full(d)) => d.light.exposure,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
|
||||
@@ -212,7 +212,7 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
|
||||
_ => {}
|
||||
}
|
||||
if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
|
||||
add(format!("Rules: {}", rs.describe()), json!({"ruleSet": Null}));
|
||||
add(format!("Rules: {}", rs.describe_with(cat)), json!({"ruleSet": Null}));
|
||||
}
|
||||
if !f.only.is_empty() {
|
||||
add(format!("Only {} photos", f.only.len()), json!({"only": []}));
|
||||
|
||||
Reference in New Issue
Block a user