Compare commits

..
121 Commits
Author SHA1 Message Date
Brandon Thomas cd35ebdace Merge pull request #81 from storytold/pr/release-0.2.0
Release: LightCraft v0.2.0
2026-10-05 02:41:21 -04:00
Brandon ThomasandClaude Opus 5.5 65e638ce8a Release: LightCraft v0.2.0
Bump the workspace version 0.1.1 -> 0.2.0 (cargo xtask version set 0.2.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:41:10 -04:00
Brandon Thomas b312319c78 Merge pull request #80 from storytold/pr/unsafe-helper-crate
Never crash: move the one unsafe FFI call into crates/sysmem
2026-10-05 02:06:26 -04:00
Brandon ThomasandClaude Opus 5.5 1ef74a787d Never crash: move the one unsafe FFI call into crates/sysmem
The macOS malloc_zone_pressure_relief call (returns freed allocator pages
after raw decodes) lived in identical copies inside the desktop app and the
CLI. It now lives in lightcraft-sysmem, an isolated L0 helper crate and the
workspace's only crate allowed unsafe: one #[allow(unsafe_code)] module, a
SAFETY comment, a safe API that returns bytes released (0 off macOS), and
tests (repeat calls, release after large frees, concurrent calls). Both apps'
alloc_release.rs now just install the safe hook, and their crate roots gain
#![forbid(unsafe_code)], so every production crate except sysmem forbids it.
AGENTS.md/CLAUDE.md name the exception, per craftrules standards/never-crash.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:06:08 -04:00
Brandon Thomas 5525ae667a Merge pull request #77 from storytold/fix/issue-29-move-import
Issue #29: Import: Move mode with rename and template folders
2026-10-04 16:15:40 -04:00
Brandon Thomas f4316dcfb8 Merge main into fix/issue-29-move-import
# Conflicts:
#	docs/parity.md
2026-10-04 16:15:25 -04:00
Brandon Thomas 116c26d8ee Merge pull request #76 from storytold/fix/issue-35-grid-frames
Issue #35: Grid: unchanged frames reuse view data and visit only nearby rows
2026-10-04 16:14:14 -04:00
Brandon Thomas 8061e0019e Merge pull request #75 from storytold/fix/issue-37-persistence
Issue #37: Catalog: compact the log on a background thread with ordered journal boundaries
2026-10-04 16:14:02 -04:00
Brandon ThomasandClaude Opus 5.5 4788f3bfff Issue #37: Catalog: compact the log on a background thread with ordered journal boundaries
At the 2,000-record threshold the UI thread serialised and fsynced the whole catalog (1-2 s
at 85k photos after streaming). Now `persist()` starts `Journal::snapshot_in_background`:

- the worker writes `catalog.snap` for op N from a clone of the catalog (photos are Arc-shared,
  so the clone is cheap) through `Store::background_writer` (a second FsStore handle; MemStore
  opt-in; none on wasm/web, which stays synchronous);
- appends go on as before (fsynced before every command returns) and are also kept in memory
  as the "tail" (records > N);
- `Journal::poll` (from `persist` and the per-frame `persist_if_dirty`) sees the worker done and
  atomically replaces the log with exactly the tail.

Crash points: before the snapshot rename -> old snapshot + whole log; between the rename and the
log rewrite -> new snapshot + whole log (records <= N skipped as stale, existing rule); after ->
new snapshot + tail. A failed snapshot keeps the log whole and retries after another
max_records records (no retry storm). At most one snapshot in flight; a synchronous snapshot
(close, Optimize Library), reopening a library and dropping the Journal first wait for it, so an
older snapshot can never replace a newer one. On-disk format unchanged.

Tests (crates/catalog/src/tests_background.rs, a gated store holds the worker mid-flight and
"crashes" by copying the files): edits during a background snapshot survive crashes at each
point and the trimmed log holds exactly seq N+1.. in order; failure loses nothing and backs
off; sync snapshot waits (no regression); drop waits; real FsStore with a leftover partial
temp file; a proptest over interleavings of edits / start / land / finish / sync snapshot /
crash. Mutations checked: trimming the log to empty, or not waiting in `snapshot`, fail them.
Engine: `threshold_compaction_runs_in_the_background` (crash copy mid-run, reopen without close).
`library.info` -> persistence gains snapshotRunning, failedSnapshots, maxBlockingMs and
lastSnapshot.{background, blockingMs}.

persist_bench, same session (load avg ~160), UI-thread time per threshold compaction:
  85k photos: synchronous 0.85-1.7 s (median 1.25 / 1.34 s) -> background: blocking median
              45 / 74 ms (clone 1.3-154 ms + log rewrite 9-23 ms); worker 1.2-2.4 s; 230-380
              single-op appends during it at median 4-6 ms (same as without)
  10k photos: synchronous 72-233 ms -> blocking median 12.5 / 14 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:01:28 -04:00
Brandon ThomasandClaude Opus 5.5 ae1cbbe87b Issue #37: Catalog: stream the snapshot into the temp file instead of building two strings
Journal::snapshot serialised the catalog into a String and then copied it into a second
`format!`-ed String before writing (2x the snapshot size in memory, ~620 MB at 85k photos).
It now streams `{"format":…,"seq":N,"catalog":` + serde_json::to_writer + `}\n` through a
1 MiB BufWriter into the store (new `Store::write_atomic_with`; FsStore writes the temp file
directly, then flush + sync_all + rename + dir fsync as before; other stores buffer, as before).

Durability and format are unchanged: same temp + fsync + atomic rename; a failed write leaves
the old snapshot (and now removes the temp file); the bytes are identical to the old format
(test `streamed_snapshot_keeps_the_on_disk_format`, which also loads a legacy snapshot), so
no version bump. Also tested: `failed_streamed_write_keeps_the_old_file`.

persist_bench, 85k photos (310 MB snapshot), compaction at the 2,000-record threshold,
before/after binaries alternated in the same session (load avg ~130-170), building+compacting
only (PERSIST_BENCH_NO_LOAD):
  compaction median: before 3.5 s / 4.6 s, after 1.9 s / 2.5 s
  peak RSS:          before 926 MB / 1263 MB, after 308 MB / 366 MB
  10k photos peak RSS: 149-235 MB -> 61-68 MB (times too noisy to compare)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:43:13 -04:00
Brandon ThomasandClaude Opus 5.5 db6242d2d3 Issue #37: Catalog: instrument persistence (append, snapshot stages) and whole-frame update time
- Journal::stats(): last/max append time (encode + write + sync_data), snapshot count, last
  snapshot by stage (serialise, write+sync+rename, log reset, bytes, records), slowest
  snapshot. Reported by `library.info` → `persistence`; printed per write to stderr under
  LIGHTCRAFT_PROFILE.
- UI: `perf.frame_ms` only covers layout (`LightcraftApp::ui`); `logic()` (control channel,
  shortcuts, render polling, `persist_if_dirty`) ran untimed. Add `logicMs`, `updateMs`
  (logic + layout) and `maxUpdateMs` to `ui.inspect` → perf and a "Frame update" row to
  Help ▸ System Info.
- `crates/catalog/examples/persist_bench.rs`: synthetic 1k/10k/85k-photo catalogs (90 % Local
  browse records) on FsStore: append latency, compaction at the 2,000-record threshold, load.

Baseline (release, load avg ~240, so absolute times are inflated; median of 3 compactions):
  1k photos  (3.6 MB snap): append 7 ms, compaction 31 ms (serialise 5, write+sync 12, reset 14)
  10k photos (36.5 MB):     append 15 ms, compaction 528 ms (serialise 463, write+sync 56)
  85k photos (310 MB):      append 12 ms, compaction 4.6 s (serialise 2.2-4.4 s, write+sync
                            0.25-0.8 s); peak RSS 963 MB; load 4.7 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:43:13 -04:00
Brandon ThomasandClaude Opus 5.5 73a2c01676 Issue #35: Grid: unchanged frames reuse view data and visit only nearby rows
An unchanged grid frame used to copy every visible id, hash them twice
(date-run and layout cache keys), clone the date runs, build a full
Vec<Cell>, rebuild the stack index, loop over every cell for the
viewport test and, in Local folder views, look up every photo to count
the browsed ones for "Add N to My Photos".

- engine: Session keeps the visible list as Arc<[PhotoId]> with a
  process-unique generation that changes whenever the list is
  recomputed; `visible_shared()` hands both out without copying.
- grid: date runs, layout, stack index and the Local count are cached
  by that generation (GridCache; replaces the id-hashing date_runs and
  grid_layout caches). The layout gains a row index (GridRow), so the
  visible + prefetch rows and the date headers are found by binary
  search; the sticky header too.
- GridStats counters (frames, cells visited, view/layout/stack-index
  builds, last frame time) for the bench and tests.

Measured with the new `cargo run --release -p lightcraft-ui-egui
--example grid_bench` (synthetic library, 1400x900, thumbs 160, missing
files so no decoding is timed, scrolled to the middle, 120 unchanged
frames; machine at load ~200-250, so medians of the whole app frame,
before/after interleaved in the same session):

  photos layout  source  median frame ms  allocs/frame   cells visited
                         before -> after  before -> after  before -> after
     200 photo   all      1.29 -> 1.28    4747 -> 4729     200 -> 93
     200 square  all      1.09 -> 1.09    4771 -> 4753     200 -> 74
  10 000 photo   all      1.52 -> 1.41    5425 -> 4911   10000 -> 100
  10 000 photo   local    1.90 -> 1.42    5475 -> 4948   10000 -> 100
  10 000 square  local    1.62 -> 1.07    5365 -> 4838   10000 -> 76
  85 000 photo   all      2.41 -> 1.29    8996 -> 4729   85000 -> 93
  85 000 square  all      2.29 -> 1.08    9020 -> 4753   85000 -> 74
  85 000 photo   local   17.6-34.0 -> 1.32  9049 -> 4766   85000 -> 93
  85 000 square  local   16.2-25.0 -> 1.10  9073 -> 4790   85000 -> 74

Frame cost no longer depends on the number of results; what is left
(~1 ms, ~4.7k allocs) is drawing the ~75-100 cells near the screen.
Headless snapshots of the demo grid (justified with a sticky header,
after keyboard navigation, square) are byte-identical before/after.

Tests: layout rows cover every cell in order and the binary search
matches a linear scan (square + justified, groups); a 3000-photo
headless library checks that unchanged frames build nothing and visit
< 300 cells, plus date headers, scroll-to-selection, stack badges,
keyboard navigation and a rotation re-laying the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:40:19 -04:00
Brandon ThomasandClaude Opus 5.5 589741b21e Issue #29: Import: Move mode with rename and template folders
library.import mode `move` (UI: Transfer ▸ Move, MCP import tool): reuses
Copy's destination, folder organisation (incl. custom templates) and rename
template. Each photo and its XMP sidecars (both namings) are placed without
touching the source (hard link on the same volume, else a new file that is
copied, synced and compared byte for byte; taken names get -1, -2…, never
overwritten). Sources are removed only after the catalog records are
committed and saved, and only when the destination is still intact; failed,
duplicate, unchecked and in-library files keep their sources; sources that
can't be removed are kept and reported (`kept`). Undo removes the catalog
records and leaves the moved files at the destination.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:39:30 -04:00
Brandon Thomas 840b294e23 Merge pull request #60 from storybored379/feat/gpx-tracklog
LRC-MAP-TRACKLOG: Auto-Tag from Tracklog (GPS from a GPX file by capture time)
2026-10-04 15:32:00 -04:00
Brandon Thomas f7361a7d01 Merge main into feat/gpx-tracklog
# Conflicts:
#	docs/parity.md
#	docs/whats-new.md
2026-10-04 15:31:45 -04:00
Brandon Thomas 8011396e91 Merge pull request #51 from storybored379/feat/copyright-status
LRC-LIB-METADATA: Copyright Status, Rights Usage Terms and Copyright Info URL
2026-10-04 15:30:24 -04:00
Brandon Thomas efdd8a4e10 Merge main into feat/copyright-status
# Conflicts:
#	crates/engine/src/tests_xmp.rs
#	docs/parity.md
2026-10-04 15:26:53 -04:00
Brandon Thomas ba5fff320b Merge pull request #59 from storytold/pr/never-crash-ui
Never crash: ui-egui and the web app
2026-10-04 14:24:24 -04:00
Brandon Thomas 528b4b46db Merge pull request #58 from storytold/pr/never-crash-guard
Never crash: last-resort guard around command execution
2026-10-04 14:24:15 -04:00
Brandon Thomas 815be4ef7f Merge pull request #57 from storytold/pr/never-crash-engine-mcp
Never crash: engine (outside export.rs) and mcp
2026-10-04 14:23:58 -04:00
Brandon Thomas 7b75cf4f91 Merge pull request #55 from storytold/pr/never-crash-gpu-merge
Never crash: gpu and merge
2026-10-04 14:23:40 -04:00
Brandon Thomas 3f20f98931 Merge pull request #56 from storytold/pr/never-crash-core
Never crash: color, geom, raster, develop, scenes, preview, pipeline
2026-10-04 14:23:22 -04:00
Brandon Thomas 2700c8a814 Merge pull request #54 from storytold/pr/never-crash-codecs-catalog
Never crash: codecs and catalog
2026-10-04 14:23:05 -04:00
Brandon Thomas 2e2f7ee5ac Merge pull request #53 from storytold/pr/never-crash-raw-tiff
Never crash: raw and tiff decoders
2026-10-04 14:22:47 -04:00
Brandon Thomas 302ed02d94 Merge pull request #52 from storytold/pr/never-crash-steering
Never crash: steering rule and clippy.toml
2026-10-04 14:22:29 -04:00
Brandon Thomas c02b39a15d Merge pull request #73 from storytold/fix/issue-30-import-entry-point
CI: clean under clippy 1.99 (as_chunks for constant chunk sizes, ? in export resize parsing)
2026-10-04 14:01:10 -04:00
Brandon Thomas b56e40e8d5 Merge main into fix/issue-30-import-entry-point
# Conflicts:
#	crates/ui-egui/src/import.rs
#	docs/parity.md
2026-10-04 14:00:55 -04:00
Brandon Thomas 42c19ddd1a Merge pull request #72 from storytold/fix/issue-32-folder-template
Issue #32: Import: custom date-based folder template for Copy
2026-10-04 14:00:34 -04:00
Brandon Thomas 6bf64277c4 Merge pull request #71 from storytold/fix/issue-31-rename-tag-help
Issue #31: Import: tag help beside the Rename field
2026-10-04 14:00:20 -04:00
Brandon Thomas fb47b35837 Merge pull request #70 from storytold/fix/issue-19-sidecar-capture-time
Issue #19: XMP sidecar capture time fills in when the file has none
2026-10-04 14:00:07 -04:00
Brandon Thomas 3b60b5bbc7 Merge pull request #69 from storytold/fix/issue-47-masking-fit
Issue #47: Masking: keep the panel's contents within its width when a mask is selected
2026-10-04 13:59:55 -04:00
Brandon Thomas 3165abf162 Merge pull request #68 from storytold/fix/issue-20-resizable-panels
Issue #20: Panels: resize the left sidebar and the right panel by dragging their edge
2026-10-04 13:59:41 -04:00
Brandon Thomas 00308b50f9 Merge pull request #67 from storytold/fix/issue-33-async-devices
Issue #33: Devices: serve the cached card list and rescan in the background
2026-10-04 13:59:27 -04:00
Brandon Thomas e7722296d3 Merge pull request #66 from storytold/fix/issue-27-quit-last
Issue #27: Menus: keep Quit last in the in-window File menu
2026-10-04 13:59:13 -04:00
Brandon Thomas f1dce86609 Merge pull request #65 from storytold/fix/issue-21-local-roots
Issue #21: Local: kept folders stay roots while browsing below them
2026-10-04 13:59:00 -04:00
Brandon Thomas 9d756b145d Merge pull request #64 from storytold/fix/issue-22-local-path-identity
Issue #22: Local: recognise equivalent folder paths
2026-10-04 13:58:47 -04:00
Brandon Thomas 9698defed6 Merge pull request #63 from storytold/fix/issue-36-smart-album-counts
Issue #36: cache smart-album counts in the sidebar
2026-10-04 13:58:33 -04:00
Brandon Thomas f940e0d03a Merge pull request #62 from storytold/fix/issue-34-missing-scope
Issue #34: Missing Photos checks library photos only
2026-10-04 13:58:21 -04:00
Brandon Thomas 40a599dac0 Merge pull request #61 from storytold/fix/clippy-1.99
CI: clean under clippy 1.99
2026-10-04 13:57:45 -04:00
Brandon ThomasandClaude Opus 5.5 ef57ae208b Never crash: the per-crate deny attribute is the end state, not workspace lints
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:57:00 -04:00
storybored379andClaude Opus 5.5 1901a756bb LRC-MAP-TRACKLOG: Auto-Tag from Tracklog — GPS from a GPX track log by capture time
GPX 1.0/1.1 reader in lightcraft-meta (timed track points per segment; interpolation only within a
segment, nearest point within maxGap otherwise). Engine command photo.autoTagTracklog (path or gpx
text, camera time-zone offset, maxGap, replace, dryRun; one undo step) and Photo ▸ Auto-Tag from
Tracklog… with a time-zone prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:52:12 -07:00
Brandon ThomasandClaude Opus 5.5 bd2e756b24 Never crash: gpu and merge free of panicking calls
gpu (its render already runs under catch_unwind and falls back to the CPU):
- a failed readback (device lost, out of memory) logs, marks the GPU
  broken and returns zeros, so crate::render discards the result and
  renders on the CPU, instead of panicking;
- an unknown kernel name does the same instead of panic!;
- finish-parameter fields are looked up as Option (debug_assert on an
  unknown name) instead of panic!;
- the warp sampler matches (mode, warp) like the CPU pipeline;
- Buf::raw keeps one documented expect (Some from construction to Drop).

merge:
- pyramid building (push_pull, Blender::finish), camera graph growth,
  bundle adjustment, focal median and alignment use let-else / filter_map
  instead of expect;
- synth::bracket_dngs / pano_views return Result (a missing demo scene
  or a DNG write error is an error); lightcraft-cli synth-merge and the
  engine merge tests handle it.

lightcraft-gpu, lightcraft-merge and lightcraft-cli now #![deny]
unwrap/expect/panic/unimplemented/todo/unreachable; clippy.toml as in the
steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:34:53 -04:00
Brandon ThomasandClaude Opus 5.5 fc13807f91 Never crash: color, geom, raster, develop, scenes, preview, pipeline
- color: RgbSpace::to_xyz/from_xyz and bradford() no longer expect a
  matrix inverse. Degenerate (collinear) primaries, which readers reject
  before building a space from a file, leave the primaries unscaled /
  give the identity; the constant Bradford and OKLab matrices fall back
  to the identity, which can't happen (round trips are tested).
- pipeline geometry: sample() matches (mode, warp) instead of
  expect("warp"); a warp plan without a warp (sample_plan never makes
  one) samples through the equivalent affine.
- color, geom, raster, develop, scenes, preview and pipeline now #![deny]
  unwrap/expect/panic/unimplemented/todo/unreachable; clippy.toml as in
  the steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:34:53 -04:00
Brandon ThomasandClaude Opus 5.5 685cf6f2e8 Never crash: ui-egui and the web app free of panicking calls
- import / merge polling: let-else on the task instead of expect("task").
- the merge worker catches a panic and reports "the merge failed
  unexpectedly" instead of dropping its channel, which left "Merging…"
  up forever.
- web: window() returns Option; its ten callers handle a missing window.
- lightcraft-ui-egui and lightcraft-web now #![deny] unwrap/expect/panic/
  unimplemented/todo/unreachable; clippy.toml as in the steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:33:45 -04:00
Brandon ThomasandClaude Opus 5.5 5218981c7b Never crash: last-resort guard around command execution
Session::execute (the entry point of the UI, CLI, control channel and
MCP) runs each command through lightcraft_engine::guard::catch: a panic
that escapes a command becomes that command's error (EngineError::Other,
shown like any failed command) and the session, catalog and undo history
stay usable. The desktop app installs guard::install_hook, which keeps
the default stderr report and appends the panic location and message to
<temp>/lightcraft-panics.log.

The desktop app crate (already clean) now #![deny]s unwrap/expect/panic/
unimplemented/todo/unreachable; clippy.toml as in the steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:25:22 -04:00
Brandon ThomasandClaude Opus 5.5 a74b1b32ff Never crash: engine panic sites outside export.rs; mcp denies panics
- merge output_path: a bounded search for a free name instead of
  `for k in 1..` + unreachable!().
- library open: Option::insert returns the new library, no expect.
- relink: a one-op batch unwraps through <[Op; 1]>::try_from, no expect.
- lightcraft-mcp (already clean) now #![deny] unwrap/expect/panic/
  unimplemented/todo/unreachable; clippy.toml as in the steering PR.

crates/engine/src/export.rs keeps one expect (open PR #51 edits that
file), so lightcraft-engine doesn't get the deny attribute yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:20:44 -04:00
Brandon ThomasandClaude Opus 5.5 eb378c491a Never crash: codecs and catalog free of panicking calls
- catalog store: the journal appender is a let-else with an io::Error,
  not expect("appender just opened").
- catalog dates: group_label slices with get(), so a non-ASCII key of a
  date's length no longer panics mid-character (regression test).
- codecs jpeg_par: rows come from map_while instead of next().unwrap().
- lightcraft-codecs and lightcraft-catalog now #![deny] unwrap/expect/
  panic/unimplemented/todo/unreachable (one test let-else uses panic!
  instead of unreachable!); clippy.toml as in the steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:18:11 -04:00
Brandon ThomasandClaude Opus 5.5 b42ca76777 Never crash: raw and tiff decoders free of panicking calls
- ljpeg: a scan naming an undefined Huffman table is an error, not an expect.
- ARW2/RW2 block reads use get() + try_from instead of slice + expect.
- Vendor fallback CFAs use Cfa::bayer_static (literal names, tested)
  instead of Cfa::bayer(..).expect("static").
- dngwrite matches (data, compression) instead of a let-else unreachable!.
- lightcraft-raw and lightcraft-tiff now #![deny] unwrap/expect/panic/
  unimplemented/todo/unreachable; clippy.toml (same as the steering PR)
  keeps them allowed in tests.
- tests/vendor_robust.rs: synthetic NEF, ARW, PEF, SRW, ORF, RW2, CR2 and
  RAF files, truncated at every length and randomly mutated, must decode
  to an error, never panic (no new crash found).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:00:26 -04:00
Brandon ThomasandClaude Opus 5.5 a8c3f01e1e CI: clean under clippy 1.99 (as_chunks for constant chunk sizes, ? in export resize parsing)
Rust 1.99 (stable since 2026-09-28) adds clippy::chunks_exact_to_as_chunks and widens
clippy::question_mark, which failed `cargo xtask ci` on main. Same behaviour: the
remainder that chunks_exact skipped is the remainder as_chunks returns separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:53:43 -04:00
Brandon ThomasandClaude Opus 5.5 4f4426e498 Issue #30: Import Photos… as the clear import entry point
File now opens with Import Photos… (Cmd+Shift+I), Import from Folder… and
Import from Device (command ids file.addPhotos / file.addFolder /
file.addFromDevice unchanged; library.import's label is Import Photos).
The review dialog is titled Import Photos, names its source (a scanned
folder is not added to Local; Local -> Browse Folder... is the persistent
location), labels the transfer mode and explains Add in place vs Copy, and
its button reads Import N Photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:44:15 -04:00
Brandon ThomasandClaude Opus 5.5 0ad313d445 Issue #47: Masking: keep the panel's contents within its width when a mask is selected
Selecting a mask added the Add/Subtract/Intersect/Invert/Delete row in
one non-wrapping horizontal layout (~273 pt in a ~220 pt column), and the
Create New Mask grid needed 226 pt: the panel's contents grew wider than
the panel (a 250 pt panel came out 275 pt wide) and were drawn
shifted left, clipped by the photo area. The action row now wraps, the
tile grid sizes its tiles and columns to the available width (four tiles
of 48–52 pt, else three), and a component's name is truncated before its
options button. A headless test checks that every widget stays inside the
right panel at 250, 270, 330 and 520 pt, with and without a selected mask
and with the brush settings open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:41:42 -04:00
Brandon ThomasandClaude Opus 5.5 e95e9259ea Never crash: steering rule, clippy.toml for the no-panic lints
AGENTS.md/CLAUDE.md gain a "Never crash" section ahead of the
non-negotiables (no unwrap/expect/panic/unreachable/todo/unimplemented or
unsafe in non-test code, Result + ?, checked indexing and arithmetic on
input-derived values, bounded recursion, lock poisoning, the last-resort
guard, a regression test per crash fix), linking
craftrules/standards/never-crash.md. README's Contributing list points to it.

clippy.toml allows unwrap/expect/panic/indexing in tests only, so crates
can carry #![deny(clippy::unwrap_used, ...)] without touching their tests.

unsafe_code stays "deny" for now: apps/lightcraft and apps/lightcraft-cli
call malloc_zone_pressure_relief (libSystem) through a two-line FFI block
to return freed memory on macOS, and no safe wrapper crate exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:39:13 -04:00
Brandon ThomasandClaude Opus 5.5 24cf3abb62 Issue #32: Import: custom date-based folder template for Copy
library.import organize now also takes a folder template, e.g.
{date:%Y}/{date:%Y%m%d} -> 2026/20260114 (Organize::Template). The
template's own / (or \) make the folder levels; each level is expanded
with the rename tokens and sanitized, so metadata can never add a level or
climb out of the destination; absolute paths, drive letters, ~ and . / ..
levels are refused. Dated by capture time, else the import time; a level
whose metadata is missing becomes 'unknown'. The import dialog's Folders
menu gains 'Custom template...' with a template field, tag help, live
validation and an example full destination for every mode; the dialog
stays open (with a toast) when the import is refused. The MCP import
helper passes mode/destination/organize/rename through (docs/mcp.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:36:38 -04:00
Brandon ThomasandClaude Opus 5.5 d6476e7497 Issue #20: Panels: resize the left sidebar and the right panel by dragging their edge
Both side panels had an exact width. They are now resizable egui panels
whose width lives in UiState (leftWidth / rightWidth, serde, sanitized on
load) so it survives panel switches, view changes and restarts; egui's
own remembered size is dropped each frame and only a drag of the edge
writes back (measured from the fixed edge to the pointer, so content
wider than the panel can't creep it). Limits: left 200–480 pt, right
250–520 pt, and the photo area keeps at least 360 pt; a narrower window
shrinks the panels without forgetting the chosen width. The bottom bar,
which sits between the panels, now drops Copy/Paste Edit Settings and
then the rating pill when its centre has no room instead of overlapping
the zoom controls. The unused left_w theme token is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:18:04 -04:00
Brandon ThomasandClaude Opus 5.5 a16a81ad08 Issue #21: Local: kept folders stay roots while browsing below them
Browsing a folder inside a listed Local location (built-in or kept) no
longer adds it as a root of its own: it is highlighted inside that
location's tree, which opens down to it once per browsed folder, so its
ancestors and siblings stay reachable. A folder outside every listed
location gets a session-only row that stays while browsing below it and
offers Keep in Local; subfolder rows offer Add to Local. New UI command
local.addRoot keeps a folder in Local (saved with ui.json, so it
survives restarts; it un-hides it too); Browse Folder... uses it.
Removal stays sidebar-only (local.hide).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:15:12 -04:00
Brandon ThomasandClaude Opus 5.5 8be82cacf4 Issue #31: Import: tag help beside the Rename field
rename::TOKENS / DATE_DIRECTIVES / TEMPLATE_NOTES are now the one list of
file-name template tags (a test checks expand_tokens knows each, and that
the rename/import command descriptions mention them all). A Tags toggle
beside the import Rename field (also Rename Photos and export File name)
lists every tag with its meaning and a live example, the {date:...}
directives and the rules (extension appended, blank keeps names, missing
date/metadata, unknown tags stay literal); clicking a tag inserts it at
the text cursor. Unknown tags are flagged under the field. New query
command photo.renameTokens returns the same help as JSON. The import
preview now uses the probe's metadata and the import time for undated
photos, as the import itself does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:04:27 -04:00
Brandon ThomasandClaude Opus 5.5 ce3e2215fe Issue #22: Local: recognise equivalent folder paths
Local compared folder paths as raw strings, so a built-in location built
as "{home}/{sub}" and the same folder returned by library.browse with
native separators could be listed twice. Folder identity now goes
through catalog::query::folder_key (both separators, trailing/doubled
separators, ./.., verbatim and UNC prefixes, drive-letter case; whole
path case-folded on Windows): the sidebar's root list, the highlighted
and expanded-on-the-way folder, saved Browse Folder roots and
local.hide / local.restoreHidden all compare by it. Built-in paths are
joined with the platform separator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:04:21 -04:00
storybored379andClaude Opus 5.5 18153682f2 LRC-LIB-METADATA: Copyright Status, Rights Usage Terms and Copyright Info URL
Catalog Meta gets copyright_status (unknown / copyrighted / publicDomain), usage_terms and
copyright_url, read and written as XMP Rights Management fields (xmpRights:Marked, UsageTerms,
WebStatement) on import, sidecars and exports (kept by "copyright only"). photo.setMeta,
copy/paste metadata, metadata presets, a Copyright Status smart-album rule and the Info panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:01:00 -07:00
Brandon ThomasandClaude Opus 5.5 889583433c Issue #36: cache smart-album counts in the sidebar
The album rows read their counts from Caches::album_counts, recomputed
only when the catalog revision changes (photos, metadata and album rules
all bump it) and, while a smart album uses an "in the last..." rule,
when the session clock enters a new minute. Catalog::album_count counts
smart-album matches in place instead of building the id list for its
length. RuleSet/Filter::depends_on_now tell clock-relative rules apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:10 -04:00
Brandon ThomasandClaude Opus 5.5 1a6a9de7ee Issue #33: Devices: serve the cached card list and rescan in the background
Menus are rebuilt every frame, and File → Add from Device called
devices(), which scanned every mount (D:..Z: on Windows) while holding
the cache mutex once its 2 s cache expired. devices() now returns the
last scan at once and starts at most one background rescan when it is
stale; the scan runs without the lock. A changed result calls an
on_change hook (the UI requests a repaint), so hot-plugged cards still
appear. library.devices and Add from Device without a path scan
synchronously (devices_now). Volume roots are probed for DCIM directly
instead of listing them (Windows never lists a root). No threads or
clock on wasm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:50:29 -04:00
Brandon ThomasandClaude Opus 5.5 37ab41478d Issue #19: XMP sidecar capture time fills in when the file has none
A sidecar's exif:DateTimeOriginal / photoshop:DateCreated / xmp:CreateDate
now reaches Photo.captured on import and on Read Metadata from File when
the file has no embedded capture time (embedded wins). On import the
sidecar is read before copying, so the date also files and names copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:40:54 -04:00
Brandon ThomasandClaude Opus 5.5 20d156c878 Issue #34: Missing Photos checks library photos only
Missing Photos (sidebar count, the view, library.missing, Find Missing)
now shares one scope rule, cmd::missing::checked_path: non-deleted
library photos backed by a file. Local browse records, which can
outnumber the library by orders of magnitude, are never stat'ed. The
Missing view checks only the photos its query kept instead of every
catalog record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:39:11 -04:00
Brandon ThomasandClaude Opus 5.5 7c662bc5d6 Issue #27: Menus: keep Quit last in the in-window File menu
Registered File commands the explicit layout doesn't list were appended
after Quit LightCraft. Items in LAST (app.quit) are now held back and
placed after the fallback entries, in their own separator group. The
macOS native menu filters Quit into the app menu and is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:38:44 -04:00
Brandon Thomas fb8e1e93aa Merge pull request #46 from GlemNL/fix/issue-24-hide-local-locations
Local: Remove from Local hides a sidebar location, with a way to restore
2026-10-04 10:24:27 -04:00
Brandon Thomas 5b73e4f08b Merge main into fix/issue-24-hide-local-locations
# Conflicts:
#	crates/ui-egui/src/panels/left.rs
2026-10-04 10:24:12 -04:00
Brandon Thomas 0467eb6c58 Merge pull request #43 from GlemNL/fix/issue-23-local-folder-async
Local: browse a folder in the background with progress
2026-10-04 10:24:03 -04:00
Brandon ThomasandClaude Opus 5.5 65bc8bcb42 Headless: a running folder scan counts as busy, so settle waits for a background browse
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:19:54 -04:00
Brandon ThomasandClaude Opus 5.5 4267a68b30 Local: use ? for the browse path (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:19:54 -04:00
Brandon ThomasandClaude Opus 5.5 3ac1e55a6c Local: drop the needless struct update in library.import (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:19:54 -04:00
Brandon Thomas 94fd0e6994 Merge main into fix/issue-23-local-folder-async
# Conflicts:
#	crates/ui-egui/src/panels/grid.rs
2026-10-04 10:19:54 -04:00
Brandon Thomas 4b863e9d47 Merge pull request #45 from GlemNL/fix/issue-25-active-filters
Show active filters as chips and count matching of total photos
2026-10-04 10:19:04 -04:00
Brandon Thomas cdaacc1e83 Merge pull request #48 from GlemNL/fix/issue-26-smart-previews-location
Smart Previews: choose the folder per library
2026-10-04 10:18:57 -04:00
Brandon Thomas e1857511af Merge pull request #44 from storybored379/feat/export-naming-tokens
LR-EXP-NAMING: export file names use the Rename Photos tokens (+ num, folder, lens, iso, rating, creator)
2026-10-04 10:18:50 -04:00
Brandon Thomas 1d7d1f66ab Merge pull request #49 from GlemNL/fix/issue-28-menu-spacing
UI: more spacing between in-window menu headings
2026-10-04 10:18:44 -04:00
Brandon Thomas cea57e2dc7 Merge pull request #42 from storybored379/pr/mcp-crop-requires-args
MCP: crop returns an error when given no rect, angle or reset instead of a silent no-op
2026-10-04 10:18:37 -04:00
Brandon Thomas 14594ee635 Merge pull request #50 from storytold/pr/license-standard
Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms
2026-10-04 10:09:00 -04:00
Brandon ThomasandClaude Opus 5.5 320bcad19a Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms
Add LICENSE-MIT and LICENSE-APACHE (Copyright (c) 2026 ArtCraft Team and the
LightCraft contributors) and docs/brand/LICENSE-brand.txt from the Crafting Apps
licensing standard. Rewrite NOTICE to the standard template and drop the stale
Source Sans 3 entry (only Inter is bundled). Attribute docs/brand/ in
assets/ATTRIBUTION.md so `cargo xtask assets` passes again. README gets the
standard "License and credits" section; AGENTS.md/CLAUDE.md note the brand
exception to the asset rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:05:02 -04:00
Clément LandClaude Sonnet 5.5 00766f8c21 UI: more spacing between in-window menu headings (#28)
The headings are frameless buttons, which egui renders without padding, so
the gap now comes from the layout's item spacing (24 px). The width check
that falls back to the single "Menu" button uses the same gap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:42:39 +02:00
Clément LandClaude Sonnet 5.5 78f627228f Smart Previews: choose the folder per library, with a move/leave/delete choice (#26)
Smart previews always lived in <library>/Smart Previews, so a large set of proxies
filled the library's drive. The folder is now a per-library setting (prefs.json):

- library.smartPreviewsLocation shows the effective folder (path, count, size,
  availability) or changes it; changing it needs an explicit choice for the
  previews already built (move, leave or discard), and reset returns to the
  library's own folder.
- The new folder must accept writes before anything moves. A folder whose parent
  is missing (an unplugged drive) is an error and is never created on the current
  drive; building also refuses while a chosen folder is unavailable.
- Building, discarding, status and offline rendering all use the chosen folder.
- Settings -> Performance -> Smart previews shows the path before building, what
  it holds, the existing-previews choice, Choose Folder... and Use Library Folder.

The thumbnail cache stays in the library.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:21:25 +02:00
Clément LandClaude Sonnet 5.5 1c50d538c3 Local: Remove from Local hides a sidebar location; a row brings hidden ones back (#24)
Right-clicking a top-level Local location (built-in or the browsed folder) offers
Remove from Local. It only hides the shortcut: the folder, its photos and catalog
membership are untouched. The hidden list is saved with the UI state. While any
location is hidden, "Show N hidden locations" under Browse Folder… restores them,
and choosing a hidden folder with Browse Folder… puts it back. Both actions are
commands (local.hide, local.restoreHidden).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:10:24 +02:00
Clément LandClaude Sonnet 5.5 431186a24e Library: show active filters as removable chips and count matching of total photos (#25)
A filter set from the sidebar (By Date), the search field or the filter bar
narrows the current source, but nothing said so: a folder with a hidden date
filter looked empty.

- Chips under the grid header, one per active filter, each with an X that
  clears only that filter; "+N more" lists the ones that do not fit; Clear all.
- The header counts "N of M photos" (matching of the source's total) while a
  filter is on; the total is cached per source/catalog revision.
- A badge on the top-bar filter icon counts the active filters, filter bar
  open or not.
- An empty result says no photos match the active filters, and how many the
  source holds, instead of implying the folder is empty.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:53:25 +02:00
Clément LandClaude Sonnet 5.5 257a982678 Say when a filter hides the photos of an empty grid or filmstrip
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:42:34 +02:00
Clément LandClaude Sonnet 5.5 d6be9f6342 Local: clicking the folder being read again keeps the running read instead of restarting it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:35:48 +02:00
Clément LandClaude Sonnet 5.5 e076f65df8 Sidebar: count missing files on a worker thread; read browsed folders in smaller batches
The Missing count stat()ed every catalog photo on the UI thread, which on a network share
(and with browsed NAS photos in the catalog) froze the window for seconds during a folder
browse. It now runs in the background and shows the last finished count.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:32:18 +02:00
storybored379andClaude Opus 5.5 309922eac9 LR-EXP-NAMING: export file names use the Rename Photos tokens
rename::expand_tokens is shared by Rename Photos, import renaming and export naming, so {seq:N},
{date:%…}, {camera}, {title}, {ext} work in exports too (a bare {seq} still pads to 3 digits there).
New shared tokens: {num} (number at the end of the name), {folder}, {lens}, {iso}, {rating},
{creator}. An export name that expands to nothing keeps the original name. The MCP export tool
passes startNumber through and documents the tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:26:57 -07:00
Clément LandClaude Sonnet 5.5 919e65efaf Local: browse a folder in the background with progress; keep picked folders in the sidebar
Browse Folder listed, probed and imported the whole folder on the UI thread, so a
network share froze the window with no feedback. It now lists and reads on a worker
(progress window with Cancel), then adds the photos in small batches reusing the
scan's probes. Folders picked with Browse Folder… stay in the Local list after
browsing elsewhere.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:22:47 +02:00
storybored379andClaude Opus 5.5 d53025cc34 MCP: crop returns an error when given no rect, angle or reset instead of a silent no-op
The crop tool fell through to crop.set with an empty object when none of
its parameters were given, so a call with a guessed parameter (e.g.
`aspect: "1:1"`) or `reset: false` answered "ok" and left the photo
unchanged. It now returns an error naming the parameters it needs, as
set_develop already does for an empty call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
Brandon Thomas e3b61fc883 Merge pull request #41 from storytold/fix/issue-12-14-curve
Tone curve: drag points, reset curves, point-curve presets (fixes #12, #13, #14)
2026-10-04 03:05:47 -04:00
Brandon Thomas b80bec6e3c Merge pull request #40 from storytold/fix/issue-10-preview-only
Mark raws shown from their embedded preview as "preview only" (refs #10)
2026-10-04 03:05:18 -04:00
Brandon Thomas ad0b947f07 Merge pull request #39 from storytold/fix/issue-9-luminar
Import Luminar looks (.lmp, .mplumpack) (fixes #9)
2026-10-04 03:04:50 -04:00
Brandon ThomasandClaude Opus 5.5 966db71295 LR-EDIT-CURVE: point-curve presets (built-in, save, delete, import/export)
A Point Curve dropdown under the curve graph shows the preset the photo's
point curves match (or Custom) and lists our own built-ins (Linear, Medium
Contrast, Strong Contrast: symmetric S-curves, values chosen here) and the
user's presets; Save Point Curve... names and stores the current four
channels, right-click deletes, Import/Export Presets... read and write
.lccurve JSON files. Presets set only the point curves (parametric curve and
Refine Saturation stay) and are saved with the library preferences.

Engine commands: curve.presets, curve.applyPreset, curve.savePreset,
curve.deletePreset, curve.importPresets, curve.exportPresets; UI commands
file.importCurvePresets / file.exportCurvePresets (file dialogs on desktop).

Fixes #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon ThomasandClaude Opus 5.5 1169a1beda LR-IMP-FORMATS: mark raws shown from their embedded preview as "preview only"
Raw variants the decoder can't read yet (e.g. Nikon Huffman-compressed NEF
from a D3200/D5100) silently fell back to the camera's embedded JPEG, so
users edited a JPEG with the camera's picture style (e.g. Monochrome) baked
in without being told.

- catalog: Photo.preview_only (serde default; old catalogs load), set /
  cleared by Op::SetContent with an exact inverse; Photo::develops_raw().
- engine: ProbeInfo.preview_only carries the decoder's Unsupported reason
  into import, the import review (previewOnly) and catalog.query
  (previewOnly); photo.inspect includes it. photo.reload and photo.relink
  re-probe and clear/set it when the file decodes (or not) now.
- source_info: a preview-only raw is a rendered source (relative WB scale,
  display tone curve) instead of raw:true/5500 K + filmic tone map on a
  display-referred JPEG; import uses the JPEG default preset for it; the
  Edit panel shows the rendered-file WB sliders.
- UI: "Preview" badge on grid thumbnails, a pill on the loupe, a notice in
  Edit and Info (amber caution token, Info icon drawn in code).
- Tests: catalog serde/undo, engine import -> reopen -> reload/relink,
  headless UI badge/notice widgets.

Refs #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon ThomasandClaude Opus 5.5 4a70fcfe4f LR-PRE-IMPORT: bound plist nesting (a hostile look can't overflow the stack)
Nested <dict>/<array> recursed without limit; a crafted .lmp with deep
nesting would crash the app on import. Reading stops with an error past
64 levels. Test: hostile_nesting_is_an_error_not_a_crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon ThomasandClaude Opus 5.5 ae29c0569f LR-EDIT-CURVE: reset a curve channel or the whole tone curve
New engine command curve.reset {channel?: all|point|parametric|master|red|
green|blue} (default all = the whole Tone Curve), one undo step, usable from
CLI / MCP / control channel. In the editor: double-click a channel selector
resets that channel, a Reset button under the graph resets every curve
(point curves and parametric), and right-clicking the graph offers
Reset <channel> / Reset All Curves.

Fixes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon ThomasandClaude Opus 5.5 6c3c155c99 LR-PRE-IMPORT: import Luminar looks (.lmp, .mplumpack)
An .lmp look is an XML property list of adjustment layers with named
sliders (newer looks: a Name.lmp/Contents/preset.lmp bundle); an
.mplumpack is a zip of looks plus PresetsInfo.plist (GroupName).
preset.import, the file dialog and drag & drop now accept both. Sliders
with a clear counterpart (exposure, contrast, highlights/shadows,
whites/blacks, WB shift, saturation/vibrance, clarity/structure,
dehaze, HSL, point curves, vignette, grain) map onto develop settings;
the rest is reported in `unmapped`. Looks are named after the look and
grouped by the pack. Tests use synthetic looks and packs only.

Fixes #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon ThomasandClaude Opus 5.5 e1acdb876d LR-EDIT-CURVE: drag point-curve points in both axes
The editor stored the dragged point index as Option<usize> but read it back
as usize, so the index was lost after the first frame: pressing on a point
did nothing and only click-to-add (overwrite) worked. Points now drag in X
and Y (X clamped between the neighbours), picked at the press origin, with a
grab cursor, a highlighted point and an input/output readout; one undo step
per drag via develop.beginInteraction / endInteraction.

Fixes #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon Thomas 6eb5335a27 Merge pull request #38 from storytold/fix/issue-11-scroll
Grid and filmstrip keep your scroll position; mouse wheel scrolls the filmstrip (fixes #11)
2026-10-04 02:58:28 -04:00
Brandon ThomasandClaude Opus 5.5 103f53724f LR-FILMSTRIP: grid and filmstrip keep the user's scroll position; wheel scrolls the filmstrip
Fixes #11.

Root cause: the grid and the filmstrip remembered the last active photo in egui temp data
with mismatched types (inserted `Option<PhotoId>`, read back as `PhotoId` because of the
`let last: Option<PhotoId> = get_temp(..)` annotation), so the read always missed, "the
active photo changed" was true on every frame and both called `scroll_to_rect` on the
active photo every frame. Any wheel or scroll-bar scroll was undone as soon as the user
stopped (the animated scroll back read as a delayed snap).

- `grid::follow_active` (shared by grid and filmstrip): scroll to the active photo only
  when it changed since the strip was last drawn, or when the strip was not drawn on the
  previous pass (view switch). Time passing, finished renders, edits and a released scroll
  bar no longer move either strip.
- Filmstrip: a vertical mouse wheel scrolls it sideways (horizontal trackpad scrolling
  still works); it recentres on a new active photo only when that is off screen, so a
  click on a visible thumbnail leaves it alone.
- `ui.inspect` reports `scroll: {grid, filmstrip}`; headless tests in `tests_scroll.rs`.
- docs/parity.md (LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP), docs/control-protocol.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:56:32 -04:00
Brandon Thomas 296c67bf9b Merge pull request #17 from storytold/fix/issue-8-export-dialog-width
Export dialog no longer widens every frame (fixes #8)
2026-10-04 02:47:12 -04:00
Brandon Thomas 0a1e38db70 Merge pull request #16 from storytold/fix/issue-7-windows-console
Windows: the desktop app opens no console window (fixes #7)
2026-10-04 02:32:06 -04:00
Brandon ThomasandClaude Opus 5.5 b8c2d16a06 LR-EXP-DIALOG: export dialog no longer widens every frame
Rows with a trailing button (Folder / Choose…, watermark Graphic /
Choose…, Save preset / Save) sized their text field as the available
width minus a guessed button width. When the real button was wider
(e.g. Linux fonts, a folder picker present), the auto-sized dialog grew
a little every frame. These rows now lay out right to left: the button
first, then a field taking exactly the width that's left.

Test: export_dialog_keeps_its_width (grew 492 → 693 px in 60 frames
before; stable now).

Fixes #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:31:34 -04:00
Brandon ThomasandClaude Opus 5.5 3f8dbb41c0 Windows: the desktop app opens no console window
Release builds of lightcraft.exe are GUI-subsystem programs
(windows_subsystem = "windows"), so launching the app no longer opens
a terminal. Debug builds keep the console; lightcraft-cli.exe stays a
console program for terminal use (--help / --version / scripting).
Checked with cargo check --release --target x86_64-pc-windows-msvc.

Fixes #7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:31:34 -04:00
Brandon Thomas f5a4bb609d Merge pull request #1 from leipsfur/fix/arw-lossless-tiles
M3.7: Sony lossless-compressed ARW tiles (2×2 CFA cells per LJ92 sample)
2026-10-04 01:39:29 -04:00
Brandon Thomas c0794b95e8 Merge remote-tracking branch 'origin/main' into review/pr-1 2026-10-04 01:36:45 -04:00
Brandon Thomas c5c08c6af9 Merge pull request #15 from storybored379/pr/library-state-album-source
Library: an album source serializes (library.state no longer panics)
2026-10-04 01:26:26 -04:00
Brandon ThomasandClaude Opus 5.5 d6eac6685c M3.7: header probe agrees with decode on subsampled lossless JPEG; lossless ARW corpus samples
- tiffraw: Mode::Header parses the first LJ92 frame header (Compression 7),
  so probe_info reports Unsupported for subsampled frames (Sony lossless
  M/S) as the full decode does, instead of claiming the file decodes.
- corpus: CC0 ILCE-7M4 lossless L/M/S samples from raw.pixls.us in
  `cargo xtask corpus`; M/S listed as known unsupported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:21:33 -04:00
Brandon Thomas 2956f97352 Merge branch 'pr-1' into review/pr-1 2026-10-04 01:13:20 -04:00
storybored379andClaude Opus 5.5 6a83818192 Library: an album source serializes (library.state no longer panics)
LibrarySource was an internally tagged enum (tag = "kind") whose Album
variant holds an AlbumId, and serde cannot serialize an integer newtype
in an internally tagged enum. With an album open:

- library.state unwrapped the error and panicked; over the control
  channel this closed the desktop app.
- view.json (source, filter, sort, selection) was written empty
  (to_vec_pretty(...).unwrap_or_default()), so the view was lost on the
  next launch.

Adjacent tagging (content = "id") keeps the unit variants as
{"kind": "all"} and writes the album as {"kind": "album", "id": 8}: the
same shape library.source takes, so the reported source can be passed
back as is. Existing view.json files read unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 17:38:43 -07:00
Brandon ThomasandClaude Opus 5.5 8146a11cec LR-IMP-ADD-DIALOG: folder scan follow-ups (Cancel, busy guard, inspect)
- Cancel drops the scan at once (the worker stops at its next file) and
  has a widget id (button:scanCancel) for agents.
- Add from Device while a scan runs no longer flips the running scan to
  copy-into-library.
- ui.inspect reports the scan's {done, total}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:07:44 -04:00
Brandon Thomas fc66026ef0 Merge pull request #5 from GlemNL/feat/click-zoom-ratio
Loupe: animated click-zoom with selectable 2:1 / 3:1 / 5:1 ratio
2026-10-03 19:07:36 -04:00
Brandon ThomasandClaude Opus 5.5 a4b168550d Loupe click-zoom: 1:1 default, Z/Space use the ratio, render once at the final size
Review follow-ups on the contributor's click-zoom:
- Ratios 1:1 (default), 2:1, 3:1, 4:1, 8:1; set through the new
  view.clickZoom {ratio} command (bottom-bar dropdown, CLI, MCP).
- Z / Space zoom to the same ratio as a click, with the same ease.
- The loupe requests its render at the final size; the animation only
  changes how it is drawn (no render per animation frame). The test
  checks every animation frame asks for one render.
- docs/parity.md: LR-VIEW-ZOOM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:07:18 -04:00
Brandon Thomas b49e32c094 Merge remote-tracking branch 'origin/main' into pr5-work 2026-10-03 18:36:14 -04:00
Brandon Thomas 542343099e Merge pull request #4 from GlemNL/feat/crop-double-click-apply
Crop: double-click inside the crop box applies the crop
2026-10-03 18:35:46 -04:00
Brandon Thomas ad567f6632 Merge pull request #6 from GlemNL/feat/zoomed-scrub-cursor
Loupe: zoomed Grab cursor only over the photo
2026-10-03 18:35:27 -04:00
Brandon Thomas d473a4d9bf Merge pull request #2 from GlemNL/fix/nas-folder-import
Import: scan folders in the background (fixes Add Folder on network shares)
2026-10-03 18:15:21 -04:00
Clément LandClaude Sonnet 5.5 97ba282044 Import: update copy-import test for the background folder scan
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:25:05 +02:00
Clément LandClaude Sonnet 5.5 3e70c0edfe Loupe: zoomed Grab cursor only over the photo, so slider cursor wins in side panels
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:12:35 +02:00
Clément LandClaude Sonnet 5.5 7b8df44947 Loupe: animated click-zoom with selectable 2:1 / 3:1 / 5:1 ratio
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:04:58 +02:00
Clément LandClaude Sonnet 5.5 ca1f4dbd8a Crop: double-click inside the crop box applies the crop
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:58:04 +02:00
Clément LandClaude Sonnet 5.5 5828a7d2d3 Import: scan folders on a background thread with progress and Cancel
Add Folder read every file on the UI thread before opening the review; on a
NAS (~26k files) the window froze and was dropped. The scan now runs in a
worker (ScanInput/scan_with) and the review opens when it finishes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:43:02 +02:00
Christian BretzandClaude Opus 5.5 5d143cf108 M3.7: Sony lossless-compressed ARW tiles (2×2 CFA cells per LJ92 sample)
Sony's lossless-compressed raw (Compression 7, e.g. ILCE-7M4) stores
512×512 tiles as LJ92 frames of 256×256 with four components: each
frame sample is one 2×2 CFA cell (top-left, top-right, bottom-left,
bottom-right). The generic TIFF path reads a frame as a row-major sample
stream (DNG's convention), which interleaved each tile's left and right
halves row by row: vertical bands, scrambled CFA, purple cast.

arw.rs now detects the layout from the first tile's frame header and
places the cells itself; other lossless-JPEG layouts keep the generic
path. Component order verified on a real ILCE-7M4 file (green
differences to the diagonal neighbour cell match those within a cell).
RENDER_CACHE_VERSION bumped so cached thumbnails of affected files are
re-rendered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCLtGBctp1CxX2MdCuKCrm
2026-10-02 17:51:58 +02:00
163 changed files with 10225 additions and 1052 deletions

No files matched your search

+29
View File
@@ -17,6 +17,34 @@ branches can still break each other (e.g. a new struct field vs. a new construct
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
and often so a crash loses minutes, not hours.
## Never crash (outranks feature work)
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
of it. Full standard: `../craftrules/standards/never-crash.md`
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
+ `.expect("why it can't fail")`.
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
fallback where possible, and a line here naming it.
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
integration tests, examples and benches. New crates start with the attribute.
## Non-negotiables
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
- **Pure Rust** in the product. No C/C++ dependencies.
@@ -31,6 +59,7 @@ and often so a crash loses minutes, not hours.
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
+29
View File
@@ -17,6 +17,34 @@ branches can still break each other (e.g. a new struct field vs. a new construct
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
and often so a crash loses minutes, not hours.
## Never crash (outranks feature work)
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
of it. Full standard: `../craftrules/standards/never-crash.md`
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
+ `.expect("why it can't fail")`.
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
fallback where possible, and a line here naming it.
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
integration tests, examples and benches. New crates start with the attribute.
## Non-negotiables
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
- **Pure Rust** in the product. No C/C++ dependencies.
@@ -31,6 +59,7 @@ and often so a crash loses minutes, not hours.
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
Generated
+28 -21
View File
@@ -2073,13 +2073,14 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"eframe",
"egui",
"lightcraft-codecs",
"lightcraft-engine",
"lightcraft-raster",
"lightcraft-sysmem",
"lightcraft-ui-egui",
"log",
"muda",
@@ -2090,7 +2091,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -2099,11 +2100,12 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"web-time",
]
[[package]]
name = "lightcraft-cli"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -2111,13 +2113,14 @@ dependencies = [
"lightcraft-mcp",
"lightcraft-merge",
"lightcraft-raster",
"lightcraft-sysmem",
"lightcraft-ui-egui",
"serde_json",
]
[[package]]
name = "lightcraft-codecs"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"bytemuck",
"image",
@@ -2142,7 +2145,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -2151,7 +2154,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -2162,7 +2165,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"ab_glyph",
"libc",
@@ -2189,7 +2192,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@@ -2197,7 +2200,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -2213,7 +2216,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -2224,7 +2227,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -2240,7 +2243,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -2252,7 +2255,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"image",
"lightcraft-color",
@@ -2268,7 +2271,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -2280,7 +2283,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -2292,7 +2295,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"jpeg-encoder",
"lightcraft-color",
@@ -2311,7 +2314,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"image",
"lightcraft-color",
@@ -2320,9 +2323,13 @@ dependencies = [
"serde",
]
[[package]]
name = "lightcraft-sysmem"
version = "0.2.0"
[[package]]
name = "lightcraft-tiff"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@@ -2331,7 +2338,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"egui",
"lightcraft-catalog",
@@ -2351,7 +2358,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"eframe",
"egui",
@@ -5119,7 +5126,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"brotli",
"flate2",
+2 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.1"
version = "0.2.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -12,6 +12,7 @@ repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
# internal
lightcraft-geom = { path = "crates/geom" }
lightcraft-sysmem = { path = "crates/sysmem" }
lightcraft-color = { path = "crates/color" }
lightcraft-raster = { path = "crates/raster" }
lightcraft-tiff = { path = "crates/tiff" }
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11 -7
View File
@@ -1,12 +1,16 @@
LightCraft
Copyright the LightCraft contributors. Licensed under MIT OR Apache-2.0.
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
This product bundles third-party material under the following licences:
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
- Source Sans 3 (assets/fonts/SourceSans3-*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt.
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Third-party material
Every bundled asset (fonts, icons, images) and its licence is listed in assets/ATTRIBUTION.md.
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in assets/ATTRIBUTION.md.
Algorithms are implemented from public specifications and papers (see crate READMEs). Rust dependencies
(egui, eframe, image, rayon, serde, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
+14 -2
View File
@@ -347,6 +347,9 @@ Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. T
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit.
- **Pure Rust**, enforced crate layering, everything is a command, and `cargo xtask ci` green before every commit
(one task id per commit).
- **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`, `panic!` or
`unsafe`, checked indexing on anything derived from input, and a regression test with every crash fix. Details in
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work).
Questions, ideas or a bug you'd like to talk through first? Bring them to [Discord](https://discord.gg/artcraft).
@@ -396,12 +399,21 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
## License and credits
LightCraft is licensed under **MIT OR Apache-2.0**.
LightCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. Required notices are in [NOTICE](NOTICE).
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River*
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
Inter (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
Inter (SIL OFL 1.1). All icons are original.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
+1
View File
@@ -15,6 +15,7 @@ workspace = true
[dependencies]
lightcraft-engine = { workspace = true }
lightcraft-sysmem = { workspace = true }
lightcraft-mcp = { workspace = true }
lightcraft-ui-egui = { workspace = true }
lightcraft-codecs = { workspace = true }
+6 -26
View File
@@ -1,32 +1,12 @@
//! Give memory the system allocator keeps after frees back to the system
//! ([`lightcraft_engine::memory::set_release_hook`]).
//!
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
//! process's memory footprint but stay in its resident size until the system runs short. After a
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
/// Install the hook for this platform.
/// Install the hook.
pub fn install() {
#[cfg(target_os = "macos")]
lightcraft_engine::memory::set_release_hook(macos::release);
lightcraft_engine::memory::set_release_hook(release);
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos {
unsafe extern "C" {
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
}
pub fn release() {
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
// it only returns free pages to the system and touches no memory we own.
unsafe {
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
}
}
fn release() {
let _ = lightcraft_sysmem::release_free_memory();
}
+8 -2
View File
@@ -10,6 +10,8 @@
//! lightcraft-cli commands [--json]
//! lightcraft-cli controls [--json]
//! ```
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;
@@ -283,14 +285,18 @@ fn synth_merge(args: &[String]) -> Result<(), String> {
let mut written = Vec::new();
match kind {
"hdr" => {
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).map_err(|e| e.to_string())?.into_iter().enumerate() {
let p = Path::new(&dir).join(format!("bracket-{k}.dng"));
std::fs::write(&p, b).map_err(|e| e.to_string())?;
written.push(p);
}
}
"panorama" | "pano" => {
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]).into_iter().enumerate() {
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0])
.map_err(|e| e.to_string())?
.into_iter()
.enumerate()
{
let p = Path::new(&dir).join(format!("view-{k}.png"));
let img = v.to_srgb8();
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default())
+1
View File
@@ -14,6 +14,7 @@
//! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables
//! persistence and `?workers=0` renders on the main thread.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod bench;
pub mod files;
+20 -12
View File
@@ -29,17 +29,18 @@ const LIBRARY_DIR: &str = "library";
/// How often view state, UI prefs and the thumbnail index are saved (ms).
const SAVE_EVERY_MS: f64 = 1000.0;
fn window() -> web_sys::Window {
web_sys::window().expect("no window")
/// The page's window (`None` only outside a browser page, e.g. in a worker).
fn window() -> Option<web_sys::Window> {
web_sys::window()
}
/// Milliseconds since navigation start.
fn perf_now() -> f64 {
window().performance().map(|p| p.now()).unwrap_or(0.0)
window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
}
fn set_status(text: &str) {
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_status")) {
el.set_text_content(Some(text));
}
}
@@ -59,7 +60,7 @@ struct Options {
impl Options {
fn from_url() -> Options {
let q = window().location().search().unwrap_or_default();
let q = window().and_then(|w| w.location().search().ok()).unwrap_or_default();
let params: Vec<(String, String)> = q
.trim_start_matches('?')
.split('&')
@@ -102,7 +103,7 @@ async fn read_file(originals: Originals, backend: Option<Backend>, file: web_sys
/// Show the browser's open dialog; picked files are imported asynchronously.
fn open_picker(originals: Originals, backend: Option<Backend>, ctx: egui::Context) {
let Some(doc) = window().document() else { return };
let Some(doc) = window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_multiple(true);
@@ -131,7 +132,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
opts.set_type(crate::store::mime_for(name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?;
let doc = window().document().ok_or("no document")?;
let doc = window().and_then(|w| w.document()).ok_or("no document")?;
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
a.set_href(&url);
a.set_download(name);
@@ -140,7 +141,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let revoke = Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
if let Some(w) = window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
}
log::info!("exported {name} ({} bytes)", bytes.len());
Ok(())
}
@@ -153,7 +156,10 @@ 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,
write: Some(Box::new(download)),
// downloads happen on the main thread: exports run in the foreground on the web
write_shared: None,
@@ -313,7 +319,7 @@ impl WebApp {
}
app.ui = app.ui.sanitized();
let n = opts.workers.unwrap_or_else(|| {
let cores = window().navigator().hardware_concurrency() as usize;
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
cores.saturating_sub(1).clamp(1, 4)
});
let workers =
@@ -514,8 +520,10 @@ pub fn start() {
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
let opts = Options::from_url();
wasm_bindgen_futures::spawn_local(async move {
let Some(canvas) =
window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
let Some(canvas) = window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
log::error!("missing <canvas id=\"lightcraft_canvas\">");
return;
@@ -525,7 +533,7 @@ pub fn start() {
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
match r {
Ok(()) => {
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
el.remove();
}
}
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
lightcraft-engine = { workspace = true }
lightcraft-sysmem = { workspace = true }
lightcraft-ui-egui = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raster = { workspace = true }
+6 -26
View File
@@ -1,32 +1,12 @@
//! Give memory the system allocator keeps after frees back to the system
//! ([`lightcraft_engine::memory::set_release_hook`]).
//!
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
//! process's memory footprint but stay in its resident size until the system runs short. After a
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
/// Install the hook for this platform.
/// Install the hook.
pub fn install() {
#[cfg(target_os = "macos")]
lightcraft_engine::memory::set_release_hook(macos::release);
lightcraft_engine::memory::set_release_hook(release);
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos {
unsafe extern "C" {
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
}
pub fn release() {
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
// it only returns free pages to the system and touches no memory we own.
unsafe {
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
}
}
fn release() {
let _ = lightcraft_sysmem::release_free_memory();
}
+35 -1
View File
@@ -12,6 +12,13 @@
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `lightcraft_ui_egui::control` for the methods.
//!
//! On Windows, release builds are GUI-subsystem programs: launching the app opens no console
//! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to
//! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;
mod control_server;
@@ -161,13 +168,21 @@ fn services() -> Services {
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Import Presets")
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "cube"])
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
.pick_files()
.unwrap_or_default()
.into_iter()
.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")
@@ -176,6 +191,24 @@ fn services() -> Services {
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Import Point Curve Presets")
.add_filter("Point Curve Presets", &["lccurve", "json"])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_curve_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title("Export Point Curve Presets")
.add_filter("Point Curve Presets", &["lccurve"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
@@ -240,6 +273,7 @@ OPTIONS:
";
fn main() -> eframe::Result {
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
alloc_release::install();
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
+1
View File
@@ -17,6 +17,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
+4
View File
@@ -0,0 +1,4 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
+1
View File
@@ -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 }
+170
View File
@@ -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)]
+9 -4
View File
@@ -128,13 +128,15 @@ pub fn display_time(iso: &str) -> String {
/// Header text for a group key.
pub fn group_label(key: &str) -> String {
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| MONTHS.get(m.wrapping_sub(1)));
// `get` rather than `[..]`: a non-ASCII key must not split a character
let year = key.get(..4).unwrap_or(key);
match key.len() {
10 => match (weekday(key), month(key), key[8..10].parse::<u32>().ok()) {
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {}", &key[..4]),
10 => match (weekday(key), month(key), key.get(8..10).and_then(|d| d.parse::<u32>().ok())) {
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {year}"),
_ => key.to_string(),
},
7 => month(key).map(|m| format!("{m} {}", &key[..4])).unwrap_or_else(|| key.to_string()),
7 => month(key).map(|m| format!("{m} {year}")).unwrap_or_else(|| key.to_string()),
4 => key.to_string(),
_ => "Unknown Date".into(),
}
@@ -196,6 +198,9 @@ mod tests {
assert_eq!(group_label("2026-02"), "February 2026");
assert_eq!(group_label("2026"), "2026");
assert_eq!(group_label(""), "Unknown Date");
// non-ASCII keys of a date's length used to be sliced mid-character
assert_eq!(group_label("2024-01é0"), "2024-01é0");
assert_eq!(group_label("202é01"), "January 202é01");
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
assert_eq!(shift_iso("nope", 5), None);
+282 -5
View File
@@ -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, &copy));
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}");
}
}
}
+12 -3
View File
@@ -6,6 +6,7 @@
//! - **undo/redo**: the engine keeps inverse ops;
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod dates;
pub mod journal;
@@ -20,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::*;
@@ -179,6 +180,10 @@ pub enum Op {
file_size: u64,
#[serde(default)]
content_hash: Option<String>,
/// Why the file can only be shown from its embedded preview (see [`Photo::preview_only`]);
/// `None` = its raw data decodes (or it isn't a raw).
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_only: Option<String>,
},
/// The name shown for a colour label (`None` = its colour's name).
SetLabelName {
@@ -285,7 +290,8 @@ impl Catalog {
/// Number of photos shown for an album in the sources list (excludes deleted photos).
pub fn album_count(&self, id: AlbumId) -> usize {
match self.albums.get(&id) {
Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(),
// counted in place: no id list is built just for its length
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
None => 0,
}
@@ -514,7 +520,7 @@ impl Catalog {
let old_format = format.map(|f| std::mem::replace(&mut p.format, f));
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format }
}
Op::SetContent { id, width, height, file_size, content_hash } => {
Op::SetContent { id, width, height, file_size, content_hash, preview_only } => {
let p = self.photo_mut(id)?;
Op::SetContent {
id,
@@ -522,6 +528,7 @@ impl Catalog {
height: std::mem::replace(&mut p.height, height),
file_size: std::mem::replace(&mut p.file_size, file_size),
content_hash: std::mem::replace(&mut p.content_hash, content_hash),
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
}
}
Op::SetFile { id, file_name, source } => {
@@ -615,4 +622,6 @@ impl Catalog {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_background;
#[cfg(test)]
mod tests_journal;
+78
View File
@@ -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>,
}
@@ -200,6 +266,12 @@ pub struct Photo {
/// Assisted culling scores (`None` until analysed).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub analysis: Option<Analysis>,
/// A raw file whose sensor data can't be decoded yet (an unsupported raw variant): why (the
/// raw decoder's reason, e.g. "Nikon Huffman-compressed NEF …"). The photo is shown and
/// edited from the camera's embedded JPEG preview — a rendered image with the camera's
/// picture style baked in — so it is treated as a rendered (non-raw) source.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_only: Option<String>,
}
/// What assisted culling measured on a photo.
@@ -246,8 +318,14 @@ impl Photo {
copy_name: None,
import_look: None,
analysis: None,
preview_only: None,
}
}
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
/// its embedded preview ([`Photo::preview_only`]).
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
pub fn import_defaults(&self) -> DevelopSettings {
+51
View File
@@ -174,6 +174,11 @@ impl Filter {
if v.is_empty() { "all photos".into() } else { v.join(", ") }
}
/// Whether matches depend on the clock (only "in the last…" rules do).
pub fn depends_on_now(&self) -> bool {
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
}
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
if p.deleted != self.deleted {
return false;
@@ -346,6 +351,52 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
}
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
/// No file-system access: the paths compared should already be absolute.
pub fn folder_key(path: &str) -> String {
let mut s = path.replace('\\', "/");
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
s = match rest.strip_prefix("UNC/") {
Some(unc) => format!("//{unc}"),
None => rest.to_string(),
};
}
// the part `..` can't climb above: `//server/share`, `c:` or `/`
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
let mut it = unc.splitn(3, '/');
let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
(format!("//{server}/{share}"), rest.to_string())
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
(s[..2].to_ascii_lowercase(), s[2..].to_string())
} else {
(String::new(), s.clone())
};
let absolute = rest.starts_with('/') || !prefix.is_empty();
let mut parts: Vec<&str> = Vec::new();
for c in rest.split('/') {
match c {
"" | "." => {}
".." if parts.last().is_some_and(|p| *p != "..") => {
parts.pop();
}
".." if absolute => {}
c => parts.push(c),
}
}
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
if cfg!(windows) { key.to_lowercase() } else { key }
}
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
/// (see [`folder_key`]).
pub fn folder_within(path: &str, root: &str) -> bool {
let (p, r) = (folder_key(path), folder_key(root));
p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
}
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use):
/// `hdr`, `panorama` or `hdrPanorama`.
+21
View File
@@ -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),
@@ -341,6 +346,15 @@ impl RuleSet {
}
}
/// Whether matches depend on the clock ("in the last…" rules, nested groups included): the
/// same photos can enter or leave the set without any catalog change.
pub fn depends_on_now(&self) -> bool {
self.rules.iter().any(|r| match r {
Rule::Group { group } => group.depends_on_now(),
Rule::Field { op, .. } => op == "inLast" || op == "notInLast",
})
}
/// Unknown fields or operators (for command validation), as readable messages.
pub fn problems(&self) -> Vec<String> {
let mut out = Vec::new();
@@ -440,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"}]}}]}));
+1 -1
View File
@@ -277,7 +277,7 @@ mod tests {
#[test]
fn group_merge_set_top_remove_and_arrange() {
let (mut c, ids) = cat(6, &[]);
let [a, b, x, d, e, f] = ids[..] else { unreachable!() };
let [a, b, x, d, e, f] = ids[..] else { panic!("six ids") };
let op = c.group_ops(b, &[a, b, x], false).unwrap();
let inv = c.apply(op).unwrap();
let s = c.stack_of(a).unwrap().clone();
+72 -1
View File
@@ -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) {
@@ -87,7 +131,7 @@ impl Store for FsStore {
}
self.appender = Some((name.to_string(), f));
}
let (_, f) = self.appender.as_mut().expect("appender just opened");
let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) };
f.write_all(data)?;
f.sync_data()
}
@@ -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>)
}
}
+66
View File
@@ -231,3 +231,69 @@ fn merge_results_are_recognised() {
assert_eq!(merged_kind("hdr.jpg"), None);
assert_eq!(merged_kind("panorama.jpg"), None);
}
/// Preview-only raws (an undecodable raw variant shown from its embedded JPEG): the reason is
/// stored with the photo, old catalogs without it still load, and Reload's op sets / clears it
/// with an exact inverse that survives serialisation (the journal).
#[test]
fn preview_only_survives_serde_and_set_content_undo() {
let mut c = Catalog::new();
let a = c.alloc_photo_id();
let mut p = Photo::new(a, Source::File { path: "/x/DSC_0001.NEF".into() }, "DSC_0001.NEF", "NEF", 6000, 4000, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.preview_only = Some("Nikon Huffman-compressed NEF".into());
assert!(!p.develops_raw());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
// the catalog round-trips it
let back: Catalog = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(back.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
// an older catalog (no field) loads as decodable
let mut v = serde_json::to_value(&c).unwrap();
let photos = v["photos"].as_object_mut().unwrap();
let ph = photos.values_mut().next().unwrap();
ph.as_object_mut().unwrap().remove("preview_only").expect("serialised when set");
let old: Catalog = serde_json::from_value(v).unwrap();
assert_eq!(old.photo(a).unwrap().preview_only, None);
assert!(old.photo(a).unwrap().develops_raw());
// Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it
let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None };
let inv = c.apply(op).unwrap();
assert!(c.photo(a).unwrap().develops_raw());
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
assert_eq!(c.photo(a).unwrap().width, 6000);
}
/// Equivalent spellings of one folder (separators, trailing or doubled separators, `.`/`..`,
/// drive-letter case, verbatim and UNC prefixes) share an identity; different folders don't.
#[test]
fn folder_identity_ignores_spelling() {
use crate::query::{folder_key, folder_within};
let same = |a: &str, b: &str| assert_eq!(folder_key(a), folder_key(b), "{a} vs {b}");
let differ = |a: &str, b: &str| assert_ne!(folder_key(a), folder_key(b), "{a} vs {b}");
for (a, b) in [
("D:/Example/Photos", "D:\\Example\\Photos"),
("D:/Example/Photos", "d:\\Example\\Photos\\"),
("D:\\Example/Photos", "D:/Example//Photos/."),
("D:/Example/Photos", "D:/Example/Other/../Photos"),
("D:/Example/Photos", "\\\\?\\D:\\Example\\Photos"),
("\\\\server\\share\\Photos", "//server/share/Photos/"),
("\\\\?\\UNC\\server\\share\\Photos", "//server/share/Photos"),
("/home/me/Photos", "/home/me/Photos/"),
("/home/me/Photos", "/home//me/./Photos"),
("/", "//"),
("C:\\", "c:/"),
] {
same(a, b);
}
differ("D:/Example/Photos", "D:/Example/Photos2");
differ("D:/Example/Photos", "E:/Example/Photos");
differ("/home/me/Photos", "/home/me");
assert_eq!(folder_key("/a/../../b"), "/b", "`..` stops at the root");
assert!(folder_within("D:\\Example\\Photos\\2026", "D:/Example/Photos/"));
assert!(folder_within("D:/Example/Photos", "d:\\Example\\Photos"));
assert!(!folder_within("D:/Example/Photos2", "D:/Example/Photos"));
assert!(folder_within("/a/b", "/"));
assert!(folder_within("C:\\x", "c:\\"));
}
+338
View File
@@ -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());
}
}
+45
View File
@@ -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);
}
+3 -1
View File
@@ -323,7 +323,9 @@ fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccIn
}
// Naive: RGB = (1 - C)(1 - K) … treated as sRGB-encoded.
let px = samples
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| {
let k = 1.0 - c[3];
[(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear)
+2 -2
View File
@@ -91,7 +91,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
let n = img.width as usize * img.height as usize;
let (data, ct): (std::borrow::Cow<[u8]>, jpeg_encoder::ColorType) = match img.channels {
1 => (data[..n].into(), jpeg_encoder::ColorType::Luma),
2 => (data.chunks_exact(2).take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
2 => (data.as_chunks::<2>().0.iter().take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
3 => (data[..n * 3].into(), jpeg_encoder::ColorType::Rgb),
_ => (data[..n * 4].into(), jpeg_encoder::ColorType::Rgba),
};
@@ -254,7 +254,7 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
}};
}
fn expand_ga<T: Copy>(s: &[T], n: usize) -> Vec<T> {
s.chunks_exact(2).take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
}
match img.samples {
Samples::U8(s) => match ch {
+1 -1
View File
@@ -236,7 +236,7 @@ pub(crate) fn cms_to_linear_rec2020(icc: &[u8], samples: &[f32], channels: usize
for (s, d) in src_chunks.zip(dst_chunks) {
t.transform(s, d).ok()?;
}
Some(out.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect())
Some(out.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect())
};
std::panic::catch_unwind(run).ok().flatten()
}
+2 -2
View File
@@ -159,7 +159,7 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
let (model, buf, depth) = match info.pixel_format {
PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8),
PixelFormat::L16 => {
let v = px.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(Model::Gray, Buf::U16(v), 16)
}
PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8),
@@ -226,7 +226,7 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
let Some(e) = entries.iter().find(|e| e.tag == 0xB002) else { return vec![] };
let Some(list) = t.bytes(e) else { return vec![] };
let mut out = Vec::new();
for (i, rec) in list.chunks_exact(16).enumerate().take(16) {
for (i, rec) in list.as_chunks::<16>().0.iter().enumerate().take(16) {
if i == 0 {
continue;
}
+1 -1
View File
@@ -274,7 +274,7 @@ pub fn encode(
{
let rows: Vec<(usize, Vec<&mut [f32]>)> = {
let mut its: Vec<_> = full.iter_mut().map(|p| p.chunks_mut(width)).collect();
(0..height).map(|y| (y, its.iter_mut().map(|it| it.next().unwrap()).collect())).collect()
(0..height).map_while(|y| Some((y, its.iter_mut().map(|it| it.next()).collect::<Option<_>>()?))).collect()
};
let conv = |(y, mut out): (usize, Vec<&mut [f32]>)| {
let src = &data[y * width * channels..(y + 1) * width * channels];
+1
View File
@@ -12,6 +12,7 @@
//!
//! No decoder panics on malformed input (property-tested).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod convert;
pub mod encode;
+1 -1
View File
@@ -37,7 +37,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
};
buf.truncate(out.buffer_size());
let (samples, depth) = match out.bit_depth {
png::BitDepth::Sixteen => (Buf::U16(buf.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
png::BitDepth::Sixteen => (Buf::U16(buf.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
_ => (Buf::U8(buf), info.bit_depth as u8),
};
let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth };
+8 -3
View File
@@ -9,6 +9,7 @@
//!
//! The pipeline's working space is **linear Rec.2020, D65** ([`WORKING`]).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod cct;
pub mod perceptual;
@@ -125,14 +126,17 @@ pub const WORKING: RgbSpace = REC2020;
impl RgbSpace {
/// Matrix taking linear RGB in this space to XYZ (white Y = 1) relative to its own white.
/// Degenerate (collinear) primaries, which readers reject before building a space from a
/// file, leave the primaries unscaled instead of panicking.
pub fn to_xyz(&self) -> Mat3 {
let p = [self.r.to_xyz(), self.g.to_xyz(), self.b.to_xyz()];
let m = Mat3([[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]);
let s = m.inverse().expect("primaries are independent").apply(self.white.to_xyz());
let s = m.inverse().map_or([1.0; 3], |inv| inv.apply(self.white.to_xyz()));
m.mul(&Mat3::diag(s[0], s[1], s[2]))
}
/// Inverse of [`to_xyz`](Self::to_xyz); the identity for degenerate primaries.
pub fn from_xyz(&self) -> Mat3 {
self.to_xyz().inverse().expect("invertible")
self.to_xyz().inverse().unwrap_or(Mat3::IDENTITY)
}
/// Luminance weights (the Y row of `to_xyz`).
pub fn luma(&self) -> [f64; 3] {
@@ -154,7 +158,8 @@ pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
}
let s = BRADFORD.apply(src.to_xyz());
let d = BRADFORD.apply(dst.to_xyz());
let inv = BRADFORD.inverse().expect("invertible");
// a constant, invertible matrix: the fallback (no adaptation) can't happen
let Some(inv) = BRADFORD.inverse() else { return Mat3::IDENTITY };
inv.mul(&Mat3::diag(d[0] / s[0], d[1] / s[1], d[2] / s[2])).mul(&BRADFORD)
}
+3 -2
View File
@@ -28,9 +28,10 @@ fn mats() -> &'static OkMats {
let to_lms = XYZ_TO_LMS.mul(&REC2020.to_xyz());
OkMats {
to_lms: to_lms.to_f32(),
from_lms: to_lms.inverse().expect("invertible").to_f32(),
// constant, invertible matrices (round trips tested below): the identity fallback can't happen
from_lms: to_lms.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
to_lab: LMS_TO_LAB.to_f32(),
from_lab: LMS_TO_LAB.inverse().expect("invertible").to_f32(),
from_lab: LMS_TO_LAB.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
}
})
}
+1
View File
@@ -4,6 +4,7 @@
//! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history
//! are operations on it. Numeric sliders are addressed by id through [`controls`].
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod controls;
pub mod presets;
+22 -5
View File
@@ -148,6 +148,24 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"path": out, "id": new, "original": id.0}))
}
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
let same = info.content_hash == ph.content_hash
&& info.file_size == ph.file_size
&& (info.width, info.height) == (ph.width, ph.height)
&& info.preview_only == ph.preview_only;
(!same).then_some(Op::SetContent {
id,
width: info.width,
height: info.height,
file_size: info.file_size,
content_hash: info.content_hash,
preview_only: info.preview_only,
})
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
@@ -164,12 +182,11 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
if info.content_hash == ph.content_hash && info.file_size == ph.file_size && (info.width, info.height) == (ph.width, ph.height) {
continue;
}
ops.push(Op::SetContent { id, width: info.width, height: info.height, file_size: info.file_size, content_hash: info.content_hash });
let Some(op) = content_op(id, ph, info.clone()) else { continue };
ops.push(op);
// virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
ops.extend(content_op(c.id, c, info.clone()));
reloaded.push(c.id);
}
reloaded.push(id);
@@ -245,7 +262,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
"Reload from Disk",
[],
None,
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor) → {reloaded}",
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}",
has_selection,
reload
),
+357
View File
@@ -0,0 +1,357 @@
//! Tone-curve commands: reset a channel or the whole curve; point-curve presets (built-in Linear /
//! Medium / Strong Contrast plus the user's, saved with the library preferences, imported and
//! exported as small JSON files).
use lightcraft_develop::ToneCurve;
use lightcraft_geom::Point;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
use crate::{Result, Session};
/// Channels `curve.reset` accepts.
pub const RESET_CHANNELS: [&str; 7] = ["all", "point", "parametric", "master", "red", "green", "blue"];
/// `c` with `channel` back at its default: `all` (the whole Tone Curve, incl. Refine Saturation),
/// `point` (the four point curves), `parametric` (region sliders and splits) or one point channel.
pub fn reset_channel(c: &mut ToneCurve, channel: &str) -> std::result::Result<(), String> {
let d = ToneCurve::default();
match channel {
"all" => *c = d,
"point" => {
c.master.clear();
c.red.clear();
c.green.clear();
c.blue.clear();
}
"parametric" => {
(c.highlights, c.lights, c.darks, c.shadows) = (d.highlights, d.lights, d.darks, d.shadows);
(c.split_shadows, c.split_mid, c.split_highlights) = (d.split_shadows, d.split_mid, d.split_highlights);
}
"master" | "rgb" | "luma" => c.master.clear(),
"red" => c.red.clear(),
"green" => c.green.clear(),
"blue" => c.blue.clear(),
other => return Err(format!("unknown channel `{other}` (one of {})", RESET_CHANNELS.join(", "))),
}
Ok(())
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.reset";
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let channel = str_param(p, "channel").unwrap_or("all");
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
reset_channel(&mut d.curve, channel).map_err(|e| bad(C, e))?;
let label = if channel == "all" { "Reset Tone Curve".to_string() } else { format!("Reset Tone Curve ({channel})") };
s.set_develop(id, d, &label)?;
Ok(json!({"channel": channel}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"curve.reset",
"Reset Tone Curve",
[],
None,
"{channel?: all|point|parametric|master|red|green|blue (default all)} — the active photo's tone curve (or one channel) back to linear",
has_active,
reset
),
cmd!(
query "curve.presets",
"Point Curve Presets",
[],
None,
"{} → {presets: [{name, master, red, green, blue: [[x,y],...], builtin?}], current: name of the preset the active photo's point curves match, or null}",
always,
|s, _| Ok(list(s))
),
cmd!(
"curve.applyPreset",
"Apply Point Curve Preset",
[],
None,
"{name} — sets the active photo's point curves (all four channels; the parametric curve stays)",
has_active,
apply_preset
),
cmd!(
"curve.savePreset",
"Save Point Curve Preset",
[],
None,
"{name} — the active photo's point curves under a name (replaces a user preset of that name; built-in names are reserved), saved with the library",
has_active,
save_preset
),
cmd!(
"curve.deletePreset",
"Delete Point Curve Preset",
[],
None,
"{name} — a user preset (built-ins can't be deleted)",
always,
delete_preset
),
cmd!(
"curve.importPresets",
"Import Point Curve Presets",
[],
None,
"{path | paths: [file]} — .lccurve JSON files ({format, version, presets: [...]}, an array of presets, or one preset); same-named user presets are replaced → {imported: [name], failed: [[file, error]]}",
always,
import_presets
),
cmd!(
"curve.exportPresets",
"Export Point Curve Presets",
[],
None,
"{path, names?: [name] (default: every user preset)} — writes a .lccurve JSON file → {path, count}",
always,
export_presets
),
]
}
// ---------------------------------------------------------------- point-curve presets
/// File extension of exported curve presets (JSON).
pub const CURVE_PRESET_EXT: &str = "lccurve";
/// `format` tag of an exported curve-preset file.
const FILE_FORMAT: &str = "lightcraft.curvePresets";
/// A named set of point curves (master + red/green/blue, points in 0..1; empty = linear).
/// Built-ins ship with the app; the user's are saved with the library preferences.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CurvePreset {
pub name: String,
pub master: Vec<[f64; 2]>,
pub red: Vec<[f64; 2]>,
pub green: Vec<[f64; 2]>,
pub blue: Vec<[f64; 2]>,
/// Built-in presets can't be deleted or overwritten (never stored).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub builtin: bool,
}
/// Points clamped to 0..1, sorted by x, duplicates in x dropped; a linear curve becomes empty.
fn normalize(v: &[[f64; 2]]) -> Vec<[f64; 2]> {
let mut out: Vec<[f64; 2]> =
v.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect();
out.sort_by(|a, b| a[0].total_cmp(&b[0]));
out.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6);
if out.len() < 2 || out.iter().all(|p| (p[0] - p[1]).abs() < 1e-9) {
out.clear();
}
out
}
fn from_points(v: &[Point]) -> Vec<[f64; 2]> {
normalize(&v.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>())
}
fn to_points(v: &[[f64; 2]]) -> Vec<Point> {
normalize(v).iter().map(|p| Point::new(p[0], p[1])).collect()
}
impl CurvePreset {
/// The point curves of `c` under `name`.
pub fn from_curve(name: &str, c: &ToneCurve) -> CurvePreset {
CurvePreset {
name: name.into(),
master: from_points(&c.master),
red: from_points(&c.red),
green: from_points(&c.green),
blue: from_points(&c.blue),
builtin: false,
}
}
/// Set the four point curves of `c` (the parametric curve and Refine Saturation stay).
pub fn apply(&self, c: &mut ToneCurve) {
c.master = to_points(&self.master);
c.red = to_points(&self.red);
c.green = to_points(&self.green);
c.blue = to_points(&self.blue);
}
/// Does `c` carry exactly this preset's point curves?
pub fn matches(&self, c: &ToneCurve) -> bool {
let same = |a: &[[f64; 2]], b: &[Point]| {
let (a, b) = (normalize(a), from_points(b));
a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6)
};
same(&self.master, &c.master) && same(&self.red, &c.red) && same(&self.green, &c.green) && same(&self.blue, &c.blue)
}
fn is_linear(&self) -> bool {
[&self.master, &self.red, &self.green, &self.blue].iter().all(|v| normalize(v).is_empty())
}
}
/// The presets that ship with the app: a linear curve and two symmetric S-curves (our own
/// values: the quarter-tones pulled 0.05 / 0.11 towards the ends, midpoint fixed).
pub fn builtin_presets() -> Vec<CurvePreset> {
let s = |name: &str, master: Vec<[f64; 2]>| CurvePreset { name: name.into(), master, builtin: true, ..Default::default() };
vec![
s("Linear", Vec::new()),
s("Medium Contrast", vec![[0.0, 0.0], [0.25, 0.2], [0.5, 0.5], [0.75, 0.8], [1.0, 1.0]]),
s("Strong Contrast", vec![[0.0, 0.0], [0.25, 0.14], [0.5, 0.5], [0.75, 0.86], [1.0, 1.0]]),
]
}
/// Built-in presets, then the user's.
pub fn all_presets(s: &Session) -> Vec<CurvePreset> {
let mut v = builtin_presets();
v.extend(s.curve_presets.iter().cloned());
v
}
/// Name of the preset whose point curves `c` carries (built-ins first), if any.
pub fn matching_preset(s: &Session, c: &ToneCurve) -> Option<String> {
all_presets(s).into_iter().find(|p| p.matches(c)).map(|p| p.name)
}
fn is_builtin_name(name: &str) -> bool {
builtin_presets().iter().any(|p| p.name.eq_ignore_ascii_case(name))
}
/// Add or replace (same name, ignoring case) a user preset.
fn upsert(s: &mut Session, p: CurvePreset) {
match s.curve_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&p.name)) {
Some(x) => *x = p,
None => s.curve_presets.push(p),
}
}
fn name_param(p: &Value, c: &str) -> Result<String> {
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`"))
}
fn list(s: &Session) -> Value {
let current = s.active().and_then(|id| s.develop_of(id)).and_then(|d| matching_preset(s, &d.curve));
json!({"presets": all_presets(s), "current": current})
}
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.applyPreset";
let name = name_param(p, C)?;
let preset =
all_presets(s).into_iter().find(|x| x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(C, format!("no curve preset `{name}`")))?;
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
preset.apply(&mut d.curve);
s.set_develop(id, d, &format!("Point Curve: {}", preset.name))?;
Ok(json!({"name": preset.name}))
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.savePreset";
let name = name_param(p, C)?;
if is_builtin_name(&name) {
return Err(bad(C, format!("`{name}` is a built-in preset: choose another name")));
}
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let preset = CurvePreset::from_curve(&name, &s.develop_of(id).unwrap_or_default().curve);
if preset.is_linear() {
return Err(bad(C, "the point curve is linear: shape it first"));
}
upsert(s, preset);
s.save_prefs()?;
Ok(list(s))
}
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.deletePreset";
let name = name_param(p, C)?;
if is_builtin_name(&name) {
return Err(bad(C, "built-in presets can't be deleted"));
}
let n = s.curve_presets.len();
s.curve_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
if n == s.curve_presets.len() {
return Err(bad(C, format!("no curve preset `{name}`")));
}
s.save_prefs()?;
Ok(list(s))
}
/// Presets in a curve-preset file: `{format, version, presets: [...]}`, a bare array, or one preset.
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<CurvePreset>, String> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a curve preset file: {e}"))?;
let items = match &v {
Value::Object(o) if o.contains_key("presets") => o["presets"].clone(),
Value::Array(_) => v.clone(),
Value::Object(_) => Value::Array(vec![v.clone()]),
_ => return Err("not a curve preset file".into()),
};
let list: Vec<CurvePreset> = serde_json::from_value(items).map_err(|e| format!("not a curve preset file: {e}"))?;
let list: Vec<CurvePreset> = list.into_iter().filter(|p| !p.name.trim().is_empty()).collect();
if list.is_empty() {
return Err("no curve presets in the file".into());
}
Ok(list)
}
/// The JSON of a curve-preset file holding `presets`.
pub fn to_file(presets: &[CurvePreset]) -> String {
let presets: Vec<CurvePreset> = presets.iter().map(|p| CurvePreset { builtin: false, ..p.clone() }).collect();
serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "presets": presets})).unwrap_or_default()
}
fn import_presets(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.importPresets";
let mut paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if let Some(x) = str_param(p, "path") {
paths.push(x.to_string());
}
if paths.is_empty() {
return Err(bad(C, "missing `path` / `paths`"));
}
let (mut imported, mut failed) = (Vec::new(), Vec::new());
for f in &paths {
match std::fs::read(f).map_err(|e| e.to_string()).and_then(|b| parse_file(&b)) {
Ok(list) => {
for mut preset in list {
preset.name = preset.name.trim().to_string();
preset.builtin = false;
if is_builtin_name(&preset.name) {
preset.name = format!("{} (imported)", preset.name);
}
imported.push(preset.name.clone());
upsert(s, preset);
}
}
Err(e) => failed.push(json!([f, e])),
}
}
if !imported.is_empty() {
s.save_prefs()?;
}
Ok(json!({"imported": imported, "failed": failed}))
}
fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.exportPresets";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let mut path = std::path::PathBuf::from(path);
if path.extension().is_none() {
path.set_extension(CURVE_PRESET_EXT);
}
let names: Option<Vec<String>> =
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
let chosen: Vec<CurvePreset> = match &names {
Some(n) => all_presets(s).into_iter().filter(|x| n.iter().any(|m| m.eq_ignore_ascii_case(&x.name))).collect(),
None => s.curve_presets.clone(),
};
if chosen.is_empty() {
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
+37 -14
View File
@@ -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};
@@ -250,13 +250,14 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"selected": s.selection.ids.len()}))
}),
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()).unwrap_or_default())
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),
@@ -718,7 +731,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Review Import",
[],
None,
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?}], duplicates, scanned} — nothing is added",
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added",
always,
|s, p| {
let paths = strs(p, "paths");
@@ -732,10 +745,10 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(
"library.import",
"Add Photos…",
"Import Photos",
["File"],
Some("Cmd+Shift+I"),
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
always,
|s, p| {
let paths = strs(p, "paths");
@@ -745,7 +758,8 @@ pub fn specs() -> Vec<CommandSpec> {
let mode = match str_param(p, "mode").unwrap_or("add") {
"add" => crate::import::ImportMode::Add,
"copy" => crate::import::ImportMode::Copy,
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy)"))),
"move" => crate::import::ImportMode::Move,
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
};
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
Some(id) => {
@@ -760,11 +774,19 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("library.import", "album must be a regular album"));
}
let organize = match str_param(p, "organize") {
Some(o) => {
crate::import::Organize::parse(o).ok_or_else(|| bad("library.import", format!("unknown organize `{o}` (date|month|flat)")))?
}
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
bad(
"library.import",
format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"),
)
})?,
None => Default::default(),
};
if let crate::import::Organize::Template(t) = &organize
&& let Some(e) = crate::rename::folder_template_error(t)
{
return Err(bad("library.import", e));
}
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &metadata_preset
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
@@ -781,7 +803,7 @@ pub fn specs() -> Vec<CommandSpec> {
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
metadata_preset,
convert_dng: super::bool_or(p, "dng", false),
..Default::default()
local: super::bool_or(p, "local", false),
};
let undo0 = s.undo.len();
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
@@ -845,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,
+105 -2
View File
@@ -59,9 +59,103 @@ 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.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
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, |_, _| {
Ok(crate::rename::token_help_json())
}),
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?;
Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default())
}),
@@ -70,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rename Photos",
[],
None,
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
has_selection,
|s, p| {
let (ids, template, start) = rename_args(s, p, "photo.rename")?;
@@ -127,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()
+38 -9
View File
@@ -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
),
+45 -9
View File
@@ -5,24 +5,48 @@
use std::collections::HashMap;
use std::path::Path;
use lightcraft_catalog::{Op, PhotoId, Source};
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
/// The file Missing Photos checks for `p`, if it is in scope: a library photo (not in Recently
/// Deleted, not a Local-only record seen while browsing a folder) whose original is a file.
///
/// Local browse records are out of scope on purpose: browsing can leave tens of thousands of
/// them in the catalog, they never show in library views (Missing Photos included), and a
/// folder that is gone is simply not browsed again. The sidebar count, the Missing Photos view,
/// `library.missing` and Find Missing Photos all use this one rule.
pub fn checked_path(p: &Photo) -> Option<&str> {
match &p.source {
Source::File { path } if p.in_library() => Some(path),
_ => None,
}
}
/// Every in-scope photo's file ([`checked_path`]), without touching the disk.
pub fn candidates(cat: &Catalog) -> Vec<String> {
cat.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect()
}
/// [`missing`] with the existence check supplied (tests count the file-system calls).
pub fn missing_with(cat: &Catalog, mut exists: impl FnMut(&str) -> bool) -> Vec<(PhotoId, String)> {
cat.photos().filter_map(|p| checked_path(p).filter(|f| !exists(f)).map(|f| (p.id, f.to_string()))).collect()
}
/// Library photos whose original file can't be found: (id, path).
pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
if cfg!(target_arch = "wasm32") {
return Vec::new();
}
s.catalog
.photos()
.filter_map(|p| match &p.source {
Source::File { path } if !p.deleted && !Path::new(path).exists() => Some((p.id, path.clone())),
_ => None,
})
.collect()
missing_with(&s.catalog, |f| Path::new(f).exists())
}
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
/// the photos its query already narrowed to).
pub fn is_missing(cat: &Catalog, id: PhotoId) -> bool {
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| !Path::new(f).exists())
}
fn relink_op(id: PhotoId, path: &str) -> Op {
@@ -42,7 +66,19 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "only photos from files can be relinked"));
}
let abs = abs.to_string_lossy().to_string();
s.commit("Relink Photo", relink_op(id, &abs))?;
// the file may differ from the one imported (another export, a raw that decodes now or not):
// its size, dimensions and preview-only state follow it
let mut ops = vec![relink_op(id, &abs)];
if s.media.file_probe.is_some()
&& let (Some(Ok(info)), Some(ph)) = (crate::import::probe_paths(s, std::slice::from_ref(&abs)).pop(), s.catalog.photo(id))
{
ops.extend(crate::cmd::convert::content_op(id, ph, info));
}
let op = match <[Op; 1]>::try_from(ops) {
Ok([op]) => op,
Err(ops) => Op::Batch { ops },
};
s.commit("Relink Photo", op)?;
s.media.forget(id);
Ok(json!({"id": id.0, "path": abs}))
}
+2
View File
@@ -9,6 +9,7 @@ mod browse;
mod color;
pub(crate) mod convert;
mod cull;
pub mod curves;
mod develop;
mod edit;
mod export;
@@ -118,6 +119,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(library::specs());
v.extend(develop::specs());
v.extend(color::specs());
v.extend(curves::specs());
v.extend(masks::specs());
v.extend(organize::specs());
v.extend(keywords::specs());
+2 -2
View File
@@ -1,4 +1,4 @@
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset and zip bundles; export `.lcpreset`.
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset, Luminar looks and zip bundles; export `.lcpreset`.
use serde_json::{Value, json};
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Presets",
[],
None,
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\") and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
always,
import
),
+79 -1
View File
@@ -104,7 +104,12 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let (mut built, mut removed, mut failed) = (0usize, 0usize, Vec::new());
if !discard {
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
// a chosen folder on another drive must be there: never recreate it on this one
if s.smart_previews_dir.is_some() {
crate::smart::check_writable(&dir).map_err(|e| bad(C, e))?;
} else {
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
}
}
for id in ids {
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
@@ -134,6 +139,69 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"built": built, "removed": removed, "failed": failed}))
}
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
/// used now (a chosen folder on an unplugged drive is `available: false`).
#[cfg(not(target_arch = "wasm32"))]
fn location_json(s: &Session) -> Value {
let dir = s.media.smart_dir.clone();
let default = s.library.as_ref().filter(|l| l.on_disk).map(|l| crate::smart::dir(&l.dir));
let (count, bytes) = dir.as_deref().map_or((0, 0), crate::smart::stats);
json!({
"path": dir.as_ref().map(|d| d.to_string_lossy()),
"default": default.map(|d| d.to_string_lossy().to_string()),
"custom": s.smart_previews_dir.is_some(),
"available": dir.as_ref().is_some_and(|d| d.is_dir()) || s.smart_previews_dir.is_none(),
"count": count,
"bytes": bytes,
})
}
/// Show or change where smart previews are kept (per library).
#[cfg(not(target_arch = "wasm32"))]
fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.smartPreviewsLocation";
let current = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
let reset = p.get("reset").and_then(Value::as_bool).unwrap_or(false);
let chosen = str_param(p, "path").filter(|x| !x.trim().is_empty());
if !reset && chosen.is_none() {
return Ok(location_json(s));
}
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()).ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
let default = crate::smart::dir(&lib_dir);
let (new_dir, custom) = match chosen {
Some(x) if !reset => {
let d = std::path::PathBuf::from(x.trim());
if !d.is_absolute() {
return Err(bad(C, "path must be an absolute folder path"));
}
(d.clone(), (d != default).then_some(d))
}
_ => (default, None),
};
if new_dir == current {
return Ok(location_json(s));
}
// the new place must work before anything moves; there is no fallback to another drive
crate::smart::check_writable(&new_dir).map_err(|e| bad(C, e))?;
let (held, _) = crate::smart::stats(&current);
let existing = match str_param(p, "existing") {
Some(x) => Some(crate::smart::Existing::parse(x).ok_or_else(|| bad(C, "existing must be move, leave or discard"))?),
None if held == 0 => Some(crate::smart::Existing::Leave),
None => None,
};
let Some(existing) = existing else {
return Err(bad(C, format!("{held} smart preview(s) are in {}: pass existing = move, leave or discard", current.display())));
};
let (moved, failed) = crate::smart::migrate(&current, &new_dir, existing);
s.smart_previews_dir = custom;
s.media.smart_dir = Some(new_dir);
s.save_prefs()?;
let mut r = location_json(s);
r["handled"] = json!(moved);
r["failed"] = json!(failed);
Ok(r)
}
/// Whether photo `id` has a smart preview.
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
match (&s.media.smart_dir, s.catalog.photo(id)) {
@@ -154,6 +222,16 @@ pub fn specs() -> Vec<CommandSpec> {
always,
smart
),
#[cfg(not(target_arch = "wasm32"))]
cmd!(
"library.smartPreviewsLocation",
"Smart Previews Location",
[],
None,
"{path?: absolute folder, reset?: bool, existing?: move|leave|discard} — without path/reset: where this library keeps its smart previews → {path, default, custom, available, count, bytes}. With path (or reset = back to `Smart Previews` in the library): use that folder instead (must exist or have an existing parent, and accept writes; never falls back to another drive). Smart previews already in the old folder need `existing`: move them, leave them (build again), or discard them → also {handled, failed}",
always,
smart_location
),
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
+2
View File
@@ -26,6 +26,8 @@ pub fn photo_summary(p: &Photo) -> Value {
"deleted": p.deleted,
"copyOf": p.copy_of.map(|c| c.0),
"copyName": p.copy_name,
// an undecodable raw variant, shown and edited from its embedded JPEG: why
"previewOnly": p.preview_only,
})
}
+202 -21
View File
@@ -1,6 +1,15 @@
//! Cameras and memory cards: mounted volumes with a `DCIM` folder (the DCF layout every camera
//! writes). Listed by `library.devices`; the app offers them under File → Add from Device, and
//! writes). Listed by `library.devices`; the app offers them under File → Import from Device, and
//! the import review copies from them into the library.
//!
//! Menus are rebuilt every frame (even with their popups closed), so [`devices`] never touches the
//! disk: it serves the last scan and, when that is older than [`MAX_AGE`], starts one background
//! rescan (coalesced; no lock is held while it runs). A changed result calls the [`on_change`]
//! hook, which the UI uses to repaint, so hot-plugged cards still appear. [`devices_now`] scans
//! synchronously for explicit requests (`library.devices`, Add from Device without a path).
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde::Serialize;
@@ -14,6 +23,9 @@ pub struct Device {
pub root: String,
}
/// How long a scan is served before [`devices`] starts another one.
pub const MAX_AGE: Duration = Duration::from_secs(2);
/// Folders whose children are mounted volumes on this platform. `LIGHTCRAFT_DEVICE_ROOTS`
/// (paths joined like `PATH`) replaces them (tests, unusual mounts).
fn mount_parents() -> Vec<std::path::PathBuf> {
@@ -34,21 +46,114 @@ fn mount_parents() -> Vec<std::path::PathBuf> {
out
}
/// Mounted volumes that look like a camera or a memory card. Cached for a couple of seconds:
/// menus ask often, and listing mounts can be slow (network volumes).
pub fn devices() -> Vec<Device> {
use std::sync::Mutex;
use std::time::{Duration, Instant};
static CACHE: Mutex<Option<(Instant, Vec<Device>)>> = Mutex::new(None);
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((t, d)) = c.as_ref()
&& t.elapsed() < Duration::from_secs(2)
{
return d.clone();
type Scanner = dyn Fn() -> Vec<Device> + Send + Sync;
type Notify = dyn Fn() + Send + Sync;
#[derive(Default)]
struct WatchState {
list: Vec<Device>,
/// When the served list was scanned (`None`: never). No clock (and no threads) on wasm, where
/// there are no mounted volumes either.
#[cfg(not(target_arch = "wasm32"))]
scanned: Option<std::time::Instant>,
/// A background scan is running.
refreshing: bool,
}
/// A cached device list, refreshed off the calling thread.
pub struct DeviceWatch {
scanner: Box<Scanner>,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
max_age: Duration,
state: Mutex<WatchState>,
notify: Mutex<Option<Arc<Notify>>>,
}
impl DeviceWatch {
pub fn new(scanner: impl Fn() -> Vec<Device> + Send + Sync + 'static, max_age: Duration) -> Arc<Self> {
Arc::new(DeviceWatch { scanner: Box::new(scanner), max_age, state: Mutex::default(), notify: Mutex::new(None) })
}
let d = scan();
*c = Some((Instant::now(), d.clone()));
d
fn state(&self) -> std::sync::MutexGuard<'_, WatchState> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
/// Called (on the scanning thread) whenever a scan changes the list.
pub fn on_change(&self, f: impl Fn() + Send + Sync + 'static) {
*self.notify.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(f));
}
/// The last scan, immediately; starts a background rescan when it is stale and none is running.
pub fn get(self: &Arc<Self>) -> Vec<Device> {
let mut s = self.state();
#[cfg(not(target_arch = "wasm32"))]
if !s.refreshing && s.scanned.is_none_or(|t| t.elapsed() >= self.max_age) {
s.refreshing = true;
let me = self.clone();
// the scan runs without the state lock; `get` only ever waits for a list clone
let spawned = std::thread::Builder::new().name("lightcraft-devices".into()).spawn(move || {
let list = (me.scanner)();
me.store(list);
});
if spawned.is_err() {
s.refreshing = false;
}
}
s.list.clone()
}
/// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls.
pub fn scan_now(&self) -> Vec<Device> {
let list = (self.scanner)();
self.store(list.clone());
list
}
fn store(&self, list: Vec<Device>) {
let changed = {
let mut s = self.state();
s.refreshing = false;
#[cfg(not(target_arch = "wasm32"))]
{
s.scanned = Some(std::time::Instant::now());
}
let changed = s.list != list;
s.list = list;
changed
};
if changed {
let f = self.notify.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(f) = f {
f();
}
}
}
/// A background scan is running.
pub fn refreshing(&self) -> bool {
self.state().refreshing
}
}
fn global() -> &'static Arc<DeviceWatch> {
static WATCH: std::sync::OnceLock<Arc<DeviceWatch>> = std::sync::OnceLock::new();
WATCH.get_or_init(|| DeviceWatch::new(scan, MAX_AGE))
}
/// Mounted volumes that look like a camera or a memory card, without blocking: the last scan,
/// refreshed in the background when older than [`MAX_AGE`] (empty until the first scan lands).
pub fn devices() -> Vec<Device> {
global().get()
}
/// Scan the mounted volumes now (blocking), for explicit requests.
pub fn devices_now() -> Vec<Device> {
global().scan_now()
}
/// Run `f` whenever a scan changes the device list (the UI requests a repaint).
pub fn on_change(f: impl Fn() + Send + Sync + 'static) {
global().on_change(f);
}
fn scan() -> Vec<Device> {
@@ -73,10 +178,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
roots.extend(extra);
let mut out: Vec<Device> = Vec::new();
for root in roots {
let Ok(rd) = std::fs::read_dir(&root) else { continue };
let Some(dcim) = rd.flatten().map(|e| e.path()).find(|p| p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM"))) else {
continue;
};
let Some(dcim) = dcim_in(&root) else { continue };
let name = root.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| root.to_string_lossy().to_string());
if !out.iter().any(|d| d.root == root.to_string_lossy()) {
out.push(Device { name, path: dcim.to_string_lossy().to_string(), root: root.to_string_lossy().to_string() });
@@ -85,9 +187,22 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
out
}
/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is
/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't.
fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) {
return Some(p);
}
if cfg!(windows) {
return None;
}
std::fs::read_dir(root).ok()?.flatten().map(|e| e.path()).find(|p| p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")) && p.is_dir())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn volumes_with_a_dcim_folder_are_devices() {
@@ -96,10 +211,76 @@ mod tests {
std::fs::create_dir_all(base.join("EOS_DIGITAL/DCIM/100CANON")).unwrap();
std::fs::create_dir_all(base.join("Backup/Photos")).unwrap();
std::fs::create_dir_all(base.join("SD/dcim")).unwrap();
std::fs::create_dir_all(base.join("X/Dcim")).unwrap();
let d = devices_in(std::slice::from_ref(&base), Vec::new());
let names: Vec<&str> = d.iter().map(|x| x.name.as_str()).collect();
assert_eq!(names, ["EOS_DIGITAL", "SD"], "{d:?}");
assert!(d[0].path.ends_with("DCIM") && d[1].path.ends_with("dcim"));
assert_eq!(names, ["EOS_DIGITAL", "SD", "X"], "{d:?}");
// the DCIM folder itself, in a spelling that opens on that volume
for x in &d {
let p = std::path::Path::new(&x.path);
assert!(p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")), "{x:?}");
}
let _ = std::fs::remove_dir_all(&base);
}
fn card(name: &str) -> Device {
Device { name: name.into(), path: format!("/v/{name}/DCIM"), root: format!("/v/{name}") }
}
/// Wait (bounded) for `f`.
fn until(f: impl Fn() -> bool) -> bool {
let t = std::time::Instant::now();
while t.elapsed() < Duration::from_secs(20) {
if f() {
return true;
}
std::thread::sleep(Duration::from_millis(5));
}
false
}
#[test]
fn slow_scans_never_block_and_hot_plug_still_arrives() {
// a scanner that takes 1.5 s (a sleeping network drive) and counts its runs
const SLOW: Duration = Duration::from_millis(1500);
let mounted = Arc::new(Mutex::new(Vec::<Device>::new()));
let runs = Arc::new(AtomicUsize::new(0));
let (m, r) = (mounted.clone(), runs.clone());
let watch = DeviceWatch::new(
move || {
r.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(SLOW);
m.lock().unwrap().clone()
},
Duration::from_millis(20),
);
let changes = Arc::new(AtomicUsize::new(0));
let c = changes.clone();
watch.on_change(move || {
c.fetch_add(1, Ordering::SeqCst);
});
// cold: served at once (empty) while one scan runs in the background
let t = std::time::Instant::now();
for _ in 0..50 {
assert!(watch.get().is_empty());
}
assert!(t.elapsed() < SLOW / 2, "get() waited for the scan: {:?}", t.elapsed());
assert!(watch.refreshing());
assert!(until(|| !watch.refreshing()));
assert_eq!(runs.load(Ordering::SeqCst), 1, "requests while scanning are coalesced");
assert_eq!(changes.load(Ordering::SeqCst), 0, "nothing changed");
// a card is plugged in: the next stale request starts a rescan, still without waiting
mounted.lock().unwrap().push(card("EOS_DIGITAL"));
std::thread::sleep(Duration::from_millis(40));
let t = std::time::Instant::now();
assert!(watch.get().is_empty(), "the old list is served while rescanning");
assert!(t.elapsed() < SLOW / 2, "warm get() waited: {:?}", t.elapsed());
assert!(until(|| changes.load(Ordering::SeqCst) == 1), "a changed scan notifies");
assert_eq!(watch.get(), [card("EOS_DIGITAL")]);
// explicit requests scan synchronously
assert!(until(|| !watch.refreshing()));
mounted.lock().unwrap().clear();
assert!(watch.scan_now().is_empty());
assert_eq!(changes.load(Ordering::SeqCst), 2);
}
}
+71 -38
View File
@@ -434,8 +434,11 @@ pub struct ExportOptions {
pub limit_kb: Option<u32>,
pub sharpen: SharpenFor,
pub sharpen_amount: SharpenAmount,
/// File name template: `{name}` (original stem), `{seq}` (zero-padded to 3, counting from
/// `start_number`), `{date}` (capture date, YYYYMMDD); the extension is appended.
/// File name template with the batch-rename tokens ([`crate::rename::expand_tokens`]): `{name}`
/// (original stem), `{seq}` (zero-padded to 3, counting from `start_number`; `{seq:N}` for N
/// digits), `{date}` (capture date, YYYYMMDD; `{date:%Y-%m-%d}`…), `{num}`, `{folder}`,
/// `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; the output
/// extension is appended.
pub naming: String,
/// First `{seq}` value.
pub start_number: u32,
@@ -550,10 +553,9 @@ impl ExportOptions {
r(ResizeMode::ShortEdge, v)
} else if let Some(v) = f("megapixels") {
r(ResizeMode::Megapixels, v)
} else if let Some(v) = f("percent") {
r(ResizeMode::Percent, v)
} else {
return None;
let v = f("percent")?;
r(ResizeMode::Percent, v)
}
}
})
@@ -618,36 +620,23 @@ impl ExportOptions {
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
}
/// Output file name for photo `stem` at 1-based position `seq` in a batch.
pub fn file_name(&self, stem: &str, seq: usize) -> String {
self.file_name_dated(stem, seq, None)
}
/// [`ExportOptions::file_name`] with the capture time (ISO 8601) for `{date}`.
pub fn file_name_dated(&self, stem: &str, seq: usize, captured: Option<&str>) -> String {
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
/// extension is kept for [`ExportFormat::Original`]).
pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String {
let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() };
let n = seq + self.start_number.max(1) as usize - 1;
let date: String = captured.map(|c| c.chars().take(10).filter(char::is_ascii_digit).collect()).unwrap_or_default();
let name = base.replace("{name}", stem).replace("{seq}", &format!("{n:03}")).replace("{date}", &date);
let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
format!("{name}.{}", self.format.extension())
}
/// Output file name for photo file `file_name` (the original's extension is kept for
/// [`ExportFormat::Original`]).
pub fn file_name_for(&self, file_name: &str, seq: usize) -> String {
self.file_name_for_dated(file_name, seq, None)
}
/// [`ExportOptions::file_name_for`] with the capture time for `{date}`.
pub fn file_name_for_dated(&self, file_name: &str, seq: usize, captured: Option<&str>) -> String {
let (stem, ext) = file_name.rsplit_once('.').unwrap_or((file_name, ""));
let name = self.file_name_dated(stem, seq, captured);
let name = crate::rename::expand_tokens(base, p, n, 3);
let mut name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
let (stem, ext) = p.file_name.rsplit_once('.').unwrap_or((&p.file_name, ""));
if name.trim().is_empty() {
// every token came out empty (no title, no capture time…): keep the original name
name = stem.to_string();
}
if self.format == ExportFormat::Original {
let base = name.trim_end_matches('.');
if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") }
} else {
name
format!("{name}.{}", self.format.extension())
}
}
}
@@ -669,7 +658,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar
let (w, h) = (img.width, img.height);
match &mut img.samples {
DeepSamples::U16(v) => {
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
if let Some(out) = unsharp(w, h, &src, target, amount) {
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
*d = s.round().clamp(0.0, 65535.0) as u16;
@@ -677,7 +666,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar
}
}
DeepSamples::F32(v) => {
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect();
if let Some(out) = unsharp(w, h, &src, target, amount) {
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
*d = s.max(0.0);
@@ -864,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),
@@ -962,7 +960,7 @@ pub fn prepare_export(
seq: usize,
) -> Result<PreparedExport, String> {
let p = session.catalog.photo(id).ok_or("no such photo")?;
let file_name = o.file_name_for_dated(&p.file_name, seq, p.captured.as_deref());
let file_name = o.file_name_for(p, seq);
let work = if o.format.is_rendered() {
let (w, h) = output_size(p, o);
let meta = export_metadata(p, o);
@@ -1180,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();
@@ -1191,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());
}
@@ -1249,12 +1257,35 @@ mod tests {
assert_eq!(o.format, ExportFormat::Jpeg);
assert_eq!(o.quality, 100);
assert_eq!(o.resize, Some(Resize { dont_enlarge: true, height: 0, ..Resize::long_edge(2048) }));
assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg");
assert_eq!(o.file_name_for(&named("IMG/1.png"), 7), "IMG_1-007.jpg");
assert_eq!(o.ppi, 240);
let orig = ExportOptions { format: ExportFormat::Original, naming: "{name}-{seq}".into(), ..Default::default() };
assert_eq!(orig.file_name_for("DSC_1.NEF", 2), "DSC_1-002.NEF");
assert_eq!(orig.file_name_for("noext", 1), "noext-001");
assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for("a.cr2", 1), "a.dng");
assert_eq!(orig.file_name_for(&named("DSC_1.NEF"), 2), "DSC_1-002.NEF");
assert_eq!(orig.file_name_for(&named("noext"), 1), "noext-001");
assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for(&named("a.cr2"), 1), "a.dng");
}
fn named(file_name: &str) -> lightcraft_catalog::Photo {
use lightcraft_catalog::{Photo, PhotoId, Source};
Photo::new(PhotoId(1), Source::Demo { scene: 0 }, file_name, "", 1, 1, "2026-01-01T00:00:00")
}
/// Export naming takes the same tokens as Rename Photos and import renaming.
#[test]
fn naming_uses_the_rename_tokens() {
let mut p = named("DSC_0815.NEF");
p.captured = Some("2026-09-30T14:05:09".into());
p.meta.camera = "Model X/2".into();
p.meta.title = "Harbour".into();
p.rating = 5;
let o = |naming: &str| ExportOptions { naming: naming.into(), start_number: 9, ..Default::default() };
assert_eq!(o("{date:%Y-%m-%d}_{title}_{seq:2}").file_name_for(&p, 1), "2026-09-30_Harbour_09.jpg");
assert_eq!(o("{camera}-{num}-{rating}").file_name_for(&p, 1), "Model X_2-0815-5.jpg", "path separators become _");
assert_eq!(o("{name}-{seq}").file_name_for(&p, 3), "DSC_0815-011.jpg", "a bare {{seq}} keeps its 3 digits");
assert_eq!(o("{ext}_{name}").file_name_for(&p, 1), "NEF_DSC_0815.jpg");
// nothing left after expanding: the original name
p.meta.title.clear();
assert_eq!(o("{title}").file_name_for(&p, 1), "DSC_0815.jpg");
}
#[test]
@@ -1282,7 +1313,9 @@ mod tests {
let full = ExportOptions::default();
assert_eq!(ExportOptions::from_json(&full.to_json()), full);
assert!(ExportOptions::has_size_param(&full.to_json()), "full size is explicit");
assert_eq!(o.file_name_dated("IMG", 2, Some("2026-09-30T10:00:00")), "20260930-IMG-043.tif");
let mut p = named("IMG.png");
p.captured = Some("2026-09-30T10:00:00".into());
assert_eq!(o.file_name_for(&p, 2), "20260930-IMG-043.tif");
}
#[test]
+62
View File
@@ -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(),
};
@@ -114,6 +117,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
as_shot_wb: None,
content_hash,
xmp: lightcraft_meta::embedded(bytes).xmp,
preview_only: Some(why),
..Default::default()
});
}
@@ -141,6 +145,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
as_shot_wb,
content_hash,
xmp: lightcraft_meta::embedded(bytes).xmp,
preview_only: None,
});
}
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
@@ -170,6 +175,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
content_hash,
embedded_lens: None,
xmp: None,
preview_only: None,
})
}
@@ -396,6 +402,62 @@ mod tests {
assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err());
}
/// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's
/// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a
/// library reopen, and Reload / Relink clear it once the file decodes (undoably).
#[test]
fn unsupported_raw_imports_as_preview_only_and_survives_reopen() {
use serde_json::json;
let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (lib, files) = (dir.join("lib"), dir.join("files"));
std::fs::create_dir_all(&files).unwrap();
let path = files.join("DSC_0001.cr3");
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
let mut s = crate::Session::new().with_fs();
s.open_library(&lib, false).unwrap();
// the import review already says so
let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap();
assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}");
let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap();
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap().clone();
let why = p.preview_only.clone().expect("marked preview only");
assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}");
assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)");
assert!(!p.develops_raw());
// rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults
assert_eq!(crate::media::source_info(&p), SourceInfo::default());
assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default());
assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok());
// agents see it
let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap();
assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}");
let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap();
assert_eq!(i["preview_only"], json!(why), "{i}");
// a library reopen keeps it
s.persist().unwrap();
drop(s);
let mut s = crate::Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
// the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back
std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
assert_eq!(r["reloaded"], json!([id.0]), "{r}");
assert!(s.catalog.photo(id).unwrap().develops_raw());
assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
// relinking to a file that decodes clears it too
let dng = files.join("DSC_0001.dng");
std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().preview_only, None);
drop(s);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quick_jobs_show_embedded_previews_then_cached_renders() {
use crate::media::QuickSource;
+55
View File
@@ -0,0 +1,55 @@
//! Last-resort panic guard (never-crash standard, `AGENTS.md`): a panic that escapes a command
//! becomes that command's error and the session (catalog, library, undo history) stays usable.
//! It's a safety net, not a way to report errors: commands return `Err` themselves.
use std::any::Any;
use std::io::Write;
use std::panic::{self, AssertUnwindSafe};
use std::path::PathBuf;
/// The text of a panic payload (`panic!("…")` gives a `&str` or a `String`).
pub fn panic_message(payload: &(dyn Any + Send)) -> String {
match (payload.downcast_ref::<&str>(), payload.downcast_ref::<String>()) {
(Some(s), _) => (*s).to_string(),
(_, Some(s)) => s.clone(),
_ => "unknown panic".into(),
}
}
/// Run `f`; a panic inside it becomes `Err("<what> failed unexpectedly: <message>")`. On wasm32
/// (built with `panic = "abort"`) nothing can be caught and this just runs `f`.
pub fn catch<T>(what: &str, f: impl FnOnce() -> T) -> Result<T, String> {
panic::catch_unwind(AssertUnwindSafe(f)).map_err(|p| {
let msg = format!("{what} failed unexpectedly: {}", panic_message(p.as_ref()));
log::error!("{msg}");
msg
})
}
/// Install a panic hook that keeps the default report (stderr) and also appends the panic's
/// location and message to `log` (best effort), so a panic in a GUI session leaves a trace.
pub fn install_hook(log: PathBuf) {
let default = panic::take_hook();
panic::set_hook(Box::new(move |info| {
default(info);
let at = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default();
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let thread = std::thread::current().name().unwrap_or("unnamed").to_string();
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {
let _ = writeln!(f, "{secs} [{thread}] panic at {at}: {}", panic_message(info.payload()));
}
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_panic_becomes_an_error() {
assert_eq!(catch("ok", || 7), Ok(7));
let e = catch("`demo.cmd`", || -> u32 { panic!("boom {}", 1) }).unwrap_err();
assert_eq!(e, "`demo.cmd` failed unexpectedly: boom 1");
assert!(catch("x", || std::panic::panic_any(5u8)).unwrap_err().ends_with("unknown panic"));
}
}
+286 -81
View File
@@ -6,12 +6,16 @@
//! 3. Probe the rest — dimensions, metadata, and the **content hash** of the bytes — in parallel
//! on native targets.
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy.
//! 6. Read each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
//! metadata and develop settings are restored (see [`crate::sidecar`]).
//! 5. *Add* in place (the photo points at the original file), *copy* into the library's
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy, or *move*
//! there: placed like a copy (with its XMP sidecars), and the original removed only once its
//! catalog record is committed and saved (see [`crate::import_move`] for the safety rules).
//! 6. Apply each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
//! metadata and develop settings are restored (see [`crate::sidecar`]). Its capture time fills
//! in when the file has none (read before step 5, so it also files and names the copy).
//! 7. Optionally apply a preset and add keywords (the import dialog's options).
//! 8. Commit all new photos as one undoable op.
//! 8. Commit all new photos as one undoable op. (Undoing a move removes the catalog records;
//! the files stay at the destination — undo never moves files back over a card.)
//!
//! The import dialog first calls [`scan`] (`library.importPreview`): the same expansion, probing
//! and duplicate detection without adding anything, so the user can review the candidates. The
@@ -39,6 +43,9 @@ pub enum ImportMode {
Add,
/// Copy the files into the library's `Originals/` folder.
Copy,
/// Move the files there: as Copy, then each original (and its sidecars) is removed from the
/// source once the copy is verified and catalogued.
Move,
}
/// What an import does besides adding the photos.
@@ -66,8 +73,9 @@ pub struct ImportOptions {
pub convert_dng: bool,
}
/// How copies are filed in the destination.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
/// How copies are filed in the destination. The date is the capture time, else the time of the
/// import.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Organize {
/// `YYYY/YYYY-MM-DD/` by capture date.
#[default]
@@ -76,17 +84,36 @@ pub enum Organize {
ByMonth,
/// All in the destination itself.
Flat,
/// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own
/// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`];
/// never outside the destination).
Template(String),
}
impl Organize {
/// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`).
pub fn parse(s: &str) -> Option<Organize> {
match s {
"date" | "day" | "byDay" => Some(Organize::ByDay),
"month" | "byMonth" => Some(Organize::ByMonth),
"flat" | "none" | "intoOneFolder" => Some(Organize::Flat),
t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())),
_ => None,
}
}
/// The folders (inside the destination) a copy of `p` goes to.
pub fn folders(&self, p: &Photo) -> Vec<String> {
let date = p.date();
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
let year = day.get(..4).unwrap_or("undated");
match self {
Organize::ByDay => vec![year.into(), day.into()],
Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()],
Organize::Flat => Vec::new(),
Organize::Template(t) => crate::rename::expand_folder(t, p, 1),
}
}
}
/// A file found by [`scan`], for the import review.
@@ -108,6 +135,9 @@ pub struct ImportCandidate {
pub existing: Option<u64>,
/// Not readable.
pub error: Option<String>,
/// A raw variant that can't be decoded yet (why): it imports as preview only.
#[serde(skip_serializing_if = "Option::is_none")]
pub preview_only: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -123,6 +153,13 @@ pub struct Duplicate {
pub struct ImportReport {
pub imported: Vec<u64>,
pub duplicates: Vec<Duplicate>,
/// Move: the originals that were moved (and removed from the source).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub moved: Vec<Moved>,
/// Move: originals (or sidecars) left at the source, and why — the photo may still have been
/// imported (from its copy, or in place).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub kept: Vec<Kept>,
/// (path, error)
pub failed: Vec<(String, String)>,
/// Files found after expanding folders.
@@ -131,6 +168,23 @@ pub struct ImportReport {
pub sidecars: usize,
}
/// A file moved by an import.
#[derive(Clone, Debug, Default, Serialize)]
pub struct Moved {
pub from: String,
pub to: String,
/// Its sidecars' new paths.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sidecars: Vec<String>,
}
/// A source a Move left where it was.
#[derive(Clone, Debug, Default, Serialize)]
pub struct Kept {
pub path: String,
pub reason: String,
}
/// The develop settings a photo gets on import: raws start from their as-shot white balance with
/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the
/// camera intended); a user default preset ([`ImportDefaults`]) goes on top.
@@ -211,7 +265,8 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) {
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
}
let base = p.camera_defaults();
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
// a raw shown from its embedded JPEG gets the rendered-file default, not the raw one
let raw = p.develops_raw();
let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id));
match preset {
Some(pr) => {
@@ -261,11 +316,12 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
/// Probe files (headers + content hash) as an import would.
pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
probe_all(s, paths)
probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default())
}
fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
let Some(probe) = s.media.file_probe.clone() else {
fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec<Result<ProbeInfo, String>> {
use std::sync::atomic::Ordering::Relaxed;
let Some(probe) = probe.cloned() else {
return paths
.iter()
.map(|p| {
@@ -291,7 +347,11 @@ fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
if i >= paths.len() {
break;
}
if progress.cancel.load(Relaxed) {
break;
}
let r = probe(&paths[i]);
progress.done.fetch_add(1, Relaxed);
out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r);
}
});
@@ -300,19 +360,23 @@ fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect();
}
}
paths.iter().map(|p| probe(p)).collect()
paths
.iter()
.map(|p| {
if progress.cancel.load(Relaxed) {
return Err("cancelled".into());
}
let r = probe(p);
progress.done.fetch_add(1, Relaxed);
r
})
.collect()
}
/// Copy `src` into `root` (filed per `organize` by `date`) as `name` (default: its own name),
/// made unique with -1, -2…; returns the new path.
fn copy_into(root: &Path, src: &str, date: &str, organize: Organize, name: Option<&str>) -> Result<String, String> {
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
let year = day.get(..4).unwrap_or("undated");
let dir = match organize {
Organize::ByDay => root.join(year).join(day),
Organize::ByMonth => root.join(year).join(day.get(..7).unwrap_or("undated")),
Organize::Flat => root.to_path_buf(),
};
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
/// returns the new path.
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result<String, String> {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let name = name
.map(str::to_string)
@@ -331,57 +395,112 @@ fn copy_into(root: &Path, src: &str, date: &str, organize: Organize, name: Optio
Ok(dst.to_string_lossy().to_string())
}
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
/// share can take minutes to read).
pub struct ScanInput {
probe: Option<crate::media::FileProbe>,
skip: Option<PathBuf>,
/// path → (photo, its summary) for files already in the library.
by_path: HashMap<String, (PhotoId, ImportCandidate)>,
by_hash: HashMap<String, PhotoId>,
cache: HashMap<String, ProbeInfo>,
}
/// Progress and cancellation of a running [`scan_with`].
#[derive(Default)]
pub struct ScanProgress {
/// Files to probe, set once the folders are expanded.
pub total: std::sync::atomic::AtomicUsize,
pub done: std::sync::atomic::AtomicUsize,
pub cancel: std::sync::atomic::AtomicBool,
}
/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows.
pub struct ScanOutput {
pub candidates: Vec<ImportCandidate>,
pub probes: HashMap<String, ProbeInfo>,
}
impl ScanInput {
pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec<String>) {
let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
let mut by_path = HashMap::new();
let mut by_hash = HashMap::new();
for p in s.catalog.photos() {
if let Source::File { path } = &p.source {
let c = ImportCandidate {
path: path.clone(),
format: p.format.clone(),
kind: p.kind,
width: p.width,
height: p.height,
file_size: p.file_size,
captured: p.captured.clone(),
duplicate: Some("path".into()),
existing: Some(p.id.0),
..Default::default()
};
by_path.insert(path.clone(), (p.id, c));
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), p.id);
}
}
let input = ScanInput { probe: s.media.file_probe.clone(), skip, by_path, by_hash, cache: std::mem::take(&mut s.import_probes) };
(input, paths.to_vec())
}
}
/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content
/// against the library and earlier candidates). Nothing is added; the probes are kept for the
/// import that follows.
pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
let files = expand(paths, lib_dir.as_deref());
let mut by_path: HashMap<String, PhotoId> = HashMap::new();
let mut by_hash: HashMap<String, PhotoId> = HashMap::new();
for p in s.catalog.photos() {
if let Source::File { path } = &p.source {
by_path.insert(path.clone(), p.id);
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), p.id);
}
}
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(f.as_str())).cloned().collect();
let (input, paths) = ScanInput::new(s, paths);
let out = scan_with(input, &paths, &ScanProgress::default());
s.import_probes = out.probes;
out.candidates
}
/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it
/// has) when `progress.cancel` is set.
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
use std::sync::atomic::Ordering::Relaxed;
let files = expand(paths, input.skip.as_deref());
let todo: Vec<String> = files.iter().filter(|f| !input.by_path.contains_key(f.as_str())).cloned().collect();
progress.total.store(todo.len(), Relaxed);
// probes from a preceding `scan` are reused when the file is unchanged (same size)
let cached: Vec<Option<ProbeInfo>> = todo
.iter()
.map(|f| {
let info = s.import_probes.remove(f)?;
let info = input.cache.remove(f)?;
let size = std::fs::metadata(f).map(|m| m.len()).ok();
(size.is_none() || size == Some(info.file_size)).then_some(info)
})
.collect();
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
let mut fresh = probe_all(s, &missing).into_iter();
progress.done.store(todo.len() - missing.len(), Relaxed);
let mut fresh = probe_all(input.probe.as_ref(), &missing, progress).into_iter();
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
let mut probes: HashMap<String, Result<ProbeInfo, String>> = todo.into_iter().zip(probed).collect();
let mut seen_hash: HashMap<String, Option<u64>> = by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
let mut seen_hash: HashMap<String, Option<u64>> = input.by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
let mut out = Vec::with_capacity(files.len());
let mut kept = HashMap::new();
for f in files {
let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() };
if let Some(id) = by_path.get(&f) {
c.duplicate = Some("path".into());
c.existing = Some(id.0);
if let Some(p) = s.catalog.photo(*id) {
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
(p.format.clone(), p.kind, p.width, p.height, p.file_size, p.captured.clone());
}
if let Some((_, c)) = input.by_path.get(&f) {
let mut c = c.clone();
c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
out.push(c);
continue;
}
let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() };
match probes.remove(&f) {
Some(Ok(info)) => {
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
(info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone());
c.preview_only = info.preview_only.clone();
if let Some(h) = &info.content_hash {
match seen_hash.get(h) {
Some(existing) => {
@@ -393,14 +512,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
}
}
}
s.import_probes.insert(f, info);
kept.insert(f, info);
}
Some(Err(e)) => c.error = Some(e),
None => c.error = Some("not probed".into()),
}
out.push(c);
}
out
ScanOutput { candidates: out, probes: kept }
}
/// Import files/folders. See the module docs.
@@ -413,7 +532,11 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
let mode = opts.mode;
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of every
// added file in its own storage already, so "copy" there means "add".
let mode = if mode == ImportMode::Copy && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
if mode == ImportMode::Move && opts.local {
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
}
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
// where copies go: the chosen folder, else the library's Originals/
let copy_root = opts
@@ -422,8 +545,8 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
.filter(|d| !d.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
if mode == ImportMode::Copy && copy_root.is_none() {
return Err(crate::EngineError::Other("copying needs a destination folder or an open library".into()));
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
}
let mut seq = opts.rename_start.max(1);
let files = expand(paths, lib_dir.as_deref());
@@ -454,10 +577,17 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
}
}
let probed = probe_all(s, &todo);
// files a preceding scan already probed are not read again (a network share is slow to read)
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| s.import_probes.remove(f)).collect();
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
let mut fresh = probe_all(s.media.file_probe.as_ref(), &missing, &ScanProgress::default()).into_iter();
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
let now = (s.clock)();
let mut ops = promote;
// Move: placed at the destination, sources untouched until the records are committed
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
for (path, info) in todo.into_iter().zip(probed) {
let info = match info {
Ok(i) => i,
@@ -474,28 +604,72 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
continue;
}
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
// and names the copy when the file itself has none
let raw = info.kind == MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc),
Err(e) => {
log::warn!("import {path}: XMP: {e}");
None
}
});
let mut info = info;
if info.captured.is_none() {
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
}
let stored = match (mode, &copy_root) {
(ImportMode::Copy, Some(root))
if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) =>
(ImportMode::Copy | ImportMode::Move, Some(root))
if !crate::import_move::inside(Path::new(&path), root)
&& !lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
{
// the templates see the photo as it will be catalogued (undated: the import time)
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
// the template sees the photo as it will be catalogued
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let n = crate::rename::expand(t, &q, seq);
seq += 1;
n
});
match copy_into(root, &path, info.captured.as_deref().unwrap_or(&now), opts.organize, name.as_deref()) {
Ok(p) => p,
Err(e) => {
report.failed.push((path, e));
continue;
let folders = opts.organize.folders(&q);
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
let name = name.unwrap_or(own);
match crate::import_move::place(Path::new(&path), &dir, &name) {
Ok(pl) => {
let dst = pl.dst.to_string_lossy().to_string();
placed.push(pl);
dst
}
Err(e) => {
report.failed.push((path, e));
continue;
}
}
} else {
if mode == ImportMode::Move {
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
}
match copy_into(root, &path, &folders, name.as_deref()) {
Ok(p) => p,
Err(e) => {
report.failed.push((path, e));
continue;
}
}
}
}
(ImportMode::Move, _) => {
let reason = "already inside the destination or the library: added where it is";
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
path.clone()
}
_ => path.clone(),
};
let id = s.catalog.alloc_photo_id();
@@ -504,7 +678,6 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
}
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
let mut stored = stored;
let mut info = info;
if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") {
match crate::cmd::convert::write_dng_for(s, &stored, String::new()) {
Ok(dng) => {
@@ -525,20 +698,11 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
p.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
apply_import_defaults(s, &mut p);
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
if let Some(x) = packet {
match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
Ok(sc) if sc != crate::sidecar::SidecarData::default() => {
crate::sidecar::merge_into(&mut p, &sc, &now);
report.sidecars += 1;
}
Ok(_) => {}
Err(e) => log::warn!("import {path}: XMP: {e}"),
}
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
report.sidecars += 1;
}
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
@@ -560,12 +724,52 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
p.local = opts.local;
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
if !ops.is_empty() {
s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })?;
let log0 = s.pending_log.len();
if !ops.is_empty()
&& let Err(e) = s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })
{
// nothing was catalogued: take the moves back (their sources were never touched)
placed.iter().for_each(crate::import_move::rollback);
return Err(e);
}
if !placed.is_empty() {
finish_moves(s, placed, log0, &mut report);
}
Ok(report)
}
/// Move, after the commit: save the library, then remove each source whose destination is
/// still intact. Nothing is removed when the library can't be saved.
fn finish_moves(s: &mut Session, placed: Vec<crate::import_move::Placed>, log0: usize, report: &mut ImportReport) {
// (saving here clears the pending ops, so the sidecars `execute` would auto-write are written here)
let auto: Vec<Op> = if s.xmp.auto_write { s.pending_log.get(log0..).map(<[Op]>::to_vec).unwrap_or_default() } else { Vec::new() };
let before = s.pending_log.len();
if let Err(e) = s.persist() {
for p in placed {
let reason = format!("the library could not be saved ({e}): the original stays; the photo uses the copy at {}", p.dst.display());
report.kept.push(Kept { path: p.src.to_string_lossy().to_string(), reason });
}
return;
}
for p in placed {
let from = p.src.to_string_lossy().to_string();
match crate::import_move::finish(&p) {
Ok(kept) => {
let sidecars = p.sidecars.iter().map(|sc| sc.dst.to_string_lossy().to_string()).collect();
report.moved.push(Moved { from, to: p.dst.to_string_lossy().to_string(), sidecars });
report.kept.extend(kept.into_iter().map(|(path, reason)| Kept { path, reason }));
}
Err(reason) => {
log::warn!("import: move {from}: {reason}");
report.kept.push(Kept { path: from, reason });
}
}
}
if s.pending_log.len() < before && s.interaction.is_none() && !auto.is_empty() {
s.auto_write_sidecars(&auto);
}
}
/// The current local time as ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, UTC on targets without a clock
/// offset); for [`Session::clock`] on native hosts.
pub fn system_clock() -> String {
@@ -598,6 +802,7 @@ impl Session {
if let Some(info) = self.import_probes.get(&c.path) {
p.as_shot_wb = info.as_shot_wb;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
}
p.develop = std::sync::Arc::new(p.import_defaults());
let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge());
+307
View File
@@ -0,0 +1,307 @@
//! Import → **Move**: the file transfer behind [`crate::import::ImportMode::Move`].
//!
//! Moving deletes the user's originals, so it is done in two phases and every step that could
//! lose data comes last:
//!
//! 1. [`place`] puts the photo (and its XMP sidecars) at the destination **without touching the
//! source**: a hard link when source and destination share a volume (instant, no extra space,
//! never overwrites), else a copy that is written to a new file (never overwriting), synced to
//! disk and compared byte for byte with the original. A name that is taken gets -1, -2…
//! Anything that fails is undone ([`rollback`]): the partial destination is removed and the
//! source stays as it was.
//! 2. Only after the catalog records pointing at the destinations were committed and saved
//! does [`finish`] remove each source — after checking once more that its destination is
//! still there with the same size. A source that can't be removed (a read-only card, a
//! locked file) stays and is reported; the photo is then imported from the copy.
//!
//! A `IMG_1.xmp` sidecar is shared by `IMG_1.CR3` and `IMG_1.JPG`: it is copied along with each,
//! and removed from the source only once no photo with that name is left beside it.
//!
//! (Not `std::fs::rename`: it would remove the source before the catalog knows the new place,
//! and on Unix it silently replaces an existing destination.)
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
/// A photo put in place by [`place`]; its source is untouched.
#[derive(Clone, Debug)]
pub(crate) struct Placed {
pub src: PathBuf,
pub dst: PathBuf,
pub sidecars: Vec<PlacedSidecar>,
}
#[derive(Clone, Debug)]
pub(crate) struct PlacedSidecar {
pub src: PathBuf,
pub dst: PathBuf,
/// `false`: an identical sidecar was already there (the other half of a raw + JPEG pair).
pub created: bool,
/// Named after the photo's stem (`IMG_1.xmp`), so possibly shared with a sibling photo.
pub stem: bool,
}
/// Test-only fault injection (per thread), to exercise the failure paths.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) enum Fault {
#[default]
None,
/// Hard links fail, as across volumes: the copy path runs.
NoLink,
/// Writing the copy fails half-way (implies `NoLink`).
FailWrite,
/// The copy comes out different from the original, caught by the verification (implies `NoLink`).
Corrupt,
/// Removing the source fails (as on a read-only card).
FailRemove,
}
#[cfg(test)]
thread_local! {
static FAULT: std::cell::Cell<Fault> = const { std::cell::Cell::new(Fault::None) };
}
/// Inject a fault for the calling thread's next moves (tests only).
#[cfg(test)]
pub(crate) fn inject(f: Fault) {
FAULT.with(|c| c.set(f));
}
fn injected(f: Fault) -> bool {
#[cfg(test)]
{
FAULT.with(|c| c.get()) == f
}
#[cfg(not(test))]
{
let _ = f;
false
}
}
/// Is `path` inside `dir` (as written, or once symlinks and `..` are resolved)?
pub(crate) fn inside(path: &Path, dir: &Path) -> bool {
if path.starts_with(dir) {
return true;
}
match (fs::canonicalize(path), fs::canonicalize(dir)) {
(Ok(p), Ok(d)) => p.starts_with(d),
_ => false,
}
}
pub(crate) fn is_symlink(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
}
/// The XMP sidecars beside `src`, both naming conventions (`IMG_1.xmp`, `IMG_1.CR3.xmp`; the
/// `.xmp` extension in any case): (path, named after the stem).
pub(crate) fn sidecars_of(src: &Path) -> Vec<(PathBuf, bool)> {
let (Some(dir), Some(name)) = (src.parent(), src.file_name().map(|n| n.to_string_lossy().to_string())) else { return Vec::new() };
let stem = src.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let Ok(rd) = fs::read_dir(dir) else { return Vec::new() };
let mut out: Vec<(PathBuf, bool)> = rd
.flatten()
.filter_map(|e| {
let n = e.file_name().to_string_lossy().to_string();
let (base, ext) = n.rsplit_once('.')?;
if !ext.eq_ignore_ascii_case("xmp") || !e.path().is_file() {
return None;
}
if base == name {
Some((e.path(), false))
} else if base == stem {
Some((e.path(), true))
} else {
None
}
})
.collect();
out.sort();
out
}
/// Put `src` into `dir` as `name` (-1, -2… when taken) with its sidecars, leaving the source
/// untouched. On failure nothing is left at the destination.
pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result<Placed, String> {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (name.to_string(), String::new()),
};
let mut dst = None;
for i in 0..100_000u32 {
let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) };
match link_or_copy(src, &d) {
Ok(()) => {
dst = Some(d);
break;
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("move {}: {e} (the original is kept)", src.display())),
}
}
let Some(dst) = dst else { return Err(format!("move {}: no free name in {}", src.display(), dir.display())) };
let mut placed = Placed { src: src.to_path_buf(), dst, sidecars: Vec::new() };
for (sc, stem_named) in sidecars_of(src) {
let sc_ext = sc.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "xmp".into());
let sc_dst =
if stem_named { placed.dst.with_extension(&sc_ext) } else { PathBuf::from(format!("{}.{sc_ext}", placed.dst.to_string_lossy())) };
let r = if sc_dst.exists() {
match same_bytes(&sc, &sc_dst) {
Ok(true) => Ok(false),
Ok(false) => Err(format!("a different sidecar is already at {} (the original is kept)", sc_dst.display())),
Err(e) => Err(format!("{}: {e} (the original is kept)", sc_dst.display())),
}
} else {
link_or_copy(&sc, &sc_dst).map(|()| true).map_err(|e| format!("move {}: {e} (the original is kept)", sc.display()))
};
match r {
Ok(created) => placed.sidecars.push(PlacedSidecar { src: sc, dst: sc_dst, created, stem: stem_named }),
Err(e) => {
rollback(&placed);
return Err(e);
}
}
}
Ok(placed)
}
/// Undo a [`place`]: remove what it created at the destination (the sources were never touched).
pub(crate) fn rollback(p: &Placed) {
for sc in p.sidecars.iter().filter(|s| s.created) {
let _ = fs::remove_file(&sc.dst);
}
let _ = fs::remove_file(&p.dst);
}
/// Remove a placed photo's source (its catalog record is committed and saved): only when the
/// destination is still there with the same size. Then its sidecars, unless another photo still
/// uses a shared one. `Err` = the source was kept (why); `Ok` lists sidecars kept (path, why).
pub(crate) fn finish(p: &Placed) -> Result<Vec<(String, String)>, String> {
let same = match (fs::metadata(&p.src), fs::metadata(&p.dst)) {
(Ok(s), Ok(d)) => s.is_file() && d.is_file() && s.len() == d.len(),
_ => false,
};
if !same {
return Err(format!("{} is missing or changed: the original is kept", p.dst.display()));
}
if injected(Fault::FailRemove) {
return Err("could not remove the original (injected): it stays where it was".into());
}
fs::remove_file(&p.src).map_err(|e| format!("could not remove the original ({e}): it stays where it was"))?;
let mut kept = Vec::new();
for sc in &p.sidecars {
let path = sc.src.to_string_lossy().to_string();
if !same_bytes(&sc.src, &sc.dst).unwrap_or(false) {
kept.push((path, format!("{} is missing or changed: the sidecar is kept", sc.dst.display())));
continue;
}
if sc.stem
&& let Some(other) = sibling_photo(&sc.src)
{
kept.push((path, format!("also the sidecar of {other}: kept beside it")));
continue;
}
if let Err(e) = fs::remove_file(&sc.src) {
kept.push((path, format!("could not remove the sidecar ({e})")));
}
}
Ok(kept)
}
/// A photo (by extension) still beside a stem-named sidecar, sharing its stem.
fn sibling_photo(sidecar: &Path) -> Option<String> {
let dir = sidecar.parent()?;
let stem = sidecar.file_stem()?.to_string_lossy().to_string();
fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).find_map(|f| {
let ok = f.file_stem().is_some_and(|s| s.to_string_lossy() == stem) && crate::import::is_supported(&f);
ok.then(|| f.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())
})
}
/// `dst` = `src` without touching `src`: a hard link, else a verified copy. Never overwrites
/// (`AlreadyExists` when `dst` is taken).
fn link_or_copy(src: &Path, dst: &Path) -> io::Result<()> {
let force_copy = injected(Fault::NoLink) || injected(Fault::FailWrite) || injected(Fault::Corrupt);
if !force_copy && !is_symlink(src) {
match fs::hard_link(src, dst) {
Ok(()) => {
let (a, b) = (fs::metadata(src)?, fs::metadata(dst)?);
if a.len() == b.len() {
return Ok(());
}
let _ = fs::remove_file(dst);
return Err(io::Error::other("the link doesn't match the original"));
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Err(e),
// another volume, or no hard links there (FAT/exFAT cards, some shares): copy
Err(_) => {}
}
}
copy_verified(src, dst)
}
/// Copy into a new file, sync it to disk and compare it with the original; on any failure the
/// partial copy is removed.
fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> {
let mut out = OpenOptions::new().write(true).create_new(true).open(dst)?;
let r = (|| {
let mut inp = File::open(src)?;
if injected(Fault::FailWrite) {
out.write_all(b"partial")?;
return Err(io::Error::other("write failed (injected)"));
}
io::copy(&mut inp, &mut out)?;
if injected(Fault::Corrupt) {
out.write_all(b"!")?;
}
out.sync_all()?;
if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) {
let _ = out.set_modified(m);
}
if !same_bytes(src, dst)? {
return Err(io::Error::other("the copy differs from the original"));
}
Ok(())
})();
drop(out);
if r.is_err() {
let _ = fs::remove_file(dst);
}
r
}
/// Do two files hold the same bytes?
pub(crate) fn same_bytes(a: &Path, b: &Path) -> io::Result<bool> {
if fs::metadata(a)?.len() != fs::metadata(b)?.len() {
return Ok(false);
}
let (mut fa, mut fb) = (File::open(a)?, File::open(b)?);
let (mut ba, mut bb) = (vec![0u8; 1 << 20], vec![0u8; 1 << 20]);
loop {
let (na, nb) = (fill(&mut fa, &mut ba)?, fill(&mut fb, &mut bb)?);
if na != nb || ba.get(..na) != bb.get(..nb) {
return Ok(false);
}
if na == 0 {
return Ok(true);
}
}
}
/// Read until `buf` is full or the file ends.
fn fill(f: &mut File, buf: &mut [u8]) -> io::Result<usize> {
let mut n = 0;
while let Some(rest) = buf.get_mut(n..).filter(|r| !r.is_empty()) {
match f.read(rest) {
Ok(0) => break,
Ok(k) => n += k,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(n)
}
+66 -11
View File
@@ -17,12 +17,15 @@ pub mod demo;
pub mod devices;
pub mod export;
pub mod files;
pub mod guard;
pub mod import;
mod import_move;
pub mod library;
pub mod media;
pub mod memory;
pub mod merge;
pub mod preset_import;
pub mod preset_luminar;
pub mod presets;
pub mod rename;
pub mod sidecar;
@@ -36,7 +39,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
use lightcraft_develop::DevelopSettings;
pub use media::{RenderJob, SourceLevel};
use serde_json::Value;
pub use view::{Browse, LibrarySource, Selection};
pub use view::{Browse, FilterChip, LibrarySource, Selection, filter_chips};
pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline};
#[derive(Debug, thiserror::Error)]
@@ -70,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).
@@ -80,8 +86,14 @@ 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>,
pub redo: Vec<UndoEntry>,
pub interaction: Option<Interaction>,
@@ -124,6 +136,9 @@ pub struct Session {
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
pub filter_presets: Vec<cmd::filters::FilterPreset>,
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
/// persisted in prefs.json.
pub curve_presets: Vec<cmd::curves::CurvePreset>,
/// Saved colour-label name sets.
pub label_sets: Vec<cmd::manage::LabelSet>,
/// Look-alike signatures by content key (Find Similar).
@@ -151,6 +166,8 @@ pub struct Session {
pub import_defaults: import::ImportDefaults,
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
pub cache_mb: u32,
/// Where smart previews are kept when not in the library folder (persisted in prefs.json).
pub smart_previews_dir: Option<std::path::PathBuf>,
}
impl Default for Session {
@@ -168,8 +185,10 @@ 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(),
interaction: None,
@@ -194,6 +213,7 @@ impl Session {
export_presets: Vec::new(),
metadata_presets: Vec::new(),
filter_presets: Vec::new(),
curve_presets: Vec::new(),
label_sets: Vec::new(),
signatures: Default::default(),
lut_profiles: Vec::new(),
@@ -207,6 +227,7 @@ impl Session {
preview_build: None,
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
smart_previews_dir: None,
}
}
@@ -226,7 +247,8 @@ impl Session {
let log_start = self.pending_log.len();
let was_active = self.active();
self.depth += 1;
let r = (spec.run)(self, params);
// last-resort guard: a panic in a command is that command's error, not a crash
let r = guard::catch(&format!("`{id}`"), || (spec.run)(self, params)).unwrap_or_else(|e| Err(EngineError::Other(e)));
self.depth -= 1;
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
self.previous_active = was_active;
@@ -473,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
@@ -482,35 +504,66 @@ 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))
});
}
if self.source == LibrarySource::Missing {
let missing: std::collections::HashSet<PhotoId> = cmd::missing::missing(self).into_iter().map(|m| m.0).collect();
self.visible.retain(|id| missing.contains(id));
// only the photos the query kept (library photos, not Local browse records) are checked
let cat = &self.catalog;
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
}
/// Photos in the current source (folder, album, …) before the filter bar and search narrow
/// them; `None` where that is not a separate number (Missing Photos).
pub fn source_total(&mut self) -> Option<usize> {
if self.source == LibrarySource::Missing {
return None;
}
let key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse));
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
if self.source == LibrarySource::Folder {
let b = self.browse.clone().unwrap_or_default();
f.folder = Some(b.path);
f.subfolders = b.subfolders;
}
let n = self.catalog.query(&f, &self.sort).len();
self.total = Some((key, n));
}
self.total.as_ref().map(|t| t.1)
}
pub fn visible_cloned(&mut self) -> Vec<PhotoId> {
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) {
@@ -548,6 +601,8 @@ mod tests_export;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_import_move;
#[cfg(test)]
mod tests_libops;
#[cfg(test)]
mod tests_library;
+39 -4
View File
@@ -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")
}
@@ -112,6 +117,8 @@ struct PrefsFile {
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
/// Filter presets.
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
/// Point-curve presets.
curve_presets: Vec<crate::cmd::curves::CurvePreset>,
/// Colour-label name sets.
label_sets: Vec<crate::cmd::manage::LabelSet>,
/// Imported LUT profiles.
@@ -124,6 +131,8 @@ struct PrefsFile {
import: crate::import::ImportDefaults,
/// Thumbnail disk cache budget (MB, 0 = default).
cache_mb: u32,
/// Folder for smart previews (default: `Smart Previews` in the library).
smart_previews_dir: Option<String>,
}
fn presets_json(s: &Session) -> String {
@@ -137,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())
}
@@ -156,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();
@@ -189,6 +213,7 @@ impl Session {
self.export_presets = prefs.export_presets;
self.metadata_presets = prefs.metadata_presets;
self.filter_presets = prefs.filter_presets;
self.curve_presets = prefs.curve_presets;
self.label_sets = prefs.label_sets;
self.lut_profiles = prefs.lut_profiles;
crate::cmd::lut_profiles::register_all(self);
@@ -197,6 +222,10 @@ impl Session {
self.recent_keywords = prefs.recent_keywords;
self.import_defaults = prefs.import;
self.cache_mb = prefs.cache_mb;
self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from);
if let Some(d) = &self.smart_previews_dir {
self.media.smart_dir = Some(d.clone());
}
// view state
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
self.source = v.source;
@@ -219,8 +248,8 @@ impl Session {
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
}
let view_written = self.view_json();
self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
Ok(&self.library.as_ref().expect("just set").report)
let library = self.library.insert(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
Ok(&library.report)
}
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.
@@ -236,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(()) };
@@ -257,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);
}
}
@@ -309,6 +342,7 @@ impl Session {
export_presets: self.export_presets.clone(),
metadata_presets: self.metadata_presets.clone(),
filter_presets: self.filter_presets.clone(),
curve_presets: self.curve_presets.clone(),
label_sets: self.label_sets.clone(),
lut_profiles: self.lut_profiles.clone(),
keyword_sets: self.keyword_sets.clone(),
@@ -316,6 +350,7 @@ impl Session {
recent_keywords: self.recent_keywords.clone(),
import: self.import_defaults.clone(),
cache_mb: self.cache_mb,
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
})
.unwrap_or_default();
let Some(lib) = self.library.as_mut() else { return Ok(()) };
+7 -6
View File
@@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 3;
pub const RENDER_CACHE_VERSION: u64 = 4;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
@@ -523,11 +523,9 @@ pub fn source_info(p: &Photo) -> SourceInfo {
if matches!(p.source, Source::Demo { .. }) {
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
}
if p.kind == MediaKind::Raw {
SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens }
} else {
SourceInfo::default()
}
// A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white
// balance and the display tone curve, like any other rendered file.
if p.develops_raw() { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } } else { SourceInfo::default() }
}
impl crate::Session {
@@ -751,6 +749,9 @@ pub struct ProbeInfo {
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
/// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file.
pub xmp: Option<String>,
/// A raw variant that can't be decoded yet: why. The file is described (and will be shown and
/// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`].
pub preview_only: Option<String>,
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
+4 -8
View File
@@ -309,14 +309,10 @@ fn output_path(first: &str, suffix: &str) -> String {
let p = std::path::Path::new(first);
let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default();
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into());
for k in 1.. {
let name = if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") };
let cand = dir.join(name);
if !cand.exists() {
return cand.to_string_lossy().to_string();
}
}
unreachable!()
let cand = |k: u64| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") });
// (`u64::MAX` taken names can't exist; the last candidate is the fallback)
let free = (1..u64::MAX).map(cand).find(|c| !c.exists()).unwrap_or_else(|| cand(u64::MAX));
free.to_string_lossy().to_string()
}
/// Parse `merge.*` command parameters into a kind and finishing options.
+17 -5
View File
@@ -1,7 +1,8 @@
//! Importing other editors' presets: XMP preset files, classic `.lrtemplate` files (a Lua table
//! literal), photos that carry their edits as an XMP packet ("DNG presets" from mobile apps,
//! edited JPEG/TIFF), and zip bundles of any of these. Everything ends up as `crs:` properties
//! mapped by [`crate::crs`].
//! mapped by [`crate::crs`]. Luminar looks (`.lmp`, `.mplumpack` collections) are read by
//! [`crate::preset_luminar`].
//!
//! Written from the formats' public structure (Lua's table syntax, the ZIP application note,
//! ISO 16684-1 XMP) and black-box observation; no third-party code or preset content was used.
@@ -23,7 +24,7 @@ pub struct Imported {
}
/// Extensions of files read as presets (beyond our own `.lcpreset`).
pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng"];
pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack"];
// ------------------------------------------------------------------------------------- zip
@@ -426,15 +427,24 @@ pub fn group_from_dir(dir: &str) -> Option<String> {
"new",
"old",
"adobe",
"luminar",
"skylum",
"looks",
"lmp",
"mplumpack",
"contents",
"resources",
];
let generic = |name: &str| {
let lower = name.to_ascii_lowercase();
lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).all(|w| FILLER.contains(&w) || w.chars().all(|c| c.is_ascii_digit()))
};
dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p)).map(str::to_string)
// a macOS bundle (`Look.lmp/Contents/…`) is a file, not a group
let bundle = |p: &str| p.to_ascii_lowercase().ends_with(".lmp");
dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p) && !bundle(p)).map(str::to_string)
}
fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT);
if from_photo && let Some(o) = settings.as_object_mut() {
// a photo's own framing and absolute white balance don't belong in a look
@@ -486,6 +496,8 @@ pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<V
}
Ok(out)
}
"lmp" => crate::preset_luminar::read_lmp(name, bytes, group).map(|i| vec![i]),
"mplumpack" => crate::preset_luminar::read_mplumpack(name, bytes),
"lrtemplate" => {
let text = String::from_utf8_lossy(bytes);
let (title, props, values) = lrtemplate_props(text.trim_start_matches('\u{feff}'))?;
@@ -522,7 +534,7 @@ pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<V
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use serde_json::json;
+688
View File
@@ -0,0 +1,688 @@
//! Luminar looks: `.lmp` files and `.mplumpack` collections.
//!
//! An `.lmp` look is an Apple XML property list: a dict whose `AdjustmentLayers` array holds layers
//! (`Identifier`, `Enabled`, `Amount` = opacity 0..1, `BlendModeIdentifier`, optional nested
//! `Sublayers.AdjustmentLayers`), each with `Effects` (`Identifier` such as
//! `MIPLDevelopCommonEffectID`, and `Parameters`: name → `{Value: number}`; parameters at their
//! default are left out). Newer looks may be a macOS bundle directory `Name.lmp/Contents/preset.lmp`
//! (plus `Info.plist` and LUT resources). An `.mplumpack` is a zip of `.lmp` files with a
//! `PresetsInfo.plist` (`GroupName`) and icons.
//!
//! The look's sliders with a clear counterpart become `crs:` fields (mapped by [`crate::crs`]);
//! everything else (AI tools, Orton, glow, LUTs…) is reported as unmapped. Sliders are on a
//! −100..100 scale; Exposure is read as ±100 ↔ ±4 EV. Layer opacity scales additive sliders.
//!
//! Written from black-box inspection of freely distributed looks and the public property-list
//! format; no Skylum code or look content was used.
use std::collections::BTreeMap;
use std::path::Path;
use crate::crs::Props;
use crate::preset_import::{Imported, build, group_from_dir, read_zip};
// ----------------------------------------------------------------------------- property lists
/// A value of an XML property list.
#[derive(Clone, Debug, PartialEq)]
pub enum Plist {
Dict(Vec<(String, Plist)>),
Array(Vec<Plist>),
Str(String),
Num(f64),
Bool(bool),
/// `<data>` (base64; not decoded)
Data,
}
impl Plist {
pub fn get(&self, key: &str) -> Option<&Plist> {
match self {
Plist::Dict(m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
pub fn str(&self) -> Option<&str> {
match self {
Plist::Str(s) => Some(s),
_ => None,
}
}
pub fn num(&self) -> Option<f64> {
match self {
Plist::Num(n) => Some(*n),
_ => None,
}
}
pub fn array(&self) -> &[Plist] {
match self {
Plist::Array(a) => a,
_ => &[],
}
}
}
struct Parser<'a> {
s: &'a str,
i: usize,
/// Nesting of the element being read (bounded: a hostile file must not overflow the stack).
depth: usize,
}
/// Deepest `<dict>` / `<array>` nesting read (real looks nest a handful of levels).
const MAX_DEPTH: usize = 64;
fn unescape(t: &str) -> String {
let mut out = String::with_capacity(t.len());
let mut rest = t;
while let Some(p) = rest.find('&') {
out.push_str(&rest[..p]);
rest = &rest[p..];
let Some(end) = rest.find(';') else { break };
let ent = &rest[1..end];
let c = match ent {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
_ => ent
.strip_prefix("#x")
.or_else(|| ent.strip_prefix("#X"))
.and_then(|h| u32::from_str_radix(h, 16).ok())
.or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok()))
.and_then(char::from_u32),
};
match c {
Some(c) => {
out.push(c);
rest = &rest[end + 1..];
}
None => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
impl Parser<'_> {
fn err<T>(&self, what: &str) -> Result<T, String> {
Err(format!("property list: {what} at byte {}", self.i))
}
/// Skip whitespace, the XML declaration, the doctype and comments.
fn skip(&mut self) {
loop {
let r = &self.s[self.i..];
let t = r.trim_start();
self.i += r.len() - t.len();
let close = if t.starts_with("<?") {
"?>"
} else if t.starts_with("<!--") {
"-->"
} else if t.starts_with("<!") {
">"
} else {
return;
};
match t.find(close) {
Some(e) => self.i += e + close.len(),
None => {
self.i = self.s.len();
return;
}
}
}
}
/// The next tag: (name, closing, self-closing).
fn tag(&mut self) -> Result<(String, bool, bool), String> {
self.skip();
let r = &self.s[self.i..];
if !r.starts_with('<') {
return self.err("expected a tag");
}
let Some(end) = r.find('>') else { return self.err("unterminated tag") };
let inner = &r[1..end];
self.i += end + 1;
let closing = inner.starts_with('/');
let selfc = inner.ends_with('/');
let name = inner.trim_start_matches('/').trim_end_matches('/').split_whitespace().next().unwrap_or("").to_string();
Ok((name, closing, selfc))
}
fn peek_close(&mut self, name: &str) -> bool {
self.skip();
let r = &self.s[self.i..];
let t = format!("</{name}>");
if r.starts_with(&t) {
self.i += t.len();
true
} else {
false
}
}
fn text(&mut self, name: &str) -> Result<String, String> {
let r = &self.s[self.i..];
let t = format!("</{name}>");
let Some(end) = r.find(&t) else { return self.err(&format!("missing {t}")) };
self.i += end + t.len();
Ok(unescape(&r[..end]))
}
fn value(&mut self) -> Result<Plist, String> {
if self.depth >= MAX_DEPTH {
return self.err("nested too deeply");
}
self.depth += 1;
let v = self.value_inner();
self.depth -= 1;
v
}
fn value_inner(&mut self) -> Result<Plist, String> {
let (name, closing, selfc) = self.tag()?;
if closing {
return self.err(&format!("unexpected </{name}>"));
}
let leaf = |p: &mut Self, f: &dyn Fn(String) -> Result<Plist, String>| if selfc { f(String::new()) } else { p.text(&name).and_then(f) };
match name.as_str() {
"plist" => {
if selfc {
return Ok(Plist::Dict(Vec::new()));
}
let v = self.value()?;
self.peek_close("plist");
Ok(v)
}
"dict" => {
let mut m = Vec::new();
if !selfc {
while !self.peek_close("dict") {
let (k, c, sc) = self.tag()?;
if k != "key" || c {
return self.err("expected <key>");
}
let key = if sc { String::new() } else { self.text("key")? };
m.push((key, self.value()?));
}
}
Ok(Plist::Dict(m))
}
"array" => {
let mut a = Vec::new();
if !selfc {
while !self.peek_close("array") {
a.push(self.value()?);
}
}
Ok(Plist::Array(a))
}
"string" | "date" => leaf(self, &|t| Ok(Plist::Str(t))),
"real" | "integer" => {
leaf(self, &|t| t.trim().parse::<f64>().map(Plist::Num).map_err(|_| format!("property list: bad number {:?}", t.trim())))
}
"data" => leaf(self, &|_| Ok(Plist::Data)),
"true" | "false" => {
if !selfc {
self.text(&name)?;
}
Ok(Plist::Bool(name == "true"))
}
_ => self.err(&format!("unknown element <{name}>")),
}
}
}
/// Parse an XML property list.
pub fn parse_plist(bytes: &[u8]) -> Result<Plist, String> {
if bytes.starts_with(b"bplist") {
return Err("binary property lists are not supported".into());
}
let text = String::from_utf8_lossy(bytes);
let mut p = Parser { s: text.trim_start_matches('\u{feff}'), i: 0, depth: 0 };
p.value()
}
// ------------------------------------------------------------------------------------ mapping
/// Luminar's colour bands (`h`/`s`/`l` + band in the HSL tool) — the same eight as `crs:`.
const BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
/// Effects whose parameters carry the basic tone / colour sliders under their own names.
const BASIC: &[&str] = &[
"MIPLDevelopCommonEffectID",
"MIPLExposureEffect",
"MIPLContrastEffect",
"MIPLHighlightsEffect",
"MIPLBlackWhiteEffect",
"MIPLWhiteBalanceEffect",
"MIPLSaturationEffect",
"MIPLVibranceEffect",
"MIPLClarityEffect",
];
/// How one Luminar slider maps: the `crs:` field, a scale, whether it adds up across layers
/// (and fades with layer opacity), and its clamp range.
struct Target {
crs: String,
scale: f64,
additive: bool,
range: (f64, f64),
}
fn target(effect: &str, param: &str, value: f64) -> Option<Target> {
let t = |crs: &str, scale: f64, additive: bool, range: (f64, f64)| Some(Target { crs: crs.to_string(), scale, additive, range });
let pm = (-100.0, 100.0);
if BASIC.contains(&effect) {
return match param {
"Exposure" => t("Exposure2012", 4.0 / 100.0, true, (-5.0, 5.0)),
"Contrast" => t("Contrast2012", 1.0, true, pm),
"Highlights" => t("Highlights2012", 1.0, true, pm),
"Shadows" => t("Shadows2012", 1.0, true, pm),
"Whites" => t("Whites2012", 1.0, true, pm),
"Blacks" => t("Blacks2012", 1.0, true, pm),
// an absolute colour temperature (raw develop) vs. a relative shift
"Temperature" if value.abs() > 1000.0 => t("Temperature", 1.0, false, (2000.0, 50000.0)),
"Temperature" => t("IncrementalTemperature", 1.0, true, pm),
"Tint" => t("IncrementalTint", 1.0, true, pm),
"Saturation" => t("Saturation", 1.0, true, pm),
"Vibrance" => t("Vibrance", 1.0, true, pm),
"Clarity" => t("Clarity2012", 1.0, true, pm),
"Dehaze" => t("Dehaze", 1.0, true, pm),
_ => None,
};
}
match (effect, param) {
("MIPLAIStructureEffect" | "MIPLStructureEffect", "Amount") => t("Clarity2012", 1.0, true, pm),
("MIPLDehazeEffect", "Amount") => t("Dehaze", 1.0, true, pm),
("MIPLVignetteEffect", "Amount") => t("PostCropVignetteAmount", 1.0, true, pm),
("MIPLVignetteEffect", "Vignette Size" | "Size") => t("PostCropVignetteMidpoint", 1.0, false, (0.0, 100.0)),
("MIPLGrainNewEffect" | "MIPLGrainEffect", "Amount") => t("GrainAmount", 1.0, true, (0.0, 100.0)),
("MIPLChannelsEffect" | "MIPLHSLEffect", _) => {
let (kind, band) = param.split_at(param.char_indices().nth(1).map_or(param.len(), |(i, _)| i));
let field = match kind {
"h" => "HueAdjustment",
"s" => "SaturationAdjustment",
"l" => "LuminanceAdjustment",
_ => return None,
};
BANDS.contains(&band).then(|| Target { crs: format!("{field}{band}"), scale: 1.0, additive: true, range: pm })
}
_ => None,
}
}
/// A point curve stored as `OptionalData`: a leading value, then x, y pairs in 0..1 with x
/// increasing. Anything else isn't read.
fn curve_points(param: &Plist) -> Option<Vec<(f64, f64)>> {
let data: Vec<f64> = param.get("OptionalData")?.array().iter().map(Plist::num).collect::<Option<_>>()?;
if data.len() < 5 || data.len().is_multiple_of(2) {
return None;
}
let pts: Vec<(f64, f64)> = data[1..].chunks(2).map(|c| (c[0], c[1])).collect();
let ok = pts.iter().all(|&(x, y)| (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y)) && pts.windows(2).all(|w| w[1].0 > w[0].0);
ok.then_some(pts)
}
/// `MIPLSmartContrastEffect` → `SmartContrast`.
fn short(effect: &str) -> &str {
let e = effect.strip_prefix("MIPL").unwrap_or(effect);
["_v1_Effect", "EffectID", "Effect"].iter().find_map(|s| e.strip_suffix(s)).unwrap_or(e)
}
#[derive(Default)]
struct Acc {
sums: BTreeMap<String, (f64, (f64, f64))>,
curves: BTreeMap<String, Vec<(f64, f64)>>,
unmapped: Vec<String>,
}
impl Acc {
fn skip(&mut self, what: String) {
if !self.unmapped.contains(&what) {
self.unmapped.push(what);
}
}
fn layers(&mut self, layers: &[Plist], weight: f64) {
for layer in layers {
if matches!(layer.get("Enabled"), Some(Plist::Bool(false))) {
continue;
}
let opacity = weight * layer.get("Amount").and_then(Plist::num).unwrap_or(1.0).clamp(0.0, 1.0);
if opacity <= 0.0 {
continue;
}
let blend = layer.get("BlendModeIdentifier").and_then(Plist::str).unwrap_or("Normal");
// a layer with a mask applies locally: not a global look
let empty = |v: &Plist| {
matches!(v, Plist::Bool(false)) || matches!(v, Plist::Dict(d) if d.is_empty()) || matches!(v, Plist::Array(a) if a.is_empty())
};
let masked = matches!(layer, Plist::Dict(m) if m.iter().any(|(k, v)| k.to_ascii_lowercase().contains("mask") && !empty(v)));
let literal = blend.eq_ignore_ascii_case("normal") && !masked;
let mut reported = false;
for effect in layer.get("Effects").map(Plist::array).unwrap_or_default() {
let id = effect.get("Identifier").and_then(Plist::str).unwrap_or("?");
let Some(Plist::Dict(params)) = effect.get("Parameters") else { continue };
for (name, pv) in params {
let value = pv.get("Value").and_then(Plist::num).or(pv.num());
if literal && id == "MIPLCurveEffect" {
let crs = match name.as_str() {
"RGB" => Some("ToneCurvePV2012"),
"Red" => Some("ToneCurvePV2012Red"),
"Green" => Some("ToneCurvePV2012Green"),
"Blue" => Some("ToneCurvePV2012Blue"),
_ => None,
};
if let (Some(crs), Some(pts)) = (crs, curve_points(pv))
&& !self.curves.contains_key(crs)
{
// a curve at partial opacity: the points fade toward the identity
self.curves.insert(crs.to_string(), pts.into_iter().map(|(x, y)| (x, x + (y - x) * opacity)).collect());
continue;
}
if pv.get("OptionalData").is_some() {
self.skip(format!("{}.{name}", short(id)));
}
continue;
}
let Some(v) = value.filter(|v| *v != 0.0) else { continue };
match target(id, name, v).filter(|_| literal) {
Some(t) => {
let x = v * t.scale * if t.additive { opacity } else { 1.0 };
let e = self.sums.entry(t.crs).or_insert((0.0, t.range));
e.0 = if t.additive { e.0 + x } else { x };
}
None => {
reported = true;
self.skip(format!("{}.{name}", short(id)));
}
}
}
}
if !literal && reported {
let lid = layer.get("Identifier").and_then(Plist::str).unwrap_or("layer");
self.skip(if masked { format!("{lid}: mask") } else { format!("{lid}: blend mode {blend}") });
}
if let Some(sub) = layer.get("Sublayers").and_then(|s| s.get("AdjustmentLayers")) {
self.layers(sub.array(), opacity);
}
}
}
}
/// The name of a look stored at `path`: its file name, or the bundle's for
/// `Name.lmp/Contents/preset.lmp`.
fn look_name(path: &str) -> String {
let p = Path::new(path);
let stem = |p: &Path| p.file_stem().map(|s| s.to_string_lossy().to_string());
let bundle = p.parent().filter(|d| d.file_name().is_some_and(|n| n.eq_ignore_ascii_case("Contents"))).and_then(Path::parent);
match bundle {
Some(b) if b.extension().is_some_and(|e| e.eq_ignore_ascii_case("lmp")) => stem(b),
_ => stem(p),
}
.unwrap_or_else(|| "Look".into())
}
/// Read one Luminar look (`.lmp`) into a preset in `group` (default "Imported Presets").
pub fn read_lmp(path: &str, bytes: &[u8], group: Option<String>) -> Result<Imported, String> {
let root = parse_plist(bytes)?;
let layers = root.get("AdjustmentLayers").ok_or("not a Luminar look (no adjustment layers)")?;
let mut acc = Acc::default();
acc.layers(layers.array(), 1.0);
let mut props = Props::new();
for (k, (v, (lo, hi))) in &acc.sums {
props.insert(format!("crs:{k}"), vec![format!("{}", v.clamp(*lo, *hi))]);
}
for (k, pts) in &acc.curves {
props.insert(format!("crs:{k}"), pts.iter().map(|(x, y)| format!("{}, {}", x * 255.0, y * 255.0)).collect());
}
if let Some(u) = root.get("uuid").and_then(Plist::str).filter(|u| !u.trim().is_empty()) {
props.insert("crs:UUID".into(), vec![u.trim().to_string()]);
}
let name = root.get("Name").and_then(Plist::str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| look_name(path));
match build(&props, &Default::default(), name, group, false) {
Some(mut i) => {
i.preset.id = i.preset.id.replacen("user.xmp.", "user.lmp.", 1);
i.unmapped.extend(acc.unmapped);
Ok(i)
}
None if acc.unmapped.is_empty() => Err("this look has no adjustments".into()),
None => Err(format!("none of this look's settings have a counterpart here ({})", acc.unmapped.join(", "))),
}
}
/// Read a Luminar looks collection (`.mplumpack`, a zip): every look, grouped by the pack's
/// `GroupName` (else the pack's file name).
pub fn read_mplumpack(path: &str, bytes: &[u8]) -> Result<Vec<Imported>, String> {
let entries = read_zip(bytes)?;
let info = entries
.iter()
.find(|(n, _)| n.rsplit('/').next().is_some_and(|b| b.eq_ignore_ascii_case("PresetsInfo.plist")))
.and_then(|(_, b)| parse_plist(b).ok())
.and_then(|p| p.get("GroupName").and_then(Plist::str).map(str::trim).filter(|g| !g.is_empty()).map(str::to_string));
let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string());
let pack = info.or_else(|| stem.as_deref().and_then(group_from_dir)).unwrap_or_else(|| "Luminar Looks".into());
let mut out = Vec::new();
let mut errors = Vec::new();
for (inner, data) in &entries {
if !Path::new(inner).extension().is_some_and(|e| e.eq_ignore_ascii_case("lmp")) {
continue;
}
match read_lmp(inner, data, Some(pack.clone())) {
Ok(i) => out.push(i),
Err(e) => errors.push(format!("{inner}: {e}")),
}
}
if out.is_empty() {
return Err(if errors.is_empty() { "no looks in this collection".into() } else { errors.join("; ") });
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::preset_import::read_presets;
use serde_json::json;
/// One effect's parameter as Luminar writes it.
fn param(name: &str, v: f64) -> String {
format!("<key>{name}</key><dict><key>OptionalDataType</key><integer>0</integer><key>Value</key><real>{v}</real></dict>")
}
fn layer(id: &str, amount: f64, blend: &str, effects: &[(&str, String)], sub: &str) -> String {
let fx: String = effects
.iter()
.map(|(e, p)| format!("<dict><key>Identifier</key><string>{e}</string><key>Parameters</key><dict>{p}</dict></dict>"))
.collect();
let sub =
if sub.is_empty() { String::new() } else { format!("<key>Sublayers</key><dict><key>AdjustmentLayers</key><array>{sub}</array></dict>") };
format!(
"<dict><key>Amount</key><real>{amount}</real><key>BlendModeIdentifier</key><string>{blend}</string><key>Effects</key><array>{fx}</array>\
<key>Enabled</key><true/><key>Identifier</key><string>{id}</string>{sub}</dict>"
)
}
fn look(layers: &str, extra: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\"><dict><key>AdjustmentLayers</key><array>{layers}</array>{extra}<key>group_identifier</key><string>Custom</string></dict></plist>"
)
}
/// A newer-style look (develop sliders nested in sublayers) written for this test.
fn modern() -> String {
let curve = "<key>RGB</key><dict><key>OptionalData</key><array><real>0.5</real><real>0</real><real>0.1</real><real>0.5</real><real>0.5</real><real>1</real><real>0.9</real></array><key>OptionalDataType</key><integer>1</integer><key>Value</key><real>50</real></dict>";
let sub = [
layer(
"DevelopAdjustmentSubLayer",
1.0,
"Normal",
&[(
"MIPLDevelopCommonEffectID",
param("Exposure", 25.0) + &param("Contrast", 12.0) + &param("Highlights", -30.0) + &param("Temperature", 15.0),
)],
&layer("CurveLayer", 1.0, "Normal", &[("MIPLCurveEffect", curve.to_string())], ""),
),
layer("Disabled", 1.0, "Normal", &[("MIPLDevelopCommonEffectID", param("Shadows", 50.0))], "").replace("<true/>", "<false/>"),
]
.concat();
let layers = [
layer("DevelopAdjustmentLayer", 1.0, "Normal", &[], &sub),
layer("AIStructureEffect", 1.0, "Normal", &[("MIPLAIStructureEffect", param("Amount", 20.0) + &param("Boost", 10.0))], ""),
layer(
"Half",
0.5,
"Normal",
&[("MIPLVibranceEffect", param("Vibrance", 30.0)), ("MIPLSaturationEffect", param("Saturation", -10.0))],
"",
),
layer("Hsl", 1.0, "Normal", &[("MIPLChannelsEffect", param("hOrange", -8.0) + &param("sBlue", 12.0) + &param("lGreen", 4.0))], ""),
layer("Vignette", 1.0, "Normal", &[("MIPLVignetteEffect", param("Amount", -20.0) + &param("Vignette Size", 40.0))], ""),
layer("Orton", 1.0, "Normal", &[("MIPLOrtonFilterEffect", param("Amount", 15.0))], ""),
layer("Screened", 1.0, "Screen", &[("MIPLContrastEffect", param("Contrast", 40.0))], ""),
layer("Untouched", 1.0, "Normal", &[("MIPLSkyEnhancerEffect", String::new())], ""),
]
.concat();
look(&layers, "<key>uuid</key><string>AB12-CD34</string><key>kMPPresetIdentifierKey</key><string>x 2.lmp</string>")
}
#[test]
fn plists_parse() {
let p = parse_plist(b"<?xml version=\"1.0\"?><!-- c --><plist><dict><key>a&amp;b</key><string>x &lt;y&#x41;</string><key>n</key><integer>-3</integer><key>e</key><dict/><key>t</key><true/><key>d</key><data>AAEC</data><key>s</key><string/></dict></plist>").unwrap();
assert_eq!(p.get("a&b").and_then(Plist::str), Some("x <yA"));
assert_eq!(p.get("n").and_then(Plist::num), Some(-3.0));
assert_eq!(p.get("e"), Some(&Plist::Dict(vec![])));
assert_eq!(p.get("t"), Some(&Plist::Bool(true)));
assert_eq!(p.get("d"), Some(&Plist::Data));
assert_eq!(p.get("s").and_then(Plist::str), Some(""));
assert!(parse_plist(b"<plist><dict><key>a</key>").is_err());
assert!(parse_plist(b"bplist00\x01\x02").is_err());
}
#[test]
fn hostile_nesting_is_an_error_not_a_crash() {
let deep = format!("<plist>{}{}</plist>", "<array>".repeat(200_000), "</array>".repeat(200_000));
assert!(parse_plist(deep.as_bytes()).unwrap_err().contains("nested too deeply"));
let ok = format!("<plist>{}<true/>{}</plist>", "<array>".repeat(20), "</array>".repeat(20));
assert!(parse_plist(ok.as_bytes()).is_ok());
}
#[test]
fn look_sliders_map_and_the_rest_is_reported() {
let i = read_presets("/x/Soft Contrast.lmp", modern().as_bytes(), None).unwrap().remove(0);
let p = &i.preset;
assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Soft Contrast", "Imported Presets", "user.lmp.ab12-cd34"));
let s = &p.settings;
assert_eq!(s["light"]["exposure"], json!(1.0), "±100 ↔ ±4 EV");
assert_eq!(s["light"]["contrast"], json!(12.0));
assert_eq!(s["light"]["highlights"], json!(-30.0));
assert!(s["light"].get("shadows").is_none(), "disabled layers are ignored");
assert_eq!(s["effects"]["clarity"], json!(20.0), "Structure → clarity");
assert_eq!(s["color"]["vibrance"], json!(15.0), "layer opacity scales the slider");
assert_eq!(s["color"]["saturation"], json!(-5.0));
assert_eq!(s["mixer"]["orange"]["hue"], json!(-8.0));
assert_eq!(s["mixer"]["blue"]["sat"], json!(12.0));
assert_eq!(s["mixer"]["green"]["lum"], json!(4.0));
assert_eq!(s["vignette"]["amount"], json!(-20.0));
assert_eq!(s["vignette"]["midpoint"], json!(40.0));
assert!(s["wb"]["temp"].as_f64().unwrap() > 6500.0, "a warm shift: {}", s["wb"]);
let c = s["curve"]["master"].as_array().unwrap();
assert_eq!(c.len(), 3);
assert!((c[0]["y"].as_f64().unwrap() - 0.1).abs() < 1e-9 && (c[2]["y"].as_f64().unwrap() - 0.9).abs() < 1e-9);
assert_eq!(i.unmapped, ["AIStructure.Boost", "OrtonFilter.Amount", "Contrast.Contrast", "Screened: blend mode Screen"]);
assert!(s["light"]["contrast"] == json!(12.0), "the screen-blended layer isn't folded in");
lightcraft_develop::DevelopSettings::default().merged(s).expect("valid develop settings");
}
#[test]
fn classic_looks_and_bundles() {
// the older flat layout: one layer per tool, the look's name inside
let layers = [
layer(
"ToneLayer",
1.0,
"Normal",
&[("MIPLExposureEffect", param("Exposure", 0.0)), ("MIPLHighlightsEffect", param("Shadows", 10.0))],
"",
),
layer("ClarityLayer", 1.0, "Normal", &[("MIPLClarityEffect", param("Clarity", 20.0))], ""),
layer("Wb", 1.0, "Normal", &[("MIPLWhiteBalanceEffect", param("Temperature", -15.0) + &param("Tint", 10.0))], ""),
]
.concat();
let lmp = look(&layers, "<key>Name</key><string>Blue Hour</string>");
let i = read_presets("whatever.lmp", lmp.as_bytes(), Some("Mine".into())).unwrap().remove(0);
assert_eq!((i.preset.name.as_str(), i.preset.group.as_str()), ("Blue Hour", "Mine"));
assert_eq!(i.preset.settings["light"], json!({"shadows": 10.0}));
assert_eq!(i.preset.settings["effects"]["clarity"], json!(20.0));
assert_eq!(i.preset.settings["wb"]["tint"], json!(10.0));
assert!(i.preset.settings["wb"]["temp"].as_f64().unwrap() < 6500.0);
assert!(i.unmapped.is_empty(), "{:?}", i.unmapped);
// a bundle's inner file is named after the bundle
let b = read_presets("/p/Vintage.lmp/Contents/preset.lmp", modern().as_bytes(), None).unwrap();
assert_eq!(b[0].preset.name, "Vintage");
// nothing we can carry over: an error that says what was there
let only = look(&layer("Orton", 1.0, "Normal", &[("MIPLOrtonFilterEffect", param("Amount", 15.0))], ""), "");
let e = read_presets("Glow.lmp", only.as_bytes(), None).unwrap_err();
assert!(e.contains("OrtonFilter.Amount"), "{e}");
assert!(read_presets("x.lmp", b"<plist><dict/></plist>", None).is_err());
}
#[test]
fn packs_group_by_their_name() {
use crate::preset_import::tests::zip;
let a = look(&layer("C", 1.0, "Normal", &[("MIPLClarityEffect", param("Clarity", 20.0))], ""), "<key>Name</key><string>Pop 1</string>");
let b = look(&layer("S", 1.0, "Normal", &[("MIPLSaturationEffect", param("Saturation", 25.0))], ""), "");
let info = b"<?xml version=\"1.0\"?><plist version=\"1.0\"><dict><key>GroupName</key><string>Magic Light</string></dict></plist>";
let pack = zip(
&[
("Pop 1.lmp", a.as_bytes()),
("__MACOSX/._Pop 1.lmp", b"junk"),
("Pop 2.lmp", b.as_bytes()),
("icon@2x.png", b"\x89PNG"),
("PresetsInfo.plist", info),
],
true,
);
let v = read_presets("/d/Magic Light-2.mplumpack", &pack, None).unwrap();
let got: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
assert_eq!(got, [("Pop 1", "Magic Light"), ("Pop 2", "Magic Light")]);
// without PresetsInfo.plist the pack's file name is the group
let v = read_presets("Seaside Looks.mplumpack", &zip(&[("Pop 2.lmp", b.as_bytes())], false), None).unwrap();
assert_eq!(v[0].preset.group, "Seaside Looks");
// a pack inside a downloaded zip, and bundle-style looks in a zip folder
let outer = zip(&[("Archive/Magic Light-2.mplumpack", &pack), ("Archive/Magic Light.plist", b"<plist/>")], false);
assert_eq!(read_presets("download.zip", &outer, None).unwrap().len(), 2);
let bundles = zip(
&[
("Wild Pack/Vintage.lmp/Contents/preset.lmp", modern().as_bytes()),
("Wild Pack/Vintage.lmp/Contents/Info.plist", b"<plist><dict/></plist>"),
("Wild Pack/Flat.lmp", a.as_bytes()),
],
true,
);
let v = read_presets("wild.zip", &bundles, None).unwrap();
let got: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
assert_eq!(got, [("Vintage", "Wild Pack"), ("Pop 1", "Wild Pack")]);
assert!(read_presets("empty.mplumpack", &zip(&[("icon.png", b"x")], false), None).is_err());
}
#[test]
fn import_command_reads_looks_and_bundle_folders() {
let dir = std::env::temp_dir().join(format!("lc-luminar-import-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Wild/Vintage.lmp/Contents/resources")).unwrap();
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/preset.lmp"), modern()).unwrap();
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/Info.plist"), "<plist><dict/></plist>").unwrap();
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/PkgInfo"), "LMP?????").unwrap();
let mut s = crate::Session::with_demo();
let r = s.execute("preset.import", &json!({"paths": [dir.join("Wild").to_string_lossy()]})).unwrap();
let im = &r["imported"][0];
assert_eq!((im["name"].as_str(), im["group"].as_str()), (Some("Vintage"), Some("Wild")), "{r}");
assert!(im["unmapped"].as_array().unwrap().iter().any(|u| u == "OrtonFilter.Amount"));
// a dropped bundle folder on its own
let r = s.execute("preset.import", &json!({"paths": [dir.join("Wild/Vintage.lmp").to_string_lossy()], "dryRun": true})).unwrap();
assert_eq!(r["imported"][0]["group"], json!("Imported Presets"), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
}
} else {
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip"].contains(&ext.as_str()) {
if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) {
out.push(p.to_string_lossy().to_string());
}
}
+258 -13
View File
@@ -1,10 +1,13 @@
//! Batch rename: a file-name template applied to the selected photos, renaming the files on
//! disk (with their XMP sidecars) safely.
//!
//! Template tokens: `{name}` (current name without extension), `{seq}` / `{seq:3}` (sequence number,
//! zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date; `%Y %y %m %d %H %M %S`),
//! `{camera}`, `{title}`, `{ext}` (extension, without the dot). Characters that aren't allowed in
//! file names become `-`. The original extension is always kept.
//! Template tokens (shared with import renaming and export file naming): `{name}` (current name
//! without extension), `{num}` (the number at the end of the name: `IMG_0042` → `0042`), `{seq}` /
//! `{seq:3}` (sequence number, zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date;
//! `%Y %y %m %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`,
//! `{rating}`, `{title}`, `{creator}`, `{ext}` (extension, without the dot). Unknown tokens stay as
//! typed. Characters that aren't allowed in file names become `-`. The original extension is always
//! kept.
//!
//! Safety:
//! - a target that exists on disk, or that another photo in the batch gets, receives a `-1`, `-2`…
@@ -79,8 +82,165 @@ fn format_date(iso: &str, fmt: &str) -> String {
out
}
/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and
/// command description shows; a test checks that [`expand_tokens`] knows each of them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct TokenHelp {
/// The token as typed, e.g. `{seq:3}`.
pub tag: &'static str,
/// Other spellings with the same meaning.
pub aliases: &'static [&'static str],
pub meaning: &'static str,
}
/// Every template token, in the order the help shows them.
pub const TOKENS: &[TokenHelp] = &[
TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" },
TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" },
TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" },
TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" },
TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" },
TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" },
TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" },
TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" },
TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" },
TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" },
TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" },
TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" },
TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" },
TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" },
];
/// The `%` directives of `{date:…}`.
pub const DATE_DIRECTIVES: &[(&str, &str)] = &[
("%Y", "year, 4 digits"),
("%y", "year, 2 digits"),
("%m", "month 01–12"),
("%d", "day 01–31"),
("%H", "hour 00–23"),
("%M", "minute"),
("%S", "second"),
("%%", "a literal %"),
];
/// How templates behave, one sentence each (shown under the token list).
pub const TEMPLATE_NOTES: &[&str] = &[
"The original extension is always added; {ext} only puts it inside the name as well.",
"A blank template keeps the original names.",
"{date} is the capture time; a photo without one uses the time it was imported.",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.",
"Unknown tags stay as typed — check the preview for a {typo}.",
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….",
];
/// The tokens as one line (`{name} {num} {seq} …`), for compact hints.
pub fn token_summary() -> String {
TOKENS.iter().map(|t| t.tag).collect::<Vec<_>>().join(" ")
}
/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48).
pub fn sample_photo() -> Photo {
let mut p =
Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00");
p.captured = Some("2026-01-14T05:58:48".into());
p.rating = 4;
p.meta.camera = "Canon EOS R5".into();
p.meta.lens = "RF24-70mm F2.8".into();
p.meta.iso = Some(400);
p.meta.title = "Harbour".into();
p.meta.creator = "Ann Lee".into();
p
}
/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column.
pub fn token_example(tag: &str) -> String {
expand_tokens(tag, &sample_photo(), 1, 1)
}
/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be
/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels.
pub fn folder_template_error(template: &str) -> Option<String> {
let t = template.trim();
if t.is_empty() {
return Some("the folder template is empty".into());
}
let b = t.as_bytes();
if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') {
return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into());
}
if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) {
return Some("the folder template may not contain . or .. folders".into());
}
None
}
/// The folders (relative to the destination) a folder template gives `p`: the template's own `/`
/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder
/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and
/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty
/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s
/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped.
pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec<String> {
template
.split(['/', '\\'])
.map(str::trim)
.filter(|s| !s.is_empty() && !matches!(*s, "." | ".."))
.map(|s| {
let v = sanitize(&expand_tokens(s, p, seq, 1));
if v.is_empty() { "unknown".to_string() } else { v }
})
.collect()
}
/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning.
pub fn unknown_tokens(template: &str) -> Vec<String> {
let p = sample_photo();
let mut out = Vec::new();
let mut rest = template;
while let Some(i) = rest.find('{') {
let Some(j) = rest[i..].find('}') else { break };
let tag = &rest[i..i + j + 1];
if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) {
out.push(tag.to_string());
}
rest = &rest[i + j + 1..];
}
out
}
/// Every token with its meaning and example, the date directives and the notes, as JSON (the
/// `photo.renameTokens` command).
pub fn token_help_json() -> serde_json::Value {
let tokens: Vec<serde_json::Value> = TOKENS
.iter()
.map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)}))
.collect();
let directives: Vec<serde_json::Value> = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect();
serde_json::json!({
"tokens": tokens,
"dateDirectives": directives,
"notes": TEMPLATE_NOTES,
"sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48",
})
}
/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension.
pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
let (stem, ext) = split_ext(&p.file_name);
let out = expand_tokens(template, p, seq, 1);
let mut stem_out = sanitize(&out);
if stem_out.is_empty() {
stem_out = sanitize(stem);
}
if stem_out.is_empty() {
stem_out = "photo".into();
}
if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") }
}
/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}`
/// is zero-padded to `seq_width` digits.
pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String {
let (stem, ext) = split_ext(&p.file_name);
let mut out = String::new();
let mut rest = template;
@@ -95,13 +255,27 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None));
let v = match name.trim().to_ascii_lowercase().as_str() {
"name" | "filename" => stem.to_string(),
"num" => {
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
stem[stem.len() - digits..].to_string()
}
"seq" | "n" => {
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(1).min(9);
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
format!("{seq:0w$}")
}
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
"folder" => match &p.source {
Source::File { path } => {
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
}
Source::Demo { .. } => String::new(),
},
"camera" => p.meta.camera.clone(),
"lens" => p.meta.lens.clone(),
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
"rating" => p.rating.to_string(),
"title" => p.meta.title.clone(),
"creator" => p.meta.creator.clone(),
"ext" => ext.to_string(),
_ => format!("{{{tok}}}"),
};
@@ -109,14 +283,7 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
rest = &rest[i + j + 1..];
}
out.push_str(rest);
let mut stem_out = sanitize(&out);
if stem_out.is_empty() {
stem_out = sanitize(stem);
}
if stem_out.is_empty() {
stem_out = "photo".into();
}
if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") }
out
}
/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`).
@@ -333,6 +500,66 @@ impl Session {
mod tests {
use super::*;
#[test]
fn folder_templates_stay_inside_the_destination() {
let mut p = sample_photo();
assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]);
assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too");
// metadata can't add levels or climb out
p.meta.camera = "../../etc/x".into();
p.meta.title = "..".into();
assert_eq!(expand_folder("{camera}/{title}/{date:%Y}", &p, 1), vec!["-..-etc-x", "unknown", "2026"]);
p.meta.camera = "C:\\Windows".into();
assert_eq!(expand_folder("{camera}", &p, 1), vec!["C--Windows"]);
// missing metadata → `unknown`; missing capture time → the import time
p.meta.camera.clear();
p.captured = None;
assert_eq!(expand_folder("{camera}//{date:%Y%m%d}", &p, 1), vec!["unknown", "20260120"]);
for bad in ["", " ", "/abs/{date}", "\\\\server\\x", "C:/x", "c:x", "~/x", "../{date}", "{date}/../x", "a/./b", "{date:%Y}/.."] {
assert!(folder_template_error(bad).is_some(), "{bad:?} should be rejected");
}
for ok in ["{date:%Y}/{date:%Y%m%d}", "Trips/{camera}", "{date}", "a//b"] {
assert_eq!(folder_template_error(ok), None, "{ok:?}");
}
// even unchecked, rejected levels are skipped rather than followed
assert_eq!(expand_folder("/../{date:%Y}/./x", &p, 1), vec!["2026", "x"]);
}
/// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is
/// replaced, never left literal, and the help's examples match the documented behaviour.
#[test]
fn token_help_matches_the_implementation() {
let p = sample_photo();
for t in TOKENS {
for tag in std::iter::once(&t.tag).chain(t.aliases) {
let v = expand_tokens(tag, &p, 1, 1);
assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})");
assert!(!v.is_empty(), "{tag}: the sample photo should give an example");
}
}
assert_eq!(token_example("{seq:3}"), "001");
assert_eq!(token_example("{date}"), "20260114");
assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848");
assert_eq!(token_example("{ext}"), "CR3");
assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3");
for (d, _) in DATE_DIRECTIVES {
let v = format_date("2026-01-14T05:58:48", d);
assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive");
}
assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]);
assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty());
let json = token_help_json();
assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len());
assert_eq!(json["tokens"][0]["example"], "IMG_0042");
// the command descriptions that list the tokens list all of them
for id in ["photo.rename", "photo.renamePreview", "library.import"] {
let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) {
assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag);
}
}
}
#[test]
fn templates() {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00");
@@ -348,4 +575,22 @@ mod tests {
assert_eq!(expand(" ", &p, 1), "IMG_0042.CR2", "empty template keeps the name");
assert_eq!(expand("../{name}", &p, 1), "-IMG_0042.CR2", "no path components");
}
#[test]
fn number_folder_and_metadata_tokens() {
let path = std::path::Path::new("shoots").join("2026-09 Coast").join("DSC_0815.NEF");
let mut p = Photo::new(PhotoId(1), Source::File { path: path.to_string_lossy().into() }, "DSC_0815.NEF", "NEF", 1, 1, "2026-01-01T00:00:00");
p.meta.lens = "24-70mm F2.8".into();
p.meta.iso = Some(400);
p.meta.creator = "A. Person".into();
p.rating = 4;
assert_eq!(expand("{folder}_{num}", &p, 1), "2026-09 Coast_0815.NEF");
assert_eq!(expand("{rating}star-{iso}-{lens}-{creator}", &p, 1), "4star-400-24-70mm F2.8-A. Person.NEF");
// no number at the end of the name / no folder: empty
p.file_name = "beach.NEF".into();
p.source = Source::Demo { scene: 0 };
assert_eq!(expand("{folder}{name}{num}", &p, 1), "beach.NEF");
// a bare {seq} is padded to the caller's width
assert_eq!(expand_tokens("{seq}|{seq:2}", &p, 7, 3), "007|07");
}
}
+30 -2
View File
@@ -5,7 +5,9 @@
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
//!
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata; develop
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
@@ -72,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>,
@@ -80,6 +86,9 @@ pub struct SidecarData {
pub alt_text: Option<String>,
pub extended_description: Option<String>,
pub keywords: Option<Vec<String>>,
/// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
pub captured: Option<String>,
pub develop: Option<DevelopPatch>,
}
@@ -100,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(),
@@ -108,6 +120,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
alt_text: m.alt_text.clone(),
extended_description: m.extended_description.clone(),
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
captured: m.capture_time.map(|d| d.to_iso()),
..Default::default()
};
if let Some(r) = m.rating {
@@ -136,8 +149,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
Ok(out)
}
/// Apply sidecar data to a photo record (the sidecar wins). Returns true if develop changed.
/// Apply sidecar data to a photo record (the sidecar wins, except that a capture time embedded in
/// the file is kept). Returns true if develop changed.
pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
if p.captured.is_none() {
p.captured = sc.captured.clone();
}
if let Some(r) = sc.rating {
p.rating = r;
}
@@ -152,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),
@@ -164,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();
}
@@ -212,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),
@@ -306,6 +331,9 @@ impl Session {
Op::SetLabel { id, label: q.label },
Op::SetMeta { id, meta: Box::new(q.meta.clone()) },
];
if q.captured != p.captured {
ops.push(Op::SetCaptured { id, captured: q.captured.clone() });
}
if develop_changed {
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
}
+113
View File
@@ -70,6 +70,82 @@ pub fn dir(library: &Path) -> PathBuf {
library.join("Smart Previews")
}
/// Check that smart previews can be written to `dir`: it must be (or, when its parent exists,
/// become) a directory that accepts a new file. Never creates missing parents, so an unplugged
/// drive's mount point is not silently recreated on the system drive.
pub fn check_writable(dir: &Path) -> Result<(), String> {
if !dir.is_dir() {
let parent_ok = dir.parent().is_some_and(|p| p.is_dir());
if !parent_ok {
return Err(format!("{} is not available — reconnect the drive or choose another folder", dir.display()));
}
std::fs::create_dir(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
}
let probe = dir.join(format!(".lightcraft-write-test-{}", std::process::id()));
std::fs::write(&probe, b"x").map_err(|e| format!("{} is not writable (is the drive full or read-only?): {e}", dir.display()))?;
let _ = std::fs::remove_file(&probe);
Ok(())
}
/// The number of smart previews in `dir` and their total size in bytes.
pub fn stats(dir: &Path) -> (usize, u64) {
let Ok(rd) = std::fs::read_dir(dir) else { return (0, 0) };
rd.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "lcsp"))
.fold((0, 0), |(n, b), e| (n + 1, b + e.metadata().map_or(0, |m| m.len())))
}
/// What to do with the smart previews in the old folder when the location changes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Existing {
/// Move them to the new folder (copy and delete across drives).
Move,
/// Leave them where they are (they stop being used; build again in the new folder).
Leave,
/// Delete them.
Discard,
}
impl Existing {
pub fn parse(s: &str) -> Option<Existing> {
match s {
"move" => Some(Existing::Move),
"leave" => Some(Existing::Leave),
"discard" => Some(Existing::Discard),
_ => None,
}
}
}
/// Apply `what` to the smart previews in `from` for the new folder `to` → (moved or removed, failed).
pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
let (mut done, mut failed) = (0, Vec::new());
if what == Existing::Leave {
return (done, failed);
}
let Ok(rd) = std::fs::read_dir(from) else { return (done, failed) };
for e in rd.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) {
let src = e.path();
let r = match what {
Existing::Discard => std::fs::remove_file(&src),
_ => {
let dst = to.join(e.file_name());
if dst.exists() {
// named by content: the copy already there is the same preview
std::fs::remove_file(&src)
} else {
std::fs::rename(&src, &dst).or_else(|_| std::fs::copy(&src, &dst).and_then(|_| std::fs::remove_file(&src)))
}
}
};
match r {
Ok(()) => done += 1,
Err(err) => failed.push(format!("{}: {err}", src.display())),
}
}
(done, failed)
}
/// Load the proxy at `path`.
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
@@ -80,6 +156,43 @@ pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
mod tests {
use super::*;
fn temp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-smart-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn writable_check_does_not_create_missing_parents() {
let base = temp("writable");
assert!(check_writable(&base.join("new")).is_ok(), "one missing level is created");
assert!(base.join("new").is_dir());
let err = check_writable(&base.join("gone/deeper")).unwrap_err();
assert!(err.contains("not available"), "{err}");
assert!(!base.join("gone").exists(), "no fallback onto the current drive");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn migrate_moves_leaves_or_discards() {
let base = temp("migrate");
let (a, b) = (base.join("a"), base.join("b"));
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("1.lcsp"), b"one").unwrap();
std::fs::write(a.join("note.txt"), b"keep").unwrap();
assert_eq!(stats(&a), (1, 3));
assert_eq!(migrate(&a, &b, Existing::Leave).0, 0);
assert_eq!(stats(&a).0, 1);
assert_eq!(migrate(&a, &b, Existing::Move).0, 1);
assert_eq!((stats(&a).0, stats(&b).0), (0, 1));
assert!(a.join("note.txt").exists(), "only smart previews are touched");
assert_eq!(migrate(&b, &a, Existing::Discard).0, 1);
assert_eq!(stats(&b).0, 0);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn roundtrip_keeps_the_picture() {
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
+32
View File
@@ -671,3 +671,35 @@ fn mask_components_invert_duplicate_rename_change_mode_and_delete() {
assert!(active_dev(&s).masks.is_empty());
assert_eq!(s.active_mask, None);
}
#[test]
fn curve_reset_by_channel_and_whole() {
let mut s = demo();
let s_curve = [[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]];
for ch in ["master", "red", "green", "blue"] {
s.execute("develop.curve", &json!({"channel": ch, "points": s_curve})).unwrap();
}
s.execute("develop.set", &json!({"control": "curve.shadows", "value": 30})).unwrap();
s.execute("develop.set", &json!({"control": "curve.refineSaturation", "value": 50})).unwrap();
// one channel
s.execute("curve.reset", &json!({"channel": "red"})).unwrap();
let c = active_dev(&s).curve;
assert!(c.red.is_empty() && c.green.len() == 4 && c.master.len() == 4, "only red resets");
assert_eq!(c.shadows, 30.0);
// parametric only
s.execute("curve.reset", &json!({"channel": "parametric"})).unwrap();
let c = active_dev(&s).curve;
assert_eq!(c.shadows, 0.0);
assert_eq!(c.master.len(), 4, "point curves stay");
// all point curves, then undo restores them in one step
s.execute("curve.reset", &json!({"channel": "point"})).unwrap();
let c = active_dev(&s).curve;
assert!(c.master.is_empty() && c.green.is_empty() && c.blue.is_empty());
assert_eq!(c.refine_saturation, 50.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(active_dev(&s).curve.master.len(), 4);
// everything (the default)
s.execute("curve.reset", &json!({})).unwrap();
assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default());
assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err());
}
+123
View File
@@ -257,6 +257,37 @@ fn copy_with_destination_organize_rename_and_metadata_preset() {
}
}
/// Copy with a custom folder template: `{date:%Y}/{date:%Y%m%d}` files a photo under
/// `2026/20260114/` (an undated file by the import time); templates that would leave the
/// destination are refused.
#[test]
fn copy_with_a_folder_template() {
let src = temp_dir("tplsrc");
let dest = temp_dir("tpldest");
write_png(&src.join("a.png"), 1);
let mut s = Session::new().with_fs();
s.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
for bad in ["../{date}", "/tmp/{date}", "{date:%Y}/../x", "C:/x"] {
let r = s.execute(
"library.import",
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": bad}),
);
assert!(r.is_err(), "{bad} should be refused");
}
assert_eq!(s.catalog.len(), 0);
let r = s
.execute(
"library.import",
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"}),
)
.unwrap();
assert_eq!(ids(&r, "imported"), 1, "{r}");
assert!(dest.join("2026").join("20260114").join("a.png").is_file());
for d in [&src, &dest] {
let _ = std::fs::remove_dir_all(d);
}
}
/// Auto import: files in the watched folder are added once their size held between two scans,
/// into the named album; non-photos are tried once; the selection stays put.
#[test]
@@ -521,3 +552,95 @@ fn dust_spots_are_found_and_healed() {
assert!(s.develop_of(id).unwrap().spots.is_empty(), "one undo step");
let _ = std::fs::remove_dir_all(&dir);
}
/// Smart previews can live outside the library: the folder is saved per library, used for
/// building, status and offline rendering, changing it needs an explicit choice for the
/// previews already built, and an unavailable folder is an error (never a silent fallback).
#[test]
fn smart_previews_folder_is_chosen_per_library() {
let src = temp_dir("smartloc-src");
let lib = temp_dir("smartloc-lib");
let away = temp_dir("smartloc-away");
write_png(&src.join("a.png"), 7);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
let loc = s.execute("library.smartPreviewsLocation", &json!({})).unwrap();
assert_eq!(loc["custom"], false);
assert_eq!(loc["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 1);
// relative paths and missing drives are refused, and nothing is created on this drive
assert!(s.execute("library.smartPreviewsLocation", &json!({"path": "rel/dir"})).is_err());
let gone = away.join("unplugged/previews");
let err = s.execute("library.smartPreviewsLocation", &json!({"path": gone.to_string_lossy()})).unwrap_err().to_string();
assert!(err.contains("not available"), "{err}");
assert!(!away.join("unplugged").exists());
// the previews already built need an explicit choice
let to = away.join("proxies");
let err = s.execute("library.smartPreviewsLocation", &json!({"path": to.to_string_lossy()})).unwrap_err().to_string();
assert!(err.contains("existing"), "{err}");
assert_eq!(s.execute("library.smartPreviewsLocation", &json!({})).unwrap()["custom"], false);
let r = s.execute("library.smartPreviewsLocation", &json!({"path": to.to_string_lossy(), "existing": "move"})).unwrap();
assert_eq!((r["custom"].clone(), r["handled"].clone(), r["count"].clone()), (json!(true), json!(1), json!(1)), "{r}");
assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 0);
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], true);
// the setting is saved with the library
let mut again = Session::new().with_fs();
again.open_library(&lib, false).unwrap();
assert_eq!(again.execute("library.smartPreviewsLocation", &json!({})).unwrap()["path"], to.to_string_lossy().as_ref());
// offline original: the render comes from the proxy in the chosen folder
std::fs::rename(&src, src.with_extension("offline")).unwrap();
again.media.forget(id);
again.render_now(id, 48, 32).expect("renders from the smart preview in the chosen folder");
// the chosen drive goes away: building says so and creates nothing on this drive
let drive = away.join("drive");
std::fs::create_dir_all(&drive).unwrap();
let on_drive = drive.join("proxies");
again.execute("library.smartPreviewsLocation", &json!({"path": on_drive.to_string_lossy(), "existing": "leave"})).unwrap();
std::fs::remove_dir_all(&drive).unwrap();
let loc = again.execute("library.smartPreviewsLocation", &json!({})).unwrap();
assert_eq!(loc["available"], false, "{loc}");
let err = again.execute("library.smartPreviews", &json!({"ids": [id.0]})).unwrap_err().to_string();
assert!(err.contains("not available"), "{err}");
assert!(!drive.exists());
// back to the library's own folder, discarding the proxies left in the first chosen folder
let r = again.execute("library.smartPreviewsLocation", &json!({"reset": true})).unwrap();
assert_eq!(r["custom"], false, "{r}");
assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
}
/// Missing Photos covers library photos only: a small library next to many Local browse
/// records (and a deleted photo) checks — and lists, and counts — the library files alone.
#[test]
fn missing_photos_skip_local_browse_records() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
let gone = std::env::temp_dir().join(format!("lc-missing-scope-{}", std::process::id()));
let mut s = Session::new();
let mut ops = Vec::new();
for i in 0..205u64 {
let path = gone.join(format!("IMG_{i:04}.jpg")).to_string_lossy().to_string();
let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
p.local = i >= 5; // 5 library photos, 200 seen while browsing
p.deleted = i == 4;
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
s.commit("Add", Op::Batch { ops }).unwrap();
let mut checked = 0;
let lost = crate::cmd::missing::missing_with(&s.catalog, |_| {
checked += 1;
false
});
assert_eq!((checked, lost.len()), (4, 4), "only the non-deleted library files are checked");
assert_eq!(crate::cmd::missing::candidates(&s.catalog).len(), 4, "the sidebar count checks the same files");
assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(4));
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts");
}
+278
View File
@@ -0,0 +1,278 @@
//! Import → Move: files (and their sidecars) end up at the destination, and a source is removed
//! only when its move fully succeeded. Temp dirs only.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use crate::Session;
use crate::import_move::{Fault, inject};
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-move-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn write_png(path: &Path, seed: u8) {
let (w, h) = (40usize, 24usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn xmp(rating: u8) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/></rdf:RDF></x:xmpmeta>"#
)
}
fn session() -> Session {
let mut s = Session::new().with_fs();
// undated files are filed by the import time
s.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
s
}
fn import_move(s: &mut Session, paths: &[&Path], dest: &Path, extra: Value) -> Value {
let mut p =
json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>(), "mode": "move", "destination": dest.to_string_lossy()});
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
s.execute("library.import", &p).unwrap()
}
fn len(v: &Value, key: &str) -> usize {
v[key].as_array().map(Vec::len).unwrap_or(0)
}
fn photo_paths(s: &Session) -> Vec<String> {
let mut v: Vec<String> = s
.catalog
.photos()
.filter_map(|p| match &p.source {
lightcraft_catalog::Source::File { path } => Some(path.clone()),
_ => None,
})
.collect();
v.sort();
v
}
fn files_under(d: &Path) -> Vec<String> {
fn walk(d: &Path, base: &Path, out: &mut Vec<String>) {
for e in std::fs::read_dir(d).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, base, out);
} else {
out.push(p.strip_prefix(base).unwrap().to_string_lossy().replace('\\', "/"));
}
}
}
let mut out = Vec::new();
walk(d, d, &mut out);
out.sort();
out
}
/// Same volume: renamed into the folder template, sidecars (both namings) along, sources gone,
/// the catalog points at the new files; undo forgets the photos but leaves the files.
#[test]
fn move_into_a_folder_template_with_sidecars() {
let base = temp_dir("same");
let (card, dest) = (base.join("card"), base.join("Photos"));
write_png(&card.join("a.png"), 1);
std::fs::write(card.join("a.xmp"), xmp(3)).unwrap();
write_png(&card.join("b.png"), 2);
std::fs::write(card.join("b.png.xmp"), xmp(4)).unwrap();
let a_bytes = std::fs::read(card.join("a.png")).unwrap();
let mut s = session();
let r = import_move(&mut s, &[&card], &dest, json!({"organize": "{date:%Y}/{date:%Y%m%d}", "rename": "{date:%Y%m%d}_{seq:3}"}));
assert_eq!(len(&r, "imported"), 2, "{r}");
assert_eq!(len(&r, "moved"), 2, "{r}");
assert_eq!(len(&r, "kept"), 0, "{r}");
assert_eq!(
files_under(&dest),
["2026/20260114/20260114_001.png", "2026/20260114/20260114_001.xmp", "2026/20260114/20260114_002.png", "2026/20260114/20260114_002.png.xmp"]
);
assert_eq!(files_under(&card), Vec::<String>::new(), "the card is empty");
assert_eq!(std::fs::read(dest.join("2026/20260114/20260114_001.png")).unwrap(), a_bytes);
let day = dest.join("2026").join("20260114");
assert_eq!(photo_paths(&s), [day.join("20260114_001.png").to_string_lossy(), day.join("20260114_002.png").to_string_lossy()]);
let mut ratings: Vec<u8> = s.catalog.photos().map(|p| p.rating).collect();
ratings.sort();
assert_eq!(ratings, [3, 4], "the sidecars were read");
assert_eq!(r["moved"][0]["sidecars"][0], day.join("20260114_001.xmp").to_string_lossy().as_ref());
// undo: the photos leave the library, the moved files stay where they are now
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.len(), 0);
assert_eq!(files_under(&dest).len(), 4);
let _ = std::fs::remove_dir_all(&base);
}
/// Another volume (no hard links): copied, verified and only then the source removed.
#[test]
fn move_across_volumes_copies_then_removes() {
let base = temp_dir("xvol");
let (card, dest) = (base.join("card"), base.join("out"));
write_png(&card.join("a.png"), 1);
std::fs::write(card.join("a.xmp"), xmp(2)).unwrap();
let bytes = std::fs::read(card.join("a.png")).unwrap();
let mut s = session();
inject(Fault::NoLink);
let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"}));
inject(Fault::None);
assert_eq!(len(&r, "moved"), 1, "{r}");
assert_eq!(files_under(&dest), ["a.png", "a.xmp"]);
assert_eq!(std::fs::read(dest.join("a.png")).unwrap(), bytes);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
assert_eq!(std::fs::metadata(dest.join("a.png")).unwrap().nlink(), 1, "a real copy, not a link");
}
assert!(!card.join("a.png").exists() && !card.join("a.xmp").exists());
let _ = std::fs::remove_dir_all(&base);
}
/// A copy that fails while writing, or comes out different, leaves nothing behind and keeps the
/// source; nothing is catalogued.
#[test]
fn a_failed_or_corrupt_copy_keeps_the_source() {
for fault in [Fault::FailWrite, Fault::Corrupt] {
let base = temp_dir(&format!("fail-{fault:?}"));
let (card, dest) = (base.join("card"), base.join("out"));
write_png(&card.join("a.png"), 1);
std::fs::write(card.join("a.xmp"), xmp(2)).unwrap();
let bytes = std::fs::read(card.join("a.png")).unwrap();
let mut s = session();
inject(fault);
let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"}));
inject(Fault::None);
assert_eq!(len(&r, "failed"), 1, "{fault:?}: {r}");
assert_eq!(len(&r, "moved"), 0, "{fault:?}: {r}");
assert_eq!(s.catalog.len(), 0);
assert_eq!(std::fs::read(card.join("a.png")).unwrap(), bytes, "{fault:?}: source intact");
assert!(card.join("a.xmp").is_file());
assert_eq!(files_under(&dest), Vec::<String>::new(), "{fault:?}: no partial copy left");
let _ = std::fs::remove_dir_all(&base);
}
}
/// A source that can't be removed (a read-only card) stays and is reported; the photo is
/// imported from its copy.
#[test]
fn a_source_that_cannot_be_removed_is_kept_and_reported() {
let base = temp_dir("ro");
let (card, dest) = (base.join("card"), base.join("out"));
write_png(&card.join("a.png"), 1);
let mut s = session();
inject(Fault::FailRemove);
let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"}));
inject(Fault::None);
assert_eq!(len(&r, "imported"), 1, "{r}");
assert_eq!(len(&r, "moved"), 0, "{r}");
assert_eq!(len(&r, "kept"), 1, "{r}");
assert!(r["kept"][0]["reason"].as_str().unwrap().contains("could not remove"), "{r}");
assert!(card.join("a.png").is_file() && dest.join("a.png").is_file());
assert_eq!(photo_paths(&s), [dest.join("a.png").to_string_lossy()]);
let _ = std::fs::remove_dir_all(&base);
}
/// Duplicates, unreadable files and files that fail (here: a different sidecar already at the
/// destination) keep their sources; the rest of the batch still moves.
#[test]
fn duplicates_and_failures_keep_their_sources() {
let base = temp_dir("dup");
let (lib_src, card, dest) = (base.join("old"), base.join("card"), base.join("out"));
write_png(&lib_src.join("known.png"), 7);
let mut s = session();
s.execute("library.import", &json!({"paths": [lib_src.to_string_lossy()]})).unwrap();
std::fs::create_dir_all(&card).unwrap();
std::fs::copy(lib_src.join("known.png"), card.join("same.png")).unwrap(); // same bytes
write_png(&card.join("a.png"), 1);
std::fs::write(card.join("a.xmp"), xmp(1)).unwrap();
write_png(&card.join("b.png"), 2);
std::fs::write(card.join("broken.jpg"), "garbage").unwrap();
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("a.xmp"), xmp(5)).unwrap(); // someone else's sidecar
let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"}));
assert_eq!(len(&r, "duplicates"), 1, "{r}");
assert_eq!(len(&r, "failed"), 2, "{r}"); // broken.jpg (unreadable) and a.png (sidecar clash)
assert_eq!(len(&r, "moved"), 1, "{r}");
assert_eq!(r["moved"][0]["from"], card.join("b.png").to_string_lossy().as_ref());
assert_eq!(files_under(&card), ["a.png", "a.xmp", "broken.jpg", "same.png"]);
assert_eq!(files_under(&dest), ["a.xmp", "b.png"], "a.png's copy was taken back");
assert_eq!(std::fs::read_to_string(dest.join("a.xmp")).unwrap(), xmp(5), "never overwritten");
let _ = std::fs::remove_dir_all(&base);
}
/// A taken name gets -1 (never overwritten), its sidecar follows the new name; a file already
/// inside the destination is added where it is, not moved.
#[test]
fn collisions_are_numbered_and_files_in_the_destination_stay() {
let base = temp_dir("coll");
let (card, dest) = (base.join("card"), base.join("out"));
write_png(&card.join("a.png"), 1);
std::fs::write(card.join("a.xmp"), xmp(2)).unwrap();
write_png(&dest.join("a.png"), 9); // a different photo, same name
let theirs = std::fs::read(dest.join("a.png")).unwrap();
write_png(&dest.join("inbox/c.png"), 3);
let mut s = session();
let r = import_move(&mut s, &[&card, &dest.join("inbox")], &dest, json!({"organize": "flat"}));
assert_eq!(len(&r, "imported"), 2, "{r}");
assert_eq!(len(&r, "moved"), 1, "{r}");
assert_eq!(len(&r, "kept"), 1, "{r}");
assert_eq!(files_under(&dest), ["a-1.png", "a-1.xmp", "a.png", "inbox/c.png"]);
assert_eq!(std::fs::read(dest.join("a.png")).unwrap(), theirs);
assert!(photo_paths(&s).contains(&dest.join("inbox").join("c.png").to_string_lossy().to_string()));
let _ = std::fs::remove_dir_all(&base);
}
/// A stem-named sidecar shared with a photo that isn't moved is copied along but stays beside
/// that photo.
#[test]
fn a_shared_sidecar_stays_with_the_photo_left_behind() {
let base = temp_dir("shared");
let (card, dest) = (base.join("card"), base.join("out"));
write_png(&card.join("x.png"), 1);
std::fs::write(card.join("x.tif"), "the other half (not imported)").unwrap();
std::fs::write(card.join("x.xmp"), xmp(2)).unwrap();
let mut s = session();
let r = import_move(&mut s, &[&card.join("x.png")], &dest, json!({"organize": "flat"}));
assert_eq!(len(&r, "moved"), 1, "{r}");
assert_eq!(files_under(&dest), ["x.png", "x.xmp"]);
assert_eq!(files_under(&card), ["x.tif", "x.xmp"]);
assert!(r["kept"][0]["reason"].as_str().unwrap().contains("x.tif"), "{r}");
let _ = std::fs::remove_dir_all(&base);
}
/// Into the open library's Originals/ (default destination): the records are saved before the
/// sources go, and are there after reopening. Move needs somewhere to go and never browses.
#[test]
fn move_into_the_library_is_saved_before_sources_go() {
let base = temp_dir("lib");
let (card, lib) = (base.join("card"), base.join("lib"));
write_png(&card.join("a.png"), 1);
let mut s = session();
let p = json!({"paths": [card.to_string_lossy()], "mode": "move"});
assert!(s.execute("library.import", &p).is_err(), "no destination, no library");
let browse = json!({"paths": [card.to_string_lossy()], "mode": "move", "local": true, "destination": base.join("x").to_string_lossy()});
assert!(s.execute("library.import", &browse).is_err(), "browsing never moves");
assert!(card.join("a.png").is_file());
s.open_library(&lib, false).unwrap();
let r = s.execute("library.import", &p).unwrap();
assert_eq!(len(&r, "moved"), 1, "{r}");
assert!(!card.join("a.png").exists());
let want = lib.join("Originals").join("2026").join("2026-01-14").join("a.png");
assert!(want.is_file());
drop(s);
let mut again = Session::new().with_fs();
again.open_library(&lib, false).unwrap();
assert_eq!(photo_paths(&again), [want.to_string_lossy()]);
let _ = std::fs::remove_dir_all(&base);
}
+65
View File
@@ -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);
}
+57
View File
@@ -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(&copy).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(&copy, true);
assert_eq!(s2.catalog.to_snapshot(), expect);
let _ = std::fs::remove_dir_all(&copy);
}
}
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);
+2 -2
View File
@@ -21,7 +21,7 @@ fn import(s: &mut Session, paths: Vec<String>) -> Vec<u64> {
fn hdr_merge_command_creates_and_imports_a_dng() {
let dir = temp_dir("hdr");
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, b).unwrap();
paths.push(p.to_string_lossy().to_string());
@@ -71,7 +71,7 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
fn panorama_command_with_auto_crop() {
let dir = temp_dir("pano");
let mut paths = Vec::new();
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).into_iter().enumerate() {
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).unwrap().into_iter().enumerate() {
let img = v.to_srgb8();
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
let p = dir.join(format!("P_{i}.png"));
+47
View File
@@ -176,3 +176,50 @@ fn stacks_group_collapse_and_replay() {
assert_eq!(s2.catalog.stacks().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn album_source_reports_in_library_state_and_persists_the_view() {
// An album source made `library.state` panic (serde can't put an integer in an internally
// tagged enum) and `view.json` empty, so the view was lost on the next launch.
let dir = temp_dir("album-view");
let mut s = open(&dir);
let id = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap();
s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap();
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
let st = s.execute("library.state", &json!({})).unwrap();
assert_eq!(st["source"], json!({"kind": "album", "id": id}));
// the reported source is what `library.source` takes
s.execute("library.source", &json!({"kind": "all"})).unwrap();
s.execute("library.source", &st["source"]).unwrap();
assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
assert_eq!(s.execute("library.state", &json!({})).unwrap()["source"], json!({"kind": "album", "id": id}));
s.close_library().unwrap();
drop(s);
let s2 = open(&dir);
assert_eq!(s2.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored too");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn filter_chips_clear_one_filter_and_total_ignores_the_filter() {
let dir = temp_dir("chips");
let mut s = open(&dir);
let all = s.visible_cloned().len();
assert!(all > 1);
assert_eq!(s.source_total(), Some(all));
s.execute("library.filter", &json!({"rating": 4, "date": "2026-01-16", "text": "x"})).unwrap();
// the total is the source's, whatever the filter hides
assert_eq!(s.source_total(), Some(all));
let chips = crate::filter_chips(&s.filter, &s.catalog);
let labels: Vec<&str> = chips.iter().map(|c| c.label.as_str()).collect();
assert_eq!(labels, ["Search: “x”", "Rating ≥ 4★", "Date: January 16, 2026"]);
// clearing the date chip leaves the other two filters alone
s.execute("library.filter", &chips[2].clear).unwrap();
assert_eq!(s.filter.date, None);
assert_eq!((s.filter.rating, s.filter.text.as_str()), (4, "x"));
for c in crate::filter_chips(&s.filter, &s.catalog) {
s.execute("library.filter", &c.clear).unwrap();
}
assert_eq!(s.filter, Default::default());
}
+59
View File
@@ -202,3 +202,62 @@ fn metadata_presets_apply_to_photos_and_imports() {
assert!(s.import_defaults.metadata_preset.is_none(), "deleting the import preset clears it");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn curve_presets_save_apply_export_import_and_persist() {
let lib = temp_dir("curves");
let file = lib.join("out").join("looks");
{
let mut s = Session::new().with_fs();
s.open_library(&lib, true).unwrap();
let r = s.execute("curve.presets", &json!({})).unwrap();
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().filter_map(|p| p["name"].as_str()).collect();
assert_eq!(names, ["Linear", "Medium Contrast", "Strong Contrast"]);
assert_eq!(r["current"], "Linear", "a fresh photo has linear point curves");
// built-in: an S-curve on the master channel, parametric untouched
s.execute("develop.set", &json!({"control": "curve.shadows", "value": 20})).unwrap();
s.execute("curve.applyPreset", &json!({"name": "strong contrast"})).unwrap();
let d = s.develop_of(s.active().unwrap()).unwrap();
assert_eq!(d.curve.master.len(), 5);
assert!(d.curve.master[1].y < 0.25 && d.curve.master[3].y > 0.75, "an S-curve");
assert_eq!(d.curve.shadows, 20.0);
assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Strong Contrast");
// save a custom one (with a red channel)
s.execute("develop.curve", &json!({"channel": "red", "points": [[0.0, 0.1], [1.0, 0.9]]})).unwrap();
assert!(s.execute("curve.savePreset", &json!({"name": "Linear"})).is_err(), "built-in names are reserved");
s.execute("curve.savePreset", &json!({"name": "Faded Red"})).unwrap();
assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Faded Red");
// Linear resets every point channel
s.execute("curve.applyPreset", &json!({"name": "Linear"})).unwrap();
let d = s.develop_of(s.active().unwrap()).unwrap();
assert!(d.curve.master.is_empty() && d.curve.red.is_empty());
s.execute("curve.applyPreset", &json!({"name": "Faded Red"})).unwrap();
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().curve.red.len(), 2);
// export (extension added), delete, re-import
let r = s.execute("curve.exportPresets", &json!({"path": file.to_string_lossy()})).unwrap();
assert_eq!(r["count"], 1);
let path = r["path"].as_str().unwrap().to_string();
assert!(path.ends_with(".lccurve"));
assert!(s.execute("curve.deletePreset", &json!({"name": "Medium Contrast"})).is_err());
s.execute("curve.deletePreset", &json!({"name": "faded red"})).unwrap();
assert!(s.curve_presets.is_empty());
let r = s.execute("curve.importPresets", &json!({"path": path})).unwrap();
assert_eq!(r["imported"], json!(["Faded Red"]));
assert_eq!(s.curve_presets.len(), 1);
// a file carrying a built-in name doesn't shadow it; junk fails cleanly
let other = lib.join("one.lccurve");
std::fs::write(&other, r#"{"name": "Linear", "master": [[0, 0.05], [1, 1]]}"#).unwrap();
let junk = lib.join("junk.lccurve");
std::fs::write(&junk, "not json").unwrap();
let r = s.execute("curve.importPresets", &json!({"paths": [other.to_string_lossy(), junk.to_string_lossy()]})).unwrap();
assert_eq!(r["imported"], json!(["Linear (imported)"]));
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
s.close_library().unwrap();
}
// saved with the library preferences
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let names: Vec<String> = s.curve_presets.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["Faded Red", "Linear (imported)"]);
let _ = std::fs::remove_dir_all(&lib);
}
+131
View File
@@ -445,3 +445,134 @@ fn dng_export_compression_choices() {
}
assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}");
}
fn write_jpeg(path: &Path, capture: Option<&str>) {
let (w, h) = (32usize, 24usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 7) as u8, (i / w * 9) as u8, 90, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let exif = capture.map(|c| {
lightcraft_meta::write_exif(&lightcraft_meta::Metadata { capture_time: lightcraft_meta::DateTime::parse_iso(c), ..Default::default() })
});
let meta = lightcraft_codecs::EncodeMeta { exif: exif.as_deref(), ..Default::default() };
let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
std::fs::write(path, bytes).unwrap();
}
const DATED_SIDECAR: &str = 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:exif="http://ns.adobe.com/exif/1.0/"
exif:DateTimeOriginal="2026-01-14T05:58:48" />
</rdf:RDF>
</x:xmpmeta>"#;
#[test]
fn sidecar_capture_time_fills_in_when_the_file_has_none() {
let src = temp_dir("sidecar-date");
write_jpeg(&src.join("sample.jpg"), None);
std::fs::write(src.join("sample.jpg.xmp"), DATED_SIDECAR).unwrap();
// the embedded capture time wins over the sidecar's
write_jpeg(&src.join("dated.jpg"), Some("2020-05-06T07:08:09"));
std::fs::write(src.join("dated.jpg.xmp"), DATED_SIDECAR).unwrap();
let lib = temp_dir("sidecar-date-lib");
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
// copy: the sidecar's date also files the copy
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap();
assert_eq!(r["sidecars"], 2, "{r}");
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().clone();
let sample = by_name(&s, "sample.jpg");
assert_eq!(sample.captured.as_deref(), Some("2026-01-14T05:58:48"));
let lightcraft_catalog::Source::File { path } = &sample.source else { panic!("not a file") };
assert!(Path::new(path).starts_with(lib.join("Originals").join("2026").join("2026-01-14")), "{path}");
assert_eq!(by_name(&s, "dated.jpg").captured.as_deref(), Some("2020-05-06T07:08:09"));
// Read Metadata from File fills in a missing capture time too
let id = sample.id;
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None }).unwrap();
std::fs::copy(src.join("sample.jpg.xmp"), format!("{path}.xmp")).unwrap();
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2026-01-14T05:58:48"));
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);
}
+94 -1
View File
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
/// The "My Photos" source in the left panel.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[serde(tag = "kind", content = "id", rename_all = "camelCase")]
pub enum LibrarySource {
#[default]
All,
@@ -108,3 +108,96 @@ impl Selection {
self.active = Some(id);
}
}
/// One active filter, shown as a removable chip above the grid.
#[derive(Clone, Debug, PartialEq)]
pub struct FilterChip {
pub label: String,
/// `library.filter` patch that clears just this filter.
pub clear: serde_json::Value,
}
/// The chips for every constraint in the filter bar / search / sidebar (not the source itself).
pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
use lightcraft_catalog::{MediaKind, RatingOp};
use serde_json::{Value::Null, json};
let mut v = Vec::new();
let mut add = |label: String, clear: serde_json::Value| v.push(FilterChip { label, clear });
if !f.text.trim().is_empty() {
add(format!("Search: “{}”", f.text.trim()), json!({"text": ""}));
}
if f.rating > 0 {
let op = match f.rating_op {
RatingOp::AtLeast => "≥",
RatingOp::Exactly => "=",
RatingOp::AtMost => "≤",
};
add(format!("Rating {op} {}★", f.rating), json!({"rating": 0}));
}
if let Some(fl) = f.flag {
add(format!("Flag: {}", format!("{fl:?}").to_lowercase()), json!({"flag": Null}));
}
let mut labels = f.labels.clone();
labels.extend(f.label.filter(|l| !labels.contains(l)));
if !labels.is_empty() {
let names: Vec<String> = labels.iter().map(|l| cat.label_name(*l)).collect();
add(format!("Label: {}", names.join(" or ")), json!({"label": Null, "labels": []}));
}
if let Some(k) = f.kind {
let n = match k {
MediaKind::Image => "Photos",
MediaKind::Raw => "Raw",
MediaKind::Video => "Videos",
};
add(format!("Type: {n}"), json!({"kind": Null}));
}
if let Some(m) = &f.merged {
let n = match m.as_str() {
"hdr" => "HDR",
"panorama" => "Panoramas",
"hdrPanorama" => "HDR panoramas",
_ => "Merged",
};
add(format!("Type: {n}"), json!({"merged": Null}));
}
if let Some(e) = f.edited {
add(if e { "Edited".into() } else { "Unedited".into() }, json!({"edited": Null}));
}
for (name, key, val) in [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword)] {
if let Some(x) = val {
add(format!("{name}: {x}"), json!({key: Null}));
}
}
if let Some(d) = &f.date {
add(format!("Date: {}", date_label(d)), json!({"date": Null}));
}
if let Some(d) = &f.imported {
add(format!("Imported: {}", date_label(d)), json!({"imported": Null}));
}
match (&f.date_from, &f.date_to) {
(Some(a), Some(b)) => add(format!("Captured {a} – {b}"), json!({"dateFrom": Null, "dateTo": Null})),
(Some(a), None) => add(format!("Captured from {a}"), json!({"dateFrom": Null})),
(None, Some(b)) => add(format!("Captured until {b}"), json!({"dateTo": Null})),
_ => {}
}
if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
add(format!("Rules: {}", rs.describe()), json!({"ruleSet": Null}));
}
if !f.only.is_empty() {
add(format!("Only {} photos", f.only.len()), json!({"only": []}));
}
v
}
/// `2026-01-16` → "January 16, 2026"; `2026-01` → "January 2026"; anything else as is.
fn date_label(d: &str) -> String {
const MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
let parts: Vec<&str> = d.split('-').collect();
let month = parts.get(1).and_then(|m| m.parse::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
match (parts.as_slice(), month) {
([y, _, day], Some(m)) => format!("{m} {}, {y}", day.trim_start_matches('0')),
([y, _], Some(m)) => format!("{m} {y}"),
_ => d.to_string(),
}
}
+1
View File
@@ -3,6 +3,7 @@
//! Conventions: image coordinates are y-down. *Normalized* coordinates map the full (uncropped,
//! oriented) image to `0..1` on both axes, so tools and settings are resolution independent.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod crop;
mod homography;
+21 -3
View File
@@ -86,6 +86,9 @@ pub struct Buf {
impl Buf {
pub fn raw(&self) -> &wgpu::Buffer {
// `buf` is `Some` from construction (`Gpu::buffer`/`upload`) until `Drop` takes it back
// for the pool, so a live `Buf` always has it.
#[allow(clippy::expect_used)]
&self.buf.as_ref().expect("live buffer").0
}
}
@@ -442,7 +445,12 @@ impl Gpu {
/// Record kernel `name` with parameters `p` over buffers `bufs` (None = unused binding).
pub fn run(&self, enc: &mut wgpu::CommandEncoder, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) {
let k = self.kernels.get(name).unwrap_or_else(|| panic!("unknown kernel {name}"));
let Some(k) = self.kernels.get(name) else {
// a kernel name typo: fail this render over to the CPU instead of crashing
log::error!("gpu: unknown kernel {name}");
crate::mark_broken();
return;
};
assert_eq!(bufs.len(), k.nbuf, "{name}: binding count");
let params = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
@@ -479,11 +487,21 @@ impl Gpu {
let _ = tx.send(r);
});
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
// A failed readback (device lost, out of memory) marks the GPU broken: the caller
// (`crate::render`) then discards this render's zeroed result and renders on the CPU.
let failed = |why: String| {
log::error!("gpu readback failed: {why}");
crate::mark_broken();
vec![<T as bytemuck::Zeroable>::zeroed(); len]
};
match rx.recv() {
Ok(Ok(())) => {}
other => panic!("gpu readback failed: {other:?}"),
other => return failed(format!("{other:?}")),
}
let data = slice.get_mapped_range().expect("mapped");
let data = match slice.get_mapped_range() {
Ok(d) => d,
Err(e) => return failed(format!("{e:?}")),
};
let out: Vec<T> = bytemuck::cast_slice(&data).to_vec();
drop(data);
staging.unmap();
+1
View File
@@ -11,6 +11,7 @@
//! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here
//! compiles to the CPU fallback on wasm32.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
+12 -10
View File
@@ -86,15 +86,17 @@ pub fn finish_fields() -> Vec<(&'static str, usize)> {
.collect()
}
fn index(name: &str) -> usize {
/// Offset of field `name`; `None` (and a debug assertion) for a name not in [`FIELDS`].
fn index(name: &str) -> Option<usize> {
let mut i = 0;
for (n, len) in FIELDS {
if *n == name {
return i;
return Some(i);
}
i += len;
}
panic!("unknown finish field {name}")
debug_assert!(false, "unknown finish field {name}");
None
}
/// A parameter block being filled by name.
@@ -105,20 +107,20 @@ impl Block {
Block(vec![0; FIELDS.iter().map(|f| f.1).sum()])
}
fn f(&mut self, name: &str, v: f32) {
let i = index(name);
self.0[i] = v.to_bits();
self.u(name, v.to_bits());
}
fn u(&mut self, name: &str, v: u32) {
let i = index(name);
self.0[i] = v;
if let Some(slot) = index(name).and_then(|i| self.0.get_mut(i)) {
*slot = v;
}
}
fn b(&mut self, name: &str, v: bool) {
self.u(name, v as u32);
}
fn fs(&mut self, name: &str, v: &[f32]) {
let i = index(name);
for (k, x) in v.iter().enumerate() {
self.0[i + k] = x.to_bits();
let Some(i) = index(name) else { return };
for (slot, x) in self.0.iter_mut().skip(i).zip(v) {
*slot = x.to_bits();
}
}
}
+13 -11
View File
@@ -402,17 +402,19 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
Some(d) => (Arc::new(resize(cx, &oriented, (sp.ow, sp.oh), d, 3, Filter::Mitchell)), d),
None => (oriented, (sp.ow, sp.oh)),
};
match &sp.mode {
SampleMode::Copy => base,
SampleMode::Affine(xf) => {
let out = cx.gpu.buffer(w * h * 3);
let mut p = vec![bw as u32, bh as u32, w as u32, h as u32];
p.extend(affine_bits(xf));
cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
Arc::new(out)
}
SampleMode::Warp(o2t) => {
let wp = fr.warp.as_ref().expect("warp mode has a warp");
let mut affine = |xf: &lightcraft_geom::Affine| {
let out = cx.gpu.buffer(w * h * 3);
let mut p = vec![bw as u32, bh as u32, w as u32, h as u32];
p.extend(affine_bits(xf));
cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
Arc::new(out)
};
match (&sp.mode, fr.warp.as_ref()) {
(SampleMode::Copy, _) => base,
(SampleMode::Affine(xf), _) => affine(xf),
// `sample_plan` plans a warp only when there is one; without it the same mapping is affine
(SampleMode::Warp(o2t), None) => affine(&(lightcraft_geom::Affine::scale(sp.sx, sp.sy) * *o2t)),
(SampleMode::Warp(o2t), Some(wp)) => {
let out = cx.gpu.buffer(w * h * 3);
let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy);
cx.run("sample_warp", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
+19
View File
@@ -210,4 +210,23 @@ mod tests {
assert_eq!(r["files"].as_array().map(Vec::len), Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
/// The export file-name template takes the Rename Photos tokens.
#[test]
fn export_naming_tokens() {
let dir = std::env::temp_dir().join(format!("lc-mcp-export-naming-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut h = Headless::demo();
let ids: Vec<u64> = h.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
for (id, title) in ids.iter().zip(["Harbour", "Hills"]) {
h.call("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [id], "title": title}})).unwrap();
h.call("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 4}})).unwrap();
}
let params = json!({"dir": dir.to_string_lossy(), "ids": ids, "longEdge": 32, "format": "png", "naming": "{title}_{rating}star_{seq:2}", "startNumber": 5});
let r = h.call("engine.execute", json!({"command": "app.export", "params": params})).unwrap();
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
names.sort();
assert_eq!(names, ["Harbour_4star_05.png", "Hills_4star_06.png"], "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+1
View File
@@ -13,6 +13,7 @@
//!
//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod backend;
mod base64;
+53
View File
@@ -72,6 +72,47 @@ fn tools_list_has_helpers_and_every_command() {
assert!(r["result"]["tools"].as_array().unwrap().iter().all(|t| !t["name"].as_str().unwrap().starts_with("cmd_")));
}
/// The `import` helper passes the copy options through: a folder template files the copy.
#[test]
fn import_tool_copies_with_a_folder_template() {
let base = std::env::temp_dir().join(format!("lc-mcp-import-tpl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[20, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("a.png"), png).unwrap();
let mut b = Headless::demo();
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let args =
json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"});
let r = call_tool(&mut b, "import", &args);
assert!(!r.is_error, "{r:?}");
assert!(dest.join("2026").join("20260114").join("a.png").is_file());
let _ = std::fs::remove_dir_all(&base);
}
/// The `import` helper moves: renamed into the folder template, the source removed.
#[test]
fn import_tool_moves_with_a_folder_template() {
let base = std::env::temp_dir().join(format!("lc-mcp-import-move-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[21, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("sample.png"), png).unwrap();
let mut b = Headless::demo();
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(),
"organize": "{date:%Y}/{date:%Y%m%d}", "rename": "{date:%Y%m%d}_{seq:3}"});
let r = call_tool(&mut b, "import", &args);
assert!(!r.is_error, "{r:?}");
assert!(dest.join("2026").join("20260114").join("20260114_001.png").is_file());
assert!(!src.join("sample.png").exists(), "moved");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn command_tools_run_commands() {
let mut b = Headless::demo();
@@ -102,6 +143,18 @@ fn set_develop_values_and_settings() {
assert!(call_tool(&mut b, "set_develop", &json!({"values": {"no.such": 1}})).is_error);
}
#[test]
fn crop_needs_a_rect_angle_or_reset() {
let mut b = Headless::demo();
let first = b.session.visible_cloned()[0];
// A guessed parameter must not succeed silently.
let r = call_tool(&mut b, "crop", &json!({"id": first.0, "aspect": "1:1"}));
assert!(r.is_error, "{r:?}");
assert!(call_tool(&mut b, "crop", &json!({"id": first.0, "reset": false})).is_error);
assert!(!call_tool(&mut b, "crop", &json!({"id": first.0, "rect": [0.1, 0.0, 0.9, 1.0]})).is_error);
assert!(!call_tool(&mut b, "crop", &json!({"id": first.0, "reset": true})).is_error);
}
#[test]
fn ui_tools_need_the_app() {
let mut b = Headless::demo();
+19 -5
View File
@@ -96,7 +96,14 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"import",
"Import photos",
"Add image/raw files to the library. Folders are scanned recursively for photos; relative paths resolve against the server's working directory. The first imported photo becomes active. Returns the new photo ids.",
json!({"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"}, "album": {"type": "integer", "description": "Also add to this album id"}}),
json!({
"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"},
"album": {"type": "integer", "description": "Also add to this album id"},
"mode": {"type": "string", "enum": ["add", "copy", "move"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/); move = as copy, then each original and its XMP sidecars are removed from the source once the copy is verified and catalogued (duplicates and failures keep their sources; `kept` lists sources left in place and why; undo leaves the moved files at the destination)"},
"destination": {"type": "string", "description": "Copy or move: destination folder"},
"organize": {"type": "string", "description": "Copy or move: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"},
"rename": {"type": "string", "description": "Copy or move: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"}
}),
&["paths"],
),
tool(
@@ -154,7 +161,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
tool(
"crop",
"Crop / straighten",
"Set the crop of the active photo: `rect` [x0,y0,x1,y1] in normalized coordinates (0..1, origin top-left) of the straightened frame, `angle` in degrees. `reset: true` clears it.",
"Set the crop of the active photo: `rect` [x0,y0,x1,y1] in normalized coordinates (0..1, origin top-left) of the straightened frame, `angle` in degrees. `reset: true` clears it. Give at least one of them.",
json!({"id": photo_id, "rect": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4}, "angle": {"type": "number"}, "reset": {"type": "boolean"}}),
&[],
),
@@ -192,7 +199,8 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"},
"sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]},
"sharpenAmount": {"type": "string", "enum": ["low", "standard", "high"]},
"naming": {"type": "string", "description": "e.g. {name}-{seq}"},
"naming": {"type": "string", "description": "File-name template, e.g. {name}-{seq} or {date:%Y-%m-%d}_{title}_{seq:2} (tokens: {name} {num} {seq} {seq:N} {date} {date:%…} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext})"},
"startNumber": {"type": "integer", "description": "First {seq} value (default 1)"},
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
"removeLocation": {"type": "boolean"},
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
@@ -453,8 +461,10 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
return ToolResult::error(format!("no photos found in {paths:?}"));
}
let mut p = json!({"paths": files});
if let Some(a) = args.get("album") {
p["album"] = a.clone();
for k in ["album", "mode", "destination", "organize", "rename"] {
if let Some(v) = args.get(k).filter(|v| !v.is_null()) {
p[k] = v.clone();
}
}
ToolResult::from(exec(b, "library.import", p).map(|mut r| {
if let Some(o) = r.as_object_mut() {
@@ -515,6 +525,9 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
}
None => ToolResult::error("missing `preset`"),
},
"crop" if args.get("rect").is_none() && args.get("angle").is_none() && args.get("reset").and_then(Value::as_bool) != Some(true) => {
ToolResult::error("give `rect`, `angle` or `reset: true`")
}
"crop" => ToolResult::from(activate(b, args).and_then(|_| {
if args.get("reset").and_then(Value::as_bool) == Some(true) {
exec(b, "crop.reset", json!({}))
@@ -547,6 +560,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
"sharpen",
"sharpenAmount",
"naming",
"startNumber",
"metadata",
"removeLocation",
"watermark",
+2 -1
View File
@@ -97,7 +97,8 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz
}
}
let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() };
let (name, f) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97).expect("the best fit qualifies");
// the fit with `most` inliers always qualifies
let Some((name, f)) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) else { return Alignment::identity() };
Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale }
}
+1 -1
View File
@@ -54,7 +54,7 @@ impl Features {
pub fn detect(img: &Plane, max: usize) -> Features {
let mut levels = vec![img.clone()];
while levels.len() < 4 {
let l = levels.last().expect("non-empty");
let Some(l) = levels.last() else { break };
if l.width.min(l.height) < 96 {
break;
}
+1
View File
@@ -11,6 +11,7 @@
//! Every algorithm here is our own implementation from published papers (cited in each module);
//! no code from GPL projects was consulted.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod align;
pub mod features;
+8 -8
View File
@@ -106,14 +106,14 @@ pub fn push_pull(vals: &mut Buf, wgt: &[f32]) {
}
pm.data[i * (c + 1) + c] = a;
}
let mut levels = vec![pm];
while levels.last().is_some_and(|l| l.w > 1 || l.h > 1) {
let d = down(levels.last().expect("level"));
levels.push(d);
// the finer levels, and the coarsest (1 × 1) one
let (mut levels, mut l) = (Vec::new(), pm);
while l.w > 1 || l.h > 1 {
let d = down(&l);
levels.push(std::mem::replace(&mut l, d));
}
// normalise the coarsest, then fill each finer level's holes from the coarser one
let mut filled = {
let l = levels.last().expect("level");
let mut f = Buf::new(l.w, l.h, c);
for i in 0..l.w * l.h {
let a = l.data[i * (c + 1) + c];
@@ -123,8 +123,7 @@ pub fn push_pull(vals: &mut Buf, wgt: &[f32]) {
}
f
};
for li in (0..levels.len() - 1).rev() {
let l = &levels[li];
for l in levels.iter().rev() {
let coarse = up(&filled, l.w, l.h);
let mut f = Buf::new(l.w, l.h, c);
for i in 0..l.w * l.h {
@@ -235,7 +234,8 @@ impl Blender {
}
res = Some(b);
}
res.expect("level 0")
// `0..=levels` always yields level 0
res.unwrap_or_else(|| Buf::new(self.w, self.h, 3))
}
}
+7 -4
View File
@@ -191,7 +191,7 @@ pub fn initial_cameras(ids: &[usize], sizes: &[(usize, usize)], pairs: &[PairMat
let s = d.signum() * d.abs().cbrt();
let rel = rel.map(|r| r.map(|v| v / s));
let rel = linalg::orthonormalize(&rel);
let rk = cams[known].expect("known").r;
let Some(rk) = cams[known].map(|c| c.r) else { continue };
cams[new] = Some(Camera { f: f0, r: linalg::mul3(&rel, &rk), cx: c(new).0, cy: c(new).1 });
grew = true;
}
@@ -277,7 +277,9 @@ pub fn bundle_adjust(cams: &[Option<Camera>], pairs: &[PairMatch], anchor: usize
let cost = |cs: &[Option<Camera>]| -> (f64, f64) {
let (mut c, mut sq) = (0.0, 0.0);
for o in &obs {
let r = residual(cs[o.i].as_ref().expect("cam"), cs[o.j].as_ref().expect("cam"), o.a, o.b);
// every observation joins two posed cameras (see `obs` above)
let (Some(ci), Some(cj)) = (cs[o.i].as_ref(), cs[o.j].as_ref()) else { continue };
let r = residual(ci, cj, o.a, o.b);
for v in r {
let a = v.abs();
sq += v * v;
@@ -298,11 +300,12 @@ pub fn bundle_adjust(cams: &[Option<Camera>], pairs: &[PairMatch], anchor: usize
.fold(
|| (vec![0.0f64; np * np], vec![0.0f64; np]),
|(mut jtj, mut jtr), o| {
let (ci, cj) = (cur[o.i].as_ref().expect("cam"), cur[o.j].as_ref().expect("cam"));
let (Some(ci), Some(cj)) = (cur[o.i].as_ref(), cur[o.j].as_ref()) else { return (jtj, jtr) };
let r = residual(ci, cj, o.a, o.b);
let mut cols: Vec<(usize, [f64; 4])> = Vec::with_capacity(8);
for (cam, which) in [(o.i, 0), (o.j, 1)] {
let base = 4 * idx[cam].expect("idx");
let Some(i) = idx[cam] else { continue };
let base = 4 * i;
for k in 0..4 {
if k < 3 && cam == anchor {
continue;
+1 -1
View File
@@ -223,7 +223,7 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re
Projection::Auto => choose_projection(&frames, &cams),
p => p,
};
let fmed = median(used.iter().map(|&i| cams[i].expect("used").f).collect());
let fmed = median(used.iter().filter_map(|&i| cams[i].map(|c| c.f)).collect());
let Some((bx0, by0, bx1, by1)) = bounds(&frames, &cams, projection, fmed) else {
return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0)));
};
+16 -11
View File
@@ -8,9 +8,13 @@ use lightcraft_raster::Rgb32f;
use crate::frame::FrameColor;
use crate::linalg::{self, M3};
use crate::output::{DngSamples, write_linear_dng};
use crate::{MergeError, Result};
fn scene(kind: lightcraft_scenes::Kind) -> lightcraft_scenes::Scene {
lightcraft_scenes::demo_library().into_iter().find(|s| s.kind == kind).expect("scene kind in the demo library")
fn scene(kind: lightcraft_scenes::Kind) -> Result<lightcraft_scenes::Scene> {
lightcraft_scenes::demo_library()
.into_iter()
.find(|s| s.kind == kind)
.ok_or_else(|| MergeError::Mismatch(format!("no {kind:?} scene in the demo library")))
}
fn srgb_color() -> FrameColor {
@@ -19,12 +23,12 @@ fn srgb_color() -> FrameColor {
/// Exposure brackets (`evs`, e.g. `[-2, 0, 2]`) of a sunset scene as 16-bit linear DNGs, each
/// shifted by a few pixels (handheld) with EXIF exposure times; `w × h` pixels.
pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec<Vec<u8>> {
pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result<Vec<Vec<u8>>> {
let m = 12usize;
let truth = scene(lightcraft_scenes::Kind::OceanSunset).render(w + 2 * m, h + 2 * m);
let truth = scene(lightcraft_scenes::Kind::OceanSunset)?.render(w + 2 * m, h + 2 * m);
let mut l: Vec<f32> = truth.data.iter().map(|p| p[1]).collect();
l.sort_by(|a, b| a.total_cmp(b));
let s = 0.18 / l[l.len() / 2].max(1e-6);
let s = 0.18 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6);
let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32();
evs.iter()
.enumerate()
@@ -44,20 +48,21 @@ pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec<Vec<u8>> {
iso: Some(100),
..Default::default()
};
write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16).expect("encode")
write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16)
})
.collect()
}
/// `yaws.len()` overlapping views (degrees of yaw, small pitch/roll wobble) of a canyon scene used
/// as a 200° × 70° spherical environment, `w × h` pixels with focal length `f`, as linear sRGB.
pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec<Rgb32f> {
let env = scene(lightcraft_scenes::Kind::Canyon).render(2400, 840);
pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Result<Vec<Rgb32f>> {
let env = scene(lightcraft_scenes::Kind::Canyon)?.render(2400, 840);
let mut l: Vec<f32> = env.data.iter().map(|p| p[1]).collect();
l.sort_by(|a, b| a.total_cmp(b));
let s = 0.25 / l[l.len() / 2].max(1e-6);
let s = 0.25 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6);
let (theta, phi) = (200.0f64, 70.0f64);
yaws.iter()
Ok(yaws
.iter()
.enumerate()
.map(|(i, yaw)| {
let wob = (i as f64 * 1.3).sin();
@@ -77,5 +82,5 @@ pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec<Rgb32f> {
env.sample_bilinear(px as f32, py as f32).map(|v| (v * s).min(1.0))
})
})
.collect()
.collect())
}
+374
View File
@@ -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
View File
@@ -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();
}
+39
View File
@@ -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());
+16 -13
View File
@@ -211,19 +211,22 @@ impl Frame {
Some((nw, nh)) => std::borrow::Cow::Owned(resize(o, nw, nh, Filter::Mitchell)),
None => std::borrow::Cow::Borrowed(o),
};
match plan.mode {
SampleMode::Copy => base.into_owned(),
SampleMode::Warp(o2t) => sample_warped(&base, plan.sx, plan.sy, self.warp.as_ref().expect("warp"), o2t, w, h),
SampleMode::Affine(xf) => {
let mut out = Rgb32f::new(w, h);
par_rows(&mut out.data, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
*px = base.sample_bilinear(p.x as f32, p.y as f32);
}
});
out
}
let affine = |xf: Affine| {
let mut out = Rgb32f::new(w, h);
par_rows(&mut out.data, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
*px = base.sample_bilinear(p.x as f32, p.y as f32);
}
});
out
};
match (plan.mode, self.warp.as_ref()) {
(SampleMode::Copy, _) => base.into_owned(),
(SampleMode::Warp(o2t), Some(warp)) => sample_warped(&base, plan.sx, plan.sy, warp, o2t, w, h),
// `sample_plan` plans a warp only when there is one; without it the same mapping is affine
(SampleMode::Warp(o2t), None) => affine(Affine::scale(plan.sx, plan.sy) * o2t),
(SampleMode::Affine(xf), _) => affine(xf),
}
}
+1
View File
@@ -21,6 +21,7 @@
//! [`render_cached`] each stage's output is reused while its inputs are unchanged ([`StageCache`]):
//! dragging a tone, colour or exposure slider re-runs only the per-pixel stage.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod auto;
pub mod colorops;
+2 -2
View File
@@ -217,12 +217,12 @@ impl DeepImage {
let trc = self.space.trc();
match &self.samples {
DeepSamples::U16(v) => {
for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) {
for (p, c) in out.data.iter_mut().zip(v.as_chunks::<3>().0) {
*p = [c[0], c[1], c[2]].map(|x| ((x as u32 * 255 + 32767) / 65535) as u8).into_rgba();
}
}
DeepSamples::F32(v) => {
for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) {
for (p, c) in out.data.iter_mut().zip(v.as_chunks::<3>().0) {
*p = [c[0], c[1], c[2]].map(|x| (trc.encode(x) * 255.0 + 0.5) as u8).into_rgba();
}
}
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more