mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge main into fix/issue-29-move-import
# Conflicts: # docs/parity.md
This commit is contained in:
Generated
+1
@@ -2099,6 +2099,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -156,6 +156,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
||||
})),
|
||||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
pick_tracklog: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
|
||||
@@ -174,6 +174,14 @@ fn services() -> Services {
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Auto-Tag from Tracklog")
|
||||
.add_filter("GPS Track Log", &["gpx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Presets")
|
||||
|
||||
@@ -15,6 +15,7 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Catalog persistence costs on large synthetic catalogs (issue #37): log append, compaction
|
||||
//! (snapshot serialise / write+fsync / log reset), synchronous and on the background worker, load,
|
||||
//! and a catalog clone.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --release -p lightcraft-catalog --example persist_bench -- <dir> [photos…]
|
||||
//! ```
|
||||
//!
|
||||
//! `<dir>` is a scratch directory (wiped per size). Default sizes: 1000 10000 85000. 90 % of the
|
||||
//! photos are Local browse records, the rest library photos with a few edits and history steps.
|
||||
//! Peak memory: run under `/usr/bin/time -l` (macOS) / `-v` (Linux) with one size;
|
||||
//! `PERSIST_BENCH_NO_LOAD=1` skips the final load, so the peak is building + compacting.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::*;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
}
|
||||
|
||||
fn photo(c: &mut Catalog, i: u64) -> Photo {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(
|
||||
id,
|
||||
Source::File { path: format!("/Users/someone/Pictures/2026/09/{:05}/IMG_{i:05}.CR3", i / 500) },
|
||||
&format!("IMG_{i:05}.CR3"),
|
||||
"CR3",
|
||||
6000,
|
||||
4000,
|
||||
"2026-09-30T12:00:00",
|
||||
);
|
||||
p.kind = MediaKind::Raw;
|
||||
p.file_size = 25_000_000 + i;
|
||||
p.captured = Some(format!("2026-09-{:02}T10:{:02}:{:02}", 1 + i % 28, i % 60, (i / 60) % 60));
|
||||
p.content_hash = Some(format!("{:032x}", i.wrapping_mul(0x9e37_79b9_7f4a_7c15)));
|
||||
p.meta.camera = "Camera Model X".into();
|
||||
p.meta.lens = "24-70mm f/2.8".into();
|
||||
p.meta.focal_mm = Some(35.0);
|
||||
p.meta.aperture = Some(4.0);
|
||||
p.meta.shutter = "1/250".into();
|
||||
p.meta.iso = Some(400);
|
||||
p.as_shot_wb = Some((5200.0, 3.0));
|
||||
p.local = !i.is_multiple_of(10);
|
||||
if !p.local {
|
||||
let mut d = DevelopSettings::default();
|
||||
d.light.exposure = (i % 7) as f64 * 0.1;
|
||||
let s = Arc::new(d);
|
||||
p.develop = s.clone();
|
||||
p.edited = Some("2026-09-30T12:00:00".into());
|
||||
p.history = vec![HistoryStep { label: "Exposure".into(), settings: s }];
|
||||
p.rating = (i % 6) as u8;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn stats(mut v: Vec<f64>) -> String {
|
||||
v.sort_by(f64::total_cmp);
|
||||
let pick = |q: f64| v.get(((v.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0);
|
||||
format!("median {:.2} ms, p95 {:.2} ms, max {:.2} ms", pick(0.5), pick(0.95), pick(1.0))
|
||||
}
|
||||
|
||||
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let dir = std::path::PathBuf::from(args.next().ok_or("usage: persist_bench <scratch dir> [photos…]")?);
|
||||
let mut sizes: Vec<u64> = args.map(|a| a.parse()).collect::<std::result::Result<_, _>>()?;
|
||||
if sizes.is_empty() {
|
||||
sizes = vec![1000, 10_000, 85_000];
|
||||
}
|
||||
for n in sizes {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut j, _, _) = Journal::open(Box::new(FsStore::open(&dir)?))?;
|
||||
let mut cat = Catalog::new();
|
||||
// bulk-add (as one import would) and log it, so the first compaction is an import's
|
||||
for chunk in (0..n).collect::<Vec<_>>().chunks(500) {
|
||||
let ops: Vec<Op> = chunk.iter().map(|i| Op::AddPhoto { photo: Box::new(photo(&mut cat, *i)) }).collect();
|
||||
for op in &ops {
|
||||
cat.apply(op.clone())?;
|
||||
}
|
||||
j.append(&ops)?;
|
||||
}
|
||||
j.snapshot(&cat)?;
|
||||
let first = j.stats().last_snapshot;
|
||||
println!("== {n} photos ({} local): snapshot {:.1} MB", cat.photos().filter(|p| p.local).count(), first.bytes as f64 / 1e6);
|
||||
|
||||
let t = Instant::now();
|
||||
let copy = cat.clone();
|
||||
println!(" catalog clone: {:.2} ms", ms(t));
|
||||
drop(copy);
|
||||
|
||||
// single-op appends (a rating change each), as commands do
|
||||
let ids: Vec<PhotoId> = cat.photos().map(|p| p.id).take(1000).collect();
|
||||
let mut appends = Vec::new();
|
||||
for k in 0..300usize {
|
||||
let id = ids.get(k % ids.len().max(1)).copied().unwrap_or(PhotoId(1));
|
||||
let op = Op::SetRating { id, rating: (k % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(std::slice::from_ref(&op))?;
|
||||
appends.push(j.stats().last_append_ms);
|
||||
}
|
||||
println!(" append (1 op, fsync): {}", stats(appends));
|
||||
|
||||
// compactions at the default threshold crossing
|
||||
let mut totals = Vec::new();
|
||||
for _ in 0..3 {
|
||||
while !j.wants_snapshot() {
|
||||
let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
}
|
||||
j.snapshot(&cat)?;
|
||||
let s = j.stats().last_snapshot;
|
||||
println!(
|
||||
" compaction ({} records): serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms",
|
||||
s.records, s.serialize_ms, s.write_sync_ms, s.reset_ms, s.total_ms
|
||||
);
|
||||
totals.push(s.total_ms);
|
||||
}
|
||||
println!(" compaction total: {}", stats(totals));
|
||||
|
||||
// the same at the threshold, written by the worker while single-op appends go on
|
||||
let mut blocking = Vec::new();
|
||||
for _ in 0..3 {
|
||||
while !j.wants_snapshot() {
|
||||
let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
}
|
||||
let t = Instant::now();
|
||||
j.snapshot_in_background(&cat)?;
|
||||
let start_ms = ms(t);
|
||||
let mut during = Vec::new();
|
||||
while !j.snapshot_written() {
|
||||
let op = Op::SetRating { id: ids.get(1).copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
during.push(j.stats().last_append_ms);
|
||||
}
|
||||
let t = Instant::now();
|
||||
j.poll()?;
|
||||
let finish_ms = ms(t);
|
||||
let s = j.stats().last_snapshot;
|
||||
println!(
|
||||
" background compaction ({} records): start {start_ms:.1} ms + finish {finish_ms:.1} ms blocking; worker {:.1} ms; {} appends during it: {}",
|
||||
s.records,
|
||||
s.total_ms,
|
||||
during.len(),
|
||||
stats(during)
|
||||
);
|
||||
blocking.push(s.blocking_ms);
|
||||
}
|
||||
println!(" background compaction blocking: {}", stats(blocking));
|
||||
|
||||
drop(j);
|
||||
if std::env::var_os("PERSIST_BENCH_NO_LOAD").is_some() {
|
||||
// peak memory of building + compacting only
|
||||
continue;
|
||||
}
|
||||
let t = Instant::now();
|
||||
let (_, loaded, _) = Journal::open(Box::new(FsStore::open(&dir)?))?;
|
||||
println!(" open (load snapshot + replay): {:.1} ms", ms(t));
|
||||
if loaded.len() != cat.len() {
|
||||
return Err("reloaded catalog differs".into());
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 0227748e171237a13833c9175fe02c7fe6ff332266798961a6a43ec37b81d1b4 # shrinks to actions = [(3, 0), (0, 0), (4, 0), (0, 0)]
|
||||
cc 0b0ab2531bd46fbe261b55ecffebea9d3e8949900ab7bc33fb27ca19f5cd7b8b # shrinks to actions = [(4, 443), (3, 474), (2, 27), (5, 858), (5, 466), (0, 901), (4, 907), (3, 839), (0, 630), (7, 578), (4, 13), (2, 361), (6, 136), (6, 12), (6, 223), (1, 394), (6, 78), (5, 837), (6, 765), (4, 207), (2, 185), (2, 583), (2, 56), (5, 430), (1, 702), (1, 547), (5, 552), (4, 762), (0, 443), (0, 851), (7, 240), (4, 438), (3, 599), (6, 70), (4, 694), (1, 327), (7, 859), (6, 814), (4, 336), (3, 438), (0, 131), (0, 831), (3, 18), (2, 963), (6, 10), (3, 114)]
|
||||
@@ -14,6 +14,19 @@
|
||||
//! the log) are skipped;
|
||||
//! - a bad record **followed by good ones** is real damage: replay stops there, the log is kept
|
||||
//! as `catalog.log.damaged-<seq>`, and a fresh snapshot is written so the library stays usable.
|
||||
//!
|
||||
//! **Background compaction** ([`Journal::snapshot_in_background`], when the store offers a
|
||||
//! [`Store::background_writer`]): a worker thread writes the snapshot of a copy of the catalog at
|
||||
//! op `N` while appends go on (durably, as always) to the same log. Once the snapshot is durable,
|
||||
//! [`Journal::poll`] atomically replaces the log with the records appended since the snapshot
|
||||
//! started (`seq > N`, kept in memory). Every crash point is covered by the rules above:
|
||||
//! - before the rename of `catalog.snap`: the old snapshot + the whole log (nothing was removed);
|
||||
//! - between the rename and the log rewrite: the new snapshot + the whole log, whose records
|
||||
//! `<= N` are skipped as stale;
|
||||
//! - after the log rewrite: the new snapshot + exactly the records `> N`.
|
||||
//!
|
||||
//! At most one snapshot is in flight; a synchronous [`Journal::snapshot`] and dropping the journal
|
||||
//! first wait for it, so an older snapshot can never replace a newer one.
|
||||
|
||||
use crate::store::Store;
|
||||
use crate::{Catalog, CatalogError, Op, Result};
|
||||
@@ -55,6 +68,65 @@ pub struct LoadReport {
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
|
||||
/// per write under `LIGHTCRAFT_PROFILE`). Times are wall-clock milliseconds on the calling
|
||||
/// thread, i.e. how long the caller (the UI thread, for the app) was blocked.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersistStats {
|
||||
/// [`Journal::append`] calls that wrote something.
|
||||
pub appends: u64,
|
||||
/// Encode + write + `sync_data` of the last / slowest append.
|
||||
pub last_append_ms: f64,
|
||||
pub max_append_ms: f64,
|
||||
/// Snapshots written (compactions, plus the ones on close / repair).
|
||||
pub snapshots: u64,
|
||||
/// The last snapshot, by stage.
|
||||
pub last_snapshot: SnapshotTiming,
|
||||
/// Total time of the slowest snapshot.
|
||||
pub max_snapshot_ms: f64,
|
||||
/// Longest time a snapshot blocked the caller (for background snapshots: copying the catalog
|
||||
/// and starting the worker, plus rewriting the log when it is done).
|
||||
pub max_blocking_ms: f64,
|
||||
/// A background snapshot is being written.
|
||||
pub snapshot_running: bool,
|
||||
/// Background snapshots that failed (the log was kept whole; retried later).
|
||||
pub failed_snapshots: u64,
|
||||
}
|
||||
|
||||
/// One snapshot (compaction), by stage.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SnapshotTiming {
|
||||
/// Serialising the catalog to JSON, streamed into the temp file (buffered writes included).
|
||||
pub serialize_ms: f64,
|
||||
/// Flushing, `sync_all` and the atomic rename of `catalog.snap`.
|
||||
pub write_sync_ms: f64,
|
||||
/// Resetting `catalog.log` (an atomic rewrite to empty, or to the records appended while a
|
||||
/// background snapshot ran; fsynced).
|
||||
pub reset_ms: f64,
|
||||
/// Start to finish (for a background snapshot: until [`Journal::poll`] saw it done).
|
||||
pub total_ms: f64,
|
||||
/// How long the caller was blocked (equal to `total_ms` unless `background`).
|
||||
pub blocking_ms: f64,
|
||||
/// Written by a worker thread.
|
||||
pub background: bool,
|
||||
/// Size of `catalog.snap`.
|
||||
pub bytes: u64,
|
||||
/// Log records the snapshot compacted.
|
||||
pub records: u64,
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_PROFILE` is set: print persistence timings to stderr.
|
||||
fn profiling() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
|
||||
}
|
||||
|
||||
fn ms_since(t: web_time::Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
}
|
||||
|
||||
pub struct Journal {
|
||||
store: Box<dyn Store>,
|
||||
/// `seq` of the last durable op.
|
||||
@@ -63,6 +135,26 @@ pub struct Journal {
|
||||
log_records: u64,
|
||||
log_bytes: u64,
|
||||
pub policy: SnapshotPolicy,
|
||||
stats: PersistStats,
|
||||
/// The background snapshot in flight.
|
||||
pending: Option<Pending>,
|
||||
/// After a failed background snapshot: don't retry before the log has this many records.
|
||||
retry_at_records: u64,
|
||||
}
|
||||
|
||||
/// A background snapshot in flight.
|
||||
struct Pending {
|
||||
/// The snapshot holds the state after this op.
|
||||
seq: u64,
|
||||
/// Log bytes / records appended since it started (all `> seq`): the log once it's done.
|
||||
tail: Vec<u8>,
|
||||
tail_records: u64,
|
||||
/// Log records it compacts.
|
||||
records: u64,
|
||||
started: web_time::Instant,
|
||||
/// Time the caller spent starting it.
|
||||
start_ms: f64,
|
||||
worker: std::thread::JoinHandle<std::io::Result<SnapshotTiming>>,
|
||||
}
|
||||
|
||||
fn io(e: std::io::Error) -> CatalogError {
|
||||
@@ -90,6 +182,29 @@ pub fn decode_record(line: &str) -> Option<(u64, Op)> {
|
||||
serde_json::from_str(body).ok().map(|op| (seq, op))
|
||||
}
|
||||
|
||||
/// Write `catalog.snap` for the state after op `seq`, streaming the JSON into the store (no
|
||||
/// whole-file string). The bytes are exactly
|
||||
/// `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":<serde_json of the catalog>}\n`,
|
||||
/// as before streaming. Fills in the serialise / write+sync times and the size.
|
||||
fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io::Result<SnapshotTiming> {
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut serialize_ms = 0.0;
|
||||
let bytes = store.write_atomic_with(SNAPSHOT, &mut |w| {
|
||||
use std::io::Write;
|
||||
let t = web_time::Instant::now();
|
||||
// serde_json emits many tiny writes: buffer them in a concrete writer (inlined), so only
|
||||
// 1 MiB chunks go through the store's `dyn Write`
|
||||
let mut b = std::io::BufWriter::with_capacity(1 << 20, w);
|
||||
write!(b, "{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{seq},\"catalog\":")?;
|
||||
serde_json::to_writer(&mut b, catalog).map_err(std::io::Error::other)?;
|
||||
b.write_all(b"}\n")?;
|
||||
b.flush()?;
|
||||
serialize_ms = ms_since(t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapFile {
|
||||
format: String,
|
||||
@@ -117,7 +232,17 @@ impl Journal {
|
||||
None => (Catalog::new(), 0),
|
||||
};
|
||||
report.snapshot_seq = snapshot_seq;
|
||||
let mut j = Journal { store, seq: snapshot_seq, snapshot_seq, log_records: 0, log_bytes: 0, policy: SnapshotPolicy::default() };
|
||||
let mut j = Journal {
|
||||
store,
|
||||
seq: snapshot_seq,
|
||||
snapshot_seq,
|
||||
log_records: 0,
|
||||
log_bytes: 0,
|
||||
policy: SnapshotPolicy::default(),
|
||||
stats: PersistStats::default(),
|
||||
pending: None,
|
||||
retry_at_records: 0,
|
||||
};
|
||||
let log = log.unwrap_or_default();
|
||||
|
||||
// Scan records; `good_end` is the byte offset just past the last good record.
|
||||
@@ -190,6 +315,7 @@ impl Journal {
|
||||
if ops.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut buf = String::new();
|
||||
let mut seq = self.seq;
|
||||
for op in ops {
|
||||
@@ -198,28 +324,166 @@ impl Journal {
|
||||
buf.push('\n');
|
||||
}
|
||||
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
|
||||
if let Some(p) = self.pending.as_mut() {
|
||||
p.tail.extend_from_slice(buf.as_bytes());
|
||||
p.tail_records += ops.len() as u64;
|
||||
}
|
||||
self.seq = seq;
|
||||
self.log_records += ops.len() as u64;
|
||||
self.log_bytes += buf.len() as u64;
|
||||
let ms = ms_since(t0);
|
||||
self.stats.appends += 1;
|
||||
self.stats.last_append_ms = ms;
|
||||
self.stats.max_append_ms = self.stats.max_append_ms.max(ms);
|
||||
if profiling() {
|
||||
eprintln!("catalog: append {} op(s), {} B: {ms:.2} ms", ops.len(), buf.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
|
||||
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
let body = serde_json::to_string(catalog).map_err(|e| CatalogError::Invalid(e.to_string()))?;
|
||||
let file = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{},\"catalog\":{body}}}\n", self.seq);
|
||||
self.store.write_atomic(SNAPSHOT, file.as_bytes()).map_err(io)?;
|
||||
// never two snapshot writers; the one in flight is older, so it must land first
|
||||
if let Err(e) = self.wait() {
|
||||
log::warn!("catalog: background snapshot failed ({e}); writing one now");
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut timing = write_snapshot(self.store.as_mut(), self.seq, catalog).map_err(io)?;
|
||||
let t2 = web_time::Instant::now();
|
||||
// A crash here leaves old records in the log; they are skipped by seq on load.
|
||||
self.store.write_atomic(LOG, b"").map_err(io)?;
|
||||
timing.reset_ms = ms_since(t2);
|
||||
timing.total_ms = ms_since(t0);
|
||||
timing.blocking_ms = timing.total_ms;
|
||||
timing.records = self.log_records;
|
||||
self.record_snapshot(timing);
|
||||
self.snapshot_seq = self.seq;
|
||||
self.log_records = 0;
|
||||
self.log_bytes = 0;
|
||||
self.retry_at_records = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compact like [`Journal::snapshot`], but write the snapshot on a worker thread from a copy
|
||||
/// of `catalog` (cheap: photos are shared) when the store supports it (else synchronously).
|
||||
/// `catalog` must reflect every appended op. Appends go on meanwhile; [`Journal::poll`]
|
||||
/// finishes the compaction. No-op while a snapshot is already in flight.
|
||||
pub fn snapshot_in_background(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
if self.pending.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let Some(mut writer) = self.store.background_writer() else { return self.snapshot(catalog) };
|
||||
let copy = catalog.clone();
|
||||
let seq = self.seq;
|
||||
let spawned = std::thread::Builder::new().name("catalog-snapshot".into()).spawn(move || write_snapshot(writer.as_mut(), seq, ©));
|
||||
let worker = match spawned {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::warn!("catalog: can't start a snapshot thread ({e}); writing it now");
|
||||
return self.snapshot(catalog);
|
||||
}
|
||||
};
|
||||
self.pending =
|
||||
Some(Pending { seq, tail: Vec::new(), tail_records: 0, records: self.log_records, started: t0, start_ms: ms_since(t0), worker });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish a background snapshot if its worker is done (cheap otherwise). Returns whether one
|
||||
/// finished. An error means the snapshot failed: nothing was lost (the log is whole), and
|
||||
/// compaction is retried once the log has grown by another [`SnapshotPolicy::max_records`].
|
||||
pub fn poll(&mut self) -> Result<bool> {
|
||||
if !self.pending.as_ref().is_some_and(|p| p.worker.is_finished()) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.finish().map(|()| true)
|
||||
}
|
||||
|
||||
/// Wait for the background snapshot in flight, if any, and finish it.
|
||||
pub fn wait(&mut self) -> Result<()> {
|
||||
if self.pending.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
self.finish()
|
||||
}
|
||||
|
||||
/// A background snapshot is in flight (until [`Journal::poll`] or [`Journal::wait`] finish it).
|
||||
pub fn snapshot_running(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
/// The background worker is done (its result not yet applied by [`Journal::poll`]).
|
||||
pub fn snapshot_written(&self) -> bool {
|
||||
self.pending.as_ref().is_some_and(|p| p.worker.is_finished())
|
||||
}
|
||||
|
||||
/// Join the worker; on success replace the log with the records appended meanwhile.
|
||||
fn finish(&mut self) -> Result<()> {
|
||||
let Some(p) = self.pending.take() else { return Ok(()) };
|
||||
let t0 = web_time::Instant::now();
|
||||
let written = p.worker.join().unwrap_or_else(|_| Err(std::io::Error::other("snapshot thread panicked")));
|
||||
let mut timing = match written {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// the log still holds every record: nothing lost, retry later
|
||||
self.stats.failed_snapshots += 1;
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
log::error!("catalog: background snapshot failed: {e}");
|
||||
return Err(io(e));
|
||||
}
|
||||
};
|
||||
// `catalog.snap` (op `p.seq`) is durable; the log's records `<= p.seq` are now stale.
|
||||
// Keep exactly the ones appended since (a crash before this rewrite skips the stale ones).
|
||||
let t1 = web_time::Instant::now();
|
||||
self.snapshot_seq = p.seq;
|
||||
if let Err(e) = self.store.write_atomic(LOG, &p.tail) {
|
||||
// the log stays whole (stale records are skipped on load); compact again later
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
log::error!("catalog: can't trim the log after a snapshot: {e}");
|
||||
return Err(io(e));
|
||||
}
|
||||
timing.reset_ms = ms_since(t1);
|
||||
timing.total_ms = ms_since(p.started);
|
||||
timing.blocking_ms = p.start_ms + ms_since(t0);
|
||||
timing.background = true;
|
||||
timing.records = p.records;
|
||||
self.record_snapshot(timing);
|
||||
self.log_records = p.tail_records;
|
||||
self.log_bytes = p.tail.len() as u64;
|
||||
self.retry_at_records = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_snapshot(&mut self, t: SnapshotTiming) {
|
||||
self.stats.snapshots += 1;
|
||||
self.stats.last_snapshot = t;
|
||||
self.stats.max_snapshot_ms = self.stats.max_snapshot_ms.max(t.total_ms);
|
||||
self.stats.max_blocking_ms = self.stats.max_blocking_ms.max(t.blocking_ms);
|
||||
if profiling() {
|
||||
eprintln!(
|
||||
"catalog: {}snapshot of {} records, {} B: serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms, blocking {:.1} ms",
|
||||
if t.background { "background " } else { "" },
|
||||
t.records,
|
||||
t.bytes,
|
||||
t.serialize_ms,
|
||||
t.write_sync_ms,
|
||||
t.reset_ms,
|
||||
t.total_ms,
|
||||
t.blocking_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where persistence time went so far.
|
||||
pub fn stats(&self) -> PersistStats {
|
||||
PersistStats { snapshot_running: self.pending.is_some(), ..self.stats }
|
||||
}
|
||||
|
||||
/// The log is long enough to be worth compacting.
|
||||
pub fn wants_snapshot(&self) -> bool {
|
||||
self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes
|
||||
self.pending.is_none()
|
||||
&& self.log_records >= self.retry_at_records
|
||||
&& (self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes)
|
||||
}
|
||||
|
||||
pub fn seq(&self) -> u64 {
|
||||
@@ -238,3 +502,16 @@ impl Journal {
|
||||
self.store.describe()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Journal {
|
||||
/// Wait for a background snapshot (never leave a writer behind that could replace a newer
|
||||
/// snapshot of a journal reopened on the same files). The log is left whole: the snapshot's
|
||||
/// records in it are skipped as stale on the next load.
|
||||
fn drop(&mut self) {
|
||||
if let Some(p) = self.pending.take()
|
||||
&& let Ok(Err(e)) = p.worker.join()
|
||||
{
|
||||
log::error!("catalog: background snapshot failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use dates::{DateRun, GroupBy};
|
||||
pub use journal::{Journal, LoadReport, SnapshotPolicy};
|
||||
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use model::*;
|
||||
@@ -622,4 +622,6 @@ impl Catalog {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
|
||||
@@ -93,6 +93,65 @@ pub enum Source {
|
||||
Demo { scene: u32 },
|
||||
}
|
||||
|
||||
/// Copyright status (IPTC / XMP Rights Management `xmpRights:Marked`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CopyrightStatus {
|
||||
/// Not stated (no `xmpRights:Marked`).
|
||||
#[default]
|
||||
Unknown,
|
||||
/// `xmpRights:Marked` = True.
|
||||
Copyrighted,
|
||||
/// `xmpRights:Marked` = False.
|
||||
PublicDomain,
|
||||
}
|
||||
|
||||
impl CopyrightStatus {
|
||||
pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain];
|
||||
/// `unknown` / `copyrighted` / `publicDomain` (also `public domain`, `public-domain`).
|
||||
pub fn parse(s: &str) -> Option<CopyrightStatus> {
|
||||
match s.trim().to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
|
||||
"unknown" | "" => Some(CopyrightStatus::Unknown),
|
||||
"copyrighted" => Some(CopyrightStatus::Copyrighted),
|
||||
"publicdomain" => Some(CopyrightStatus::PublicDomain),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The id used by commands (`photo.setMeta`'s `copyrightStatus`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => "unknown",
|
||||
CopyrightStatus::Copyrighted => "copyrighted",
|
||||
CopyrightStatus::PublicDomain => "publicDomain",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => "Unknown",
|
||||
CopyrightStatus::Copyrighted => "Copyrighted",
|
||||
CopyrightStatus::PublicDomain => "Public Domain",
|
||||
}
|
||||
}
|
||||
/// As `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain.
|
||||
pub fn marked(self) -> Option<bool> {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => None,
|
||||
CopyrightStatus::Copyrighted => Some(true),
|
||||
CopyrightStatus::PublicDomain => Some(false),
|
||||
}
|
||||
}
|
||||
pub fn from_marked(m: Option<bool>) -> CopyrightStatus {
|
||||
match m {
|
||||
None => CopyrightStatus::Unknown,
|
||||
Some(true) => CopyrightStatus::Copyrighted,
|
||||
Some(false) => CopyrightStatus::PublicDomain,
|
||||
}
|
||||
}
|
||||
pub fn is_unknown(&self) -> bool {
|
||||
*self == CopyrightStatus::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Descriptive + capture metadata.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -115,6 +174,13 @@ pub struct Meta {
|
||||
pub alt_text: String,
|
||||
pub extended_description: String,
|
||||
pub copyright: String,
|
||||
/// Copyright status, rights usage terms and copyright info URL (IPTC Core rights fields).
|
||||
#[serde(skip_serializing_if = "CopyrightStatus::is_unknown")]
|
||||
pub copyright_status: CopyrightStatus,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub usage_terms: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub copyright_url: String,
|
||||
pub creator: String,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[
|
||||
("location", "Location", Kind::Text),
|
||||
("creator", "Creator", Kind::Text),
|
||||
("copyright", "Copyright", Kind::Text),
|
||||
("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])),
|
||||
("captureDate", "Capture Date", Kind::Date),
|
||||
("importDate", "Import Date", Kind::Date),
|
||||
("editDate", "Edit Date", Kind::Date),
|
||||
@@ -311,6 +312,10 @@ impl Rule {
|
||||
"location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")),
|
||||
"creator" => text(&m.creator),
|
||||
"copyright" => text(&m.copyright),
|
||||
"copyrightStatus" => {
|
||||
let want = crate::CopyrightStatus::parse(&want);
|
||||
(want == Some(m.copyright_status)) == (op == "is")
|
||||
}
|
||||
"captureDate" => date_op(op, p.captured.as_deref(), value),
|
||||
"importDate" => date_op(op, Some(&p.imported), value),
|
||||
"editDate" => date_op(op, p.edited.as_deref(), value),
|
||||
@@ -449,6 +454,13 @@ mod tests {
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]}));
|
||||
no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]}));
|
||||
yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]}));
|
||||
// copyright status (unknown until set)
|
||||
yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]}));
|
||||
no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]}));
|
||||
let mut pd = photo(2);
|
||||
pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
|
||||
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat));
|
||||
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat));
|
||||
// nested: rating ≥ 4 and (label is blue or keywords contain food)
|
||||
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [
|
||||
{"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]}));
|
||||
|
||||
@@ -16,12 +16,30 @@ pub trait Store: Send {
|
||||
/// Replace a file atomically: after a crash either the old or the new content is visible,
|
||||
/// never a mix (native: write temp + fsync + rename + fsync directory).
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||
/// [`Store::write_atomic`] with streamed content: `fill` writes the new content into the
|
||||
/// given writer (an error aborts and leaves the old file). Same durability: the file is
|
||||
/// replaced only once all of it is written (native: and fsynced). Returns its length.
|
||||
///
|
||||
/// The default buffers the content in memory; [`FsStore`] streams it into the temp file, so
|
||||
/// a large snapshot is never held in memory as a whole.
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn io::Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
let mut buf = Vec::new();
|
||||
fill(&mut buf)?;
|
||||
self.write_atomic(name, &buf)?;
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
/// Append and make durable (fsync) before returning.
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||
/// Cut a file to `len` bytes (drop a torn tail before appending again).
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()>;
|
||||
/// Human-readable location (diagnostics).
|
||||
fn describe(&self) -> String;
|
||||
/// Another handle on the same files that a worker thread can write `catalog.snap` through
|
||||
/// while this one keeps appending to the log (background compaction). `None` (the default):
|
||||
/// snapshots are written synchronously.
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Files in a directory.
|
||||
@@ -77,6 +95,32 @@ impl Store for FsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn io::Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
use std::io::Write;
|
||||
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
|
||||
self.appender = None;
|
||||
}
|
||||
let tmp = self.path(&format!("{name}.tmp"));
|
||||
let written = (|| {
|
||||
let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?), n: 0 };
|
||||
fill(&mut w)?;
|
||||
w.flush()?;
|
||||
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
|
||||
f.sync_all()?;
|
||||
Ok(w.n)
|
||||
})();
|
||||
let n = match written {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
std::fs::rename(&tmp, self.path(name))?;
|
||||
self.sync_dir();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
if self.appender.as_ref().is_none_or(|(n, _)| n != name) {
|
||||
@@ -104,12 +148,36 @@ impl Store for FsStore {
|
||||
fn describe(&self) -> String {
|
||||
self.dir.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
Some(Box::new(FsStore { dir: self.dir.clone(), appender: None }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the bytes written through it.
|
||||
struct Counter<W> {
|
||||
inner: W,
|
||||
n: u64,
|
||||
}
|
||||
|
||||
impl<W: io::Write> io::Write for Counter<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let k = self.inner.write(buf)?;
|
||||
self.n += k as u64;
|
||||
Ok(k)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory files. Clones share the same files (so a test can "reopen" or corrupt them).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemStore {
|
||||
pub files: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
|
||||
/// Offer a [`Store::background_writer`] (off by default: snapshots stay synchronous).
|
||||
pub background: bool,
|
||||
}
|
||||
|
||||
impl MemStore {
|
||||
@@ -145,4 +213,7 @@ impl Store for MemStore {
|
||||
fn describe(&self) -> String {
|
||||
"memory".into()
|
||||
}
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
self.background.then(|| Box::new(self.clone()) as Box<dyn Store>)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Background compaction (issue #37): a snapshot written by a worker while appends go on.
|
||||
//! "Crashes" are simulated by copying the files at a given moment and loading the copy: every
|
||||
//! acknowledged (appended) op must be there, in order, whatever the snapshot was doing.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::journal::{LOG, SNAPSHOT};
|
||||
use crate::*;
|
||||
|
||||
/// A MemStore whose snapshot writes wait for a gate (to hold a background snapshot mid-flight)
|
||||
/// and can be made to fail.
|
||||
#[derive(Clone)]
|
||||
struct Gated {
|
||||
files: MemStore,
|
||||
gate: Arc<(Mutex<bool>, Condvar)>,
|
||||
fail: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Gated {
|
||||
fn new() -> Gated {
|
||||
Gated { files: MemStore::new(), gate: Arc::new((Mutex::new(false), Condvar::new())), fail: Arc::new(AtomicBool::new(false)) }
|
||||
}
|
||||
fn set_gate(&self, open: bool) {
|
||||
*self.gate.0.lock().unwrap() = open;
|
||||
self.gate.1.notify_all();
|
||||
}
|
||||
/// The files as a crash at this moment would leave them.
|
||||
fn crash(&self) -> MemStore {
|
||||
let m = MemStore::new();
|
||||
*m.files.lock().unwrap() = self.files.files.lock().unwrap().clone();
|
||||
m
|
||||
}
|
||||
fn snapshot_seq_on_disk(&self) -> Option<u64> {
|
||||
let v: serde_json::Value = serde_json::from_slice(&self.files.get(SNAPSHOT)?).ok()?;
|
||||
v["seq"].as_u64()
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Gated {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> std::io::Result<u64> {
|
||||
if name == SNAPSHOT {
|
||||
let (lock, cv) = &*self.gate;
|
||||
let (open, timeout) = cv.wait_timeout_while(lock.lock().unwrap(), Duration::from_secs(20), |open| !*open).unwrap();
|
||||
if !*open || timeout.timed_out() {
|
||||
return Err(std::io::Error::other("gate never opened"));
|
||||
}
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
fill(&mut buf)?;
|
||||
if self.fail.load(Ordering::SeqCst) {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.write_atomic(name, &buf)?;
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"gated".into()
|
||||
}
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
Some(Box::new(self.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply and durably append one edit (two ops in one append every fourth time).
|
||||
fn edit(j: &mut Journal, live: &mut Catalog, k: u64) {
|
||||
let one = |live: &mut Catalog, k: u64| {
|
||||
let op = if live.len() < 4 || k.is_multiple_of(3) {
|
||||
let id = live.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "t")) }
|
||||
} else {
|
||||
Op::SetRating { id: PhotoId(1 + k % live.len() as u64), rating: (k % 6) as u8 }
|
||||
};
|
||||
live.apply(op.clone()).unwrap();
|
||||
op
|
||||
};
|
||||
let mut ops = vec![one(live, k)];
|
||||
if k.is_multiple_of(4) {
|
||||
ops.push(one(live, k / 4 + 1));
|
||||
}
|
||||
j.append(&ops).unwrap();
|
||||
}
|
||||
|
||||
/// Load the files a crash now would leave: they must hold exactly the live state.
|
||||
fn assert_recovers(g: &Gated, live: &Catalog) -> LoadReport {
|
||||
let (_, c, r) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), live.to_snapshot(), "{r:?}");
|
||||
assert_eq!((r.failed, r.torn_bytes, r.damaged.clone()), (0, 0, None));
|
||||
r
|
||||
}
|
||||
|
||||
fn wait_written(j: &Journal) {
|
||||
for _ in 0..20_000 {
|
||||
if j.snapshot_written() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
panic!("background snapshot never finished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_snapshot_keeps_edits_made_while_it_runs() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..30 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let n = j.seq();
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
assert!(j.snapshot_running() && !j.wants_snapshot() && j.stats().snapshot_running);
|
||||
|
||||
// edits while the snapshot is being written are appended (durably) as usual
|
||||
let mut during = 0;
|
||||
for k in 30..50 {
|
||||
edit(&mut j, &mut live, k);
|
||||
during = j.seq() - n;
|
||||
}
|
||||
// crash before the snapshot landed: no snapshot yet, the whole log replays
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.replayed as u64), (0, j.seq()));
|
||||
|
||||
// the snapshot lands (op n); crash before the log was trimmed: its records are stale
|
||||
g.set_gate(true);
|
||||
wait_written(&j);
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(n));
|
||||
for k in 50..55 {
|
||||
edit(&mut j, &mut live, k);
|
||||
during = j.seq() - n;
|
||||
}
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.stale as u64, r.replayed as u64), (n, n, during));
|
||||
|
||||
// finishing trims the log to exactly the records after the snapshot, in order
|
||||
assert!(j.poll().unwrap());
|
||||
assert!(!j.snapshot_running());
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (n, during));
|
||||
let log = String::from_utf8(g.files.get(LOG).unwrap()).unwrap();
|
||||
let seqs: Vec<u64> = log.lines().map(|l| journal::decode_record(l).unwrap().0).collect();
|
||||
assert_eq!(seqs, ((n + 1)..=j.seq()).collect::<Vec<_>>());
|
||||
assert_eq!(j.log_bytes(), log.len() as u64);
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.stale, r.replayed as u64), (n, 0, during));
|
||||
let s = j.stats();
|
||||
assert!(s.last_snapshot.background && s.last_snapshot.records == n && !s.snapshot_running, "{s:?}");
|
||||
|
||||
// and appending goes on after it
|
||||
edit(&mut j, &mut live, 99);
|
||||
assert_recovers(&g, &live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_background_snapshot_loses_nothing_and_retries_later() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
j.policy = SnapshotPolicy { max_records: 10, max_bytes: u64::MAX };
|
||||
for k in 0..12 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
assert!(j.wants_snapshot());
|
||||
g.fail.store(true, Ordering::SeqCst);
|
||||
g.set_gate(true);
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
edit(&mut j, &mut live, 12);
|
||||
wait_written(&j);
|
||||
assert!(j.poll().is_err());
|
||||
assert_eq!((j.snapshot_seq(), j.stats().failed_snapshots), (0, 1));
|
||||
assert!(g.files.get(SNAPSHOT).is_none());
|
||||
assert_recovers(&g, &live);
|
||||
// no retry storm: the next attempt waits for another `max_records` records
|
||||
assert!(!j.wants_snapshot());
|
||||
g.fail.store(false, Ordering::SeqCst);
|
||||
while !j.wants_snapshot() {
|
||||
let k = j.seq();
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
j.wait().unwrap();
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (j.seq(), 0));
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.replayed), (j.seq(), 0));
|
||||
}
|
||||
|
||||
/// A synchronous snapshot (close, "Optimize Library") waits for the background one, so the older
|
||||
/// snapshot can't land after (and replace) the newer one.
|
||||
#[test]
|
||||
fn sync_snapshot_waits_for_the_background_one() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..10 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
for k in 10..20 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let opener = {
|
||||
let g = g.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
g.set_gate(true);
|
||||
})
|
||||
};
|
||||
j.snapshot(&live).unwrap();
|
||||
opener.join().unwrap();
|
||||
assert!(!j.snapshot_running());
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(j.seq()));
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (j.seq(), 0));
|
||||
assert!(g.files.get(LOG).unwrap().is_empty());
|
||||
assert_eq!(j.stats().snapshots, 2);
|
||||
assert_recovers(&g, &live);
|
||||
}
|
||||
|
||||
/// Dropping the journal (e.g. reopening the library) waits for the snapshot in flight and leaves
|
||||
/// the log whole: reopening finds every op.
|
||||
#[test]
|
||||
fn dropping_the_journal_waits_for_its_snapshot() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..10 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let n = j.seq();
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
for k in 10..15 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let opener = {
|
||||
let g = g.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
g.set_gate(true);
|
||||
})
|
||||
};
|
||||
let total = j.seq();
|
||||
drop(j);
|
||||
opener.join().unwrap();
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(n));
|
||||
let (_, c, r) = Journal::open(Box::new(g.files.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
assert_eq!((r.snapshot_seq, r.stale as u64, r.replayed as u64), (n, n, total - n));
|
||||
}
|
||||
|
||||
/// On disk, with a real worker thread: a crash mid-write leaves only a temp file, which loading
|
||||
/// ignores; edits during the snapshot survive; reopening resumes appends.
|
||||
#[test]
|
||||
fn fs_store_background_snapshot() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-bg-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
for k in 0..200 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
// a previous crash mid-snapshot left a partial temp file
|
||||
std::fs::write(dir.join(format!("{SNAPSHOT}.tmp")), b"{\"format\":\"lightcraft-catal").unwrap();
|
||||
let (mut j2, c2, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!((c2.to_snapshot(), r.snapshot_seq), (live.to_snapshot(), 0));
|
||||
drop(j);
|
||||
let n = j2.seq();
|
||||
j2.snapshot_in_background(&live).unwrap();
|
||||
for k in 200..260 {
|
||||
edit(&mut j2, &mut live, k);
|
||||
}
|
||||
j2.wait().unwrap();
|
||||
assert_eq!((j2.snapshot_seq(), j2.log_records()), (n, j2.seq() - n));
|
||||
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
|
||||
edit(&mut j2, &mut live, 999);
|
||||
drop(j2);
|
||||
let (_, c3, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!(c3.to_snapshot(), live.to_snapshot());
|
||||
assert_eq!((r.snapshot_seq, r.stale), (n, 0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(48))]
|
||||
|
||||
/// Any interleaving of edits, background snapshots (started, landed, finished), synchronous
|
||||
/// snapshots and crashes: a crash at any point recovers exactly the acknowledged ops.
|
||||
#[test]
|
||||
fn background_compaction_never_loses_acknowledged_ops(actions in proptest::collection::vec((0u8..8, 0u64..1000), 1..60)) {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for (ctl, k) in actions {
|
||||
match ctl {
|
||||
0..=2 => edit(&mut j, &mut live, k),
|
||||
3 => j.snapshot_in_background(&live).unwrap(),
|
||||
4 | 5 => {
|
||||
// the worker writes its snapshot; finish it now or later
|
||||
if j.snapshot_running() {
|
||||
g.set_gate(true);
|
||||
wait_written(&j);
|
||||
g.set_gate(false);
|
||||
if ctl == 4 {
|
||||
prop_assert!(j.poll().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
g.set_gate(true);
|
||||
j.snapshot(&live).unwrap();
|
||||
g.set_gate(false);
|
||||
prop_assert_eq!(g.snapshot_seq_on_disk(), Some(j.seq()));
|
||||
}
|
||||
_ => {
|
||||
// a crash now
|
||||
let (_, c, r) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
prop_assert_eq!(r.failed, 0);
|
||||
prop_assert!(r.damaged.is_none());
|
||||
}
|
||||
}
|
||||
// the snapshot on disk never covers more than what was appended
|
||||
prop_assert!(g.snapshot_seq_on_disk().unwrap_or(0) <= j.seq());
|
||||
}
|
||||
g.set_gate(true);
|
||||
let (_, c, _) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
drop(j);
|
||||
let (_, c, _) = Journal::open(Box::new(g.files.clone())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
}
|
||||
}
|
||||
@@ -274,3 +274,48 @@ fn fs_store_roundtrip() {
|
||||
assert_eq!(c3.to_snapshot(), c.to_snapshot());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The streamed snapshot is byte-for-byte the format written before streaming (issue #37), so
|
||||
/// existing libraries load and older builds can read new snapshots; FsStore and MemStore agree.
|
||||
#[test]
|
||||
fn streamed_snapshot_keeps_the_on_disk_format() {
|
||||
let (m, _, full) = sample_catalog_with_log();
|
||||
let (mut j, c, _) = open(&m);
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
j.snapshot(&c).unwrap();
|
||||
let legacy = format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{},\"catalog\":{}}}\n", j.seq(), c.to_snapshot());
|
||||
assert_eq!(String::from_utf8(m.get(SNAPSHOT).unwrap()).unwrap(), legacy);
|
||||
assert_eq!(j.stats().last_snapshot.bytes, legacy.len() as u64);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-stream-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// an old-style snapshot (written by a build before streaming) loads, and is rewritten the same
|
||||
std::fs::write(dir.join(SNAPSHOT), legacy.as_bytes()).unwrap();
|
||||
let (mut fj, fc, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!((fc.to_snapshot(), r.snapshot_seq), (c.to_snapshot(), j.seq()));
|
||||
fj.snapshot(&fc).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), legacy.as_bytes());
|
||||
drop(fj);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A snapshot that fails while streaming (serialisation or I/O error) leaves the old file in
|
||||
/// place and removes its temp file.
|
||||
#[test]
|
||||
fn failed_streamed_write_keeps_the_old_file() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-streamfail-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = FsStore::open(&dir).unwrap();
|
||||
s.write_atomic(SNAPSHOT, b"old").unwrap();
|
||||
let r = s.write_atomic_with(SNAPSHOT, &mut |w| {
|
||||
w.write_all(b"partial new content")?;
|
||||
Err(std::io::Error::other("serialisation failed"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"old");
|
||||
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
|
||||
assert_eq!(s.write_atomic_with(SNAPSHOT, &mut |w| w.write_all(b"new")).unwrap(), 3);
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete,
|
||||
//! metadata, albums, import.
|
||||
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
@@ -252,11 +252,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| {
|
||||
Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default())
|
||||
}),
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright, creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?;
|
||||
let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta;
|
||||
let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description,
|
||||
"copyright": m.copyright, "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
|
||||
"copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url,
|
||||
"creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
|
||||
"keywords": m.keywords});
|
||||
s.meta_clipboard = Some(v.clone());
|
||||
Ok(v)
|
||||
@@ -266,7 +267,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste Metadata",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, fields?: [title|caption|copyright|creator|location|keywords] (default: all copied)}",
|
||||
"{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let c = "photo.pasteMetadata";
|
||||
@@ -370,7 +371,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Info",
|
||||
[],
|
||||
None,
|
||||
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
||||
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let strs =
|
||||
@@ -390,6 +391,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
},
|
||||
Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")),
|
||||
};
|
||||
let status = match str_param(p, "copyrightStatus") {
|
||||
Some(t) => Some(
|
||||
CopyrightStatus::parse(t)
|
||||
.ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
@@ -397,10 +405,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(g) = gps {
|
||||
m.gps = g;
|
||||
}
|
||||
if let Some(st) = status {
|
||||
m.copyright_status = st;
|
||||
}
|
||||
for (k, field) in [
|
||||
("title", &mut m.title),
|
||||
("caption", &mut m.caption),
|
||||
("copyright", &mut m.copyright),
|
||||
("usageTerms", &mut m.usage_terms),
|
||||
("copyrightUrl", &mut m.copyright_url),
|
||||
("creator", &mut m.creator),
|
||||
("location", &mut m.location),
|
||||
("city", &mut m.city),
|
||||
@@ -854,6 +867,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"logRecords": j.log_records(),
|
||||
"logBytes": j.log_bytes(),
|
||||
"lastError": lib.last_error,
|
||||
"persistence": j.stats(),
|
||||
"cache": cache,
|
||||
"load": {
|
||||
"created": lib.report.created,
|
||||
|
||||
@@ -59,6 +59,97 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Geotag photos from a GPX track log by capture time (Lightroom Classic's Map ▸ Tracklog ▸
|
||||
/// Auto-Tag Photos). GPX times are UTC; capture times are the camera's local clock, so a photo's
|
||||
/// recorded zone (Exif `OffsetTimeOriginal`) or the `offset` parameter converts them.
|
||||
fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
use lightcraft_meta::{DateTime, Match, parse_gpx};
|
||||
const C: &str = "photo.autoTagTracklog";
|
||||
let text = match (str_param(p, "gpx"), str_param(p, "path")) {
|
||||
(Some(t), _) => t.to_string(),
|
||||
(None, Some(path)) => std::fs::read_to_string(path).map_err(|e| bad(C, format!("can't read {path}: {e}")))?,
|
||||
(None, None) => return Err(bad(C, "missing `path` (a .gpx file) or `gpx` (its text)")),
|
||||
};
|
||||
let log = parse_gpx(&text).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (start, end) = log.span().ok_or_else(|| bad(C, "the track log has no timed track points"))?;
|
||||
// minutes east of UTC; `None` = not given (zone-less capture times are then read as UTC)
|
||||
let offset: Option<i64> = match p.get("offset") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(o)) if o.trim().is_empty() => None,
|
||||
Some(Value::Number(h)) => {
|
||||
let h = h.as_f64().unwrap_or(0.0);
|
||||
if h.abs() > 18.0 {
|
||||
return Err(bad(C, "offset is at most ±18 hours"));
|
||||
}
|
||||
Some((h * 60.0).round() as i64)
|
||||
}
|
||||
Some(Value::String(o)) => {
|
||||
Some(DateTime::parse_offset(o).ok_or_else(|| bad(C, format!("can't read offset `{o}` (e.g. +02:00 or -5)")))? as i64)
|
||||
}
|
||||
Some(_) => return Err(bad(C, "offset is `+HH:MM` or a number of hours")),
|
||||
};
|
||||
let max_gap = f64_or(p, "maxGap", 600.0);
|
||||
if max_gap.is_nan() || max_gap < 0.0 {
|
||||
return Err(bad(C, "maxGap must be ≥ 0 seconds"));
|
||||
}
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0);
|
||||
let mut ops = Vec::new();
|
||||
let mut photos = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
if ph.meta.gps.is_some() && !replace {
|
||||
has_gps += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(dt) = ph.captured.as_deref().and_then(DateTime::parse_iso) else {
|
||||
no_time += 1;
|
||||
continue;
|
||||
};
|
||||
// UTC seconds: the recorded zone wins, else the caller's offset
|
||||
let utc = match dt.offset_minutes {
|
||||
Some(_) => dt.unix_seconds(),
|
||||
None => {
|
||||
if offset.is_none() {
|
||||
assumed_utc += 1;
|
||||
}
|
||||
dt.unix_seconds() - offset.unwrap_or(0) * 60
|
||||
}
|
||||
} as f64
|
||||
+ dt.millis as f64 / 1000.0;
|
||||
let Some((g, m)) = log.locate(utc, max_gap) else {
|
||||
outside += 1;
|
||||
continue;
|
||||
};
|
||||
if m == Match::Interpolated {
|
||||
interpolated += 1;
|
||||
}
|
||||
let pos = ((g.latitude * 1e7).round() / 1e7, (g.longitude * 1e7).round() / 1e7);
|
||||
photos.push(json!({"id": id.0, "gps": [pos.0, pos.1], "match": if m == Match::Interpolated { "interpolated" } else { "nearest" }}));
|
||||
if ph.meta.gps != Some(pos) {
|
||||
let mut meta = ph.meta.clone();
|
||||
meta.gps = Some(pos);
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
}
|
||||
let tagged = photos.len();
|
||||
if !bool_or(p, "dryRun", false) && !ops.is_empty() {
|
||||
s.commit("Auto-Tag from Tracklog", Op::Batch { ops })?;
|
||||
}
|
||||
let iso = |t: f64| format!("{}Z", lightcraft_catalog::dates::civil(t.floor() as i64));
|
||||
Ok(json!({
|
||||
"tagged": tagged,
|
||||
"interpolated": interpolated,
|
||||
"nearest": tagged - interpolated,
|
||||
"skipped": {"noTime": no_time, "outside": outside, "hasGps": has_gps},
|
||||
"assumedUtc": assumed_utc,
|
||||
"points": log.points.len(),
|
||||
"start": iso(start),
|
||||
"end": iso(end),
|
||||
"photos": photos,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| {
|
||||
@@ -130,6 +221,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": n, "captured": out}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"photo.autoTagTracklog",
|
||||
"Auto-Tag Photos from Tracklog",
|
||||
[],
|
||||
None,
|
||||
"{path?: GPX file | gpx?: GPX text, ids?, offset?: camera clock's UTC offset for photos whose capture time has no zone (`+02:00`, or hours; default UTC), maxGap?: seconds (600), replace?: bool (also photos that already have GPS), dryRun?: bool} → {tagged, interpolated, nearest, skipped: {noTime, outside, hasGps}, assumedUtc, points, start, end, photos: [{id, gps, match}]} — positions from the track log by capture time; one undo step",
|
||||
has_selection,
|
||||
auto_tag_tracklog
|
||||
),
|
||||
cmd!(query "label.names", "Color Label Names", [], None, "{} → [{label, name, custom}]", always, |s, _| {
|
||||
Ok(json!(ColorLabel::ALL
|
||||
.iter()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Metadata presets: named sets of descriptive fields (copyright, creator, place, keywords…)
|
||||
//! applied to photos in one step or to every import (Settings → Import). Saved with the library.
|
||||
|
||||
use lightcraft_catalog::Meta;
|
||||
use lightcraft_catalog::{CopyrightStatus, Meta};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -9,7 +9,20 @@ use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The fields a metadata preset can hold (photo.setMeta names).
|
||||
pub const FIELDS: &[&str] = &["title", "caption", "copyright", "creator", "location", "city", "state", "country", "keywords"];
|
||||
pub const FIELDS: &[&str] = &[
|
||||
"title",
|
||||
"caption",
|
||||
"copyright",
|
||||
"copyrightStatus",
|
||||
"usageTerms",
|
||||
"copyrightUrl",
|
||||
"creator",
|
||||
"location",
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"keywords",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MetadataPreset {
|
||||
@@ -23,6 +36,8 @@ fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
|
||||
"title" => &mut m.title,
|
||||
"caption" => &mut m.caption,
|
||||
"copyright" => &mut m.copyright,
|
||||
"usageTerms" => &mut m.usage_terms,
|
||||
"copyrightUrl" => &mut m.copyright_url,
|
||||
"creator" => &mut m.creator,
|
||||
"location" => &mut m.location,
|
||||
"city" => &mut m.city,
|
||||
@@ -35,7 +50,11 @@ fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
|
||||
/// Apply preset `fields` to `m`: text fields replace, keywords are added.
|
||||
pub fn apply_to(m: &mut Meta, fields: &Value) {
|
||||
for (k, v) in fields.as_object().into_iter().flatten() {
|
||||
if k == "keywords" {
|
||||
if k == "copyrightStatus" {
|
||||
if let Some(st) = v.as_str().and_then(CopyrightStatus::parse) {
|
||||
m.copyright_status = st;
|
||||
}
|
||||
} else if k == "keywords" {
|
||||
for kw in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(kw)) {
|
||||
m.keywords.push(kw.to_string());
|
||||
@@ -59,6 +78,9 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) {
|
||||
return Err(bad(C, format!("unknown field `{k}` ({})", FIELDS.join(", "))));
|
||||
}
|
||||
if let Some(v) = o.get("copyrightStatus").filter(|v| v.as_str().and_then(CopyrightStatus::parse).is_none()) {
|
||||
return Err(bad(C, format!("copyrightStatus {v}: unknown, copyrighted or publicDomain")));
|
||||
}
|
||||
Value::Object(o.clone())
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`fields` must be an object")),
|
||||
@@ -66,17 +88,24 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => {
|
||||
let id = s.active().ok_or_else(|| bad(C, "no `fields` and no active photo"))?;
|
||||
let mut m = s.catalog.photo(id).ok_or_else(|| bad(C, "no photo"))?.meta.clone();
|
||||
let only: Vec<String> = p
|
||||
.get("only")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_else(|| ["copyright", "creator", "location", "city", "state", "country"].map(String::from).to_vec());
|
||||
let only: Vec<String> =
|
||||
p.get("only").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_else(
|
||||
|| {
|
||||
["copyright", "copyrightStatus", "usageTerms", "copyrightUrl", "creator", "location", "city", "state", "country"]
|
||||
.map(String::from)
|
||||
.to_vec()
|
||||
},
|
||||
);
|
||||
let mut o = Map::new();
|
||||
for k in only.iter().filter(|k| FIELDS.contains(&k.as_str())) {
|
||||
if k == "keywords" {
|
||||
if !m.keywords.is_empty() {
|
||||
o.insert(k.clone(), json!(m.keywords));
|
||||
}
|
||||
} else if k == "copyrightStatus" {
|
||||
if !m.copyright_status.is_unknown() {
|
||||
o.insert(k.clone(), json!(m.copyright_status.id()));
|
||||
}
|
||||
} else if let Some(v) = text_field(&mut m, k).filter(|v| !v.trim().is_empty()) {
|
||||
o.insert(k.clone(), json!(v.clone()));
|
||||
}
|
||||
@@ -136,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Metadata Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, fields?: {copyright?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default copyright, creator, place)} — adds or replaces",
|
||||
"{name, fields?: {copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default the copyright fields, creator, place)} — adds or replaces",
|
||||
always,
|
||||
save
|
||||
),
|
||||
|
||||
@@ -853,7 +853,16 @@ fn parse_shutter(s: &str) -> Option<f64> {
|
||||
pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option<Metadata> {
|
||||
let m = &photo.meta;
|
||||
let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
|
||||
let mut out = Metadata { copyright: text(&m.copyright), artist: text(&m.creator), software: Some("LightCraft".into()), ..Default::default() };
|
||||
// copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL
|
||||
let mut out = Metadata {
|
||||
copyright: text(&m.copyright),
|
||||
copyright_marked: m.copyright_status.marked(),
|
||||
usage_terms: text(&m.usage_terms),
|
||||
copyright_url: text(&m.copyright_url),
|
||||
artist: text(&m.creator),
|
||||
software: Some("LightCraft".into()),
|
||||
..Default::default()
|
||||
};
|
||||
match o.metadata {
|
||||
MetadataPolicy::None => return None,
|
||||
MetadataPolicy::Copyright => return Some(out),
|
||||
@@ -1169,6 +1178,9 @@ mod tests {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
|
||||
p.meta.camera = "Synthetic X2".into();
|
||||
p.meta.copyright = "(c) Me".into();
|
||||
p.meta.copyright_status = lightcraft_catalog::CopyrightStatus::Copyrighted;
|
||||
p.meta.usage_terms = "Editorial use only".into();
|
||||
p.meta.copyright_url = "https://example.com/rights".into();
|
||||
p.meta.shutter = "1/250".into();
|
||||
p.meta.gps = Some((43.0, -110.0));
|
||||
let all = export_metadata(&p, &ExportOptions::default()).unwrap();
|
||||
@@ -1180,12 +1192,19 @@ mod tests {
|
||||
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();
|
||||
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());
|
||||
// embedded and readable back from the JPEG
|
||||
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap();
|
||||
let back = lightcraft_meta::extract(&jpg);
|
||||
assert_eq!(back.model.as_deref(), Some("Synthetic X2"));
|
||||
assert_eq!(back.copyright.as_deref(), Some("(c) Me"));
|
||||
assert_eq!((back.copyright_marked, back.usage_terms.as_deref()), (Some(true), Some("Editorial use only")));
|
||||
assert_eq!(back.copyright_url.as_deref(), Some("https://example.com/rights"));
|
||||
assert!(back.gps.is_some());
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
title: m.title.clone().unwrap_or_default(),
|
||||
caption: m.caption.clone().unwrap_or_default(),
|
||||
copyright: m.copyright.clone().unwrap_or_default(),
|
||||
copyright_status: lightcraft_catalog::CopyrightStatus::from_marked(m.copyright_marked),
|
||||
usage_terms: m.usage_terms.clone().unwrap_or_default(),
|
||||
copyright_url: m.copyright_url.clone().unwrap_or_default(),
|
||||
creator: m.artist.clone().unwrap_or_default(),
|
||||
keywords: m.keywords.clone(),
|
||||
};
|
||||
|
||||
@@ -73,6 +73,9 @@ pub struct Interaction {
|
||||
pub original: Arc<DevelopSettings>,
|
||||
}
|
||||
|
||||
/// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one).
|
||||
static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
pub struct Session {
|
||||
/// Auto Sync: edits to the active photo also change the other selected photos (the settings
|
||||
/// that changed, nothing else).
|
||||
@@ -83,8 +86,12 @@ pub struct Session {
|
||||
pub sort: Sort,
|
||||
pub selection: Selection,
|
||||
/// The grid order for the current source/filter/sort (cached by catalog revision).
|
||||
visible: Vec<PhotoId>,
|
||||
/// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]).
|
||||
visible: Arc<[PhotoId]>,
|
||||
visible_key: Option<(u64, String)>,
|
||||
/// Identifies the current `visible` list: a new value (unique in the process) every time it
|
||||
/// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids.
|
||||
visible_gen: u64,
|
||||
/// Photos in the current source with no filter on (cached like `visible`).
|
||||
total: Option<((u64, String), usize)>,
|
||||
pub undo: Vec<UndoEntry>,
|
||||
@@ -178,8 +185,9 @@ impl Session {
|
||||
filter: Filter::default(),
|
||||
sort: Sort::default(),
|
||||
selection: Selection::default(),
|
||||
visible: Vec::new(),
|
||||
visible: Vec::new().into(),
|
||||
visible_key: None,
|
||||
visible_gen: 0,
|
||||
total: None,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
@@ -487,7 +495,7 @@ impl Session {
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
self.visible = self.catalog.query(&f, &self.sort);
|
||||
let mut visible = self.catalog.query(&f, &self.sort);
|
||||
if matches!(self.source, LibrarySource::Album(_))
|
||||
&& self.sort.key == lightcraft_catalog::SortKey::CaptureDate
|
||||
&& let LibrarySource::Album(a) = self.source
|
||||
@@ -496,15 +504,15 @@ impl Session {
|
||||
&& self.filter == Filter::default()
|
||||
{
|
||||
let order = al.photos.clone();
|
||||
self.visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
|
||||
visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
|
||||
if !self.sort.ascending {
|
||||
self.visible.reverse();
|
||||
visible.reverse();
|
||||
}
|
||||
}
|
||||
if self.source == LibrarySource::RecentlyAdded {
|
||||
// newest import first, whatever the sort (the grid groups by import day)
|
||||
let cat = &self.catalog;
|
||||
self.visible.sort_by(|a, b| {
|
||||
visible.sort_by(|a, b| {
|
||||
let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default();
|
||||
key(b).cmp(&key(a)).then(a.cmp(b))
|
||||
});
|
||||
@@ -512,11 +520,13 @@ impl Session {
|
||||
if self.source == LibrarySource::Missing {
|
||||
// only the photos the query kept (library photos, not Local browse records) are checked
|
||||
let cat = &self.catalog;
|
||||
self.visible.retain(|id| cmd::missing::is_missing(cat, *id));
|
||||
visible.retain(|id| cmd::missing::is_missing(cat, *id));
|
||||
}
|
||||
if self.source != LibrarySource::RecentlyDeleted {
|
||||
self.visible = self.catalog.arrange_stacks(&self.visible);
|
||||
visible = self.catalog.arrange_stacks(&visible);
|
||||
}
|
||||
self.visible = visible.into();
|
||||
self.visible_gen = VISIBLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.visible_key = Some(key);
|
||||
}
|
||||
&self.visible
|
||||
@@ -546,6 +556,14 @@ impl Session {
|
||||
self.visible().to_vec()
|
||||
}
|
||||
|
||||
/// The visible photos without copying them, with their generation: equal generations mean
|
||||
/// the very same list (same catalog revision, source, filter, sort), so a frontend can cache
|
||||
/// whatever it derives from the list under that number.
|
||||
pub fn visible_shared(&mut self) -> (u64, Arc<[PhotoId]>) {
|
||||
self.visible();
|
||||
(self.visible_gen, self.visible.clone())
|
||||
}
|
||||
|
||||
/// Targets of photo commands: explicit `ids`/`id` param, else the selection.
|
||||
pub fn targets(&self, p: &Value) -> Vec<PhotoId> {
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//!
|
||||
//! Every top-level [`Session::execute`] persists the ops it produced (fsynced) before returning,
|
||||
//! so a crash loses at most the command in flight. The log is compacted into a snapshot when it
|
||||
//! grows (see [`lightcraft_catalog::SnapshotPolicy`]) and on [`Session::close_library`].
|
||||
//! grows (see [`lightcraft_catalog::SnapshotPolicy`]; written by a worker thread on native, see
|
||||
//! [`lightcraft_catalog::journal`]) and on [`Session::close_library`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -91,6 +92,10 @@ impl Library {
|
||||
pub fn journal(&self) -> &Journal {
|
||||
&self.journal
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn journal_mut(&mut self) -> &mut Journal {
|
||||
&mut self.journal
|
||||
}
|
||||
pub fn thumbs_dir(&self) -> PathBuf {
|
||||
self.dir.join("thumbs")
|
||||
}
|
||||
@@ -141,6 +146,15 @@ fn presets_json(s: &Session) -> String {
|
||||
serde_json::to_string_pretty(&f).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Finish a background compaction that is done (cheap otherwise). A failed one lost nothing (the
|
||||
/// log is kept whole) and is retried later; it is reported like other persistence errors.
|
||||
fn poll_compaction(lib: &mut Library) {
|
||||
if let Err(e) = lib.journal.poll() {
|
||||
log::error!("library: compaction: {e}");
|
||||
lib.last_error = Some(format!("compaction: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned>(store: &mut dyn Store, name: &str) -> Option<T> {
|
||||
store.read(name).ok().flatten().and_then(|b| serde_json::from_slice(&b).ok())
|
||||
}
|
||||
@@ -160,6 +174,12 @@ impl Session {
|
||||
pub fn open_library_in(&mut self, stores: LibraryStores, seed_demo: bool) -> Result<&LoadReport> {
|
||||
let LibraryStores { dir, catalog, mut files, on_disk } = stores;
|
||||
self.media.smart_dir = on_disk.then(|| crate::smart::dir(&dir));
|
||||
// the current library may be these same files: let its background snapshot land first
|
||||
if let Some(old) = self.library.as_mut()
|
||||
&& let Err(e) = old.journal.wait()
|
||||
{
|
||||
log::error!("library: {e}");
|
||||
}
|
||||
let (mut journal, catalog, report) = Journal::open(catalog)?;
|
||||
self.catalog = catalog;
|
||||
self.undo.clear();
|
||||
@@ -245,9 +265,11 @@ impl Session {
|
||||
self.pending_log.clear();
|
||||
lib.last_error = None;
|
||||
}
|
||||
poll_compaction(lib);
|
||||
// Never snapshot mid-interaction: the catalog then holds an uncommitted preview value.
|
||||
// The snapshot is written by a worker thread (natively); appends go on meanwhile.
|
||||
if lib.journal.wants_snapshot() && self.interaction.is_none() {
|
||||
lib.journal.snapshot(&self.catalog)?;
|
||||
lib.journal.snapshot_in_background(&self.catalog)?;
|
||||
}
|
||||
let presets = presets_json(self);
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
@@ -266,6 +288,8 @@ impl Session {
|
||||
pub fn persist_if_dirty(&mut self) {
|
||||
if self.library.is_some() && !self.pending_log.is_empty() {
|
||||
let _ = self.persist();
|
||||
} else if let Some(lib) = self.library.as_mut() {
|
||||
poll_compaction(lib);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,10 @@ pub struct SidecarData {
|
||||
pub title: Option<String>,
|
||||
pub caption: Option<String>,
|
||||
pub copyright: Option<String>,
|
||||
/// `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain.
|
||||
pub copyright_marked: Option<bool>,
|
||||
pub usage_terms: Option<String>,
|
||||
pub copyright_url: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub city: Option<String>,
|
||||
@@ -105,6 +109,9 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
title: m.title.clone(),
|
||||
caption: m.caption.clone(),
|
||||
copyright: m.copyright.clone(),
|
||||
copyright_marked: m.copyright_marked,
|
||||
usage_terms: m.usage_terms.clone(),
|
||||
copyright_url: m.copyright_url.clone(),
|
||||
creator: m.artist.clone(),
|
||||
location: lc("location").or_else(|| m.sublocation.clone()),
|
||||
city: m.city.clone(),
|
||||
@@ -162,6 +169,8 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
||||
(&sc.title, &mut m.title),
|
||||
(&sc.caption, &mut m.caption),
|
||||
(&sc.copyright, &mut m.copyright),
|
||||
(&sc.usage_terms, &mut m.usage_terms),
|
||||
(&sc.copyright_url, &mut m.copyright_url),
|
||||
(&sc.creator, &mut m.creator),
|
||||
(&sc.location, &mut m.location),
|
||||
(&sc.city, &mut m.city),
|
||||
@@ -174,6 +183,9 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
||||
*dst = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(marked) = sc.copyright_marked {
|
||||
m.copyright_status = lightcraft_catalog::CopyrightStatus::from_marked(Some(marked));
|
||||
}
|
||||
if let Some(k) = &sc.keywords {
|
||||
m.keywords = k.clone();
|
||||
}
|
||||
@@ -222,6 +234,9 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
|
||||
state: nz(&p.meta.state),
|
||||
country: nz(&p.meta.country),
|
||||
copyright: nz(&p.meta.copyright),
|
||||
copyright_marked: p.meta.copyright_status.marked(),
|
||||
usage_terms: nz(&p.meta.usage_terms),
|
||||
copyright_url: nz(&p.meta.copyright_url),
|
||||
artist: nz(&p.meta.creator),
|
||||
keywords: p.meta.keywords.clone(),
|
||||
rating: Some(p.rating.min(5) as i8),
|
||||
|
||||
@@ -401,3 +401,68 @@ fn keyword_sets_and_recent_keywords() {
|
||||
s.execute("keyword.deleteSet", &json!({"name": "Wedding"})).unwrap();
|
||||
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "Recent Keywords");
|
||||
}
|
||||
|
||||
/// Auto-Tag from Tracklog: GPS positions from a GPX file by capture time — interpolated within a
|
||||
/// segment, the camera's zone from the photo or `offset`; photos with GPS kept unless `replace`;
|
||||
/// one undo step, replayed from the op log.
|
||||
#[test]
|
||||
fn tracklog_auto_tag() {
|
||||
use lightcraft_catalog::{Op, PhotoId};
|
||||
const GPX: &str = r#"<?xml version="1.0"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
|
||||
<trkpt lat="46.0000" lon="7.0000"><time>2026-05-01T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="46.0010" lon="7.0020"><time>2026-05-01T10:01:00Z</time></trkpt>
|
||||
<trkpt lat="46.0020" lon="7.0040"><time>2026-05-01T10:02:00Z</time></trkpt>
|
||||
</trkseg></trk></gpx>"#;
|
||||
let dir = temp_dir("tracklog");
|
||||
let gpx_path = dir.join("walk.gpx");
|
||||
std::fs::write(&gpx_path, GPX).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.open_library(dir.join("lib"), true).unwrap();
|
||||
let ids: Vec<u64> = s.visible_cloned().iter().take(4).map(|p| p.0).collect();
|
||||
let set = |s: &mut Session, id: u64, t: &str| {
|
||||
s.commit("t", Op::SetCaptured { id: PhotoId(id), captured: Some(t.into()) }).unwrap();
|
||||
};
|
||||
set(&mut s, ids[0], "2026-05-01T12:00:30"); // camera clock on +02:00, no zone recorded
|
||||
set(&mut s, ids[1], "2026-05-01T12:01:00+02:00"); // zone recorded: exactly on the 2nd point
|
||||
set(&mut s, ids[2], "2026-05-01T15:00:00"); // hours after the log ends
|
||||
set(&mut s, ids[3], "2026-05-01T12:02:00");
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[3]], "gps": [10.0, 20.0]})).unwrap();
|
||||
let gps = |s: &Session, id: u64| s.catalog.photo(PhotoId(id)).unwrap().meta.gps;
|
||||
let before: Vec<_> = ids.iter().map(|i| gps(&s, *i)).collect();
|
||||
let path = gpx_path.to_string_lossy().to_string();
|
||||
// a dry run reports without changing anything
|
||||
let r = s.execute("photo.autoTagTracklog", &json!({"path": path, "ids": ids, "offset": "+02:00", "dryRun": true})).unwrap();
|
||||
assert_eq!((r["tagged"].as_u64(), r["interpolated"].as_u64()), (Some(2), Some(2)), "{r}");
|
||||
assert_eq!(r["skipped"], json!({"noTime": 0, "outside": 1, "hasGps": 1}));
|
||||
assert_eq!((r["points"].as_u64(), r["start"].as_str()), (Some(3), Some("2026-05-01T10:00:00Z")));
|
||||
assert_eq!(ids.iter().map(|i| gps(&s, *i)).collect::<Vec<_>>(), before);
|
||||
// tag: one undo step
|
||||
let undo_depth = s.undo.len();
|
||||
s.execute("photo.autoTagTracklog", &json!({"path": path, "ids": ids, "offset": 2})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo_depth + 1);
|
||||
assert_eq!(gps(&s, ids[0]), Some((46.0005, 7.001)), "interpolated half-way between the first two points");
|
||||
assert_eq!(gps(&s, ids[1]), Some((46.001, 7.002)));
|
||||
assert_eq!(gps(&s, ids[2]), None);
|
||||
assert_eq!(gps(&s, ids[3]), Some((10.0, 20.0)), "existing GPS kept without `replace`");
|
||||
// without an offset, zone-less capture times count as UTC (and are reported)
|
||||
let r = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "replace": true, "dryRun": true})).unwrap();
|
||||
assert_eq!((r["tagged"].as_u64(), r["assumedUtc"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
// replace: the photo that had GPS gets the track's position (its last point, 12:02 local)
|
||||
s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[3]], "offset": "+02:00", "replace": true})).unwrap();
|
||||
assert_eq!(gps(&s, ids[3]), Some((46.002, 7.004)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(gps(&s, ids[3]), Some((10.0, 20.0)));
|
||||
// the op log replays the tags
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.open_library(dir.join("lib"), false).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), expect);
|
||||
// errors: no file, not GPX, no timed points, bad offset
|
||||
assert!(s.execute("photo.autoTagTracklog", &json!({"ids": [ids[0]]})).is_err());
|
||||
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": "<kml/>", "ids": [ids[0]]})).is_err());
|
||||
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": "<gpx><trk><trkseg/></trk></gpx>", "ids": [ids[0]]})).is_err());
|
||||
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "offset": "noon"})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,57 @@ fn edits_survive_restart_without_close() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Compaction at the threshold runs on a worker thread (issue #37): commands go on (and stay
|
||||
/// durable) meanwhile, the frame loop's `persist_if_dirty` finishes it, and a crash at any time
|
||||
/// keeps every command.
|
||||
#[test]
|
||||
fn threshold_compaction_runs_in_the_background() {
|
||||
let dir = temp_dir("bg-compact");
|
||||
let mut s = open(&dir, true);
|
||||
s.library.as_mut().unwrap().journal_mut().policy = lightcraft_catalog::SnapshotPolicy { max_records: 8, max_bytes: u64::MAX };
|
||||
let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect();
|
||||
let mut background = 0;
|
||||
for k in 0..60usize {
|
||||
s.selection = crate::Selection::single(ids[k % ids.len()]);
|
||||
s.execute("photo.rate", &json!({"rating": k % 6})).unwrap();
|
||||
if s.execute("library.info", &json!({})).unwrap()["persistence"]["snapshotRunning"].as_bool().unwrap() {
|
||||
background += 1;
|
||||
}
|
||||
if k == 30 {
|
||||
// a crash while a compaction may be in flight
|
||||
let expect = s.catalog.to_snapshot();
|
||||
let copy = temp_dir("bg-compact-crash");
|
||||
std::fs::create_dir_all(©).unwrap();
|
||||
for f in ["catalog.snap", "catalog.log"] {
|
||||
if dir.join(f).exists() {
|
||||
std::fs::copy(dir.join(f), copy.join(f)).unwrap();
|
||||
}
|
||||
}
|
||||
let s2 = open(©, true);
|
||||
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(©);
|
||||
}
|
||||
}
|
||||
assert!(background > 0, "compactions ran in the background");
|
||||
for _ in 0..10_000 {
|
||||
s.persist_if_dirty();
|
||||
if !s.library.as_ref().unwrap().journal().snapshot_running() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
let info = s.execute("library.info", &json!({})).unwrap();
|
||||
let p = &info["persistence"];
|
||||
assert!(p["snapshots"].as_u64() >= Some(2) && p["lastSnapshot"]["background"] == true, "{p}");
|
||||
// the log holds exactly the records after the snapshot
|
||||
assert_eq!(info["logRecords"].as_u64().unwrap(), info["seq"].as_u64().unwrap() - info["snapshotSeq"].as_u64().unwrap(), "{info}");
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s); // no close: like a crash
|
||||
let s2 = open(&dir, true);
|
||||
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_writes_snapshot_and_view_state() {
|
||||
let dir = temp_dir("close");
|
||||
@@ -121,6 +172,12 @@ fn new_library_without_seed_is_empty_and_compacts() {
|
||||
let info = s.execute("library.info", &json!({})).unwrap();
|
||||
assert_eq!(info["logRecords"], 0);
|
||||
assert_eq!(info["snapshotSeq"], info["seq"]);
|
||||
// persistence timings (issue #37): the append and the compaction were measured
|
||||
let p = &info["persistence"];
|
||||
assert!(p["appends"].as_u64() >= Some(1), "{p}");
|
||||
assert!(p["snapshots"].as_u64() >= Some(1), "{p}");
|
||||
assert!(p["lastSnapshot"]["bytes"].as_u64() > Some(0), "{p}");
|
||||
assert!(p["lastSnapshot"]["totalMs"].as_f64() >= p["lastSnapshot"]["serializeMs"].as_f64(), "{p}");
|
||||
let s2 = open(&dir, true);
|
||||
assert_eq!(s2.catalog.len(), 0, "an existing library is never seeded");
|
||||
assert_eq!(s2.catalog.albums().count(), 1);
|
||||
|
||||
@@ -497,3 +497,82 @@ fn sidecar_capture_time_fills_in_when_the_file_has_none() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Copyright status, rights usage terms and copyright info URL: edited with `photo.setMeta` (one
|
||||
/// undo step), copied / pasted, kept in metadata presets, written to the sidecar as XMP Rights
|
||||
/// Management fields and read back on a fresh import.
|
||||
#[test]
|
||||
fn copyright_status_usage_terms_and_url_round_trip() {
|
||||
use lightcraft_catalog::CopyrightStatus;
|
||||
let src = temp_dir("rights-src");
|
||||
let lib = temp_dir("rights-lib");
|
||||
write_png(&src.join("a.png"), 1);
|
||||
write_png(&src.join("b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||||
let (a, b) = (id(&s, "a.png"), id(&s, "b.png"));
|
||||
let meta = |s: &Session, id: PhotoId| s.catalog.photo(id).unwrap().meta.clone();
|
||||
assert_eq!(meta(&s, a).copyright_status, CopyrightStatus::Unknown);
|
||||
s.execute("library.select", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute(
|
||||
"photo.setMeta",
|
||||
&json!({"copyright": "© 2026 A. Person", "copyrightStatus": "copyrighted", "usageTerms": "Editorial use only", "copyrightUrl": "https://example.com/rights"}),
|
||||
)
|
||||
.unwrap();
|
||||
let m = meta(&s, a);
|
||||
assert_eq!(
|
||||
(m.copyright_status, m.usage_terms.as_str(), m.copyright_url.as_str()),
|
||||
(CopyrightStatus::Copyrighted, "Editorial use only", "https://example.com/rights")
|
||||
);
|
||||
assert!(s.execute("photo.setMeta", &json!({"copyrightStatus": "maybe"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(meta(&s, a).copyright_status, CopyrightStatus::Unknown, "one undo step");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
// copy → paste onto b
|
||||
let clip = s.execute("photo.copyMetadata", &json!({})).unwrap();
|
||||
assert_eq!(clip["copyrightStatus"], "copyrighted");
|
||||
s.execute("photo.pasteMetadata", &json!({"ids": [b.0], "fields": ["copyrightStatus", "usageTerms"]})).unwrap();
|
||||
let mb = meta(&s, b);
|
||||
assert_eq!((mb.copyright_status, mb.usage_terms.as_str(), mb.copyright_url.as_str()), (CopyrightStatus::Copyrighted, "Editorial use only", ""));
|
||||
// a metadata preset from the active photo carries the copyright fields; applying it sets them
|
||||
s.execute("metadata.savePreset", &json!({"name": "Rights"})).unwrap();
|
||||
let presets = s.execute("metadata.presets", &json!({})).unwrap();
|
||||
assert_eq!(presets[0]["fields"]["copyrightStatus"], "copyrighted", "{presets}");
|
||||
assert_eq!(presets[0]["fields"]["copyrightUrl"], "https://example.com/rights");
|
||||
s.execute("photo.setMeta", &json!({"ids": [b.0], "copyrightStatus": "publicDomain", "usageTerms": ""})).unwrap();
|
||||
s.execute("metadata.applyPreset", &json!({"name": "Rights", "ids": [b.0]})).unwrap();
|
||||
let mb = meta(&s, b);
|
||||
assert_eq!((mb.copyright_status, mb.copyright_url.as_str()), (CopyrightStatus::Copyrighted, "https://example.com/rights"));
|
||||
assert!(s.execute("metadata.savePreset", &json!({"name": "Bad", "fields": {"copyrightStatus": "sort of"}})).is_err());
|
||||
// b becomes public domain; smart-album rule on the status
|
||||
s.execute("photo.setMeta", &json!({"ids": [b.0], "copyrightStatus": "public domain"})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
&json!({"name": "PD", "rules": {"ruleSet": {"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]}}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
// sidecars: XMP Rights Management fields, read back by a fresh library
|
||||
s.execute("photo.saveMetadataToFile", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let xmp = std::fs::read_to_string(src.join("a.xmp")).unwrap();
|
||||
assert!(xmp.contains("<xmpRights:Marked>True</xmpRights:Marked>"), "{xmp}");
|
||||
assert!(xmp.contains("<xmpRights:WebStatement>https://example.com/rights</xmpRights:WebStatement>"), "{xmp}");
|
||||
assert!(std::fs::read_to_string(src.join("b.xmp")).unwrap().contains("<xmpRights:Marked>False</xmpRights:Marked>"));
|
||||
let expect_a = meta(&s, a);
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(&lib).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let ma = meta(&s, id(&s, "a.png"));
|
||||
assert_eq!(
|
||||
(ma.copyright.as_str(), ma.copyright_status, ma.usage_terms.as_str(), ma.copyright_url.as_str()),
|
||||
(expect_a.copyright.as_str(), CopyrightStatus::Copyrighted, "Editorial use only", "https://example.com/rights")
|
||||
);
|
||||
assert_eq!(meta(&s, id(&s, "b.png")).copyright_status, CopyrightStatus::PublicDomain);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! GPS track logs in GPX 1.0 / 1.1 (the open GPS Exchange Format, <https://www.topografix.com/gpx.asp>):
|
||||
//! the timed track points of every `<trk>`, and the position at any moment for geotagging photos.
|
||||
//!
|
||||
//! What we use from the format: `<trk>` → `<trkseg>` → `<trkpt lat=… lon=…>` with optional `<ele>`
|
||||
//! (metres) and `<time>` (an `xsd:dateTime`, UTC per the schema — a time without a zone is read as
|
||||
//! UTC). Points without a time can't be matched to a photo and are left out; waypoints (`<wpt>`) and
|
||||
//! routes (`<rte>`) carry no timeline and are ignored. The schema defines a track segment as one
|
||||
//! continuous span of logging, so positions are only interpolated *within* a segment, never across
|
||||
//! the gap where reception was lost or the logger was off.
|
||||
|
||||
use quick_xml::events::Event;
|
||||
|
||||
use crate::{DateTime, Gps};
|
||||
|
||||
/// Nesting / size limits (a track log is flat; these only stop hostile input).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_POINTS: usize = 5_000_000;
|
||||
|
||||
/// One timed point of a track.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TrackPoint {
|
||||
/// Seconds since 1970-01-01T00:00:00Z (fractional when the log has sub-second times).
|
||||
pub time: f64,
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
/// Metres above sea level.
|
||||
pub elevation: Option<f64>,
|
||||
/// Track segment number, counted across the whole file (continuous logging spans).
|
||||
pub segment: u32,
|
||||
}
|
||||
|
||||
/// How a position was found by [`Tracklog::locate`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Match {
|
||||
/// The time falls between two points of the same segment: linear interpolation.
|
||||
Interpolated,
|
||||
/// The nearest point (at a segment's start or end, or across a gap).
|
||||
Nearest,
|
||||
}
|
||||
|
||||
/// The timed points of a GPX file, sorted by time.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Tracklog {
|
||||
pub points: Vec<TrackPoint>,
|
||||
/// Tracks (`<trk>`) in the file.
|
||||
pub tracks: usize,
|
||||
/// Track points without a usable time (left out of `points`).
|
||||
pub untimed: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum GpxError {
|
||||
Xml(String),
|
||||
/// Not a GPX document (no `<gpx>` root).
|
||||
NotGpx,
|
||||
/// Nesting / size limits exceeded.
|
||||
TooComplex,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GpxError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
GpxError::Xml(e) => write!(f, "malformed GPX: {e}"),
|
||||
GpxError::NotGpx => f.write_str("not a GPX file (no <gpx> element)"),
|
||||
GpxError::TooComplex => f.write_str("GPX file too large or too deeply nested"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for GpxError {}
|
||||
|
||||
/// Local name of a possibly prefixed XML name (`gpx:trkpt` → `trkpt`).
|
||||
fn local(name: &[u8]) -> &[u8] {
|
||||
name.rsplit(|&b| b == b':').next().unwrap_or(name)
|
||||
}
|
||||
|
||||
/// An `xsd:dateTime` as seconds since the Unix epoch; no zone = UTC (the GPX schema's convention).
|
||||
fn parse_time(s: &str) -> Option<f64> {
|
||||
let mut d = DateTime::parse_iso(s)?;
|
||||
// a bare date (no time of day) is too coarse to place a photo
|
||||
if !s.contains('T') {
|
||||
return None;
|
||||
}
|
||||
d.offset_minutes.get_or_insert(0);
|
||||
Some(d.unix_seconds() as f64 + d.millis as f64 / 1000.0)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Pending {
|
||||
lat: Option<f64>,
|
||||
lon: Option<f64>,
|
||||
ele: Option<f64>,
|
||||
time: Option<f64>,
|
||||
}
|
||||
|
||||
/// Parse a GPX document.
|
||||
pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
|
||||
let mut reader = quick_xml::Reader::from_str(s);
|
||||
reader.config_mut().check_end_names = false;
|
||||
let mut path: Vec<Vec<u8>> = Vec::new();
|
||||
let mut seen_gpx = false;
|
||||
let mut log = Tracklog::default();
|
||||
let mut segment: u32 = 0;
|
||||
let mut pt: Option<Pending> = None;
|
||||
let mut text = String::new();
|
||||
loop {
|
||||
let ev = reader.read_event().map_err(|e| GpxError::Xml(e.to_string()))?;
|
||||
match ev {
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
let name = local(e.name().as_ref()).to_vec();
|
||||
let parent = path.last().map(Vec::as_slice);
|
||||
match name.as_slice() {
|
||||
b"gpx" if path.is_empty() => seen_gpx = true,
|
||||
b"trk" if parent == Some(b"gpx") => log.tracks += 1,
|
||||
b"trkseg" if parent == Some(b"trk") => {
|
||||
if log.points.last().is_some_and(|p| p.segment == segment) {
|
||||
segment += 1;
|
||||
}
|
||||
}
|
||||
b"trkpt" if parent == Some(b"trkseg") => {
|
||||
let mut p = Pending::default();
|
||||
for a in e.attributes().with_checks(false).flatten() {
|
||||
let v = String::from_utf8_lossy(&a.value);
|
||||
match local(a.key.as_ref()) {
|
||||
b"lat" => p.lat = v.trim().parse().ok(),
|
||||
b"lon" => p.lon = v.trim().parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pt = Some(p);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
text.clear();
|
||||
if matches!(ev, Event::Start(_)) {
|
||||
if path.len() >= MAX_DEPTH {
|
||||
return Err(GpxError::TooComplex);
|
||||
}
|
||||
path.push(name);
|
||||
} else if name == b"trkpt" {
|
||||
finish_point(&mut log, pt.take(), segment)?;
|
||||
}
|
||||
}
|
||||
Event::Text(t) => text.push_str(&t.decode().map_err(|e| GpxError::Xml(e.to_string()))?),
|
||||
Event::CData(t) => text.push_str(&String::from_utf8_lossy(&t)),
|
||||
Event::End(_) => {
|
||||
let Some(name) = path.pop() else { continue };
|
||||
let parent = path.last().map(Vec::as_slice);
|
||||
match name.as_slice() {
|
||||
b"ele" if parent == Some(b"trkpt") => {
|
||||
if let Some(p) = pt.as_mut() {
|
||||
p.ele = text.trim().parse().ok();
|
||||
}
|
||||
}
|
||||
b"time" if parent == Some(b"trkpt") => {
|
||||
if let Some(p) = pt.as_mut() {
|
||||
p.time = parse_time(text.trim());
|
||||
}
|
||||
}
|
||||
b"trkpt" => finish_point(&mut log, pt.take(), segment)?,
|
||||
_ => {}
|
||||
}
|
||||
text.clear();
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !seen_gpx {
|
||||
return Err(GpxError::NotGpx);
|
||||
}
|
||||
// stable: points logged at the same second keep their file order
|
||||
log.points.sort_by(|a, b| a.time.total_cmp(&b.time));
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
fn finish_point(log: &mut Tracklog, p: Option<Pending>, segment: u32) -> Result<(), GpxError> {
|
||||
let Some(p) = p else { return Ok(()) };
|
||||
let (Some(lat), Some(lon)) = (p.lat, p.lon) else { return Ok(()) };
|
||||
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(time) = p.time else {
|
||||
log.untimed += 1;
|
||||
return Ok(());
|
||||
};
|
||||
if log.points.len() >= MAX_POINTS {
|
||||
return Err(GpxError::TooComplex);
|
||||
}
|
||||
log.points.push(TrackPoint { time, latitude: lat, longitude: lon, elevation: p.ele, segment });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Tracklog {
|
||||
/// First and last point times (seconds since the epoch, UTC).
|
||||
pub fn span(&self) -> Option<(f64, f64)> {
|
||||
Some((self.points.first()?.time, self.points.last()?.time))
|
||||
}
|
||||
|
||||
/// Where the logger was at `time` (seconds since the epoch, UTC).
|
||||
///
|
||||
/// Between two points of the same segment that are at most `max_gap` seconds apart, the position
|
||||
/// is interpolated linearly in time. Otherwise the nearest point counts if it is within `max_gap`
|
||||
/// seconds; beyond that there is no position (the photo was taken while nothing was logged).
|
||||
pub fn locate(&self, time: f64, max_gap: f64) -> Option<(Gps, Match)> {
|
||||
let pts = &self.points;
|
||||
if pts.is_empty() || !time.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let i = pts.partition_point(|p| p.time < time);
|
||||
let next = pts.get(i);
|
||||
let prev = i.checked_sub(1).and_then(|j| pts.get(j));
|
||||
if let Some(n) = next
|
||||
&& n.time == time
|
||||
{
|
||||
return Some((gps(n), Match::Interpolated));
|
||||
}
|
||||
if let (Some(a), Some(b)) = (prev, next)
|
||||
&& a.segment == b.segment
|
||||
&& b.time - a.time <= max_gap
|
||||
{
|
||||
let t = (time - a.time) / (b.time - a.time);
|
||||
return Some((lerp(a, b, t), Match::Interpolated));
|
||||
}
|
||||
let near = match (prev, next) {
|
||||
(Some(a), Some(b)) => {
|
||||
if time - a.time <= b.time - time {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
(Some(a), None) => a,
|
||||
(None, Some(b)) => b,
|
||||
(None, None) => return None,
|
||||
};
|
||||
((near.time - time).abs() <= max_gap).then(|| (gps(near), Match::Nearest))
|
||||
}
|
||||
}
|
||||
|
||||
fn gps(p: &TrackPoint) -> Gps {
|
||||
Gps { latitude: p.latitude, longitude: p.longitude, altitude: p.elevation }
|
||||
}
|
||||
|
||||
/// Linear interpolation, taking the short way across the ±180° meridian.
|
||||
fn lerp(a: &TrackPoint, b: &TrackPoint, t: f64) -> Gps {
|
||||
let mut dlon = b.longitude - a.longitude;
|
||||
if dlon > 180.0 {
|
||||
dlon -= 360.0;
|
||||
} else if dlon < -180.0 {
|
||||
dlon += 360.0;
|
||||
}
|
||||
let mut lon = a.longitude + dlon * t;
|
||||
if lon > 180.0 {
|
||||
lon -= 360.0;
|
||||
} else if lon < -180.0 {
|
||||
lon += 360.0;
|
||||
}
|
||||
let altitude = match (a.elevation, b.elevation) {
|
||||
(Some(x), Some(y)) => Some(x + (y - x) * t),
|
||||
(x, y) => x.or(y),
|
||||
};
|
||||
Gps { latitude: a.latitude + (b.latitude - a.latitude) * t, longitude: lon, altitude }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const LOG: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<wpt lat="1.0" lon="1.0"><time>2026-05-01T10:00:30Z</time></wpt>
|
||||
<trk><name>Walk</name>
|
||||
<trkseg>
|
||||
<trkpt lat="46.0000" lon="7.0000"><ele>1600</ele><time>2026-05-01T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="46.0010" lon="7.0020"><ele>1610</ele><time>2026-05-01T10:01:00Z</time></trkpt>
|
||||
<trkpt lat="46.0020" lon="7.0040"><time>2026-05-01T10:02:00.500Z</time></trkpt>
|
||||
<trkpt lat="46.5" lon="7.5"/>
|
||||
</trkseg>
|
||||
<trkseg>
|
||||
<trkpt lat="47.0000" lon="8.0000"><time>2026-05-01T12:00:00Z</time></trkpt>
|
||||
<trkpt lat="47.0010" lon="8.0010"><time>2026-05-01T12:00:10+00:00</time></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>"#;
|
||||
|
||||
fn t(s: &str) -> f64 {
|
||||
parse_time(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_timed_track_points() {
|
||||
let log = parse_gpx(LOG).unwrap();
|
||||
assert_eq!(log.tracks, 1);
|
||||
assert_eq!(log.points.len(), 5, "waypoints and the untimed point are left out");
|
||||
assert_eq!(log.untimed, 1);
|
||||
assert_eq!(log.points[0].elevation, Some(1600.0));
|
||||
assert_eq!(log.points[2].time, t("2026-05-01T10:02:00Z") + 0.5);
|
||||
assert_eq!((log.points[0].segment, log.points[2].segment, log.points[3].segment), (0, 0, 1));
|
||||
assert_eq!(log.span(), Some((t("2026-05-01T10:00:00Z"), t("2026-05-01T12:00:10Z"))));
|
||||
// 2026-05-01T10:00:00Z
|
||||
assert_eq!(log.points[0].time, 1_777_629_600.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolates_within_a_segment() {
|
||||
let log = parse_gpx(LOG).unwrap();
|
||||
let (g, m) = log.locate(t("2026-05-01T10:00:30Z"), 300.0).unwrap();
|
||||
assert_eq!(m, Match::Interpolated);
|
||||
assert!((g.latitude - 46.0005).abs() < 1e-9 && (g.longitude - 7.0010).abs() < 1e-9, "{g:?}");
|
||||
assert_eq!(g.altitude, Some(1605.0));
|
||||
// exactly on a point
|
||||
let (g, _) = log.locate(t("2026-05-01T10:01:00Z"), 300.0).unwrap();
|
||||
assert_eq!((g.latitude, g.longitude), (46.0010, 7.0020));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_interpolates_across_segments() {
|
||||
let log = parse_gpx(LOG).unwrap();
|
||||
// between the segments (10:02 … 12:00): only near either end
|
||||
let (g, m) = log.locate(t("2026-05-01T10:04:00Z"), 300.0).unwrap();
|
||||
assert_eq!((m, g.latitude), (Match::Nearest, 46.0020));
|
||||
let (g, m) = log.locate(t("2026-05-01T11:58:00Z"), 300.0).unwrap();
|
||||
assert_eq!((m, g.latitude), (Match::Nearest, 47.0));
|
||||
assert!(log.locate(t("2026-05-01T11:00:00Z"), 300.0).is_none(), "an hour from any point");
|
||||
// before the first and after the last point
|
||||
assert!(log.locate(t("2026-05-01T09:59:00Z"), 300.0).is_some());
|
||||
assert!(log.locate(t("2026-05-01T09:50:00Z"), 300.0).is_none());
|
||||
assert!(log.locate(t("2026-05-01T12:10:00Z"), 300.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_gap_limits_interpolation() {
|
||||
let log = parse_gpx(LOG).unwrap();
|
||||
// the points are 60 s apart: with a 20 s limit, only the nearest point within 20 s counts
|
||||
assert!(log.locate(t("2026-05-01T10:00:30Z"), 20.0).is_none());
|
||||
let (g, m) = log.locate(t("2026-05-01T10:00:50Z"), 20.0).unwrap();
|
||||
assert_eq!((m, g.latitude), (Match::Nearest, 46.0010));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpx_1_0_prefixes_and_zones() {
|
||||
let s = r#"<gpx:gpx version="1.0" xmlns:gpx="http://www.topografix.com/GPX/1/0"><gpx:trk><gpx:trkseg>
|
||||
<gpx:trkpt lat="-33.9" lon="151.2"><gpx:time>2026-01-02T10:00:00+11:00</gpx:time></gpx:trkpt>
|
||||
<gpx:trkpt lat="-33.8" lon="151.3"><gpx:time>2026-01-01T23:01:00</gpx:time></gpx:trkpt>
|
||||
</gpx:trkseg></gpx:trk></gpx:gpx>"#;
|
||||
let log = parse_gpx(s).unwrap();
|
||||
assert_eq!(log.points.len(), 2);
|
||||
// +11:00 is converted to UTC; no zone = UTC
|
||||
assert_eq!(log.points[1].time - log.points[0].time, 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolation_across_the_antimeridian() {
|
||||
let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0 };
|
||||
let b = TrackPoint { time: 10.0, latitude: 0.0, longitude: -179.0, elevation: None, segment: 0 };
|
||||
let g = lerp(&a, &b, 0.75);
|
||||
assert!((g.longitude - -179.5).abs() < 1e-9, "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_gpx_and_bad_input() {
|
||||
assert_eq!(parse_gpx("<kml><Document/></kml>"), Err(GpxError::NotGpx));
|
||||
assert!(parse_gpx("").is_err());
|
||||
assert!(
|
||||
parse_gpx("<gpx><trk><trkseg><trkpt lat=\"x\" lon=\"1\"><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk></gpx>")
|
||||
.unwrap()
|
||||
.points
|
||||
.is_empty()
|
||||
);
|
||||
let deep = "<gpx>".to_string() + &"<a>".repeat(100);
|
||||
assert_eq!(parse_gpx(&deep), Err(GpxError::TooComplex));
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -6,12 +6,14 @@
|
||||
//! - [`embedded`] / [`jpeg_segments`] / [`png_chunks`] / [`webp_chunks`] — locate Exif / XMP / ICC / IPTC blocks.
|
||||
//! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON).
|
||||
//! - [`parse_iptc`] — IPTC-IIM record 2 datasets.
|
||||
//! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time.
|
||||
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod container;
|
||||
mod datetime;
|
||||
mod exif;
|
||||
mod gpx;
|
||||
mod iptc;
|
||||
pub mod tags;
|
||||
mod xmp;
|
||||
@@ -19,6 +21,7 @@ mod xmp;
|
||||
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
|
||||
pub use datetime::DateTime;
|
||||
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
|
||||
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
|
||||
pub use iptc::parse_iptc;
|
||||
pub use lightcraft_geom::Orientation;
|
||||
pub use tags::{TagRow, file_tag_rows, tag_rows};
|
||||
@@ -83,6 +86,12 @@ pub struct Metadata {
|
||||
// descriptive / user
|
||||
pub artist: Option<String>,
|
||||
pub copyright: Option<String>,
|
||||
/// Copyright status (`xmpRights:Marked`): `true` = copyrighted, `false` = public domain, `None` = unknown.
|
||||
pub copyright_marked: Option<bool>,
|
||||
/// Rights usage terms (`xmpRights:UsageTerms`).
|
||||
pub usage_terms: Option<String>,
|
||||
/// Copyright info URL (`xmpRights:WebStatement`).
|
||||
pub copyright_url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub caption: Option<String>,
|
||||
/// Accessibility text (`Iptc4xmpCore:AltTextAccessibility`).
|
||||
@@ -142,6 +151,9 @@ impl Metadata {
|
||||
height,
|
||||
artist,
|
||||
copyright,
|
||||
copyright_marked,
|
||||
usage_terms,
|
||||
copyright_url,
|
||||
title,
|
||||
caption,
|
||||
alt_text,
|
||||
@@ -164,7 +176,7 @@ impl Metadata {
|
||||
/// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with
|
||||
/// `other`'s where `other` has them — the XMP-over-EXIF precedence rule.
|
||||
pub fn overlay_user_fields(&mut self, other: &Metadata) {
|
||||
overlay!(self, other, rating, label, title, caption, artist, copyright, gps);
|
||||
overlay!(self, other, rating, label, title, caption, artist, copyright, copyright_marked, usage_terms, copyright_url, gps);
|
||||
if !other.keywords.is_empty() {
|
||||
self.keywords = other.keywords.clone();
|
||||
}
|
||||
|
||||
@@ -420,6 +420,13 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
|
||||
state: first("photoshop:State"),
|
||||
country: first("photoshop:Country"),
|
||||
copyright: first("dc:rights"),
|
||||
copyright_marked: first("xmpRights:Marked").and_then(|s| match s.to_ascii_lowercase().as_str() {
|
||||
"true" => Some(true),
|
||||
"false" => Some(false),
|
||||
_ => None,
|
||||
}),
|
||||
usage_terms: first("xmpRights:UsageTerms"),
|
||||
copyright_url: first("xmpRights:WebStatement"),
|
||||
rating: num("xmp:Rating").map(|v| v.round().clamp(-1.0, 5.0) as i8),
|
||||
label: first("xmp:Label"),
|
||||
..Default::default()
|
||||
@@ -519,6 +526,8 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String {
|
||||
push("photoshop:City", meta.city.clone());
|
||||
push("photoshop:State", meta.state.clone());
|
||||
push("photoshop:Country", meta.country.clone());
|
||||
push("xmpRights:Marked", meta.copyright_marked.map(|m| if m { "True" } else { "False" }.to_string()));
|
||||
push("xmpRights:WebStatement", meta.copyright_url.clone());
|
||||
if let Some(g) = meta.gps {
|
||||
push("exif:GPSLatitude", Some(fmt_gps_coord(g.latitude, 'N', 'S')));
|
||||
push("exif:GPSLongitude", Some(fmt_gps_coord(g.longitude, 'E', 'W')));
|
||||
@@ -566,6 +575,7 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String {
|
||||
array(&mut x, "Iptc4xmpCore:AltTextAccessibility", "Alt", &one(&meta.alt_text), true);
|
||||
array(&mut x, "Iptc4xmpCore:ExtDescrAccessibility", "Alt", &one(&meta.extended_description), true);
|
||||
array(&mut x, "dc:rights", "Alt", &one(&meta.copyright), true);
|
||||
array(&mut x, "xmpRights:UsageTerms", "Alt", &one(&meta.usage_terms), true);
|
||||
array(&mut x, "dc:creator", "Seq", &one(&meta.artist), false);
|
||||
array(&mut x, "dc:subject", "Bag", &meta.keywords, false);
|
||||
array(&mut x, "lr:hierarchicalSubject", "Bag", &meta.hierarchical_keywords, false);
|
||||
@@ -620,6 +630,9 @@ mod tests {
|
||||
height: Some(6000),
|
||||
artist: Some("Ann \"Photo\" Lee".into()),
|
||||
copyright: Some("© 2022".into()),
|
||||
copyright_marked: Some(true),
|
||||
usage_terms: Some("Editorial use only; no <resale>".into()),
|
||||
copyright_url: Some("https://example.com/licence?a=1&b=2".into()),
|
||||
title: Some("Tïtle".into()),
|
||||
caption: Some("Line one\nline two".into()),
|
||||
alt_text: Some("A tree by the sea".into()),
|
||||
@@ -683,6 +696,32 @@ mod tests {
|
||||
assert_eq!(d.properties["dc:title"], vec!["Title & more é".to_string(), "Titel".to_string()]);
|
||||
}
|
||||
|
||||
/// Copyright status, usage terms and info URL (XMP Rights Management schema): written as
|
||||
/// `xmpRights:Marked` (Boolean), `xmpRights:UsageTerms` (Lang Alt) and `xmpRights:WebStatement` (URI).
|
||||
#[test]
|
||||
fn rights_management_fields() {
|
||||
let m = Metadata { copyright_marked: Some(false), usage_terms: Some("CC0".into()), ..Default::default() };
|
||||
let x = write_xmp(&m, None);
|
||||
assert!(x.contains("<xmpRights:Marked>False</xmpRights:Marked>"), "{x}");
|
||||
assert!(x.contains("<xmpRights:UsageTerms>\n <rdf:Alt>\n <rdf:li xml:lang=\"x-default\">CC0</rdf:li>"), "{x}");
|
||||
assert!(!x.contains("WebStatement"), "unset fields are left out");
|
||||
assert_eq!(parse_xmp(&x).unwrap().metadata, m);
|
||||
// attribute form, other prefixes; anything but True / False is "unknown"
|
||||
let read = |marked: &str| {
|
||||
let x = format!(
|
||||
r#"<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:r="http://ns.adobe.com/xap/1.0/rights/" r:Marked="{marked}" r:WebStatement="https://example.org/c"/>
|
||||
</rdf:RDF></x:xmpmeta>"#
|
||||
);
|
||||
parse_xmp(&x).unwrap().metadata
|
||||
};
|
||||
assert_eq!(read("True").copyright_marked, Some(true));
|
||||
assert_eq!(read("false").copyright_marked, Some(false));
|
||||
assert_eq!(read("").copyright_marked, None);
|
||||
assert_eq!(read("maybe").copyright_marked, None);
|
||||
assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_inputs() {
|
||||
assert!(parse_xmp("<a><b></a>").is_ok() || parse_xmp("<a><b></a>").is_err());
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Unchanged-frame cost of the photo grid (issue #35), on synthetic libraries:
|
||||
//! `cargo run --release -p lightcraft-ui-egui --example grid_bench`
|
||||
//! (`SIZES=200,10000,85000` photos, `FRAMES=60` measured frames per scenario).
|
||||
//!
|
||||
//! Each scenario builds a library of N photos (mixed aspect ratios, ~40 photos per capture day,
|
||||
//! a three-photo stack every 100 photos), shows it in the Photo Grid (justified rows) or the
|
||||
//! Square Grid, with All Photos or a recursive Local folder view as the source, settles (missing
|
||||
//! files: thumbnails fail fast, so no decoding is timed), scrolls to the middle and then times
|
||||
//! frames in which nothing changes. Prints per-frame wall-clock (min / median / p95 of the whole
|
||||
//! app frame and of the grid panel alone), heap allocations per frame on the UI thread, and the
|
||||
//! grid's own counters (cells visited, layout / view / index rebuilds).
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::cell::Cell;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source, Stack, StackId};
|
||||
use lightcraft_engine::{Browse, LibrarySource, Session};
|
||||
use lightcraft_ui_egui::headless::Headless;
|
||||
use lightcraft_ui_egui::state::ViewMode;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services};
|
||||
|
||||
/// Counts allocations made by the current thread (the UI thread is the one measured).
|
||||
struct Counting;
|
||||
|
||||
thread_local! {
|
||||
static ALLOCS: Cell<u64> = const { Cell::new(0) };
|
||||
static BYTES: Cell<u64> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
fn count(size: usize) {
|
||||
let _ = ALLOCS.try_with(|n| n.set(n.get() + 1));
|
||||
let _ = BYTES.try_with(|n| n.set(n.get() + size as u64));
|
||||
}
|
||||
|
||||
// SAFETY: every call is forwarded unchanged to the system allocator; the counters are plain
|
||||
// thread-locals that never allocate. Dev-only example.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe impl GlobalAlloc for Counting {
|
||||
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
|
||||
count(l.size());
|
||||
// SAFETY: same contract as ours.
|
||||
unsafe { System.alloc(l) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 {
|
||||
count(l.size());
|
||||
// SAFETY: same contract as ours.
|
||||
unsafe { System.alloc_zeroed(l) }
|
||||
}
|
||||
unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 {
|
||||
count(new);
|
||||
// SAFETY: same contract as ours.
|
||||
unsafe { System.realloc(p, l, new) }
|
||||
}
|
||||
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
|
||||
// SAFETY: same contract as ours.
|
||||
unsafe { System.dealloc(p, l) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static A: Counting = Counting;
|
||||
|
||||
fn allocs() -> (u64, u64) {
|
||||
(ALLOCS.with(Cell::get), BYTES.with(Cell::get))
|
||||
}
|
||||
|
||||
const ROOT: &str = "/lightcraft-grid-bench/root";
|
||||
|
||||
fn library(n: usize, local: bool) -> Session {
|
||||
let mut s = Session::new();
|
||||
let shapes = [(6000, 4000), (4000, 6000), (4000, 4000), (6000, 3375), (3000, 4000)];
|
||||
for i in 0..n {
|
||||
let id = PhotoId(i as u64 + 1);
|
||||
let (w, h) = shapes[i % shapes.len()];
|
||||
let path = format!("{ROOT}/sub{}/IMG_{i:06}.jpg", i / 1000);
|
||||
let mut p = Photo::new(id, Source::File { path }, &format!("IMG_{i:06}.jpg"), "JPEG", w, h, "2026-01-01T00:00:00");
|
||||
// ~40 photos a day, going back in time
|
||||
let day = i / 40;
|
||||
let (y, m, d) = (2026 - (day / 336) as i32, 12 - (day / 28) % 12, 28 - day % 28);
|
||||
p.captured = Some(format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:00", 8 + (i % 40) / 4, i % 60));
|
||||
p.local = local;
|
||||
let _ = s.catalog.apply(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
for k in 0..n / 100 {
|
||||
let base = k as u64 * 100 + 1;
|
||||
let stack = Stack { id: StackId(k as u64 + 1), photos: vec![PhotoId(base), PhotoId(base + 1), PhotoId(base + 2)], collapsed: false };
|
||||
let _ = s.catalog.apply(Op::AddStack { stack });
|
||||
}
|
||||
if local {
|
||||
s.source = LibrarySource::Folder;
|
||||
s.browse = Some(Browse { path: ROOT.into(), subfolders: true });
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
struct Dist {
|
||||
min: f64,
|
||||
med: f64,
|
||||
p95: f64,
|
||||
}
|
||||
|
||||
fn dist(mut v: Vec<f64>) -> Dist {
|
||||
v.sort_by(f64::total_cmp);
|
||||
let at = |q: f64| v.get(((v.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0);
|
||||
Dist { min: at(0.0), med: at(0.5), p95: at(0.95) }
|
||||
}
|
||||
|
||||
fn scenario(n: usize, view: ViewMode, local: bool, frames: usize) {
|
||||
let t0 = Instant::now();
|
||||
let session = library(n, local);
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(session, services);
|
||||
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
||||
h.app.ui.view = view;
|
||||
h.app.ui.thumb_size = 160.0;
|
||||
h.settle(Duration::from_secs(120));
|
||||
// to the middle of the grid (and let the newly visible thumbnails fail / settle)
|
||||
if let Some(mid) = h.app.session.visible().get(n / 2).copied() {
|
||||
h.app.session.selection.active = Some(mid);
|
||||
h.app.session.selection.ids = vec![mid];
|
||||
}
|
||||
h.settle(Duration::from_secs(120));
|
||||
let setup = t0.elapsed();
|
||||
let stats0 = h.app.caches.grid_stats.clone();
|
||||
let mut frame_ms = Vec::with_capacity(frames);
|
||||
let mut grid_ms = Vec::with_capacity(frames);
|
||||
let (a0, b0) = allocs();
|
||||
for _ in 0..frames {
|
||||
let t = Instant::now();
|
||||
h.step();
|
||||
frame_ms.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
grid_ms.push(h.app.caches.grid_stats.last_show.as_secs_f64() * 1e3);
|
||||
}
|
||||
let (a1, b1) = allocs();
|
||||
let st = &h.app.caches.grid_stats;
|
||||
let f = dist(frame_ms);
|
||||
let g = dist(grid_ms);
|
||||
let name = format!("{n:>6} {:<7} {:<5}", if view == ViewMode::SquareGrid { "square" } else { "photo" }, if local { "local" } else { "all" });
|
||||
println!(
|
||||
"{name} frame {:>7.2} / {:>7.2} / {:>7.2} ms grid {:>7.2} / {:>7.2} / {:>7.2} ms allocs/frame {:>7} ({:>9} B) cells/frame {:>6} rebuilds view {} layout {} stacks {} (setup {:.1} s)",
|
||||
f.min,
|
||||
f.med,
|
||||
f.p95,
|
||||
g.min,
|
||||
g.med,
|
||||
g.p95,
|
||||
(a1 - a0) / frames as u64,
|
||||
(b1 - b0) / frames as u64,
|
||||
(st.cells_visited - stats0.cells_visited) / frames as u64,
|
||||
st.view_builds - stats0.view_builds,
|
||||
st.layout_builds - stats0.layout_builds,
|
||||
st.stack_index_builds - stats0.stack_index_builds,
|
||||
setup.as_secs_f64(),
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let sizes: Vec<usize> = std::env::var("SIZES")
|
||||
.ok()
|
||||
.map(|s| s.split(',').filter_map(|x| x.trim().parse().ok()).collect())
|
||||
.unwrap_or_else(|| vec![200, 10_000, 85_000]);
|
||||
let frames = std::env::var("FRAMES").ok().and_then(|s| s.parse().ok()).unwrap_or(60);
|
||||
println!("photos layout source frame min / median / p95 grid min / median / p95");
|
||||
for &n in &sizes {
|
||||
for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] {
|
||||
for local in [false, true] {
|
||||
scenario(n, view, local, frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
||||
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
|
||||
"activeMask": app.session.active_mask,
|
||||
"widgetCount": app.widgets.len(),
|
||||
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()},
|
||||
"perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()},
|
||||
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
|
||||
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
|
||||
"status": app.ui.status,
|
||||
|
||||
@@ -735,7 +735,7 @@ mod tests {
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
|
||||
h.settle(SETTLE);
|
||||
for (key, text) in [("altText", "A lake at dawn"), ("city", "Zermatt")] {
|
||||
for (key, text) in [("altText", "A lake at dawn"), ("usageTerms", "Editorial use only"), ("city", "Zermatt")] {
|
||||
let r = h.request("ui.clickWidget", json!({"id": format!("field:{key}")}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.request("ui.key", json!({"key": "A", "cmd": true}), t);
|
||||
@@ -748,6 +748,10 @@ mod tests {
|
||||
}
|
||||
let m = &h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta;
|
||||
assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt"));
|
||||
assert_eq!(m.usage_terms, "Editorial use only");
|
||||
// the copyright status picker is on the panel too
|
||||
let w = h.request("ui.widgets", json!({"filter": "copyrightStatus"}), t);
|
||||
assert!(w["result"].to_string().contains("field:copyrightStatus"), "{w}");
|
||||
}
|
||||
|
||||
/// Local: a folder's photos show without joining the library; the breadcrumb, Include
|
||||
|
||||
+31
-23
@@ -26,6 +26,8 @@ pub mod widgets;
|
||||
#[cfg(test)]
|
||||
mod tests_curve;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
#[cfg(test)]
|
||||
mod tests_masking;
|
||||
#[cfg(test)]
|
||||
mod tests_panels;
|
||||
@@ -63,6 +65,8 @@ pub struct Services {
|
||||
pub pick_files: Option<PickFiles>,
|
||||
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
|
||||
pub pick_preset_files: Option<PickFiles>,
|
||||
/// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…).
|
||||
pub pick_tracklog: Option<PickFiles>,
|
||||
/// Save dialog for an exported `.lcpreset` file.
|
||||
pub save_preset_file: Option<SaveFile>,
|
||||
/// Open dialog for point-curve preset files (`.lccurve`).
|
||||
@@ -86,7 +90,15 @@ pub struct Services {
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Perf {
|
||||
/// Layout of the last frame ([`LightcraftApp::ui`], including commands run from widgets).
|
||||
pub frame_ms: f64,
|
||||
/// Per-frame logic before layout ([`LightcraftApp::logic`]: control channel, shortcuts,
|
||||
/// render polling, pending catalog persistence).
|
||||
pub logic_ms: f64,
|
||||
/// The whole update of the last frame: logic + layout.
|
||||
pub update_ms: f64,
|
||||
/// The slowest whole update since start.
|
||||
pub max_update_ms: f64,
|
||||
pub fps: f64,
|
||||
}
|
||||
|
||||
@@ -404,6 +416,12 @@ impl LightcraftApp {
|
||||
|
||||
/// Per-frame logic before layout (control channel, renders, shortcuts, drops).
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
let t0 = now_ms();
|
||||
self.logic_inner(ctx);
|
||||
self.perf.logic_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
fn logic_inner(&mut self, ctx: &egui::Context) {
|
||||
if !self.styled {
|
||||
theme::install_fonts(ctx);
|
||||
theme::apply(ctx);
|
||||
@@ -535,6 +553,13 @@ impl LightcraftApp {
|
||||
raw.events.extend(self.synthetic.drain(..n));
|
||||
}
|
||||
|
||||
/// Frame timings once layout is done (`t0`: when layout started).
|
||||
fn end_frame(&mut self, t0: f64) {
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
self.perf.update_ms = self.perf.logic_ms + self.perf.frame_ms;
|
||||
self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms);
|
||||
}
|
||||
|
||||
/// Lay out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
@@ -555,7 +580,7 @@ impl LightcraftApp {
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
self.widgets = widgets::take_registry(&ctx);
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
self.end_frame(t0);
|
||||
return;
|
||||
}
|
||||
// Order matters: earlier panels take the full edge (top bar spans the window; the tool strip,
|
||||
@@ -594,7 +619,7 @@ impl LightcraftApp {
|
||||
panels::grid::drag_feedback(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
self.widgets = widgets::take_registry(&ctx);
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
self.end_frame(t0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,7 +684,6 @@ mod drop_tests {
|
||||
#[derive(Default)]
|
||||
pub struct Caches {
|
||||
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
|
||||
date_runs: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateRun>>)>,
|
||||
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
|
||||
counts: Option<(u64, LibraryCounts)>,
|
||||
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
|
||||
@@ -667,8 +691,10 @@ pub struct Caches {
|
||||
album_counts: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>,
|
||||
/// How often the album counts were recomputed (tests check that unchanged frames don't).
|
||||
pub album_count_scans: usize,
|
||||
/// The grid's layout (by photos, shapes, width, thumbnail size, grouping).
|
||||
pub grid_layout: Option<(u64, std::sync::Arc<panels::grid::GridLayout>)>,
|
||||
/// The grid's date runs, layout and indexes (by the visible list's generation).
|
||||
pub grid: panels::grid::GridCache,
|
||||
/// What the grid did on its frames (benchmarks and tests check unchanged frames stay cheap).
|
||||
pub grid_stats: panels::grid::GridStats,
|
||||
}
|
||||
|
||||
/// The left panel's counts.
|
||||
@@ -758,24 +784,6 @@ impl Caches {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Date headers for `ids` in the grid.
|
||||
pub fn date_runs(
|
||||
&mut self,
|
||||
cat: &lightcraft_catalog::Catalog,
|
||||
ids: &[lightcraft_catalog::PhotoId],
|
||||
key: lightcraft_catalog::SortKey,
|
||||
by: lightcraft_catalog::GroupBy,
|
||||
) -> std::sync::Arc<Vec<lightcraft_catalog::DateRun>> {
|
||||
let k = key_of((cat.revision, ids, format!("{key:?}{by:?}")));
|
||||
match &self.date_runs {
|
||||
Some((h, r)) if *h == k => r.clone(),
|
||||
_ => {
|
||||
let r = std::sync::Arc::new(cat.date_runs(ids, key, by));
|
||||
self.date_runs = Some((k, r.clone()));
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Keyword suggestions for a photo with `current` keywords and the typed `prefix`.
|
||||
pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc<Vec<String>> {
|
||||
let k = key_of((cat.revision, current, prefix, n));
|
||||
|
||||
@@ -178,6 +178,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"app.showInFinder",
|
||||
"dialog.rename",
|
||||
"dialog.captureTime",
|
||||
"photo.tagFromTracklog",
|
||||
"---",
|
||||
"photo.delete",
|
||||
],
|
||||
@@ -929,6 +930,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_tag_from_tracklog() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-tracklog-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let gpx = dir.join("walk.gpx");
|
||||
std::fs::write(
|
||||
&gpx,
|
||||
r#"<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
|
||||
<trkpt lat="46.0" lon="7.0"><time>2026-05-01T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="46.002" lon="7.004"><time>2026-05-01T10:02:00Z</time></trkpt>
|
||||
</trkseg></trk></gpx>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = app();
|
||||
let id = app.session.visible()[0].0;
|
||||
app.session.execute("library.select", &json!({"ids": [id]})).unwrap();
|
||||
app.session.execute("photo.setCaptureTime", &json!({"time": "2026-05-01T12:01:00", "each": true})).unwrap();
|
||||
app.session.execute("photo.setMeta", &json!({"gps": null})).unwrap();
|
||||
// in the Photo menu; without a file dialog (headless) it is disabled
|
||||
let photo_menu = menu_bar(&app).into_iter().find(|(t, _)| t == "Photo").unwrap().1;
|
||||
let Some(MenuNode::Item { enabled, .. }) = find(&photo_menu, "photo.tagFromTracklog") else { panic!("not in the Photo menu") };
|
||||
assert!(!enabled);
|
||||
// a file without an offset asks for the camera's time zone; confirming runs the tagging
|
||||
run_item(&mut app, "photo.tagFromTracklog", json!({"path": gpx.to_string_lossy()})).unwrap();
|
||||
let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog else { panic!("no time-zone prompt") };
|
||||
*value = " +02:00 ".into();
|
||||
let d = app.ui.dialog.take().unwrap();
|
||||
let r = crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap();
|
||||
assert_eq!(r["tagged"], 1, "{r}");
|
||||
let p = app.session.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).unwrap();
|
||||
assert_eq!(p.meta.gps, Some((46.001, 7.002)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_preset_menu() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -140,6 +140,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),
|
||||
("dialog.labelNames", "Edit Color Label Names…", None, ""),
|
||||
("dialog.captureTime", "Edit Capture Time…", None, "Photo"),
|
||||
("photo.tagFromTracklog", "Auto-Tag from Tracklog…", None, "Photo"),
|
||||
("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"),
|
||||
];
|
||||
|
||||
@@ -866,6 +867,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
rows.push(("Library".into(), dir.to_string()));
|
||||
}
|
||||
rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps)));
|
||||
rows.push((
|
||||
"Frame update".into(),
|
||||
format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms),
|
||||
));
|
||||
rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms)));
|
||||
let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::<Vec<_>>());
|
||||
if p.get("open").and_then(Value::as_bool).unwrap_or(true) {
|
||||
@@ -916,6 +921,47 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
}
|
||||
r
|
||||
}
|
||||
"photo.tagFromTracklog" => {
|
||||
// a GPX file → GPS for the selected photos by capture time (one undo step)
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => app.services.pick_tracklog.as_mut().and_then(|f| f().into_iter().next()),
|
||||
};
|
||||
let Some(path) = path else { return Some(Ok(Value::Null)) };
|
||||
let mut params = p.as_object().cloned().unwrap_or_default();
|
||||
params.insert("path".into(), json!(path));
|
||||
let ask_zone = !params.contains_key("offset");
|
||||
let params = Value::Object(params);
|
||||
if ask_zone {
|
||||
// GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here
|
||||
crate::panels::dialogs::prompt(
|
||||
app,
|
||||
"Auto-Tag from Tracklog",
|
||||
"Camera time zone, e.g. -07:00 (empty: UTC)",
|
||||
"",
|
||||
"photo.tagFromTracklog",
|
||||
params,
|
||||
"offset",
|
||||
);
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let r = app.run("photo.autoTagTracklog", params);
|
||||
if let Ok(v) = &r {
|
||||
let n = v["tagged"].as_u64().unwrap_or(0);
|
||||
let sk = &v["skipped"];
|
||||
let mut msg = format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" });
|
||||
let outside = sk["outside"].as_u64().unwrap_or(0);
|
||||
if outside > 0 {
|
||||
msg += &format!("; {outside} outside its time range");
|
||||
}
|
||||
let kept = sk["hasGps"].as_u64().unwrap_or(0);
|
||||
if kept > 0 {
|
||||
msg += &format!("; {kept} already had a location");
|
||||
}
|
||||
app.toast(&egui::Context::default(), msg);
|
||||
}
|
||||
r
|
||||
}
|
||||
"photo.locate" => {
|
||||
let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) };
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
@@ -1111,6 +1157,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
||||
"app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => {
|
||||
app.session.active().is_some()
|
||||
}
|
||||
"photo.tagFromTracklog" => app.session.active().is_some() && app.services.pick_tracklog.is_some(),
|
||||
"app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(),
|
||||
"dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(),
|
||||
"app.showInFinder" => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Photo Grid (justified rows) and Square Grid. Virtualized: only visible cells request thumbnails.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use lightcraft_catalog::{ColorLabel, DateRun, Flag, GroupBy, PhotoId};
|
||||
use lightcraft_catalog::{Catalog, ColorLabel, DateRun, Flag, GroupBy, PhotoId, SortKey, StackId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
@@ -25,9 +26,116 @@ fn thumb_px(pts: f32, ppp: f32) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Counters of the grid's work, cumulative over frames (benchmarks and tests: an unchanged frame
|
||||
/// must not rebuild the view, its layout or its indexes, and visits only the cells near the screen).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct GridStats {
|
||||
/// Frames the grid was drawn.
|
||||
pub frames: u64,
|
||||
/// Cells looked at (tested against the viewport or drawn).
|
||||
pub cells_visited: u64,
|
||||
/// Layouts computed.
|
||||
pub layout_builds: u64,
|
||||
/// Per-view data (date runs, row index) built.
|
||||
pub view_builds: u64,
|
||||
/// Stack indexes built.
|
||||
pub stack_index_builds: u64,
|
||||
/// Wall-clock time of the last [`show`].
|
||||
pub last_show: web_time::Duration,
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t0 = web_time::Instant::now();
|
||||
show_inner(app, ui);
|
||||
app.caches.grid_stats.frames += 1;
|
||||
app.caches.grid_stats.last_show = t0.elapsed();
|
||||
}
|
||||
|
||||
/// Photo → (stack, position in the stack).
|
||||
type StackIndex = HashMap<PhotoId, (StackId, usize)>;
|
||||
|
||||
/// What the date runs depend on: the visible list's generation, the date the grid groups by
|
||||
/// (`None`: no headers) and the grouping.
|
||||
type RunsKey = (u64, Option<SortKey>, GroupBy);
|
||||
|
||||
/// What the grid derives from the visible photos, kept across frames. Everything is keyed by the
|
||||
/// session's visible-list generation ([`lightcraft_engine::Session::visible_shared`]), which
|
||||
/// changes whenever the list is recomputed (any catalog change: crops, orientation, dates, stacks;
|
||||
/// or another source, filter or sort) — so an unchanged frame neither copies nor hashes the ids.
|
||||
#[derive(Default)]
|
||||
pub struct GridCache {
|
||||
runs: Option<(RunsKey, Arc<Vec<DateRun>>)>,
|
||||
/// Layout by (runs key, width, thumbnail size, square).
|
||||
layout: Option<((RunsKey, u32, u32, bool), Arc<GridLayout>)>,
|
||||
/// Stack index by generation.
|
||||
stacks: Option<(u64, Arc<StackIndex>)>,
|
||||
/// How many of the photos are browsed (Local) ones, by generation.
|
||||
local: Option<(u64, usize)>,
|
||||
}
|
||||
|
||||
impl GridCache {
|
||||
fn runs(&mut self, stats: &mut GridStats, cat: &Catalog, ids: &[PhotoId], key: RunsKey) -> Arc<Vec<DateRun>> {
|
||||
match &self.runs {
|
||||
Some((k, r)) if *k == key => r.clone(),
|
||||
_ => {
|
||||
stats.view_builds += 1;
|
||||
let r = Arc::new(key.1.map(|sort| cat.date_runs(ids, sort, key.2)).unwrap_or_default());
|
||||
self.runs = Some((key, r.clone()));
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stacks(&mut self, stats: &mut GridStats, cat: &Catalog, generation: u64) -> Arc<StackIndex> {
|
||||
match &self.stacks {
|
||||
Some((g, s)) if *g == generation => s.clone(),
|
||||
_ => {
|
||||
stats.stack_index_builds += 1;
|
||||
let s = Arc::new(cat.stack_index());
|
||||
self.stacks = Some((generation, s.clone()));
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn local(&mut self, cat: &Catalog, ids: &[PhotoId], generation: u64) -> usize {
|
||||
match self.local {
|
||||
Some((g, n)) if g == generation => n,
|
||||
_ => {
|
||||
let n = ids.iter().filter(|id| cat.photo(**id).is_some_and(|p| p.local)).count();
|
||||
self.local = Some((generation, n));
|
||||
n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Width / height of each photo's cell: its cropped, oriented shape.
|
||||
fn aspects(cat: &Catalog, ids: &[PhotoId]) -> Vec<f32> {
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
let p = cat.photo(*id);
|
||||
let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0));
|
||||
let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes());
|
||||
let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT);
|
||||
let (w, h) = if swap { (h, w) } else { (w, h) };
|
||||
(w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The rows (of a layout's `rows`, top to bottom) that meet the y range `top..=bottom`, found by
|
||||
/// binary search.
|
||||
pub fn rows_between(rows: &[GridRow], top: f32, bottom: f32) -> &[GridRow] {
|
||||
let first = rows.partition_point(|r| r.bottom < top);
|
||||
let rest = rows.get(first..).unwrap_or(&[]);
|
||||
let n = rest.partition_point(|r| r.top <= bottom);
|
||||
rest.get(..n).unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ids = app.session.visible_cloned();
|
||||
let (generation, ids) = app.session.visible_shared();
|
||||
// header: source title + count
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
|
||||
ui.painter().rect_filled(hr, 0.0, t.canvas);
|
||||
@@ -37,7 +145,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let counted = super::chips::count_text(ids.len(), total, !chips.is_empty());
|
||||
let cnt = if sel_n > 1 { format!("{sel_n} selected · {counted}") } else { counted };
|
||||
match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) {
|
||||
Some(b) => folder_header(app, ui, hr, &b, &ids, &cnt),
|
||||
Some(b) => {
|
||||
let local = app.caches.grid.local(&app.session.catalog, &ids, generation);
|
||||
folder_header(app, ui, hr, &b, &ids, local, &cnt)
|
||||
}
|
||||
None => {
|
||||
let title = app.session.source.label(&app.session.catalog);
|
||||
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
|
||||
@@ -75,57 +186,40 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let target = app.ui.thumb_size;
|
||||
let avail_w = ui.available_width() - 8.0;
|
||||
let by = resolve_group(app.session.sort.group, target);
|
||||
let runs = if app.session.source == lightcraft_engine::LibrarySource::RecentlyDeleted {
|
||||
Vec::new()
|
||||
} else {
|
||||
let key = if app.session.source == lightcraft_engine::LibrarySource::RecentlyAdded {
|
||||
lightcraft_catalog::SortKey::ImportDate
|
||||
} else {
|
||||
app.session.sort.key
|
||||
};
|
||||
(*app.caches.date_runs(&app.session.catalog, &ids, key, by)).clone()
|
||||
let group_key = match app.session.source {
|
||||
lightcraft_engine::LibrarySource::RecentlyDeleted => None,
|
||||
lightcraft_engine::LibrarySource::RecentlyAdded => Some(SortKey::ImportDate),
|
||||
_ => Some(app.session.sort.key),
|
||||
};
|
||||
let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect();
|
||||
// the layout only changes with the photos, their shapes, the width and the thumbnail size
|
||||
let lay_key = crate::key_of((app.session.catalog.revision, &ids, avail_w.to_bits(), target.to_bits(), square, &spans));
|
||||
let lay = match &app.caches.grid_layout {
|
||||
let runs_key = (generation, group_key, by);
|
||||
let stats = &mut app.caches.grid_stats;
|
||||
let runs = app.caches.grid.runs(stats, &app.session.catalog, &ids, runs_key);
|
||||
// the layout only changes with the photos (and their shapes), the grouping, the width and
|
||||
// the thumbnail size
|
||||
let lay_key = (runs_key, avail_w.to_bits(), target.to_bits(), square);
|
||||
let lay = match &app.caches.grid.layout {
|
||||
Some((k, l)) if *k == lay_key => l.clone(),
|
||||
_ => {
|
||||
let aspects: Vec<f32> = if square {
|
||||
vec![1.0; ids.len()]
|
||||
} else {
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
let p = app.session.catalog.photo(*id);
|
||||
let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0));
|
||||
let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes());
|
||||
let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT);
|
||||
let (w, h) = if swap { (h, w) } else { (w, h) };
|
||||
(w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let l = std::sync::Arc::new(layout(&aspects, &spans, avail_w, target, square));
|
||||
app.caches.grid_layout = Some((lay_key, l.clone()));
|
||||
stats.layout_builds += 1;
|
||||
let aspects = if square { vec![1.0; ids.len()] } else { aspects(&app.session.catalog, &ids) };
|
||||
let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect();
|
||||
let l = Arc::new(layout(&aspects, &spans, avail_w, target, square));
|
||||
app.caches.grid.layout = Some((lay_key, l.clone()));
|
||||
l
|
||||
}
|
||||
};
|
||||
struct Cell {
|
||||
id: PhotoId,
|
||||
rect: Rect,
|
||||
}
|
||||
let cells: Vec<Cell> = ids.iter().zip(&lay.cells).map(|(id, r)| Cell { id: *id, rect: *r }).collect();
|
||||
let stacks = app.caches.grid.stacks(stats, &app.session.catalog, generation);
|
||||
let total_h = lay.height + 12.0;
|
||||
let active = app.session.selection.active;
|
||||
// bring the active photo into view when it changes (keyboard, click, command) or the grid
|
||||
// comes back on screen; otherwise the scroll position is the user's
|
||||
let scroll_to: Option<Rect> = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active) {
|
||||
active.and_then(|a| cells.iter().find(|c| c.id == a).map(|c| c.rect))
|
||||
active.and_then(|a| ids.iter().position(|x| *x == a)).and_then(|i| lay.cells.get(i).copied())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let stacks = app.session.catalog.stack_index();
|
||||
let mut visible_ids = HashSet::new();
|
||||
let mut visited = 0u64;
|
||||
egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover());
|
||||
let origin = area.min;
|
||||
@@ -133,39 +227,49 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if let Some(r) = scroll_to {
|
||||
ui.scroll_to_rect(r.translate(origin.to_vec2()), None);
|
||||
}
|
||||
// only the rows on screen and a screen above and below it (their thumbnails are prefetched)
|
||||
let prefetch = viewport.expand2(vec2(0.0, viewport.height()));
|
||||
for c in &cells {
|
||||
if !c.rect.intersects(prefetch) {
|
||||
continue;
|
||||
}
|
||||
visible_ids.insert(c.id);
|
||||
let r = c.rect.translate(origin.to_vec2());
|
||||
let onscreen = c.rect.intersects(viewport);
|
||||
cell(app, ui, c.id, r, square, onscreen, ppp);
|
||||
if let Some((sid, pos)) = stacks.get(&c.id) {
|
||||
stack_badge(app, ui, c.id, *sid, *pos, r, square);
|
||||
for row in rows_between(&lay.rows, prefetch.top(), prefetch.bottom()) {
|
||||
for i in row.start..row.end {
|
||||
let (Some(&id), Some(&rect)) = (ids.get(i), lay.cells.get(i)) else { continue };
|
||||
visited += 1;
|
||||
if !rect.intersects(prefetch) {
|
||||
continue;
|
||||
}
|
||||
visible_ids.insert(id);
|
||||
let r = rect.translate(origin.to_vec2());
|
||||
let onscreen = rect.intersects(viewport);
|
||||
cell(app, ui, id, r, square, onscreen, ppp);
|
||||
if let Some((sid, pos)) = stacks.get(&id) {
|
||||
stack_badge(app, ui, id, *sid, *pos, r, square);
|
||||
}
|
||||
}
|
||||
}
|
||||
// date headers; the current group's header sticks to the top while its photos scroll by
|
||||
let mut sticky: Option<usize> = None;
|
||||
for (g, hr) in lay.headers.iter().enumerate() {
|
||||
if hr.top() <= viewport.top() {
|
||||
sticky = Some(g);
|
||||
// date headers (top to bottom); the current group's header sticks to the top while its
|
||||
// photos scroll by
|
||||
let first = lay.headers.partition_point(|h| h.bottom() < viewport.top());
|
||||
for (g, hr) in lay.headers.iter().enumerate().skip(first) {
|
||||
if hr.top() > viewport.bottom() {
|
||||
break;
|
||||
}
|
||||
if hr.intersects(viewport) {
|
||||
group_header(app, ui, &runs[g], &ids, hr.translate(origin.to_vec2()), false);
|
||||
if let Some(run) = runs.get(g) {
|
||||
group_header(app, ui, run, &ids, hr.translate(origin.to_vec2()), false);
|
||||
}
|
||||
}
|
||||
let sticky = lay.headers.partition_point(|h| h.top() <= viewport.top()).checked_sub(1);
|
||||
if let Some(g) = sticky
|
||||
&& lay.headers[g].top() < viewport.top()
|
||||
&& let Some(head) = lay.headers.get(g)
|
||||
&& head.top() < viewport.top()
|
||||
&& let Some(run) = runs.get(g)
|
||||
{
|
||||
// pushed up by the next header as it arrives
|
||||
let next_top = lay.headers.get(g + 1).map(|h| h.top()).unwrap_or(f32::INFINITY);
|
||||
let y = viewport.top().min(next_top - HEADER_H);
|
||||
let hr = Rect::from_min_size(pos2(0.0, y), vec2(area.width(), HEADER_H)).translate(origin.to_vec2());
|
||||
group_header(app, ui, &runs[g], &ids, hr, true);
|
||||
group_header(app, ui, run, &ids, hr, true);
|
||||
}
|
||||
});
|
||||
app.caches.grid_stats.cells_visited += visited;
|
||||
app.renderer.evict_thumbs(&visible_ids, 600);
|
||||
let _ = (Color32::BLACK, StrokeKind::Inside, Stroke::NONE);
|
||||
}
|
||||
@@ -188,15 +292,26 @@ pub(crate) fn follow_active(ctx: &egui::Context, key: egui::Id, active: Option<P
|
||||
/// Height of a date header row (points).
|
||||
pub const HEADER_H: f32 = 40.0;
|
||||
|
||||
/// Cell rectangles (one per photo, in order), date-header rectangles (one per group) and the total
|
||||
/// height of the grid content.
|
||||
/// Cell rectangles (one per photo, in order), date-header rectangles (one per group, top to
|
||||
/// bottom), the rows of cells (top to bottom, for finding the visible ones by binary search) and
|
||||
/// the total height of the grid content.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GridLayout {
|
||||
pub cells: Vec<Rect>,
|
||||
pub headers: Vec<Rect>,
|
||||
pub rows: Vec<GridRow>,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// One row of cells: `cells[start..end]`, between `top` and `bottom`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GridRow {
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
/// The grouping actually shown: `Auto` is days with large thumbnails, months with medium ones and
|
||||
/// years when zoomed far out.
|
||||
pub fn resolve_group(by: GroupBy, thumb_size: f32) -> GroupBy {
|
||||
@@ -218,7 +333,7 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target:
|
||||
let whole = [(0, n)];
|
||||
let (groups, headed) = if groups.is_empty() { (&whole[..], false) } else { (groups, true) };
|
||||
let gap = if square { 1.0 } else { 6.0 };
|
||||
let mut out = GridLayout { cells: vec![Rect::NOTHING; n], headers: Vec::with_capacity(groups.len()), height: 0.0 };
|
||||
let mut out = GridLayout { cells: vec![Rect::NOTHING; n], headers: Vec::with_capacity(groups.len()), rows: Vec::new(), height: 0.0 };
|
||||
let mut y = 4.0f32;
|
||||
for &(start, count) in groups {
|
||||
let end = (start + count).min(n);
|
||||
@@ -237,6 +352,11 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target:
|
||||
let (c, r) = (k % cols, (k / cols).floor());
|
||||
out.cells[i] = Rect::from_min_size(pos2(4.0 + c * (cw + gap), y + r * (cw + gap)), vec2(cw, cw));
|
||||
}
|
||||
let per_row = (cols as usize).max(1);
|
||||
for (r, row_start) in (start..end).step_by(per_row).enumerate() {
|
||||
let top = y + r as f32 * (cw + gap);
|
||||
out.rows.push(GridRow { top, bottom: top + cw, start: row_start, end: (row_start + per_row).min(end) });
|
||||
}
|
||||
let rows = ((end - start) as f32 / cols).ceil();
|
||||
y += rows * (cw + gap);
|
||||
} else {
|
||||
@@ -261,6 +381,7 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target:
|
||||
out.cells[k] = Rect::from_min_size(pos2(x, y), vec2(w, h));
|
||||
x += w + gap;
|
||||
}
|
||||
out.rows.push(GridRow { top: y, bottom: y + h, start: i, end: j });
|
||||
y += h + gap;
|
||||
i = j;
|
||||
}
|
||||
@@ -503,7 +624,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
||||
|
||||
/// The header of a Local folder view: the path as a breadcrumb (each part opens that folder),
|
||||
/// Include subfolders, Add to My Photos.
|
||||
fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &lightcraft_engine::Browse, ids: &[PhotoId], cnt: &str) {
|
||||
fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &lightcraft_engine::Browse, ids: &[PhotoId], local_n: usize, cnt: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut child =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(hr.shrink2(vec2(16.0, 6.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
@@ -533,10 +654,11 @@ fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &light
|
||||
child.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(cnt).size(12.5).color(t.text_dim));
|
||||
ui.add_space(10.0);
|
||||
let local: Vec<u64> = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect();
|
||||
if !local.is_empty() {
|
||||
let label = format!("Add {} to My Photos", local.len());
|
||||
// counted once per view (cached); the ids are only gathered on a click
|
||||
if local_n > 0 {
|
||||
let label = format!("Add {local_n} to My Photos");
|
||||
if crate::widgets::text_button(ui, "addToLibrary", &label, false).clicked() {
|
||||
let local: Vec<u64> = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect();
|
||||
let n = local.len();
|
||||
match app.run("photo.addToLibrary", json!({"ids": local})) {
|
||||
Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" })),
|
||||
@@ -825,6 +947,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The row index covers every cell once, in order and top to bottom, so the visible cells
|
||||
/// are found by binary search.
|
||||
#[test]
|
||||
fn rows_index_every_cell_top_to_bottom() {
|
||||
let aspects: Vec<f32> = (0..500).map(|i| [1.5f32, 0.66, 1.0, 1.78][i % 4]).collect();
|
||||
let groups = [(0, 7), (7, 200), (207, 1), (208, 292)];
|
||||
for square in [false, true] {
|
||||
let l = layout(&aspects, &groups, 900.0, 140.0, square);
|
||||
let mut next = 0;
|
||||
for (k, row) in l.rows.iter().enumerate() {
|
||||
assert_eq!(row.start, next, "rows are contiguous");
|
||||
assert!(row.end > row.start);
|
||||
for c in &l.cells[row.start..row.end] {
|
||||
assert!((c.top() - row.top).abs() < 0.01 && (c.bottom() - row.bottom).abs() < 0.01, "{c:?} {row:?}");
|
||||
}
|
||||
if let Some(prev) = k.checked_sub(1).map(|p| l.rows[p]) {
|
||||
assert!(row.top > prev.bottom, "{prev:?} {row:?}");
|
||||
}
|
||||
next = row.end;
|
||||
}
|
||||
assert_eq!(next, aspects.len());
|
||||
// the binary search finds exactly the cells a linear scan does
|
||||
for (top, bottom) in [(0.0, 300.0), (1234.0, 2100.0), (l.height - 50.0, l.height + 900.0), (-500.0, -1.0)] {
|
||||
let band = Rect::from_min_max(pos2(0.0, top), pos2(2000.0, bottom));
|
||||
let want: Vec<usize> = (0..aspects.len()).filter(|i| l.cells[*i].intersects(band)).collect();
|
||||
let got: Vec<usize> = rows_between(&l.rows, top, bottom).iter().flat_map(|r| r.start..r.end).collect();
|
||||
assert_eq!(got, want, "square {square} {top}..{bottom}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_grid_groups_and_auto_levels() {
|
||||
let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true);
|
||||
|
||||
@@ -435,6 +435,13 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
("Alt Text", "altText", &m.alt_text, 2),
|
||||
("Extended Description", "extendedDescription", &m.extended_description, 3),
|
||||
("Copyright", "copyright", &m.copyright, 1),
|
||||
] {
|
||||
meta_field(app, ui, label, key, value, lines);
|
||||
}
|
||||
copyright_status(app, ui, m.copyright_status);
|
||||
for (label, key, value, lines) in [
|
||||
("Rights Usage Terms", "usageTerms", &m.usage_terms, 2),
|
||||
("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1),
|
||||
("Creator", "creator", &m.creator, 1),
|
||||
] {
|
||||
meta_field(app, ui, label, key, value, lines);
|
||||
@@ -599,6 +606,21 @@ fn human_size(bytes: u64) -> String {
|
||||
|
||||
/// A labelled metadata text field: the typed text lives in egui memory while focused and is
|
||||
/// saved (photo.setMeta `key`) when the field loses focus.
|
||||
/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`).
|
||||
fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new("Copyright Status").size(11.5).color(t.text_dim));
|
||||
let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(current.label()).show_ui(ui, |ui| {
|
||||
for st in lightcraft_catalog::CopyrightStatus::ALL {
|
||||
if ui.selectable_label(st == current, st.label()).clicked() && st != current {
|
||||
let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
register(ui.ctx(), "field:copyrightStatus", r.response.rect);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim));
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Headless tests of the photo grid on a larger synthetic library (issue #35): unchanged frames
|
||||
//! reuse the view's date runs, layout and stack index and visit only the rows near the screen,
|
||||
//! while date headers, scrolling to the active photo, keyboard navigation, stacks and
|
||||
//! crop/orientation changes keep working.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source, Stack, StackId};
|
||||
use lightcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::ViewMode;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(30);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
const N: u64 = 3000;
|
||||
|
||||
/// `N` photos (missing files: thumbnails fail fast), 40 per capture day, landscape except every
|
||||
/// fifth; a three-photo stack every 100 photos.
|
||||
fn library() -> Session {
|
||||
let mut s = Session::new();
|
||||
for i in 0..N {
|
||||
let (w, h) = if i % 5 == 4 { (4000, 6000) } else { (6000, 4000) };
|
||||
let path = format!("/lightcraft-grid-test/IMG_{i:05}.jpg");
|
||||
let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:05}.jpg"), "JPEG", w, h, "2026-01-01T00:00:00");
|
||||
let day = i / 40;
|
||||
p.captured = Some(format!("2026-{:02}-{:02}T{:02}:00:00", 12 - day / 28, 28 - day % 28, 8 + i % 40 / 4));
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
for k in 0..N / 100 {
|
||||
let b = k * 100 + 1;
|
||||
let stack = Stack { id: StackId(k + 1), photos: vec![PhotoId(b), PhotoId(b + 1), PhotoId(b + 2)], collapsed: false };
|
||||
s.catalog.apply(Op::AddStack { stack }).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn grid(view: ViewMode) -> Headless {
|
||||
let app = LightcraftApp::new(library(), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.app.ui.view = view;
|
||||
h.app.ui.thumb_size = 160.0;
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
fn widget(h: &Headless, id: &str) -> Option<egui::Rect> {
|
||||
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
fn select(h: &mut Headless, id: PhotoId) {
|
||||
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// The photo's cell is drawn inside the grid's canvas.
|
||||
fn on_screen(h: &Headless, id: PhotoId) -> bool {
|
||||
let canvas = h.app.canvas_rect.expect("grid drawn");
|
||||
widget(h, &format!("thumb:{}", id.0)).is_some_and(|r| canvas.contains_rect(r))
|
||||
}
|
||||
|
||||
/// Unchanged frames build nothing and look only at the cells near the screen.
|
||||
fn assert_cheap_frames(h: &mut Headless) {
|
||||
let before = h.app.caches.grid_stats.clone();
|
||||
let frames = 10;
|
||||
for _ in 0..frames {
|
||||
h.step();
|
||||
}
|
||||
let s = &h.app.caches.grid_stats;
|
||||
assert_eq!(s.frames - before.frames, frames);
|
||||
assert_eq!(s.view_builds, before.view_builds, "date runs rebuilt on an unchanged frame");
|
||||
assert_eq!(s.layout_builds, before.layout_builds, "layout rebuilt on an unchanged frame");
|
||||
assert_eq!(s.stack_index_builds, before.stack_index_builds, "stack index rebuilt on an unchanged frame");
|
||||
let per_frame = (s.cells_visited - before.cells_visited) / frames;
|
||||
assert!(per_frame > 0 && per_frame < 300, "visited {per_frame} of {N} cells per frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_grid_frames_reuse_the_view_and_visit_only_nearby_rows() {
|
||||
for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] {
|
||||
let mut h = grid(view);
|
||||
assert_eq!(h.app.caches.grid_stats.view_builds, 1, "{view:?}");
|
||||
assert_eq!(h.app.caches.grid_stats.stack_index_builds, 1, "{view:?}");
|
||||
assert_cheap_frames(&mut h);
|
||||
// far down the grid too
|
||||
let far = h.app.session.visible()[2500];
|
||||
select(&mut h, far);
|
||||
assert_cheap_frames(&mut h);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_headers_selection_scrolling_stacks_and_shape_changes() {
|
||||
let mut h = grid(ViewMode::PhotoGrid);
|
||||
let vis = h.app.session.visible().to_vec();
|
||||
// the newest day's header heads the grid
|
||||
assert!(widget(&h, "group:2026-12-28").is_some(), "{:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("group:")).collect::<Vec<_>>());
|
||||
assert_eq!(h.app.grid_scroll, Some(0.0));
|
||||
// selecting a photo far down scrolls the grid to it; its day's header is drawn (pinned or not)
|
||||
let target = PhotoId(2001); // the top of a stack, captured on day 50
|
||||
let i = vis.iter().position(|x| *x == target).unwrap();
|
||||
select(&mut h, target);
|
||||
assert!(h.app.grid_scroll.unwrap() > 1000.0, "{:?}", h.app.grid_scroll);
|
||||
assert!(on_screen(&h, target), "the active photo is scrolled into view");
|
||||
// stacks: the badge on the stack's top and members
|
||||
assert!(widget(&h, &format!("stack:{}", target.0)).is_some(), "stack badge drawn");
|
||||
assert!(widget(&h, "stack:2002").is_some(), "stack member badge drawn");
|
||||
// keyboard: the next photo becomes active and stays in view
|
||||
h.request("ui.key", json!({"key": "right"}), T);
|
||||
h.settle(SETTLE);
|
||||
let next = vis[i + 1];
|
||||
assert_eq!(h.app.session.selection.active, Some(next));
|
||||
assert!(on_screen(&h, next));
|
||||
// the user's scroll position is kept on unchanged frames
|
||||
let y = h.app.grid_scroll;
|
||||
assert_cheap_frames(&mut h);
|
||||
assert_eq!(h.app.grid_scroll, y);
|
||||
// rotating a landscape photo re-lays the grid with its new (portrait) shape
|
||||
let landscape = vis[i + 2];
|
||||
let before = widget(&h, &format!("thumb:{}", landscape.0)).unwrap();
|
||||
assert!(before.width() > before.height(), "{before:?}");
|
||||
let layouts = h.app.caches.grid_stats.layout_builds;
|
||||
let r = h.request("engine.execute", json!({"command": "photo.rotateRight", "params": {"ids": [landscape.0]}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.caches.grid_stats.layout_builds > layouts, "a shape change re-lays the grid");
|
||||
let after = widget(&h, &format!("thumb:{}", landscape.0)).unwrap();
|
||||
assert!(after.height() > after.width(), "{after:?}");
|
||||
assert_cheap_frames(&mut h);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
||||
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
|
||||
| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
|
||||
| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
|
||||
| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf, status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}` |
|
||||
| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}` |
|
||||
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
|
||||
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |
|
||||
|
||||
+8
-8
@@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| X. Cross-cutting behaviours (BEHAV) | 15 | 1 | 2 | 1 | 8/8 (100%) | 6/6 (100%) |
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 29 | 9 | 41 | 9 | — | 20/21 (95%) |
|
||||
| **Total** | 385 | 25 | 92 | 37 | 194/198 (98%) | 138/146 (95%) |
|
||||
| Lightroom Classic extras | 30 | 9 | 40 | 9 | — | 20/21 (95%) |
|
||||
| **Total** | 386 | 25 | 91 | 37 | 194/198 (98%) | 138/146 (95%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 502 in-scope rows — P0 99.0% of 198 · P1 96.2% of 146 · P2 38.6% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.4%** of 502 in-scope rows — P0 99.0% of 198 · P1 96.2% of 146 · P2 39.2% of 158.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -191,10 +191,10 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people |
|
||||
| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people |
|
||||
| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own |
|
||||
| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`) | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; no embedded map, no geocoding |
|
||||
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright, creator, place fields, keywords; `fields` picks a subset |
|
||||
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset |
|
||||
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none |
|
||||
| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets |
|
||||
|
||||
@@ -698,14 +698,14 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; like the file manager, not an undo step) |
|
||||
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |
|
||||
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) |
|
||||
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) |
|
||||
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |
|
||||
| LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor |
|
||||
| LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down |
|
||||
| LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' |
|
||||
| LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` |
|
||||
| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ✅ | `crates/catalog/src/keywords.rs`, `cmd:keyword.list`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops) |
|
||||
| LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings); no copyright status field |
|
||||
| LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) |
|
||||
| LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step |
|
||||
| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | |
|
||||
| LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | |
|
||||
@@ -727,7 +727,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-MAP-VIEW | Map view | P2 | ⬜ | | |
|
||||
| LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | |
|
||||
| LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | |
|
||||
| LRC-MAP-TRACKLOG | GPS track logs | P2 | ⬜ | | |
|
||||
| LRC-MAP-TRACKLOG | GPS track logs | P2 | ✅ | `cmd:photo.autoTagTracklog`, `cmd:photo.tagFromTracklog`, `crates/meta/src/gpx.rs` | Photo ▸ Auto-Tag from Tracklog…: a GPX 1.0 / 1.1 track log sets the GPS of the selected photos by capture time — interpolated between the points of a track segment, else the nearest point within `maxGap` (10 min); never across segment breaks. The camera's time zone comes from the photo (Exif offset) or is asked for; photos that already have a location keep it unless `replace`; `dryRun` previews; one undo step. No track drawn on a map (no Map module) |
|
||||
| LRC-MAP-FILTER | Location filter bar | P2 | ⬜ | | |
|
||||
| LRC-MAP-REVGEO | Reverse geocoding | OOS | 🚫 | | |
|
||||
| LRC-BOOK-SETTINGS | Book settings | P2 | ⬜ | | |
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
copy is verified and in the library. Duplicates and files that fail stay where they were.
|
||||
- Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.
|
||||
- Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.
|
||||
- Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.
|
||||
- Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.
|
||||
|
||||
### Editing
|
||||
- Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.
|
||||
|
||||
@@ -20,6 +20,8 @@ What we write (standard namespaces, so other tools can read the metadata):
|
||||
| Rating 0–5 | `xmp:Rating` |
|
||||
| Colour label | `xmp:Label` (`Red`, `Yellow`, `Green`, `Blue`, `Purple`) |
|
||||
| Title / caption / copyright / creator | `dc:title` / `dc:description` / `dc:rights` / `dc:creator` |
|
||||
| Copyright status | `xmpRights:Marked` (`True` copyrighted, `False` public domain, absent = unknown) |
|
||||
| Rights usage terms / copyright info URL | `xmpRights:UsageTerms` / `xmpRights:WebStatement` |
|
||||
| Keywords | `dc:subject` |
|
||||
| Capture time, GPS | `exif:DateTimeOriginal`, `photoshop:DateCreated`, `exif:GPSLatitude`/`GPSLongitude` |
|
||||
| Pick / reject flag | `lc:flag` (`pick`, `reject`, `none`) |
|
||||
|
||||
Reference in New Issue
Block a user