Compare commits

...
Author SHA1 Message Date
Brandon Thomas e1d3b56bb6 Merge main into fix/issue-99-library-lock
# Conflicts:
#	crates/catalog/src/lib.rs
2026-10-05 18:54:56 -04:00
Brandon Thomas 6100237cdb Merge pull request #125 from storytold/fix/issue-102-format-version
Issue #102: Catalog format version 2; refuse newer libraries untouched
2026-10-05 18:54:41 -04:00
Brandon Thomas 4579599745 Merge main into fix/issue-102-format-version 2026-10-05 18:54:03 -04:00
Brandon Thomas 8506fecf5f Merge pull request #124 from storytold/fix/issue-101-torn-append
Issue #101: catalog.log: cut a partial append back before retrying
2026-10-05 18:53:49 -04:00
Brandon Thomas 0f49ebf8f0 Merge main into fix/issue-101-torn-append
# Conflicts:
#	docs/whats-new.md
2026-10-05 18:52:48 -04:00
Brandon Thomas ab86862011 Merge pull request #123 from storytold/fix/issue-92-xmp-merge
Issue #92: XMP: merge into an existing sidecar instead of replacing it
2026-10-05 18:52:25 -04:00
Brandon Thomas 577330a08b Merge main into fix/issue-92-xmp-merge
# Conflicts:
#	crates/engine/src/sidecar.rs
#	docs/whats-new.md
2026-10-05 18:50:42 -04:00
Brandon Thomas 30697cfc26 Merge pull request #122 from storytold/fix/issue-92-rename-shared-sidecar
Issue #92: Rename Photos copies a stem sidecar another file still shares
2026-10-05 18:50:26 -04:00
Brandon Thomas ddbf139e5e Merge main into fix/issue-92-rename-shared-sidecar 2026-10-05 18:49:29 -04:00
Brandon Thomas ee77490dd2 Merge pull request #121 from storytold/fix/issue-105-rename-rollback
Issue #105: Rename Photos reports failed rollbacks; Find Missing matches by content
2026-10-05 18:49:15 -04:00
Brandon Thomas f31a55c8a7 Merge main into fix/issue-105-rename-rollback
# Conflicts:
#	docs/parity.md
2026-10-05 18:48:27 -04:00
Brandon Thomas 7218b990f5 Merge pull request #120 from storytold/fix/issue-95-case-only-rename
Issue #95: Rename Photos: case-only renames never overwrite another file
2026-10-05 18:48:06 -04:00
Brandon Thomas d16d9aadeb Merge main into fix/issue-95-case-only-rename 2026-10-05 18:47:18 -04:00
Brandon Thomas 1caa04acbc Merge pull request #119 from storytold/fix/issue-98-thumb-cache-own-files
Issue #98: thumbnail cache only lists, counts and deletes its own files
2026-10-05 18:47:03 -04:00
Brandon Thomas bef2060b24 Merge main into fix/issue-98-thumb-cache-own-files 2026-10-05 18:46:59 -04:00
Brandon Thomas 40303b999c Merge pull request #118 from storytold/fix/issue-96-verified-copy-import
Issue #96: Import > Copy verifies each copy like Move
2026-10-05 18:46:42 -04:00
Brandon Thomas ce5488ad99 Merge main into fix/issue-96-verified-copy-import
# Conflicts:
#	docs/whats-new.md
2026-10-05 18:46:20 -04:00
Brandon Thomas f6bc1ae5ae Merge pull request #117 from storytold/fix/issue-106-dng-smart-previews
Issue #106: Convert to DNG and smart previews: verified, atomic writes
2026-10-05 18:46:06 -04:00
Brandon Thomas 6c8a65c337 Merge main into fix/issue-106-dng-smart-previews 2026-10-05 18:45:48 -04:00
Brandon Thomas fe386f4f15 Merge pull request #116 from storytold/fix/issue-93-export-overwrite
Issue #93: Export: never overwrite an original; atomic writes
2026-10-05 18:45:31 -04:00
Brandon Thomas a6f421aafb Merge main into fix/issue-93-export-overwrite 2026-10-05 18:44:02 -04:00
Brandon ThomasandClaude Opus 5.5 cbebabccad Issue #102: Catalog format version 2; refuse newer libraries untouched
main added Op::SetBrowsed, Catalog.browsed and Photo.local_baseline while
the snapshot still said version 1, so v0.2.0 read such a library in part
(unknown ops as log damage, unknown fields dropped on its next snapshot).

- journal::VERSION = 2 (now public), with a format table and rules in the
  journal docs; a test with an exhaustive match over Op makes adding a
  variant fail until it is assigned a version.
- Journal::open reads the snapshot header first and refuses a newer
  version with CatalogError::Newer ("written by a newer version of
  LightCraft"), even when its catalog wouldn't parse.
- A log record whose CRC matches but whose op doesn't parse is "newer",
  not damage or a torn tail: refused before any file is modified.
- Opening an older (or snapshot-less) library writes a snapshot in this
  format before anything is appended (LoadReport::upgraded_from); a new
  library gets one at once, so v0.2.0 refuses it instead of reading a bare
  log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:13:00 -04:00
Brandon ThomasandClaude Opus 5.5 08c2acda26 Issue #101: catalog.log: cut a partial append back before retrying
A failed append (disk full, dropped share) could leave part of its batch in
catalog.log; the retry (same seqs) then shared a line with the fragment and
the next load treated it as mid-log damage, setting later edits aside.

- Journal::append cuts the log back to the last whole record on error (or,
  if that fails too, before the next append).
- FsStore drops a failed append handle so the next attempt reopens the file;
  truncate of a never-created file is a no-op.
- The loader recognises a CRC-valid record glued after a fragment (logs
  written by older builds) and skips only the fragment.
- log_bytes now counts the newline added to an unterminated last record.
- close_library writes the closing snapshot even when appending fails; the
  snapshot absorbs the queued ops (Journal::snapshot_with_unlogged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:13:00 -04:00
Brandon ThomasandClaude Opus 5.5 ee4b78f646 Issue #99: Library: exclusive lock so two processes can't open one library
Two processes on one library each kept their own catalog and seq counter;
the last snapshot silently dropped the other's edits.

- lightcraft_catalog:🔒 LibraryLock holds an exclusive OS lock
  (std File::try_lock: flock / LockFileEx) on <library>/catalog.lock for
  the session; who holds it goes to catalog.lock.owner (separate file: a
  Windows lock also blocks reads). Released on drop and by the OS when the
  process dies, so leftover files from a crash never lock anyone out. File
  systems without lock support open unprotected with a warning.
- Session::open_library takes the lock before reading anything and fails
  with EngineError::LibraryInUse ("this library is already open in
  LightCraft 0.x (process N on host)"); reopening the session's own
  library hands its lock over.
- lightcraft-cli (mcp/run/snapshot --library) adds how to drive the
  running app instead (--control + mcp --connect).
- Tests: in-process, leftover files, a killed child process, two engine
  sessions, CLI e2e (mcp --library holds it, run --library is refused).
  Existing tests that reopened a library while it was open now drop the
  first session.
- docs: parity row LRC-LIB-CATALOG-LOCK, mcp.md, web.md (no guard across
  browser tabs yet), whats-new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:48:34 -04:00
Brandon ThomasandClaude Opus 5.5 e96a2750a1 Issue #96: Import > Copy verifies each copy like Move
Copy import used exists() then std::fs::copy: no sync, no comparison, and
a file appearing at the chosen name in between was replaced. It now goes
through import_move::copy_new, which reuses Move's copy_verified: a new
file (create_new, retrying -1, -2… on AlreadyExists), synced and compared
byte for byte with the source; a copy that fails or differs is removed and
reported as a failed import. Copy as DNG's copy step uses the same path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:22:35 -04:00
Brandon ThomasandClaude Opus 5.5 771b0b98d7 Issue #106: Convert to DNG and smart previews: verified, atomic writes
- Convert to DNG / Copy as DNG: the encoded DNG must decode to the same raw
  data, then goes through safe_file::write_new_unique (temp file, sync,
  read-back, hard-linked to the first free <stem>.dng / -2 / -3 name, never
  replacing a file). On failure no DNG is left, the photo is not relinked
  and the raw copy is kept.
- Photo Merge writes its DNG the same way.
- Smart previews are written atomically; smart::is_valid (header + complete
  JPEG) decides "built", has_smart_preview and the offline fallback, so a
  proxy cut short is rebuilt (reported as `repaired`); moving proxies to a
  new folder never deletes a good one in favour of a damaged copy and
  copies across drives atomically.

Stacked on the #93 branch (shared lightcraft_catalog::safe_file helper).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:17:41 -04:00
Brandon ThomasandClaude Opus 5.5 f4df914731 Issue #92: Rename Photos copies a stem sidecar another file still shares
Renaming IMG_1.CR3 alone took the shared IMG_1.xmp along and left
IMG_1.JPG without it. A Stem-named sidecar that a sibling photo file
still uses is now copied (independently, never linked) instead of moved;
the last file renamed takes it. A rollback removes the copy.

Stacked on fix/issue-105-rename-rollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:12:25 -04:00
Brandon ThomasandClaude Opus 5.5 882a059fac Issue #92: XMP: merge into an existing sidecar instead of replacing it
Saving (Cmd+S or auto-write) now merges LightCraft's properties into an
existing sidecar (lightcraft_meta::merge_xmp): owned properties (rating,
label, dc/IPTC/rights fields, lc:*) are replaced or removed, capture time
and GPS only when LightCraft has a value; everything else (crs:,
xmpMM:History, unknown namespaces, packet wrapper) is kept byte for byte.
The result is re-parsed and checked before it is written (temp + fsync +
rename). A sidecar that isn't valid XMP is copied to .xmp.bak-<time>
(never overwriting) before it is replaced; one that can't be read at all
is left alone and the save fails.

Shared stems: when several catalogued files map to one stem sidecar
(IMG.CR3 + IMG.JPG), it belongs to the raw (else the first by name) and
the others write and read <file>.xmp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:08:18 -04:00
Brandon ThomasandClaude Opus 5.5 46732cb0fe Issue #93: Export: never overwrite an original; atomic writes
- lightcraft_catalog::safe_file: one tested helper for crash-safe writes
  (temp file in the same folder, sync, rename, folder sync; temp removed on
  failure), write_new/write_new_unique that never replace a file and read
  the result back, same_file, and a per-thread fault injection for tests.
- engine::originals::OriginalGuard: refuses a write whose target is a
  catalogued photo's original or XMP sidecar (by file identity), whatever
  the conflict policy or exact path. Used by run_batch (export, Export with
  Previous, control/MCP exact paths), ui.render/ui.screenshot paths, merge
  previewPath, MCP render_photo/screenshot paths (new query command
  export.checkTarget) and lightcraft-cli render.
- The conflict policy now covers an export's sidecars together with the
  file (Unique gives both the same free name, Skip skips when either is
  taken).
- export::write_file replaces the plain std::fs::write in the desktop app,
  CLI, MCP and engine writers; XMP sidecar writes use the shared helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:03:07 -04:00
Brandon Thomas 572b55a44a Merge pull request #110 from midasdf/feat/japanese-interface-and-watermarks
Add Japanese menus and vertical Japanese text watermarks
2026-10-05 16:49:18 -04:00
Brandon Thomas bd10f89d02 Merge main into PR #110 2026-10-05 16:48:57 -04:00
Brandon ThomasandClaude Opus 5.5 fb5b18843f Issue #105: Rename Photos reports failed rollbacks; Find Missing matches by content
Rollbacks in move_file, apply_rename and undo/redo no longer ignore
errors: every move that can't be undone is reported (old -> new), and the
catalog follows the files that kept their new names (an undoable partial
rename; a logged change for undo/redo), so nothing shows as missing.
A file that can't go back keeps the sidecars that moved with it.

library.findMissing: same name + size confirmed by the stored content
hash first, then renamed files by size + content hash, then a unique
name + size match; several look-alikes are skipped and reported in
'ambiguous' instead of guessed. Files already used by a photo are skipped.

Stacked on fix/issue-95-case-only-rename (same functions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:47:50 -04:00
Brandon ThomasandClaude Opus 5.5 dfbb1ad935 Issue #95: Rename Photos: case-only renames never overwrite another file
Whether an existing target is the photo itself is decided by file identity
(dev+inode on Unix; an exact directory entry check elsewhere), not by a
lower-case name comparison, so on case-sensitive volumes img_1.JPG next to
IMG_1.JPG is a collision (-1 suffix). Moves use a no-replace rename (hard
link + unlink where supported) and case changes go through a unique temp
name. Tests model case-sensitive and case-insensitive volumes and check
the real disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:36:35 -04:00
Brandon ThomasandClaude Opus 5.5 0fd0ffb783 Issue #98: thumbnail cache only lists, counts and deletes its own files
scan() now accepts only <2 hex>/<32 hex>.jpg (matching shard) and the
cache's own <32 hex>.tmp<id> temp files, so prune/clear never delete and
size() never counts foreign files in a shared thumbs/ folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:23:32 -04:00
Brandon Thomas e99aa61fc4 Merge pull request #111 from storybored379/fix/unicode-timezone
Metadata: gracefully ignore invalid Unicode timezone offsets
2026-10-05 16:22:21 -04:00
Brandon ThomasandClaude Opus 5.5 58626b982d Fonts: replace Shippori Mincho with BIZ UDMincho (no Adobe-derived glyphs)
Shippori Mincho's name table credits 'FONTDASU / Google Inc. / but / Adobe' — its kanji
come from Adobe's Source Han Serif, which our asset rules exclude even under the OFL
(CLAUDE.md: no Source Han fonts). BIZ UDMincho (Morisawa Inc., design TypeBank Co., Ltd.;
OFL-1.1, from google/fonts at 63833b7) is a Mincho of its own with no Adobe glyphs, and
smaller (6.2 MB vs 8.7 MB). Attribution, licence text and NOTICE updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:19:38 -04:00
midasdf e00025aaf6 i18n: add Japanese menus and vertical Japanese watermarks 2026-10-06 01:07:26 +09:00
Brandon Thomas 294012742e Merge pull request #108 from storytold/pr/freebsd-ci-sync
FreeBSD CI: match the shared template
2026-10-05 10:19:07 -04:00
Brandon ThomasandClaude Opus 5.5 4d13061580 FreeBSD CI: match the shared template (git + safe.directory, 4 vCPUs, pinned vmactions/freebsd-vm)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:45:24 -04:00
Brandon Thomas 63380829b1 Merge pull request #91 from storytold/pr/freebsd
FreeBSD: build and test the workspace in a FreeBSD VM
2026-10-05 09:30:12 -04:00
Brandon ThomasandClaude Opus 5.5 62e16d7adc FreeBSD CI: 4 vCPUs, git in the VM, pinned vmactions/freebsd-vm
The headless UI snapshot test settles for at most 120 s; on a 2-vCPU VM
under the full test load it timed out mid-render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:58:05 -04:00
Brandon ThomasandClaude Opus 5.5 d47e941a0c docs: point agents to the shared test corpora (#90)
Real-file test oracles live in storytold/photocraft-corpus, explained in
craftrules standards/test-corpora.md. Never commit large binary fixtures
here; fetch them pinned and sha256-verified.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:24:23 -04:00
Brandon ThomasandClaude Opus 5.5 83fb206a29 FreeBSD: build and test the workspace in a FreeBSD VM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:23:26 -04:00
Brandon Thomas 3567980442 Merge pull request #89 from storytold/chore/ignore-target-dirs
Ignore /target-*/ build dirs
2026-10-05 07:38:00 -04:00
Brandon ThomasandClaude Opus 5.5 8cd791100b Ignore /target-*/ build dirs
Agents sometimes build into target-<name>/ next to target/; ignore those too so a
'git add -A' can never commit build artifacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:37:49 -04:00
Brandon Thomas ff1e51e4d4 Merge pull request #88 from storytold/fix/issue-85-cr2-cfa
Issue #85: Raw: CR2 colour pattern from the file's CR2CFAPattern tag, not a constant
2026-10-05 07:37:19 -04:00
Brandon Thomas c05f2de251 Merge main into fix/issue-85-cr2-cfa
# Conflicts:
#	docs/parity.md
2026-10-05 07:37:00 -04:00
Brandon ThomasandClaude Opus 5.5 e87ee8e36d Issue #85: Raw: CR2 colour pattern from the file's CR2CFAPattern tag, not a constant
The Bayer layout of Canon CR2 files differs by model (GBRG on the 7D, 50D,
60D, 550D, 600D, 1200D, 1300D, 5D Mark II, 1D Mark IV; RGGB on the 40D,
5D Mark III, 6D, 5DS R and others). Read it from raw IFD tag 0xc5e0
(CR2CFAPattern: 1 RGGB, 2 BGGR, 3 GBRG, 4 GRBG), anchored at the full
decoded sensor; fall back to RGGB when the tag is missing. No model table.

Checked on 35 decoded CC0 raw.pixls.us CR2s (35 models): the tag agrees
with the mosaic's green-diagonal statistic on every file; the SensorInfo
border parity does not predict the layout. Corpus manifest gains the
reporter's samples; a corpus test asserts each layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:24:26 -04:00
Brandon Thomas efef3a5df1 Merge pull request #87 from storytold/docs/roadmap-assessment
Docs: honest parity assessment — where we stand, where we're going
2026-10-05 06:49:16 -04:00
Brandon ThomasandClaude Opus 5.5 b50fbf1285 Docs: honest parity assessment — where we stand, where we're going
ROADMAP.md: new 'Where we stand' (by dimension and by kind of user; checklist 79% vs
~60-70% as a day-to-day Lightroom replacement) and 'Where we're going' (ordered priorities:
camera colour calibration, CR3 and compressed raws, verified camera coverage, render fidelity
suite, lens profiles, AI model strategy, then HDR / Classic modules / video). Current
parity numbers, M11 status, risks (raw-source decision of 2026-10-05), log.

docs/parity.md: new rows LR-PROF-CAMERACOLOR, LR-IMP-CAMERA-COVERAGE (P0) and
LR-BEHAV-RENDER-FIDELITY (P1) so quality gaps are tracked; Top gaps reordered by user impact.

README.md: honest status summary above the feature table; stale rows fixed (compressed NEF,
DNG/original export, image watermark, Move import), camera colour and AI/HDR/video rows.

AGENTS.md / CLAUDE.md: agents read the assessment before picking work, and downgrade
✅ rows they find wrong or incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:48:54 -04:00
Brandon Thomas 92bd3aa688 Merge pull request #86 from storytold/fix/issue-10-nef-compressed
Issue #10: Raw: Nikon compressed NEF (lossless + lossy)
2026-10-05 06:08:28 -04:00
Brandon ThomasandClaude Opus 5.5 6592e62912 Issue #10: Raw: Nikon compressed NEF (lossless + lossy)
Decode Nikon Huffman-compressed NEF (compression 34713): lossless and lossy
(type 1 and 2) at 12 and 14 bits, with the maker-note 0x96 linearization
curve, predictor seeds and either byte order. Clean-room: 0x96 layout and
encoding-table sizes from prose write-ups (Clevy, Claff); the fixed Huffman
tables were recovered by black-box analysis of CC0 raw.pixls.us samples
(compressed vs uncompressed shots of the same scene) and verified on ~45
files from ~35 bodies. Lossy-after-split files stay preview only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:58:31 -04:00
Brandon Thomas 8532d4ea56 Merge pull request #84 from storytold/fix/issue-78-gpu-black-export
Issue #78: GPU export: respect device limits, fall back to CPU on GPU errors
2026-10-05 05:09:57 -04:00
Brandon ThomasandClaude Opus 5.5 2218ca5a4c Issue #78: GPU export: respect device limits, fall back to CPU on GPU errors
A GPU render no longer hands back an image it cannot vouch for:
- set the wgpu device-lost callback (wgpu never reports a lost device as an
  error, so a driver reset went unnoticed); readback waits with a timeout and
  checks the poll result and map callback instead of blocking forever
- each render runs in OutOfMemory/Validation/Internal error scopes; errors
  fail that render (OOM and limit failures for that render only, device
  errors for the process) and it is redone on the CPU
- every buffer is checked against the device limit when created (no
  validation error, no process-wide disable); LIGHTCRAFT_GPU_LIMITS=webgpu|<MiB>
  creates the device with lower limits to reproduce small adapters
- the output buffer is cleared and the per-pixel kernel writes alpha 255:
  any unwritten pixel (work silently dropped, the issue's all-black export)
  fails the render; an all-black result is re-rendered on the CPU
- renders submit per stage and every 16 M invocations, the per-pixel stage
  runs in ~4 MP bands: no long submission for a slow iGPU's watchdog to reset
- ui.inspect perf.gpuReason / perf.gpuFallback, app.gpu reason/lastFallback,
  System Info and Settings say why the GPU is off (e.g. llvmpipe skipped) and
  why the last render fell back

Tests: crates/gpu/tests/fallback.rs (limit, mid-render limit, validation
error, dropped work, black photo), engine export falls back to the CPU image,
banded 3000x2000 GPU/CPU equivalence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:06:24 -04:00
Brandon Thomas 14053b2e40 Merge pull request #83 from storytold/fix/local-record-eviction
Local: forget untouched browse records of folders not browsed for 30 days
2026-10-05 02:59:23 -04:00
Brandon Thomas 1f2f25356a Merge main into fix/local-record-eviction
# Conflicts:
#	crates/engine/src/cmd/library.rs
#	crates/engine/src/library.rs
#	docs/whats-new.md
2026-10-05 02:59:05 -04:00
Brandon Thomas 2ed6af155d Merge pull request #82 from storytold/fix/persist-errors
Persistence: a failed save fails the command (change kept in memory, queued, retried)
2026-10-05 02:58:49 -04:00
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 ThomasandClaude Opus 5.5 b14cf58630 Local: forget untouched browse records of folders not browsed for 30 days
Local browse records can be most of a large catalog (85k photos, 90 %
Local: 313.5 MB snapshot). Untouched Local records of folders whose last
browse is older than library.preferences forgetLocalDays (default 30,
0 = never) are now removed from the catalog when the library opens (and by
library.forgetLocal {dryRun?, days?}), as journaled, non-undoable ops, then
compacted in the background. Files and XMP sidecars are never touched;
browsing the folder again catalogues them afresh.

Untouched = a Local file record, not deleted, not a virtual copy, whose
state still hashes to the fingerprint taken when the browse catalogued it
(any change keeps it); records from before fingerprints need every
user-editable field empty/default. Kept too: records in albums (or as
cover), stacks, sources of virtual copies, visible/selected/edited ones,
ones referenced by undo/redo, ones with a smart preview.

Per-folder last-browsed times live in the catalog (Op::SetBrowsed,
normalised with folder_key; re-stamped at most hourly). Old catalogs load
unchanged; folders without a time are stamped now, so nothing is forgotten
on the first open after the upgrade.

persist_bench (85k photos, 76.5k Local, every 7th folder recent, 1 in 50
rated): 63.8k forgotten, plan 162 ms, snapshot 313.5 MB -> 94.9 MB.
Settings -> Performance -> Local folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:26:49 -04:00
Brandon ThomasandClaude Opus 5.5 104444edcb Persistence: a failed save fails the command (change kept in memory, queued, retried)
A command whose journal append fails now returns EngineError::NotSaved
("saved in memory but not written to disk: <reason>; LightCraft will retry")
instead of OK. The change stays applied and undoable, its ops stay queued and
are written by the next save (any command, or the frame loop with a 2 s
backoff). Queries and commands that change nothing are unaffected; a failed
compaction is reported in lastError, not as a failed command (and a failed
synchronous snapshot now backs off like a background one).

Session::unsaved(), library.info unsavedOps/unsavedError, ui.inspect unsaved;
the desktop UI toasts the failure and shows a warning on the top bar's cloud
icon until a save succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:26:49 -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
storybored379andOpenAI Codex 35e38661a7 Metadata: gracefully reject invalid Unicode timezone offsets
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-04 21:11:55 -07: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
Brandon ThomasandClaude Opus 5.5 7bf24ff856 Release 0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:55:38 -04:00
Brandon ThomasandClaude Opus 5.5 439cbd6ca0 LRC-DEV-SOFTPROOF, MENU-CTX-MASK: soft proofing with gamut warnings; mask component menu
Soft proofing (S in the loupe; S in grids stays Expand/Collapse Stack):
- pipeline Proof {space, destWarning, displayWarning}: gamut-map into the
  proof space, show it in the output space; red/blue gamut warnings
  (neutral clipping excluded). CPU only; the GPU path declines proofs.
- RenderJob::with_proof; paper-white surround and Proof Preview label;
  Soft Proofing strip under the histogram (profile, warnings, Create
  Proof Copy = a named virtual copy). view.softProof for control/MCP.
- photo.virtualCopy takes an optional name.

Mask components: MaskComponent.name; mask.component {component, action:
invert|duplicate|delete|rename|op}; right-click / … menu per component,
double-click rename, Intersect button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 04:01:01 -04:00
Brandon ThomasandClaude Opus 5.5 db8a574fea LRC-LIB-QUICKDEV, LRC-LIB-VISUALSEARCH, LRC-DEV-MATCHEXP: Quick Develop, Find Similar, Match Exposures
- develop.quickAdjust {control, delta}: adds a step to every target photo's own value (one undo
  step); the Edit panel shows Quick Develop rows (exposure ⅓ / 1 stop, contrast, highlights,
  shadows, whites, blacks, clarity, vibrance, temperature) in the grid with several selected.
- library.findSimilar {id?, similarity?}: look-alike photos by their culling signature (cached
  per content), most similar first, as a view filter (Filter.only); photo menu entry.
- develop.matchExposure: selected photos' Exposure set so shutter × ISO ÷ aperture² + slider
  matches the active photo; photos without exposure data are skipped.
- Impromptu slideshow row marked (View ▸ Slideshow).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:40:34 -04:00
Brandon ThomasandClaude Opus 5.5 81d015b5f2 LRC-LIB-REFVIEW: Reference view (⇧R)
A reference photo (photo menu ▸ Set as Reference Photo, or the active photo) shown fixed on
the left beside the active photo on the right, which the Edit panel keeps working on — for
matching a look by eye. View ▸ Reference View (⇧R); reference pane menu ▸ Clear Reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:26:53 -04:00
Brandon ThomasandClaude Opus 5.5 5d864c0374 LR-MASK-FEATHER-EDGE: Refine Edges for masks
Mask.refine (0–100, mask.refine; Masking panel ▸ Refine Edges): after a mask's components are
combined, a guided filter steered by the photo's luminance (window up to ~4 % of the long
edge) snaps soft mask edges to the photo's edges. Rendered on the CPU (the GPU path has no
kernel for it yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:16:54 -04:00
Brandon ThomasandClaude Opus 5.5 8d40b3e372 LR-REM-DUST: Find Dust Spots
lightcraft-pipeline dust.rs: sensor-dust detection on a rendered image — pixels darker than
their blurred surroundings by more than 3× the local texture, grouped into small (0.15–2 % of
the long edge), roughly round blobs. spot.findDust {sensitivity?, add?} renders the uncropped
photo, detects and adds a heal spot on each (auto source; one undo step). Remove panel ▸
Find Dust Spots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:07:49 -04:00
Brandon ThomasandClaude Opus 5.5 26b00014a8 LR-LIB-CULL: assisted culling — focus scores, bursts, best-of-burst
lightcraft-pipeline cull.rs: focus (Laplacian energy of the sharpest 16-px blocks at 512 px,
0–100, calibrated on the CC0 corpus), clipping share and an 8×8 look-alike signature.
photo.analyze scores the selection (else the view), groups look-alike shots taken within 10 s
into bursts and marks each burst's sharpest; optionally rejects photos below a focus score and
picks each burst's best (one undo step). Scores live on the photo (Analysis, Op::SetAnalysis):
rule fields Focus and Best of Similar Shots, Info panel line. Photo ▸ Assisted Culling….

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:58:52 -04:00
Brandon ThomasandClaude Opus 5.5 1b4f623e55 LRC-LIB-FOLDERS: rename and move folders on disk
folder.rename {path, name} and folder.move {path, into}: the folder is renamed / moved on disk
in one step (its files and sidecars go along) and the photos inside are relinked (and a
browsed folder follows); like the file manager it is not an undo step. Refuses clashes, moves
into itself and the library's own folders. Local folder tree: Rename Folder…, Move Folder To…,
Show in Finder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:40:44 -04:00
Brandon ThomasandClaude Opus 5.5 df4c7d7c3a Perf: chroma-only colour ops skip the OkLCh round trip
When only vibrance / saturation (and colour grading) are active, chroma is scaled on OkLab
a/b directly — the same result without sin/cos (and atan2 unless vibrance needs the hue).
Bench (loaded machine): finish 1920×1280 + vibrance/saturation 71.8 → 47.7 ms wall on the CPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:29:19 -04:00
Brandon ThomasandClaude Opus 5.5 4a490db261 LR-PROF-IMPORT: .cube 3D LUTs as creative profiles
lightcraft-pipeline gains lut.rs: a .cube parser (LUT_3D_SIZE, DOMAIN_MIN/MAX, TITLE), a
trilinear lookup and an in-process registry; the finishing stage applies a `lut:` profile to
the display-encoded colour, blended by the profile amount (0–200 %); LUT profiles render on the
CPU. profile.import {paths} reads files, folders and zips, copies each LUT into the library's
Profiles/ folder, lists them in prefs and registers them when the library opens; the profile
browser shows them under their folder / zip name; profile.deleteImported. File ▸ Import
Profiles & Presets…, the file picker and drag & drop take .cube files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:20:39 -04:00
Brandon ThomasandClaude Opus 5.5 81f77d74df LRC-LIB-KEYWORDS + LRC-LIB-VIEWS: keyword painter, square-grid captions
- Keyword painter (tool.keywordPainter {keyword}): Keywords panel ▸ Paint; clicking a photo in
  the grid toggles the keyword on it (no selection change), Esc / Stop ends.
- View ▸ Grid Info (view.gridInfo): the square grid's caption shows the file name, the
  exposure (1/250 · f/2.8 · ISO 100 · 18 mm) or the capture date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:41:10 -04:00
Brandon ThomasandClaude Opus 5.5 1aaafb6218 LR-EXP-DNGOPT: DNG export compression (lossless / ZIP / none)
ExportOptions.dng_compression (`dngCompression`: lossless (default) | deflate | uncompressed),
in the export dialog when the format is DNG. Corpus-gated test: all three decode, uncompressed
is the biggest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:27:45 -04:00
Brandon ThomasandClaude Opus 5.5 b3249bfb57 Perf: cache the photo grid's layout — 43 → 5 ms per frame at 100k photos
The justified layout (and the aspect ratio of every photo it needs) was rebuilt every frame.
It is now kept in LightcraftApp::caches, keyed by catalog revision, the photos in view, the
width, the thumbnail size, the mode and the date groups. Headless measurement (ignored test
grid_frame_100k, machine under load): 43.1 → 5.3 ms on the UI thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:21:26 -04:00
Brandon ThomasandClaude Opus 5.5 c24ed6e94a Perf: cache the left panel's counts / By Date tree and the filter bar's value lists
Both walked the whole library every frame (the filter bar even cloned every photo to list
cameras, lenses and keywords). They now come from LightcraftApp::caches, recomputed when the
catalog revision changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:11:08 -04:00
Brandon ThomasandClaude Opus 5.5 ec78cb8236 Perf: big libraries — keyword tree 5.6× faster, per-frame library queries cached
A 100k-photo scaling check (ignored test `scale_100k_library_queries`) showed the left
panel rebuilding the keyword tree every frame (323 ms), the grid recomputing date headers
(57 ms) and the Keywords panel its suggestions (44 ms) every frame.
- keyword_tree builds with hash maps instead of linear sibling scans: 323 → 57 ms at 100k.
- LightcraftApp::caches keeps the keyword tree, date runs and keyword suggestions per catalog
  revision (and inputs), so each is computed once per change instead of every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:05:38 -04:00
Brandon ThomasandClaude Opus 5.5 1fe5dea3ab LR-META-LOCATION: edit GPS by hand, Show on Map
photo.setMeta {gps: "lat, lon" | [lat, lon] | null} — decimal, or degrees / minutes / seconds
with N/S/E/W ("48°51'30"N 2°17'40"E"); applied to all target photos, saved in XMP / exports.
Info: the GPS line is an editable field, and Show on Map opens the place in OpenStreetMap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:56:13 -04:00
Brandon ThomasandClaude Opus 5.5 6b6a925ac7 LR-BEHAV-WHATSNEW + MENU-HELP-FEEDBACK: Help ▸ What's New, Send Feedback
docs/whats-new.md (release highlights, built into the app) shown by Help ▸ What's New;
Help ▸ Send Feedback… opens a new GitHub issue; the Help menu lists What's New, Keyboard
Shortcuts and System Info together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:50:56 -04:00
Brandon ThomasandClaude Opus 5.5 9adfb61819 LR-BEHAV-ACCESS: screen-reader names for the custom widgets
AccessKit (already on in the desktop build) now sees what the painted widgets are: sliders
announce their control and value, text / icon buttons and dropdowns their label or tooltip and
state, section headers and flyouts open / closed, left-panel sources their name and count, grid
thumbnails file name, rating, flag and label. Test reads the AccessKit tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:45:25 -04:00
Brandon ThomasandClaude Opus 5.5 9adcfe1a2c LR-VIEW-SECONDWINDOW: Window ▸ Second Window (⌘F11)
The active photo, fitted, in its own native window (an egui immediate viewport) with its own
render slot sized for that window; closing the window turns it off. Without native windows
(web, headless) egui shows it as a floating panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:35:18 -04:00
Brandon ThomasandClaude Opus 5.5 59090387aa ROADMAP: evening parity estimate (75.6% by rows; ≈ 270–505 agent-hours / 65–125 h wall clock left)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:26:15 -04:00
Brandon ThomasandClaude Opus 5.5 544914bb66 LR-CLOUD-SMARTPREVIEW + LR-LIB-DUPLICATE: smart previews for offline originals, Duplicate
- Smart previews (smart.rs): a ~1 MB proxy per photo in the library's "Smart Previews" folder
  (the decoded preview-size source, scaled, sRGB-encoded JPEG behind a one-line header). When
  a photo's original is missing (an unplugged drive), its source loads from the proxy: the
  photo renders, edits and exports at proxy size. library.smartPreviews {discard?},
  photo.smartPreview status; File ▸ Previews ▸ Build / Discard Smart Previews; Info shows
  "Original offline · editing the smart preview".
- Photo ▸ Duplicate (photo.duplicate): a real "-copy" file with the same settings, metadata,
  rating, flag, label and albums; one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:25:35 -04:00
Brandon ThomasandClaude Opus 5.5 608fb6dc13 LR-IMP-AUTO: watched-folder auto import
library.autoImport {folder, copy, album} (in prefs) and library.autoImportScan: files in the
watched folder the library doesn't have are added once their size held between two scans
(still-copying files wait), in place or copied into Originals, into the named album; each file
is tried once; arrivals don't take the selection. The desktop app scans every 3 s with a toast;
Settings ▸ Import ▸ Auto Import picks the folder.
Fix: library.import into an album no longer fails when nothing was selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:15:34 -04:00
Brandon ThomasandClaude Opus 5.5 a98ea7858b LR-PRE-PREMIUM + KEYC-CATALOG: 41 built-in presets, F2 renames
- 23 more own-authored presets: Portrait (Moody Portrait, Golden Glow, Clean Studio),
  Landscape (Crisp Vista, Deep Sky, Lush Greens, Desert Warmth, Misty Morning), Urban,
  Food, Seasons, Vintage (Instant '70s, Cross Process, Bleach Bypass), Sepia B&W.
- F2 opens Rename Photos…; ⌘' / ⌘R / ⇧⌘E Classic keys verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:09:09 -04:00
Brandon ThomasandClaude Opus 5.5 27387af44c LR-EXP-PSD: pick up files saved by the external editor (photo.reload)
Op::SetContent records a photo's file facts (size, dimensions, content hash; undoable).
photo.reload re-probes the photos' files and, for those that changed on disk, updates them and
drops their cached sources; render keys now include the file's content key, so the grid and
loupe re-render. The app reloads this session's external-edit copies whenever its window
regains focus (with a toast).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:03:39 -04:00
Brandon ThomasandClaude Opus 5.5 9affd0e8d2 LR-EXP-PSD + KEY-PHOTOSHOP: Edit in External Editor (⇧⌘E)
photo.editExternal renders the active photo with its edits as a 16-bit TIFF "<name>-Edit.tif"
(Adobe RGB by default; ProPhoto / P3 / sRGB) next to the original, never overwriting, adds it
stacked on top of the original and selects it. Photo ▸ Edit in External Editor (⇧⌘E, as
observed; the export dialog stays on ⇧E) then opens it in the application set in Settings ▸
General ▸ External Editor, or the system default (desktop: open -a / start / xdg-open,
spawned, never waited for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:58:19 -04:00
Brandon ThomasandClaude Opus 5.5 0fafacfccd LR-VER-AUTO, LR-VIEW-SLIDESHOW, LRC-LIB-CONVERT and more: auto versions, slideshow, DNG
- Auto versions: leaving an edited photo keeps its settings as an Auto version (when they
  differ from its latest version; 20 kept); saved with the library, not an undo step.
- View ▸ Slideshow (⌥⌘↩, `interval`): the photos in view full screen, wrapping; Space
  pauses, ←/→ step, Esc ends.
- Photo ▸ Convert to DNG (photo.convertToDng): lossless DNG next to the raw with the settings
  embedded; the photo and its virtual copies are relinked (Relink gains an optional format),
  originals kept, undoable. Import: Copy as DNG (`dng`) for copy imports.
- A fine grid over the photo while a geometry slider is dragged.
- View ▸ Show Photo Counts; Help ▸ System Info… (app.systemInfo, JSON for agents).
- Native menus: modified Enter accelerators.
- Parity rows refreshed (target / quick collection, auto sync, location rules, bring to front).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:49:39 -04:00
Brandon ThomasandClaude Opus 5.5 1c4a00c35d LRC-LIB-FOLDERS: expandable folder tree in Local
Each place in Local (Pictures, Desktop, Downloads, Home, the browsed folder) has a disclosure
triangle listing its subfolders (hidden ones skipped; listed lazily, at most every 2 s), to any
depth; folders on the way to the one in view open by themselves; clicking a folder browses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:34:24 -04:00
Brandon ThomasandClaude Opus 5.5 b163d0e16e LRC-LIB-COLLECTIONS: Quick Collection and target album (B)
Albums gain a `quick` flag: the Quick Collection is created the first time B adds to it.
album.toggleTarget adds the selection to the target album (the Quick Collection unless
album.setTarget names another regular album) or removes it when all of it is already there;
album.clearQuick empties it. B in the photo / square grids does this (with a toast); in the
loupe and edit views B stays the masking brush. Album menu: Set as Target Album / Stop Using
as Target Album / Clear Quick Collection; the target is marked + in the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:28:44 -04:00
Brandon ThomasandClaude Opus 5.5 def15b0346 LR-META-EXIF-FULL: All Metadata — every EXIF / GPS / XMP field of the file
lightcraft-meta gains tags.rs: every tag of IFD0 and the EXIF, GPS and interoperability IFDs
as (group, name, value) rows, named per the public TIFF / EXIF / DNG specifications, with
common values spelled out (exposure as 1/250 s, f/2.8, 35 mm, orientation, metering, flash,
compression…) and blobs summarised. photo.allMetadata adds the XMP fields (sidecar, else the
file's packet). Info ▸ All Metadata… opens a searchable dialog grouped by TIFF / EXIF / GPS /
XMP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:20:43 -04:00
Brandon ThomasandClaude Opus 5.5 af30fee4d6 KEY-MASKCOLOR + MENU-FILE-CLOSE: ⇧O cycles the mask colour, native Close Window
- While masking, ⇧O cycles the overlay colour as observed in the reference (the mode stays;
  View ▸ Cycle Mask Overlay Mode cycles modes).
- macOS: File ends with the system's Close Window (⌘W). Hide / Hide Others / Show All,
  Minimize (⌘M) and Zoom were already the system's own items; parity rows verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:10:10 -04:00
Brandon ThomasandClaude Opus 5.5 f65c2310f2 LR-MASK-COLORRANGE: pick colour-range samples on the photo
The Color mask tile adds an empty colour range and arms the picker: click the photo to sample
(⇧-click adds, up to five); Pick in the Masking panel re-arms it. mask.sampleColor {x, y, add?}
samples via lightcraft_pipeline::color_range_sample, which reads the exposed scene colour at
that point in the same OkLab space the mask compares in (so a click selects what was clicked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:03:14 -04:00
Brandon ThomasandClaude Opus 5.5 140d7cbc67 LR-MASK-LUMRANGE: range bar, Smoothness and Show Luminance Map for luminance-range masks
The Masking panel lists each component with its controls: luminance ranges get a dark → light
bar with two handles (the nearer one follows the pointer; one undo step per drag), a Smoothness
slider (both falloffs) and Show Luminance Map (the photo in black & white with the selected
range tinted; turning it off restores the previous overlay); colour ranges get Refine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:53:51 -04:00
Brandon ThomasandClaude Opus 5.5 3b4308040f KEY-GUIDEDUPRIGHT + KEY-GRID: ⇧G starts Guided Upright, G toggles Photo / Square Grid
As observed in the reference: G switches between Photo Grid and Square Grid (from other
views: the photo grid); ⇧G opens Crop & Geometry with Guided Upright on and the guide tool
active (tool.guidedUpright). Square Grid no longer takes ⇧G.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:47:55 -04:00
Brandon ThomasandClaude Opus 5.5 6a0d5346e8 LRC-LIB-PREVIEWS: build standard-sized / 1:1 previews in the background
library.buildPreviews {size: standard (Settings → Performance preview size) | full, ids?,
wait?} renders each photo's grid thumbnail and loupe view into the memory + disk preview cache
(selected photos, else all in view) on a background thread; library.previewProgress /
cancelPreviews. File ▸ Previews: Build Standard-Sized / 1:1, Stop, Discard Preview Cache;
toasts when a build starts and finishes. Also silences the wasm-only unused `threads` warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:42:33 -04:00
Brandon ThomasandClaude Opus 5.5 b8b2379215 LRC-LIB-FILTERBAR: colour-label multi-select (any of the chosen labels)
Filter.labels: a photo matches when its label is any of them. The filter bar's label toggles
add / remove labels from the set instead of replacing the single label; the summary reads
"label red or yellow". The filter already persists across albums (locked, as in the desktop
app).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:33:43 -04:00
Brandon ThomasandClaude Opus 5.5 a3d487d22d LRC-LIB-SMARTCOLL: smart-album rule editor — all / any / none, nested groups, 26 fields
lightcraft-catalog gains rules.rs: RuleSet {match: all|any|none, rules: [{field, op, value} |
{group}]} with text, keyword, number, date (is / before / after / between / in the last N
hours…years) and choice operators over 26 fields (rating, flag, label, type, edits, keywords,
any text, filename, title, camera, lens, location, dates, ISO, aperture, focal length,
megapixels, GPS, virtual copy, album…). Filter.ruleSet: smart albums and library.filter use it;
"in the last" counts from the session clock. album.ruleFields lists fields and operators for
agents; rules are validated.
UI: File ▸ New Smart Album… / album context menu ▸ Edit Smart Album… open the rule editor
(field / operator / value rows with per-kind editors, + / − / ⌥+ for groups, live match count).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:27:56 -04:00
Brandon ThomasandClaude Opus 5.5 b8344aa5eb LRC-LIB-KEYWORDS + KEY-SECTIONS: keyword sets with ⌥1–⌥9, ⌘1–⌘5 edit sections
- Keyword sets: Recent Keywords (the nine most recently added, newest first) plus user sets in
  prefs (keyword.sets, useSet, saveSet, deleteSet). keyword.toggleFromSet {index}: adds keyword N
  to the selection, or removes it when every photo has it; applying from Recent Keywords keeps
  the numbering. Keywords panel: set picker (save current as a set, delete) and a 3×3 grid of
  toggles; ⌥1–⌥9.
- ⌘1–⌘5 expand/collapse Light, Color, Effects, Detail, Optics as observed in the reference;
  Zoom 100% moves to ⌥⌘0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:17:15 -04:00
Brandon ThomasandClaude Opus 5.5 86c4048ad6 LRC-LIB-IMPORT: copy to any folder, organise by day / month / flat, rename on import, metadata preset
library.import gains destination (copies go there instead of the library's Originals/; no
library needed), organize (date | month | flat), rename (file-name template with the rename
tokens, numbered from renameStart; copies are catalogued under their new names) and
metadataPreset. The Add Photos dialog: Copy → Copy to (Choose… / Library), Folders, Rename with
a live example, Metadata preset; batches keep the numbering going.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:08:33 -04:00
Brandon ThomasandClaude Opus 5.5 8a3489da6b LR-IMP-FORMATS: decode lossy (DCT JPEG) DNG — Smart Previews and lossy DNG exports
DNG compression 34892: each tile / strip is a baseline JPEG of 8-bit samples (decoded with
zune-jpeg, pure Rust), then the usual linearization table, levels and crop apply. Per the DNG
specification. Lossy DNGs (Smart Previews, "lossy compression" DNG exports, many phone DNGs)
now open as raw files instead of falling back to their embedded preview. Unit test with
synthetic JPEG tiles (edge tile cut short); the corpus sample decodes (5760×3840 in 144 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:51:15 -04:00
Brandon ThomasandClaude Opus 5.5 f81c81b7a0 LRC-DEV-AUTOSYNC + LRC-LIB-LABELS + KEY-STRAIGHTEN: auto sync, label sets, ⌘-drag straighten
- develop.autoSync (Edit ▸ Auto Sync, ⌥⇧⌘A): an edit of the active photo carries only the
  settings that changed to the other selected photos, in one undo step; slider drags sync on
  release; spots / red eye and history restores stay per photo. Edit ▸ Sync Settings in the
  menu; Edit panel banner while it's on with several photos selected.
- Colour-label sets: built-in Colors / Review sets plus user sets in prefs (label.sets,
  applySet, saveSet, deleteSet); listed in Photo ▸ Set Color Label; the names dialog can start
  from a set and save one. XMP sidecars and exported originals write the label's name, and
  reading resolves custom names back to their colour.
- Crop: ⌘-drag draws a straighten line and keeps the crop tool active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:41:05 -04:00
Brandon ThomasandClaude Opus 5.5 b7d497d049 LR-IMP-PROFILES: carry masks over from presets, .lrtemplate and XMP sidecars
Local corrections stored as crs: structures (MaskGroupBasedCorrections and the older
gradient / circular gradient / paint containers) become our masks: linear and radial
gradients, brushes (dab runs → strokes), Subject / Sky, luminance and depth ranges, with
add / subtract / intersect and invert, and all local sliders. lightcraft-meta's XmpData gains
`values`, a structured view of every property (arrays of structs survive); .lrtemplate Lua
tables convert into the same shape.
Applying a preset adds its masks to the photo's own (once; Amount scales them); sidecar masks
replace them and radial masks are fitted to the photo's aspect. Unsupported components
(colour range, other AI selections) are reported as "Mask: <kind>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:31:40 -04:00
Brandon ThomasandClaude Opus 5.5 1dc501bc3a LR-IMP-PROFILES: import presets from .lrtemplate, DNG presets, zip bundles and folders
preset.import (File ▸ Import Presets…, Presets panel ⋯, drag & drop, CLI, MCP) now reads
everything people actually download or carry over from other editors:
- classic .lrtemplate files (Lua table literal parser; value.settings uses crs: field names),
  including old process-version fields (Exposure, Contrast, FillLight, HighlightRecovery,
  Shadows, Brightness, Clarity, ToneCurve) approximated with today's sliders;
- photos carrying their edits in XMP ("DNG presets" from mobile apps, edited JPEG/TIFF): the
  look only, without the photo's crop, geometry or custom white balance;
- .zip bundles (stored + deflate) of any of these, skipping __MACOSX / hidden files;
- folders: the pack's folder name becomes the group, skipping app/format container folders
  ("Lightroom Classic", "XMP", "Mobile (DNG)"…).
Each imported preset reports the settings that had no counterpart (`unmapped`: profiles,
looks, local masks); the app's toast names them. dryRun previews, group overrides.
Dropped .lcpreset/.lrtemplate/.xmp/.zip files import as presets instead of photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:14:51 -04:00
Brandon ThomasandClaude Opus 5.5 5d6b889e45 docs/mcp: quote bracketed run values for the shell
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:46:05 -04:00
Brandon ThomasandClaude Opus 5.5 9e8d75300f LR-EDIT-COLOR-MIXER-BW + LR-FILT-TYPE: Auto B&W mix, HDR / panorama type filters
- develop.autoBwMix (B&W Mixer → Auto): each hue band's colourful pixels are pushed away from
  the image's mean lightness (light bands brighter, dark ones darker; bands with almost no
  colour untouched), clamped to ±60; switches to B&W; one undo step.
- library.filter {merged: hdr | panorama | hdrPanorama | any}: Photo Merge results recognised
  by the "-HDR" / "-Pano" / "-HDR-Pano" names merges write; filter bar type picker lists HDR,
  Panoramas, HDR Panoramas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:45:30 -04:00
Brandon ThomasandClaude Opus 5.5 478f328cc0 LR-FILT-SAVED + KEY-COMMIT: filter presets, Return commits a tool
- filter.savePreset / applyPreset / presets / deletePreset: the filter bar's settings saved
  under a name with the library; filter bar → Presets (apply, save current, delete).
- Return (tool.done) closes Crop / Remove / Red Eye / Masking back to the Edit panel.
- Parity: zoom while cropping verified (works), hover previews now include versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:41:04 -04:00
Brandon ThomasandClaude Opus 5.5 deff6b6d06 LR-EXP-WATERMARK + LR-LIB-RECENT-ADDED: graphic watermarks, Recently Added by import
- Export watermark can be a graphic (watermark {image, imageWidth}): a logo with transparency,
  scaled premultiplied to a share of the photo's width, converted into the output colour
  space, placed with the same anchor / inset / opacity as text; decoded once per file.
  Export dialog: Text / Graphic, Choose…, width; size and shadow apply to text only.
- Recently Added: everything imported in the 30 days up to the latest import, newest import
  first, grouped by import day (was the latest import day only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:37:26 -04:00
Brandon ThomasandClaude Opus 5.5 b27c3bc20f LRC-LIB-METADATA + LRC-LIB-STACKS: metadata presets, split stack, move within a stack
- metadata.savePreset / applyPreset / presets / deletePreset: named sets of copyright,
  creator, title, caption, place and keywords (text replaces, keywords add; one undo step),
  saved with the library; Photo → Metadata Preset (apply, Save Metadata Preset… from the active
  photo's copyright, creator and place); Settings → Import applies one to every import
  (library.preferences import.metadataPreset).
- stack.split (this photo and those after it become their own stack; single leftovers are
  unstacked), stack.moveUp / moveDown; in the Stack menus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:33:11 -04:00
Brandon ThomasandClaude Opus 5.5 b2c8d9611f KEY-SLIDER-NUDGE + KEY-FLAGADVANCE: slider nudges, ⇧Z pick and advance, Missing Photos source
- ↑/↓ over any slider nudge it by ≈ 1/200 of its range (exposure 0.05, most sliders 1,
  temperature 50 K; ⇧ ×5), one undo step each; the key doesn't also move the selection.
- ⇧Z picks and moves to the next photo (with ⇧X / ⇧U).
- library.source {kind: missing}: photos whose file can't be found; a "Missing Photos" row
  appears in the left panel when there are any (checked every 5 s).
- Loupe context menu: Zoom submenu. Parity: stale rows fixed (compare/survey, per-camera
  defaults, paste from previous, capture-time edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:27:35 -04: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
236 changed files with 27252 additions and 1703 deletions

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
+1
View File
@@ -1,6 +1,7 @@
*~
plan/
/target
/target-*/
/dist
/corpus
/log
+37 -2
View File
@@ -5,8 +5,13 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
@@ -17,6 +22,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 +64,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.
@@ -57,6 +91,7 @@ and often so a crash loses minutes, not hours.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
- Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
## Testing & performance (do this often)
- Unit/property tests next to the code; end-to-end tests drive real binaries (`crates/mcp/tests/e2e.rs`,
+37 -2
View File
@@ -5,8 +5,13 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
@@ -17,6 +22,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 +64,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.
@@ -57,6 +91,7 @@ and often so a crash loses minutes, not hours.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
- Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
## Testing & performance (do this often)
- Unit/property tests next to the code; end-to-end tests drive real binaries (`crates/mcp/tests/e2e.rs`,
Generated
+33 -21
View File
@@ -2073,13 +2073,14 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.1.0"
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.0"
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.0"
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.0"
version = "0.2.0"
dependencies = [
"bytemuck",
"image",
@@ -2142,7 +2145,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -2151,7 +2154,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -2162,7 +2165,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"ab_glyph",
"libc",
@@ -2180,15 +2183,17 @@ dependencies = [
"lightcraft-raw",
"lightcraft-scenes",
"log",
"miniz_oxide 0.8.9",
"rayon",
"serde",
"serde_json",
"thiserror 2.0.21",
"web-time",
]
[[package]]
name = "lightcraft-geom"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@@ -2196,7 +2201,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -2212,7 +2217,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -2223,7 +2228,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -2239,7 +2244,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -2251,7 +2256,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"image",
"lightcraft-color",
@@ -2267,7 +2272,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -2279,7 +2284,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -2291,8 +2296,9 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"jpeg-encoder",
"lightcraft-color",
"lightcraft-geom",
"lightcraft-meta",
@@ -2303,11 +2309,13 @@ dependencies = [
"rayon",
"serde",
"thiserror 2.0.21",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "lightcraft-scenes"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"image",
"lightcraft-color",
@@ -2316,9 +2324,13 @@ dependencies = [
"serde",
]
[[package]]
name = "lightcraft-sysmem"
version = "0.2.0"
[[package]]
name = "lightcraft-tiff"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@@ -2327,7 +2339,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"egui",
"lightcraft-catalog",
@@ -2347,7 +2359,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"eframe",
"egui",
@@ -5115,7 +5127,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
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.0"
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.
+15 -7
View File
@@ -1,12 +1,20 @@
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.
BIZ UDMincho: Copyright 2022 The BIZ UDMincho Project Authors
(https://github.com/googlefonts/morisawa-biz-ud-mincho). SIL Open Font License 1.1;
see assets/fonts/OFL-BIZUDMincho.txt.
+33 -9
View File
@@ -280,9 +280,18 @@ lightcraft --control 7980 ~/Pictures/trip
## Feature status
LightCraft is young and moving fast; see the [roadmap](ROADMAP.md) for estimates. The detailed, row-by-row
Lightroom parity tracker (every feature, menu item and shortcut with its status, evidence and the top gaps) is
[docs/parity.md](docs/parity.md).
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in
[docs/parity.md](docs/parity.md).
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
older-Canon raws on one machine.
- **The biggest gaps:**
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **HDR, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
| Area | Status |
|---|---|
@@ -292,17 +301,20 @@ Lightroom parity tracker (every feature, menu item and shortcut with its status,
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; neutral fallback today) |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
| RAW: DNG, CR2, ARW, NEF (uncompressed), Fujifilm RAF (Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF; embedded previews for every format incl. CR3 | ✅ · compressed NEF/RAF/ORF, CR3 decode 🚧 |
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF, sizing, file-size limit, output sharpening, naming, batch, metadata policy, text watermark | ✅ · DNG export, image watermark ⬜ |
| Library persistence (crash-safe op log + snapshots), disk thumbnail cache, import with duplicate detection | ✅ |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255) with CPU fallback | ✅ · WebGPU in the browser 🚧 |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
@@ -347,6 +359,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 +411,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>
+114 -31
View File
@@ -4,6 +4,68 @@ Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every
estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling
projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revised as milestones land.
## Where we stand
*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
**switch** from Lightroom. Update it when a gap below closes.*
**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
**quality of results and camera coverage**, not missing buttons.
Caveat on the checklist: ✅ is set by whoever lands a feature, and nobody has systematically checked rows against
Lightroom's behaviour or output. Bugs keep turning up in ✅ areas (CR2 colour-filter phase on some Canon models #85,
duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
### By dimension
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No camera colour calibration**: non-DNG raws use a neutral matrix, so colour is muted/inaccurate. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; no localisation; accessibility partial; headless UI tests time out under machine load |
### By kind of user
| User | Readiness | What blocks them |
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour (muted colour on every photo) |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models |
## Where we're going
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Raises colour quality for every non-DNG raw at once.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
checked for plausible colour; fix per-model bugs (#85).
4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
(references stay in the local `plan/`), then tune against the numbers.
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance.
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
and accessibility.
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
## Milestones
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
| # | Milestone | Scope (summary) | Estimate (h) | Status |
@@ -11,7 +73,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ |
| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) |
| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) |
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed, embedded previews ✅; NEF Huffman ⬜) |
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed + Huffman lossless/lossy, embedded previews ✅; NEF lossy-after-split ⬜) |
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
@@ -19,60 +81,66 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; CR3, compressed NEF/ORF/RAF ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** ⬜ (top priority), CR3, compressed ORF/RAF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; accessibility, localisation ⬜) |
## Parity estimate (2026-10-02)
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
parity` prints this line on every run, so it stays current):
| Scope | Weighted completion | Rows |
|---|---:|---:|
| P0 (core) | **98.5%** | 198 |
| P1 (important parity) | **79.9%** | 144 |
| P2 (later / AI / niche, incl. Classic modules) | **11.7%** | 158 |
| **All in-scope rows** | **65.7%** | 500 |
| Scope | Weighted completion | Rows | 2026-10-02 |
|---|---:|---:|---:|
| P0 (core) | **98.5%** | 200 | 98.7% |
| P1 (important parity) | **95.9%** | 147 | 94.4% |
| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% |
| **All in-scope rows** | **79.2%** | 505 | 75.6% |
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
camera coverage, render fidelity), which is why P0 dipped slightly.
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
**By remaining effort** — rows are not equal: a shortcut and the whole Book module are one row each, and what is
left is the heavy part (AI, video, Classic output modules, undocumented raw codecs). Remaining work in **Opus agent-hours**
(one agent working continuously; calibrated on this project — a single lead agent closed ≈ 45 tracker rows of
UI/feature work in ≈ 5 h on 2026-10-01, and four to six parallel agents built M0–M13 in ≈ 25 active hours):
UI/feature work in ≈ 5 h on 2026-10-01, and ≈ 60 more (preset import incl. `.lrtemplate` / DNG / zip and masks, smart-album
rule editor, auto sync, keyword and label sets, import options and DNG conversion, smart previews, external-editor round
trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel agents built M0–M13 in ≈ 25 active hours):
| Work package | Tracker rows | Agent-hours | Risk |
|---|---|---:|---|
| Remaining P0/P1 UI and library features (version hover previews, filter presets, quick develop, missing-file relink, metadata presets, keyword sets…) | ≈ 30 | 20–35 | low |
| Raw codecs: CR3 (CRX), compressed NEF / ORF / RAF, RW2 v4, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, RW2 v4, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
| HDR editing, display, visualisation and export | Q. HDR, LR-EXP-HDR | 15–25 | medium |
| Classic modules: Map, Book, Slideshow, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
| Smaller P2 items: watched folders, Enhance dialog, external-editor round trip, export to Photos, help / what's new, accessibility, localisation | ≈ 25 | 20–35 | low |
| Classic modules: Map, Book, Slideshow module, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
| Smaller P2 items: Enhance dialog, export to Photos, help / what's new, accessibility, localisation, sidecar variants | ≈ 15 | 12–25 | low |
| Look tuning, performance budgets (colour NR at half resolution on CPU + GPU, 100k-photo library), packaging, hardening | — | 25–45 | medium |
| **Total remaining** | | **≈ 295–540** | |
| **Total remaining** | | **≈ 270–505** | |
Spent so far ≈ 90–130 agent-hours, so **by effort the project is ≈ 20–30% of the way to complete Lightroom + Classic
parity**, and ≈ 40–55% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 135–235 h).
Spent so far ≈ 105–145 agent-hours, so **by effort the project is ≈ 20–35% of the way to complete Lightroom + Classic
parity**, and ≈ 45–60% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 130–255 h,
most of it the raw codecs, lens data, video and HDR).
**Wall clock:** ≈ 300–540 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
CI under load cost the rest) **≈ 75–130 h of continuous work**. The AI package and the raw codecs carry most of the
**Wall clock:** ≈ 270–505 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
CI under load cost the rest) **≈ 65–125 h of continuous work**. The AI package and the raw codecs carry most of the
uncertainty: they can finish faster if suitable permissive models / documentation turn up, or stall on licensing.
## Totals
Remaining from 2026-10-02 (see *Parity estimate* above for the breakdown):
Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents |
|---|---:|---:|
| Cloud-Lightroom parity without AI or Classic modules | ≈ 135–235 h | ≈ 35–60 h |
| Full parity incl. AI, video and the Classic modules | ≈ 295–540 h | ≈ 75–130 h |
| Cloud-Lightroom parity without AI or Classic modules | ≈ 130–255 h | ≈ 35–65 h |
| Full parity incl. AI, video and the Classic modules | ≈ 270–505 h | ≈ 65–125 h |
The milestone estimates in the table above were made before work started and are kept for calibration (M0–M13 took
≈ 25 active hours with parallel agents against an estimate of ≈ 110–170 h for those milestones).
@@ -83,24 +151,25 @@ The milestone estimates in the table above were made before work started and are
fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own.
- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first,
then our own calibration; long-tail camera/lens coverage grows over time.
- **Legal decisions pending:** whether GPL-licensed *prose* format descriptions (e.g. the public CR3 write-up) may be read
by a designated engineer to produce an internal spec; freedom-to-operate review for local Laplacian filters, PatchMatch and
HEVC (HEIC).
- **Look parity** with Adobe's default rendering is subjective tuning against our own reference targets.
- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
Laplacian filters, PatchMatch and HEVC (HEIC).
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
turns it into measured comparisons against local-only Lightroom references.
## Raw format coverage and known gaps
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed, RAF uncompressed (Bayer and
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
X-Trans), RW2 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
it can't decode yet.
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
- **Nikon Huffman NEF** (lossless / lossy). Black-box findings so far: maker note `0x0096` holds per-parity predictor
seeds (2048 for 14-bit lossless); ≈ 9 bits per pixel; the category-0 code word is a 6-bit rotation of `011111`. The
Huffman tables are not stored in the files, and an automatic table search (beam search over prefix codes scored by
smoothness and bit rate, validated on Pentax files whose tables *are* stored) did not converge.
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Panasonic RW2 raw format 4** (quantised): the block layout is known (0x4000-byte chunks rotated by 0x1ff8; 128-bit
blocks of two 12-bit seeds plus four groups of a 2-bit scale and three 8-bit codes; scales 0/1 are ×1/×2 differences
from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
@@ -116,3 +185,17 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
tracker rows for camera colour, camera coverage and render fidelity.
## Japanese interface and text watermarks
English/Japanese interface language is persisted in UI state. Core menus have Japanese
translations; untranslated panels and dialogs retain English. BIZ UDMincho is bundled
under OFL for Japanese glyph coverage without system fonts. Text watermarks now accept
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
characters stay upright in top-to-bottom columns, with newlines starting columns to the
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
+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();
}
+35 -22
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;
@@ -89,6 +91,17 @@ USAGE:
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
/// that one instead (issue #99).
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
match e {
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
),
e => format!("{dir}: {e}"),
}
}
fn main() -> ExitCode {
// `--features dhat-heap`: count allocations; the profile is written when `_heap` drops
// (LIGHTCRAFT_DHAT_FILE, default dhat-heap.json).
@@ -180,7 +193,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
let r = h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
h
}
@@ -283,14 +296,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())
@@ -413,7 +430,7 @@ fn run(args: &[String]) -> Result<(), String> {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
h
}
None if demo => Headless::demo(),
@@ -527,11 +544,17 @@ fn render(args: &[String]) -> Result<(), String> {
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
}
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
std::fs::write(&output, &e.bytes).map_err(|err| format!("{output}: {err}"))?;
for (ext, bytes) in &e.sidecars {
let sc = Path::new(&output).with_extension(ext);
std::fs::write(&sc, bytes).map_err(|err| format!("{}: {err}", sc.display()))?;
eprintln!("lightcraft-cli: wrote {}", sc.display());
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
let sidecars: Vec<(String, &Vec<u8>)> =
e.sidecars.iter().map(|(ext, bytes)| (Path::new(&output).with_extension(ext).to_string_lossy().to_string(), bytes)).collect();
let guard = s.original_guard();
for p in std::iter::once(&output).chain(sidecars.iter().map(|(p, _)| p)) {
guard.check(Path::new(p)).map_err(|err| format!("render: {err}"))?;
}
lightcraft_engine::export::write_file(&output, &e.bytes)?;
for (sc, bytes) in &sidecars {
lightcraft_engine::export::write_file(sc, bytes)?;
eprintln!("lightcraft-cli: wrote {sc}");
}
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
Ok(())
@@ -574,7 +597,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
let mut session = match &library {
Some(dir) => {
let mut s = Session::new().with_fs();
s.open_library(dir, false).map_err(|e| format!("{dir}: {e}"))?;
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
s
}
None if files.is_empty() => Session::with_demo().with_fs(),
@@ -587,18 +610,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3);
}
let services = lightcraft_ui_egui::Services {
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(p, b).map_err(|e| format!("{p}: {e}"))
})),
write: Some(Box::new(|p: &str, b: &[u8]| {
if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(p, b).map_err(|e| format!("{p}: {e}"))
})),
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
write: Some(Box::new(lightcraft_engine::export::write_file)),
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
+46
View File
@@ -86,6 +86,24 @@ fn render_subcommand() {
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
}
/// Issue #93: `render IMG -o IMG` replaced its own input with the render.
#[test]
fn render_refuses_to_overwrite_its_input() {
let input = tmp("self-in.png");
gradient_png(&input);
let before = std::fs::read(&input).unwrap();
let dir = input.parent().unwrap();
std::fs::create_dir_all(dir.join("sub")).unwrap();
let other_spelling = dir.join("sub/../self-in.png");
for out in [&input, &other_spelling] {
let o =
Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1"]).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("never writes over an original"), "{}", String::from_utf8_lossy(&o.stderr));
}
assert_eq!(std::fs::read(&input).unwrap(), before, "the input is untouched");
}
#[test]
fn render_export_options() {
let input = tmp("o-in.png");
@@ -242,3 +260,31 @@ fn devices_are_listed_and_imported_from() {
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
}
/// Issue #99: a library open in one process (here `mcp --library`) is refused by a second one,
/// with who has it and how to drive the running app instead; free again once the first exits.
#[test]
fn a_library_open_in_another_process_is_refused() {
let lib = tmp("locked-lib");
let _ = std::fs::remove_dir_all(&lib);
let lib_s = lib.to_str().unwrap();
let mut holder =
Command::new(BIN).args(["mcp", "--library", lib_s]).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
let mut err = BufReader::new(holder.stderr.take().unwrap());
let mut line = String::new();
while !line.contains("opened library") {
line.clear();
assert!(err.read_line(&mut line).unwrap() > 0, "mcp exited before opening the library");
}
let (ok, _, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
assert!(!ok);
assert!(stderr.contains("already open in lightcraft-cli") && stderr.contains(&format!("process {}", holder.id())), "{stderr}");
assert!(stderr.contains("mcp --connect"), "{stderr}");
drop(holder.stdin.take()); // EOF: the server exits and lets go of the library
assert!(holder.wait().unwrap().success());
let (ok, lines, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
assert!(ok, "{stderr}");
assert_eq!(lines[0]["ok"], true);
let _ = std::fs::remove_dir_all(&lib);
}
+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;
+21 -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,
@@ -161,6 +167,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
reveal: None,
open_with: None,
open_url: Some(Box::new(|url: &str| {
let w = web_sys::window().ok_or("no window")?;
w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string())
@@ -312,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 =
@@ -513,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;
@@ -524,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();
}
}
+2
View File
@@ -56,6 +56,7 @@ impl WireJob {
let max_edge = match &job.source {
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
SourceRef::Loaded(img) => img.width.max(img.height),
SourceRef::Smart { .. } => 2560,
};
WireJob {
level: job.level,
@@ -91,6 +92,7 @@ impl WireJob {
// workers render previews (exports run in-process)
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
depth: lightcraft_engine::pipeline::OutputDepth::U8,
proof: None,
}
}
+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();
}
+58 -11
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;
@@ -95,6 +102,28 @@ fn save_prefs(app: &LightcraftApp) {
fn services() -> Services {
Services {
pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))),
open_with: Some(Box::new(|path: &str, app: &str| {
// spawned, never waited for: the editor runs alongside
let app = app.trim();
let mut c = if cfg!(target_os = "macos") {
let mut c = std::process::Command::new("open");
if !app.is_empty() {
c.args(["-a", app]);
}
c
} else if app.is_empty() {
if cfg!(target_os = "windows") {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", ""]);
c
} else {
std::process::Command::new("xdg-open")
}
} else {
std::process::Command::new(app)
};
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
})),
open_url: Some(Box::new(|url: &str| {
if !url.starts_with("https://") {
return Err("only https links are opened".into());
@@ -139,13 +168,21 @@ fn services() -> Services {
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Import Presets")
.add_filter("Presets", &["lcpreset", "xmp"])
.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")
@@ -154,18 +191,27 @@ fn services() -> Services {
.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())?;
}
std::fs::write(p, b).map_err(|e| e.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()
})),
write: Some(Box::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())?;
}
std::fs::write(p, b).map_err(|e| e.to_string())
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())
})),
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
write: Some(Box::new(lightcraft_engine::export::write_file)),
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
@@ -218,6 +264,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();
+10 -4
View File
@@ -122,6 +122,8 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
"'" => Code::Quote,
"," => Code::Comma,
"Delete" => Code::Backspace,
// with modifiers only: a bare Enter belongs to text fields and tools
"Enter" if !mods.is_empty() => Code::Enter,
"F1" => Code::F1,
"F2" => Code::F2,
"F3" => Code::F3,
@@ -209,7 +211,7 @@ impl NativeMenu {
// the application menu
let app_menu = Submenu::new("LightCraft", true);
let about = MenuItem::with_id("app.about", "About LightCraft", true, None);
let settings = MenuItem::with_id(SETTINGS, "Settings…", true, accelerator(SETTINGS_KEY));
let settings = MenuItem::with_id(SETTINGS, app.ui.language.tr("Settings…"), true, accelerator(SETTINGS_KEY));
let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
let _ = app_menu.append_items(&[
&about,
@@ -228,7 +230,7 @@ impl NativeMenu {
for (title, nodes) in &bar {
let sub = Submenu::new(title, true);
if title == "Window" {
if title == app.ui.language.tr("Window") {
let _ = sub.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(Some("Zoom")),
@@ -243,11 +245,15 @@ impl NativeMenu {
.collect();
let nodes = tidy_separators(nodes);
self.append_nodes(&sub, &nodes);
if title == "Window" {
if title == app.ui.language.tr("File") {
// ⌘W, the system's own item
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]);
}
if title == app.ui.language.tr("Window") {
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
sub.set_as_windows_menu_for_nsapp();
}
if title == "Help" {
if title == app.ui.language.tr("Help") {
sub.set_as_help_menu_for_nsapp();
}
let _ = self.menu.append(&sub);
+3
View File
@@ -9,6 +9,8 @@ covers a whole directory), or if a licence file referenced in the Licence column
| Path | Asset | Creator | Source | Licence | Added | Modifications |
|---|---|---|---|---|---|---|
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
| `assets/fonts/BIZUDMincho-Regular.ttf` | BIZ UDMincho Regular | The BIZ UDMincho Project Authors (Morisawa Inc., design TypeBank Co., Ltd.) | https://github.com/google/fonts/tree/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho | OFL-1.1 (`assets/fonts/OFL-BIZUDMincho.txt`) | 2026-10-05 | none |
| `assets/fonts/OFL-BIZUDMincho.txt` | BIZ UDMincho licence | The BIZ UDMincho Project Authors | https://github.com/google/fonts/blob/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho/OFL.txt | OFL-1.1 | 2026-10-05 | none |
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
@@ -17,6 +19,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
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The BIZ UDMincho Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+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 }
+216
View File
@@ -0,0 +1,216 @@
//! 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 {
// as a browse catalogues it: import defaults, a baseline fingerprint
p.develop = Arc::new(p.import_defaults());
p.set_local_baseline();
}
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));
// forget the untouched Local records (folders last browsed long ago; every 7th folder
// recently; every 50th Local record rated), then compact
let folders: std::collections::BTreeSet<String> = cat.photos().filter(|p| p.local).filter_map(|p| folder_of(p)).collect();
let mut ops: Vec<Op> = folders
.iter()
.enumerate()
.map(|(k, f)| Op::SetBrowsed {
folder: f.clone(),
at: Some(if k % 7 == 0 { "2026-09-30T00:00:00" } else { "2026-06-01T00:00:00" }.into()),
})
.collect();
ops.extend(cat.photos().filter(|p| p.local && p.id.0.is_multiple_of(50)).map(|p| Op::SetRating { id: p.id, rating: 2 }).collect::<Vec<_>>());
for op in &ops {
cat.apply(op.clone())?;
}
j.append(&ops)?;
j.snapshot(&cat)?;
let before = j.stats().last_snapshot.bytes;
let t = Instant::now();
let plan = cat.forget_local_plan("2026-10-05T00:00:00", DEFAULT_FORGET_DAYS, &|_| false);
let plan_ms = ms(t);
let t = Instant::now();
let ops = plan.ops();
for op in &ops {
cat.apply(op.clone())?;
}
j.append(&ops)?;
let apply_ms = ms(t);
j.snapshot(&cat)?;
let after = j.stats().last_snapshot.bytes;
println!(
" forget Local: {} of {} local records forgotten (kept: {} recent, {} touched, {} in use); plan {plan_ms:.1} ms, apply+append {apply_ms:.1} ms; snapshot {:.1} MB -> {:.1} MB",
plan.evict.len(),
plan.local,
plan.kept_recent,
plan.kept_touched,
plan.kept_in_use,
before as f64 / 1e6,
after as f64 / 1e6
);
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);
+485 -30
View File
@@ -1,8 +1,9 @@
//! Crash-safe catalog persistence: an append-only op log plus periodic snapshots.
//!
//! Files (in a [`Store`]):
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":{…}}`, the
//! state after op `N`; replaced atomically (temp + fsync + rename).
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":V,"seq":N,"catalog":{…}}`, the
//! state after op `N` in catalog format `V` ([`VERSION`]); replaced atomically (temp + fsync +
//! rename).
//! - `catalog.log` — JSON lines, one per op applied after the snapshot:
//! `{"seq":N,"crc":C,"op":{…}}` where `C` is the CRC-32 of the op's JSON text. Appends are
//! fsynced before [`Journal::append`] returns.
@@ -10,10 +11,55 @@
//! Loading = snapshot + replay of the records with `seq > snapshot seq`. Recovery rules:
//! - a **torn final record** (crash mid-append: truncated line or bad CRC at the end) is dropped
//! and the file is cut back to the last good record, so later appends start on a clean line;
//! - a **failed append** (disk full, a dropped network share) may leave part of its batch in the
//! file: [`Journal::append`] cuts the log back to the last whole record before returning the
//! error (or, if that fails too, before the next append), so the retry — the same ops with the
//! same `seq`s — starts on a clean line. Logs written by builds without that cut-back hold the
//! fragment glued to the first retried record on one line; the loader recognises the whole,
//! CRC-valid record at the end of such a line and skips only the fragment;
//! - records already covered by the snapshot (crash between writing the snapshot and resetting
//! 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.
//!
//! # Format versions
//!
//! The snapshot's `version` is the catalog format, and it covers the log beside it: the log has
//! no header of its own, and is only ever appended to under a snapshot of this build's format.
//!
//! | Version | Written by | Adds |
//! |---|---|---|
//! | (none) | early builds: a log without a snapshot | |
//! | 1 | up to v0.2.0 | |
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
//! serialized field to the catalog, a photo, an album, a stack or develop settings that an older
//! build would misread or silently drop. New fields must deserialize from older data
//! (`#[serde(default)]`), so newer builds keep reading every older format.
//! - **Opening an older format upgrades it**: right after loading, [`Journal::open`] writes a
//! snapshot in this build's format (also for a new library), before anything is appended — so a
//! library is never a mix, and older builds refuse it from then on instead of reading part of it.
//! [`LoadReport::upgraded_from`] says when that happened.
//! - **A newer format is refused, untouched**: a snapshot whose `version` is above [`VERSION`],
//! or a log record whose CRC matches (so it is exactly what was written) but whose op doesn't
//! parse, fails [`Journal::open`] with [`CatalogError::Newer`] before any file is modified — it
//! is never mistaken for a torn tail or damage. (Builds up to v0.2.0 refuse a v2 snapshot with
//! "unsupported format".)
use crate::store::Store;
use crate::{Catalog, CatalogError, Op, Result};
@@ -21,7 +67,9 @@ use crate::{Catalog, CatalogError, Op, Result};
pub const SNAPSHOT: &str = "catalog.snap";
pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
const VERSION: u32 = 1;
/// The catalog format this build writes (and the newest it reads). See the module docs →
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
pub const VERSION: u32 = 2;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
@@ -49,10 +97,75 @@ pub struct LoadReport {
pub failed: usize,
/// Bytes of a torn final record that were dropped.
pub torn_bytes: u64,
/// Partial records left mid-log by a failed append that was retried (written by builds that
/// didn't cut the log back after a failed write): skipped, the retried records were replayed.
pub torn_fragments: usize,
/// The damaged log was preserved under this name.
pub damaged: Option<String>,
/// No catalog files existed (a new library).
pub created: bool,
/// The library was in an older catalog format (`0` = before format versions: a log without a
/// snapshot) and was rewritten in this version's format ([`VERSION`]).
pub upgraded_from: Option<u32>,
}
/// 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 {
@@ -63,6 +176,29 @@ 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 failed append may have left part of its records after `log_bytes`, and cutting them off
/// failed too: the next append cuts the log back to `log_bytes` first.
tail_dirty: bool,
}
/// 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 {
@@ -76,24 +212,93 @@ pub fn encode_record(seq: u64, op: &Op) -> String {
format!("{{\"seq\":{seq},\"crc\":{crc},\"op\":{body}}}")
}
/// Parse one log line (without the newline). `None` if it's malformed or the CRC doesn't match.
/// Parse one log line (without the newline). `None` if it's malformed, the CRC doesn't match, or
/// the op is one this version doesn't know (see [`Journal::open`]).
pub fn decode_record(line: &str) -> Option<(u64, Op)> {
let rest = line.strip_prefix("{\"seq\":")?;
let (seq, rest) = rest.split_once(",\"crc\":")?;
let (crc, rest) = rest.split_once(",\"op\":")?;
let body = rest.strip_suffix('}')?;
let seq: u64 = seq.parse().ok()?;
let crc: u32 = crc.parse().ok()?;
if crc32fast::hash(body.as_bytes()) != crc {
return None;
match decode_line(line) {
Line::Record(seq, op) => Some((seq, op)),
Line::Newer(_) | Line::Bad => None,
}
serde_json::from_str(body).ok().map(|op| (seq, op))
}
/// One log line, decoded.
enum Line {
Record(u64, Op),
/// The CRC matches — the line is exactly what was written — but the op doesn't parse: it
/// was written by a newer LightCraft (an op or field this version doesn't know), not damaged.
Newer(u64),
/// Malformed or a CRC mismatch: torn or damaged.
Bad,
}
fn decode_line(line: &str) -> Line {
let parts = || {
let rest = line.strip_prefix("{\"seq\":")?;
let (seq, rest) = rest.split_once(",\"crc\":")?;
let (crc, rest) = rest.split_once(",\"op\":")?;
let body = rest.strip_suffix('}')?;
Some((seq.parse::<u64>().ok()?, crc.parse::<u32>().ok()?, body))
};
let Some((seq, crc, body)) = parts() else { return Line::Bad };
if crc32fast::hash(body.as_bytes()) != crc {
return Line::Bad;
}
match serde_json::from_str(body) {
Ok(op) => Line::Record(seq, op),
Err(_) => Line::Newer(seq),
}
}
/// The error for a library written by a newer LightCraft.
fn newer(what: String) -> CatalogError {
CatalogError::Newer(format!("{what}; this version reads catalog format v{VERSION} and older"))
}
/// A line that doesn't decode but ends with a whole record: a fragment of a failed append followed
/// by the record that retried it (the retry re-encodes the same ops with the same `seq`s). Returns
/// the fragment's length and the record, if that record continues the log (`seq <= last + 1`).
/// The record's CRC must match, so damage is never mistaken for it.
fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
const START: &[u8] = b"{\"seq\":";
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
(rec.0 <= last + 1).then_some((i, rec))
})
}
/// 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() })
}
/// Just the identification of `catalog.snap` (read before the catalog itself).
#[derive(serde::Deserialize)]
struct SnapFile {
struct SnapHeader {
format: String,
version: u32,
}
/// The whole `catalog.snap` (after [`SnapHeader`] was checked; `format` and `version` are ignored).
#[derive(serde::Deserialize)]
struct SnapFile {
seq: u64,
catalog: Catalog,
}
@@ -106,18 +311,34 @@ impl Journal {
let snap = store.read(SNAPSHOT).map_err(io)?;
let log = store.read(LOG).map_err(io)?;
report.created = snap.is_none() && log.is_none();
let (mut catalog, snapshot_seq) = match snap {
let (mut catalog, snapshot_seq, snapshot_version) = match snap {
Some(bytes) => {
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
if s.format != FORMAT || s.version > VERSION {
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: unsupported format {} v{}", s.format, s.version)));
// the header first: a newer snapshot may not parse as this version's catalog
let h: SnapHeader = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
if h.format != FORMAT {
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: not a LightCraft catalog (format {:?})", h.format)));
}
(s.catalog, s.seq)
if h.version > VERSION {
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
}
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
(s.catalog, s.seq, Some(h.version))
}
None => (Catalog::new(), 0),
None => (Catalog::new(), 0, None),
};
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,
tail_dirty: false,
};
let log = log.unwrap_or_default();
// Scan records; `good_end` is the byte offset just past the last good record.
@@ -136,7 +357,22 @@ impl Journal {
good_end = next;
continue;
}
match line.and_then(decode_record) {
let mut record = line.and_then(decode_record);
if record.is_none()
&& let Some((at, r)) = resync(&log[pos..line_end], j.seq)
{
// the torn start of a failed append, then the retried records (older builds
// appended them right after the fragment)
log::warn!("catalog log: skipped a partial record ({at} bytes) left by a failed write");
report.torn_fragments += 1;
record = Some(r);
}
if record.is_none()
&& let Some(Line::Newer(seq)) = line.map(decode_line)
{
return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know")));
}
match record {
Some((seq, op)) if seq <= j.seq => {
// already in the snapshot
let _ = op;
@@ -152,7 +388,15 @@ impl Journal {
}
_ => {
// bad record (or a gap): torn tail if nothing good follows, else damage
let rest_has_good = log[next..].split(|b| *b == b'\n').any(|l| std::str::from_utf8(l).ok().and_then(decode_record).is_some());
let mut rest_has_good = false;
for l in log[next..].split(|b| *b == b'\n') {
match std::str::from_utf8(l).map(|l| decode_line(l.trim_end_matches('\r'))) {
Ok(Line::Record(..)) => rest_has_good = true,
// refused before anything is modified
Ok(Line::Newer(seq)) => return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know"))),
Ok(Line::Bad) | Err(_) => {}
}
}
if rest_has_good {
damaged_at = Some(pos);
}
@@ -176,10 +420,34 @@ impl Journal {
report.torn_bytes = (log.len() - good_end) as u64;
log::warn!("catalog log: dropped a torn final record ({} bytes)", report.torn_bytes);
j.store.truncate(LOG, good_end as u64).map_err(io)?;
j.log_bytes = good_end as u64;
} else if needs_newline {
j.store.append(LOG, b"\n").map_err(io)?;
j.log_bytes = good_end as u64 + 1;
} else {
j.log_bytes = good_end as u64;
}
}
// Bring an older (or new, or snapshot-less) library to this version's format before
// anything is appended: a snapshot in this format makes older builds refuse the library
// instead of misreading ops or dropping fields they don't know.
if report.created {
// tiny: written directly, not counted as a compaction
let empty = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":0,\"catalog\":{}}}\n", catalog.to_snapshot());
if let Err(e) = j.store.write_atomic(SNAPSHOT, empty.as_bytes()) {
log::warn!("catalog: can't write the new library's snapshot: {e}");
}
} else if report.damaged.is_none() && snapshot_version != Some(VERSION) {
report.upgraded_from = Some(snapshot_version.unwrap_or(0));
match j.snapshot(&catalog) {
Ok(()) => {
if let Some(v) = report.upgraded_from {
log::info!("catalog: upgraded from format v{v} to v{VERSION}");
}
}
// nothing lost: the old files are intact and still load; retried on next open
Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"),
}
j.log_bytes = good_end as u64;
}
catalog.revision = 0;
Ok((j, catalog, report))
@@ -190,6 +458,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 {
@@ -197,29 +466,202 @@ impl Journal {
buf.push_str(&encode_record(seq, op));
buf.push('\n');
}
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
if self.tail_dirty {
self.store.truncate(LOG, self.log_bytes).map_err(io)?;
self.tail_dirty = false;
}
if let Err(e) = self.store.append(LOG, buf.as_bytes()) {
// Part of the batch may be in the file. Cut it back to the last whole record, so the
// retry (same seqs) starts on a clean line instead of after a fragment that would
// read back as damage.
if let Err(t) = self.store.truncate(LOG, self.log_bytes) {
log::error!("catalog: can't cut the log back after a failed append: {t}");
self.tail_dirty = true;
}
return Err(io(e));
}
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)?;
self.snapshot_with_unlogged(catalog, 0)
}
/// [`Journal::snapshot`] of a catalog that also reflects `unlogged` ops applied after the
/// last appended one but never written to the log (their append failed). The snapshot counts
/// them (it holds the state after op `seq + unlogged`), so they are saved and must not be
/// appended afterwards. Once the snapshot is durable [`Journal::seq`] includes them, even if
/// resetting the log then fails (the error is still returned).
pub fn snapshot_with_unlogged(&mut self, catalog: &Catalog, unlogged: u64) -> Result<()> {
// 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 seq = self.seq + unlogged;
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
Ok(t) => t,
Err(e) => {
// nothing lost (the log is whole); don't retry on every append
self.stats.failed_snapshots += 1;
self.retry_at_records = self.log_records + self.policy.max_records;
return Err(io(e));
}
};
// the snapshot is durable: it holds every op up to `seq`
self.seq = seq;
self.snapshot_seq = seq;
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)?;
self.snapshot_seq = self.seq;
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.log_records = 0;
self.log_bytes = 0;
self.retry_at_records = 0;
self.tail_dirty = false;
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;
self.tail_dirty = false;
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 +680,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}");
}
}
}
+49 -27
View File
@@ -51,38 +51,60 @@ pub struct KeywordNode {
impl Catalog {
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
fn insert(nodes: &mut Vec<KeywordNode>, parts: &[&str], prefix: &str, photo: u64, seen: &mut std::collections::HashSet<(String, u64)>) {
let Some((first, rest)) = parts.split_first() else { return };
let path = if prefix.is_empty() { first.to_string() } else { format!("{prefix}|{first}") };
let i = match nodes.iter().position(|n| n.name.eq_ignore_ascii_case(first)) {
Some(i) => i,
None => {
nodes.push(KeywordNode { name: first.to_string(), path: path.clone(), count: 0, children: Vec::new() });
nodes.len() - 1
}
};
if seen.insert((path.to_lowercase(), photo)) {
nodes[i].count += 1;
}
let p = nodes[i].path.clone();
insert(&mut nodes[i].children, rest, &p, photo, seen);
use std::collections::{HashMap, HashSet};
// every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and
// path as first written, and the photos counted (once per photo, even when two of its
// keywords share a parent)
struct Level {
name: String,
path: String,
count: usize,
parent: Option<String>,
}
fn sort(nodes: &mut [KeywordNode]) {
nodes.sort_by_key(|n| n.name.to_lowercase());
for n in nodes {
sort(&mut n.children);
}
}
let mut roots = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut levels: HashMap<String, Level> = HashMap::new();
let mut this_photo: HashSet<String> = HashSet::new();
for p in self.photos().filter(|p| p.in_library()) {
this_photo.clear();
for k in &p.meta.keywords {
let parts: Vec<&str> = k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect();
insert(&mut roots, &parts, "", p.id.0, &mut seen);
let mut path = String::new();
let mut lower = String::new();
let mut parent: Option<String> = None;
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
}
path.push_str(part);
lower.push_str(&part.to_lowercase());
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
name: part.to_string(),
path: path.clone(),
count: 0,
parent: parent.clone(),
});
if this_photo.insert(lower.clone()) {
l.count += 1;
}
parent = Some(lower.clone());
}
}
}
sort(&mut roots);
roots
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
for (lower, l) in &levels {
kids.entry(l.parent.clone()).or_default().push(lower.clone());
}
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
list.sort_by_key(|l| levels[l].name.to_lowercase());
list.into_iter()
.map(|l| {
let lv = &levels[&l];
let children = build(Some(l.clone()), levels, kids);
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
})
.collect()
}
build(None, &levels, &mut kids)
}
/// `SetMeta` ops for every photo whose keywords `f` changes.
+96 -4
View File
@@ -5,13 +5,23 @@
//! snapshot — crash-safe and diff-friendly (see [`journal`]);
//! - **undo/redo**: the engine keeps inverse ops;
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
//!
//! **Catalog format version** ([`journal::VERSION`], see [`journal`] → *Format versions*): adding
//! an [`Op`] variant or a serialized field means bumping it. Newer builds read every older format
//! (and upgrade it on open); older builds refuse a newer library with [`CatalogError::Newer`]
//! instead of reading part of it.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod dates;
pub mod journal;
pub mod keywords;
pub mod local;
pub mod lock;
pub mod model;
pub mod query;
pub mod rules;
pub mod safe_file;
pub mod stacks;
pub mod store;
@@ -19,11 +29,14 @@ 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 local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
pub use lock::{LibraryLock, LockError, LockOwner};
pub use model::*;
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
pub use rules::{Match, Rule, RuleSet};
use serde::{Deserialize, Serialize};
pub use store::{FsStore, MemStore, Store};
@@ -41,6 +54,10 @@ pub enum CatalogError {
Corrupt(String),
#[error("catalog storage: {0}")]
Io(String),
/// The library was written by a newer LightCraft (a newer catalog format, or a change this
/// version doesn't know). Nothing was read into the session and nothing was modified.
#[error("this library was written by a newer version of LightCraft ({0}); update LightCraft to open it. The library was left unchanged.")]
Newer(String),
}
pub type Result<T> = std::result::Result<T, CatalogError>;
@@ -147,6 +164,11 @@ pub enum Op {
id: PhotoId,
captured: Option<String>,
},
/// Assisted culling scores.
SetAnalysis {
id: PhotoId,
analysis: Option<crate::Analysis>,
},
/// Rename a photo: its file name and the source it points to. Applying the op never touches
/// the disk — the engine moves the file before it commits (and on undo/redo).
SetFile {
@@ -160,12 +182,34 @@ pub enum Op {
id: PhotoId,
file_name: String,
source: Source,
/// The file's format when it changes too (Convert to DNG).
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<String>,
},
/// What a photo's file is now (after it changed on disk: Reload).
SetContent {
id: PhotoId,
width: u32,
height: u32,
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 {
label: ColorLabel,
name: Option<String>,
},
/// When a Local folder was last browsed (ISO 8601; `None` = forget the time). Not an undo
/// step: it drives forgetting untouched Local records (see [`local`]).
SetBrowsed {
folder: String,
at: Option<String>,
},
/// Several ops as one step (undo applies the inverses in reverse).
Batch {
ops: Vec<Op>,
@@ -185,6 +229,9 @@ pub struct Catalog {
/// Custom colour label names.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
label_names: BTreeMap<ColorLabel, String>,
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
browsed: BTreeMap<String, String>,
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
@@ -233,6 +280,11 @@ impl Catalog {
pub fn albums(&self) -> impl Iterator<Item = &Album> {
self.albums.values()
}
/// The Quick Collection, once something was added to it.
pub fn quick_collection(&self) -> Option<AlbumId> {
self.albums.values().find(|a| a.quick).map(|a| a.id)
}
/// Albums (regular and smart) that contain the photo.
pub fn albums_of(&self, id: PhotoId) -> Vec<AlbumId> {
let Some(p) = self.photos.get(&id) else { return Vec::new() };
@@ -261,7 +313,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,
}
@@ -288,6 +341,11 @@ impl Catalog {
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
self.label_names.get(&l).map(String::as_str)
}
/// The label a name stands for: a custom name first, then a colour's own name (any case).
pub fn label_from_name(&self, name: &str) -> Option<ColorLabel> {
let name = name.trim();
self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
}
// ---- writes
@@ -466,6 +524,10 @@ impl Catalog {
s.collapsed = collapsed;
old
}
Op::SetAnalysis { id, analysis } => {
let p = self.photo_mut(id)?;
Op::SetAnalysis { id, analysis: std::mem::replace(&mut p.analysis, analysis) }
}
Op::SetCaptured { id, captured } => {
if let Some(c) = &captured
&& stacks::iso_seconds(c).is_none()
@@ -475,10 +537,22 @@ impl Catalog {
let p = self.photo_mut(id)?;
Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) }
}
Op::Relink { id, file_name, source } => {
Op::Relink { id, file_name, source, format } => {
let p = self.photo_mut(id)?;
let old_name = std::mem::replace(&mut p.file_name, file_name);
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source) }
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, preview_only } => {
let p = self.photo_mut(id)?;
Op::SetContent {
id,
width: std::mem::replace(&mut p.width, width),
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 } => {
if file_name.trim().is_empty() {
@@ -496,6 +570,14 @@ impl Catalog {
};
Op::SetLabelName { label, name: old }
}
Op::SetBrowsed { folder, at } => {
let folder = crate::query::folder_key(&folder);
let old = match at {
Some(t) => self.browsed.insert(folder.clone(), t),
None => self.browsed.remove(&folder),
};
Op::SetBrowsed { folder, at: old }
}
Op::Batch { ops } => {
let mut inverses = Vec::with_capacity(ops.len());
for op in ops {
@@ -571,4 +653,14 @@ impl Catalog {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_background;
#[cfg(test)]
mod tests_format_version;
#[cfg(test)]
mod tests_journal;
#[cfg(test)]
mod tests_local;
#[cfg(test)]
mod tests_lock;
#[cfg(test)]
mod tests_torn_append;
+304
View File
@@ -0,0 +1,304 @@
//! Forgetting untouched Local browse records.
//!
//! Browsing a folder (Local) catalogues its photos as `local` records so they can be shown,
//! rated and edited in place. Most are only ever looked at, and on a large disk they can be most
//! of the catalog. A record is **forgotten** (removed from the catalog, never from the disk —
//! files and XMP sidecars are not touched) when all of these hold:
//!
//! - it is a Local record ([`Photo::local`]) of a file, not deleted, not a virtual copy;
//! - its folder was last browsed more than `days` ago ([`Catalog::last_browsed`]). A folder with
//! no recorded time (a catalog from before this existed) counts as browsed *now*: it is stamped
//! with the current time and nothing in it is forgotten this time;
//! - it is **untouched** ([`Photo::untouched_local`]): nothing about it changed since it was
//! browsed — see below;
//! - nothing refers to it: no album holds it or shows it as cover, no stack holds it, no virtual
//! copy was made of it; and the caller (the engine) says it isn't in use (visible, selected,
//! in the undo history, has a smart preview…).
//!
//! **Untouched.** A record catalogued by a browse carries a fingerprint of its whole state at
//! that moment ([`Photo::local_baseline`]). It is untouched while its state still hashes to that
//! fingerprint, i.e. *any* change since — develop settings or history, versions, rating, flag,
//! label, any metadata (title, caption, keywords, location, copyright…), capture time, crop,
//! rename or relink, culling scores, file content — keeps it. Records without a fingerprint
//! (browsed before fingerprints existed) are judged by their data alone and kept unless every
//! user-editable field is still empty / default: no rating, flag or label; develop settings equal
//! to the import defaults with no history, versions or edit time; no title, caption, alt text,
//! description, keywords, creator, copyright, rights, location or GPS; no culling scores.
//! When in doubt a record is kept.
//!
//! Browsing the folder again catalogues its files afresh (new records, new fingerprints).
use std::collections::{BTreeMap, BTreeSet, HashSet};
use serde::Serialize;
use crate::{Catalog, Op, Photo, PhotoId, Source};
/// Default for how long a Local folder may go unbrowsed before its untouched records are
/// forgotten (days).
pub const DEFAULT_FORGET_DAYS: u32 = 30;
/// Records removed per logged batch (keeps log lines a reasonable size).
const BATCH: usize = 2000;
/// 64-bit FNV-1a over everything written to it (stable across builds and platforms, unlike
/// `std`'s hasher), so a photo is hashed as it serialises, without a buffer.
struct Fnv(u64);
impl std::io::Write for Fnv {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
for b in buf {
self.0 ^= u64::from(*b);
self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// The folder a file-backed photo is in (`None` for demo photos).
pub fn folder_of(p: &Photo) -> Option<String> {
match &p.source {
// `/` and `\` are both separators whatever the platform (a catalog can move between them)
Source::File { path } => {
let key = crate::query::folder_key(path);
key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { dir.to_string() })
}
Source::Demo { .. } => None,
}
}
impl Photo {
/// A hash of everything about the photo except [`Photo::local_baseline`] itself.
pub fn fingerprint(&self) -> u64 {
let mut h = Fnv(0xcbf2_9ce4_8422_2325);
let r = if self.local_baseline.is_some() {
let mut p = self.clone();
p.local_baseline = None;
serde_json::to_writer(&mut h, &p)
} else {
serde_json::to_writer(&mut h, self)
};
// (serialising a photo can't fail; if it ever did, no baseline would match: kept)
if r.is_err() { 0 } else { h.0 }
}
/// Record the current state as the browse baseline (done when a browse catalogues it).
pub fn set_local_baseline(&mut self) {
self.local_baseline = None;
self.local_baseline = Some(self.fingerprint());
}
/// A Local record nothing was changed on since it was browsed (see the module docs). Says
/// nothing about albums, stacks or virtual copies referring to it.
pub fn untouched_local(&self) -> bool {
if !self.local || self.deleted || self.copy_of.is_some() || self.copy_name.is_some() || folder_of(self).is_none() {
return false;
}
match self.local_baseline {
Some(b) => self.fingerprint() == b,
None => self.looks_unedited(),
}
}
/// Every user-editable field is still empty / default (records without a baseline).
fn looks_unedited(&self) -> bool {
let m = &self.meta;
self.rating == 0
&& self.flag == crate::Flag::None
&& self.label.is_none()
&& self.edited.is_none()
&& self.history.is_empty()
&& self.versions.is_empty()
&& *self.develop == self.import_defaults()
&& self.analysis.is_none()
&& m.title.is_empty()
&& m.caption.is_empty()
&& m.alt_text.is_empty()
&& m.extended_description.is_empty()
&& m.keywords.is_empty()
&& m.creator.is_empty()
&& m.copyright.is_empty()
&& m.copyright_status.is_unknown()
&& m.usage_terms.is_empty()
&& m.copyright_url.is_empty()
&& m.location.is_empty()
&& m.city.is_empty()
&& m.state.is_empty()
&& m.country.is_empty()
&& m.gps.is_none()
}
}
/// What forgetting Local records would do / did.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ForgetPlan {
/// Local records looked at.
pub local: usize,
/// Records to forget.
pub evict: Vec<PhotoId>,
/// Kept: their folder was browsed within the period (or has no time yet).
pub kept_recent: usize,
/// Kept: changed by the user since they were browsed.
pub kept_touched: usize,
/// Kept: in an album, a stack, the source of a virtual copy, or in use.
pub kept_in_use: usize,
/// Folders with Local records but no last-browsed time: stamped now.
pub stamp: Vec<String>,
/// Folders whose last Local record goes: their time is dropped.
pub unstamp: Vec<String>,
/// The time stamps are given.
#[serde(skip)]
pub now: String,
}
impl ForgetPlan {
/// The ops that carry the plan out (empty when there is nothing to do).
pub fn ops(&self) -> Vec<Op> {
let mut ops: Vec<Op> = self.stamp.iter().map(|f| Op::SetBrowsed { folder: f.clone(), at: Some(self.now.clone()) }).collect();
ops.extend(self.evict.chunks(BATCH).map(|c| Op::Batch { ops: c.iter().map(|id| Op::RemovePhoto { id: *id }).collect() }));
ops.extend(self.unstamp.iter().map(|f| Op::SetBrowsed { folder: f.clone(), at: None }));
ops
}
}
impl Catalog {
/// When `folder` was last browsed (ISO 8601), if known.
pub fn last_browsed(&self, folder: &str) -> Option<&str> {
self.browsed.get(&crate::query::folder_key(folder)).map(String::as_str)
}
/// Folders with a last-browsed time.
pub fn browsed_folders(&self) -> &BTreeMap<String, String> {
&self.browsed
}
/// Plan forgetting untouched Local records whose folder wasn't browsed for `days` days (as of
/// `now`, ISO 8601). `in_use` keeps records the caller still needs. `days == 0` (never) or an
/// unreadable `now` forgets nothing. See the module docs.
pub fn forget_local_plan(&self, now: &str, days: u32, in_use: &dyn Fn(&Photo) -> bool) -> ForgetPlan {
let mut plan = ForgetPlan { now: now.to_string(), ..Default::default() };
let now_s = crate::stacks::iso_seconds(now);
let max_age = i64::from(days) * 86_400;
let mut referenced: HashSet<PhotoId> = HashSet::new();
for a in self.albums.values() {
referenced.extend(a.photos.iter().copied());
referenced.extend(a.cover);
}
for s in self.stacks.values() {
referenced.extend(s.photos.iter().copied());
}
referenced.extend(self.photos.values().filter_map(|p| p.copy_of));
// the fingerprints are the costly part: work them out for the candidates in parallel
let candidates: Vec<&Photo> = self
.photos
.values()
.filter(|p| p.local && !referenced.contains(&p.id))
.filter(|p| folder_of(p).and_then(|f| self.browsed.get(&f)).is_some_and(|at| is_old(at, now_s, days, max_age)))
.map(|p| &**p)
.collect();
let untouched: HashSet<PhotoId> = untouched_of(&candidates);
let mut stamp = BTreeSet::new();
let mut remaining: BTreeSet<String> = BTreeSet::new();
for p in self.photos.values().filter(|p| p.local) {
plan.local += 1;
let Some(folder) = folder_of(p) else {
plan.kept_in_use += 1;
continue;
};
let recent = match (self.browsed.get(&folder), now_s) {
(None, _) => {
stamp.insert(folder.clone());
true
}
(Some(at), _) => !is_old(at, now_s, days, max_age),
};
let keep = if recent {
plan.kept_recent += 1;
true
} else if referenced.contains(&p.id) || in_use(p) {
plan.kept_in_use += 1;
true
} else if !untouched.contains(&p.id) {
plan.kept_touched += 1;
true
} else {
plan.evict.push(p.id);
false
};
if keep {
remaining.insert(folder);
}
}
plan.stamp = stamp.into_iter().collect();
plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect();
plan
}
}
/// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable).
fn is_old(at: &str, now_s: Option<i64>, days: u32, max_age: i64) -> bool {
days > 0 && matches!((now_s, crate::stacks::iso_seconds(at)), (Some(n), Some(a)) if n - a >= max_age)
}
/// The untouched ones among `photos` ([`Photo::untouched_local`]), checked on several threads
/// where there are many.
fn untouched_of(photos: &[&Photo]) -> HashSet<PhotoId> {
let check = |chunk: &[&Photo]| chunk.iter().filter(|p| p.untouched_local()).map(|p| p.id).collect::<Vec<_>>();
#[cfg(not(target_arch = "wasm32"))]
{
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(8);
if threads > 1 && photos.len() >= 256 {
let size = photos.len().div_ceil(threads);
return std::thread::scope(|sc| {
let jobs: Vec<_> = photos.chunks(size).map(|c| sc.spawn(move || check(c))).collect();
// a job that panicked (it can't) leaves its photos out: they are kept
jobs.into_iter().filter_map(|j| j.join().ok()).flatten().collect()
});
}
}
check(photos).into_iter().collect()
}
impl Op {
/// Every photo the op refers to.
pub fn photo_ids(&self, out: &mut impl FnMut(PhotoId)) {
match self {
Op::AddPhoto { photo } => out(photo.id),
Op::RemovePhoto { id }
| Op::SetRating { id, .. }
| Op::SetFlag { id, .. }
| Op::SetLabel { id, .. }
| Op::SetDevelop { id, .. }
| Op::SetMeta { id, .. }
| Op::SetDeleted { id, .. }
| Op::SetLocal { id, .. }
| Op::SetVersions { id, .. }
| Op::SetHistory { id, .. }
| Op::PushHistory { id, .. }
| Op::SetCaptured { id, .. }
| Op::SetAnalysis { id, .. }
| Op::SetFile { id, .. }
| Op::Relink { id, .. }
| Op::SetContent { id, .. } => out(*id),
Op::AddAlbum { album } => {
album.photos.iter().for_each(|p| out(*p));
album.cover.into_iter().for_each(&mut *out);
}
Op::SetAlbumPhotos { photos, .. } | Op::SetStack { photos, .. } => photos.iter().for_each(|p| out(*p)),
Op::SetAlbumCover { cover, .. } => cover.iter().for_each(|p| out(*p)),
Op::AddStack { stack } => stack.photos.iter().for_each(|p| out(*p)),
Op::Batch { ops } => ops.iter().for_each(|o| o.photo_ids(out)),
Op::RemoveAlbum { .. }
| Op::RenameAlbum { .. }
| Op::MoveAlbum { .. }
| Op::SetAlbumRules { .. }
| Op::RemoveStack { .. }
| Op::SetLabelName { .. }
| Op::SetBrowsed { .. } => {}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
//! One process per library: an exclusive OS lock on `<library>/catalog.lock`, held for as long as
//! the library is open (issue #99).
//!
//! Two processes on the same library (the app and `lightcraft-cli --library`, two app instances,
//! two computers on a shared folder) would each keep their own catalog and `seq` counter, and
//! whichever wrote the last snapshot would silently drop the other's edits. So opening a library
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
//!
//! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]):
//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when
//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock
//! that's actually held refuses the open.
//! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for
//! the error message. It's a separate file because a Windows lock also blocks reading the
//! locked file. It's only a hint: it may be missing or left over from a crash.
//! - File systems without lock support (some network shares): the library opens unlocked, with a
//! warning — refusing it would lock the user out of their own library. Locks over SMB/NFS are
//! advisory and depend on the server; the owner file is then the only hint.
//! - Browser storage (OPFS) has no file locks: see the web host for its own guard.
use std::fs::{File, OpenOptions, TryLockError};
use std::io;
use std::path::{Path, PathBuf};
/// The lock file in the library directory.
pub const LOCK: &str = "catalog.lock";
/// Who holds the lock (JSON, see [`LockOwner`]).
pub const OWNER: &str = "catalog.lock.owner";
/// Who has the library open (as written in [`OWNER`]).
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct LockOwner {
pub pid: u32,
/// Computer name, if known.
pub host: String,
/// The program (`LightCraft`, `lightcraft-cli`…) and its version.
pub program: String,
pub version: String,
/// When it opened the library (seconds since 1970).
pub since: u64,
}
impl LockOwner {
/// This process, as `program`.
pub fn this_process(program: &str) -> LockOwner {
LockOwner {
pid: std::process::id(),
host: host_name(),
program: program.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
since: web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
}
}
/// "LightCraft 0.3.0 (process 123 on studio-mac)".
pub fn describe(&self) -> String {
let program = if self.program.is_empty() { "another LightCraft" } else { self.program.as_str() };
let version = if self.version.is_empty() { String::new() } else { format!(" {}", self.version) };
let host = if self.host.is_empty() {
String::new()
} else if self.host == host_name() {
" on this computer".to_string()
} else {
format!(" on {}", self.host)
};
format!("{program}{version} (process {}{host})", self.pid)
}
}
/// The computer's name, best effort (empty if unknown).
fn host_name() -> String {
let env = ["COMPUTERNAME", "HOSTNAME"].iter().find_map(|k| std::env::var(k).ok().filter(|h| !h.trim().is_empty()));
env.or_else(|| std::fs::read_to_string("/etc/hostname").ok()).map(|h| h.trim().to_string()).filter(|h| !h.is_empty()).unwrap_or_default()
}
#[derive(Debug, thiserror::Error)]
pub enum LockError {
/// Another process has the library open.
#[error("This library is already open in {}. Close it there first.", .0.as_ref().map(LockOwner::describe).unwrap_or_else(|| "another LightCraft program".into()))]
InUse(Option<LockOwner>),
}
/// The exclusive lock on a library directory; released on drop (and by the OS if the process
/// dies).
#[derive(Debug)]
pub struct LibraryLock {
dir: PathBuf,
/// `None` when the file system can't lock (the library is open unlocked).
_file: Option<File>,
held: bool,
}
impl LibraryLock {
/// Lock `dir` (which must exist) for this process. Fails only when another process holds it.
pub fn acquire(dir: &Path, program: &str) -> Result<LibraryLock, LockError> {
let unlocked = |why: io::Error| {
log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display());
LibraryLock { dir: dir.to_path_buf(), _file: None, held: false }
};
let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) {
Ok(f) => f,
Err(e) => return Ok(unlocked(e)),
};
match file.try_lock() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => {
let owner = std::fs::read(dir.join(OWNER)).ok().and_then(|b| serde_json::from_slice::<LockOwner>(&b).ok());
return Err(LockError::InUse(owner));
}
Err(TryLockError::Error(e)) => return Ok(unlocked(e)),
}
let owner = LockOwner::this_process(program);
if let Ok(json) = serde_json::to_vec_pretty(&owner)
&& let Err(e) = std::fs::write(dir.join(OWNER), json)
{
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
}
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
}
/// The OS lock is held (`false`: the file system can't lock; opened unprotected).
pub fn held(&self) -> bool {
self.held
}
pub fn dir(&self) -> &Path {
&self.dir
}
}
impl Drop for LibraryLock {
/// Remove the owner note while still holding the lock (so it can't delete the next holder's),
/// then the file handle closes and the OS releases the lock.
fn drop(&mut self) {
if self.held {
let _ = std::fs::remove_file(self.dir.join(OWNER));
}
}
}
+104 -1
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>,
}
@@ -197,6 +263,32 @@ pub struct Photo {
/// unedited, and Reset returns to them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import_look: Option<Arc<DevelopSettings>>,
/// 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>,
/// A Local record: the fingerprint of its state when the browse catalogued it, to tell
/// whether the user changed anything since (see [`crate::local`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_baseline: Option<u64>,
}
/// What assisted culling measured on a photo.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Analysis {
/// Focus: 0 (blurred) .. 100 (crisp), from the detail in the photo's sharpest area.
pub sharpness: f32,
/// Exposure: share of clipped pixels, 0..1 (shadows + highlights).
pub clipped: f32,
/// Similar-shot group (burst), when the photo has neighbours that look alike.
pub group: Option<u32>,
/// The best photo of its group.
pub best: bool,
}
impl Photo {
@@ -229,8 +321,16 @@ impl Photo {
copy_of: None,
copy_name: None,
import_look: None,
analysis: None,
preview_only: None,
local_baseline: 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 {
@@ -283,12 +383,15 @@ pub struct Album {
/// live; `photos` stays empty.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smart: Option<Box<crate::Filter>>,
/// The Quick Collection: a regular album that B adds to (one per library).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub quick: bool,
}
impl Album {
/// A regular (manual) album.
pub fn new(id: AlbumId, name: impl Into<String>) -> Album {
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None }
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false }
}
pub fn is_smart(&self) -> bool {
self.smart.is_some()
+110 -1
View File
@@ -24,6 +24,12 @@ pub struct Filter {
pub rating_op: RatingOp,
pub flag: Option<Flag>,
pub label: Option<ColorLabel>,
/// Only these photos (Find Similar results…); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub only: Vec<PhotoId>,
/// Any of these labels (the filter bar's multi-select); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<ColorLabel>,
pub kind: Option<MediaKind>,
pub edited: Option<bool>,
pub album: Option<AlbumId>,
@@ -40,11 +46,18 @@ pub struct Filter {
/// `dateTo` as a prefix upper bound (`2026-04` includes all of April).
pub date_from: Option<String>,
pub date_to: Option<String>,
/// Import date prefix (Recently Added).
/// Import date prefix.
pub imported: Option<String>,
/// Imported at or after this time (ISO; Recently Added).
pub imported_from: Option<String>,
/// Photo Merge results: `hdr`, `panorama`, `hdrPanorama` or `any` (see [`merged_kind`]).
pub merged: Option<String>,
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
pub folder: Option<String>,
pub subfolders: bool,
/// Smart-album rules (all / any / none, nested groups; see [`crate::rules`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_set: Option<crate::RuleSet>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -130,6 +143,9 @@ impl Filter {
if let Some(l) = self.label {
v.push(format!("label {}", format!("{l:?}").to_lowercase()));
}
if !self.labels.is_empty() {
v.push(format!("label {}", self.labels.iter().map(|l| format!("{l:?}").to_lowercase()).collect::<Vec<_>>().join(" or ")));
}
if let Some(k) = self.kind {
v.push(format!("kind {}", format!("{k:?}").to_lowercase()));
}
@@ -152,9 +168,17 @@ impl Filter {
if !self.text.trim().is_empty() {
v.push(format!("“{}”", self.text.trim()));
}
if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
v.push(rs.describe());
}
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;
@@ -186,6 +210,23 @@ impl Filter {
if self.edited.is_some_and(|e| e != p.is_edited()) {
return false;
}
if let Some(rs) = &self.rule_set
&& !rs.matches(p, cat)
{
return false;
}
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
return false;
}
if !self.only.is_empty() && !self.only.contains(&p.id) {
return false;
}
if let Some(want) = &self.merged {
match merged_kind(&p.file_name) {
Some(k) if want == "any" || want == k => {}
_ => return false,
}
}
if let Some(a) = self.album
&& !cat.album_contains(a, p)
{
@@ -216,6 +257,11 @@ impl Filter {
{
return false;
}
if let Some(d) = &self.imported_from
&& p.imported.as_str() < d.as_str()
{
return false;
}
if let Some(k) = &self.keyword
&& !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k))
{
@@ -304,3 +350,66 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false };
!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`.
pub fn merged_kind(file_name: &str) -> Option<&'static str> {
let stem = file_name.rsplit_once('.').map_or(file_name, |(s, _)| s).to_ascii_lowercase();
let stem = stem.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-' || c == ' ');
if stem.ends_with("-hdr-pano") {
Some("hdrPanorama")
} else if stem.ends_with("-hdr") {
Some("hdr")
} else if stem.ends_with("-pano") {
Some("panorama")
} else {
None
}
}
+491
View File
@@ -0,0 +1,491 @@
//! Smart-album rules: a list of conditions matched all / any / none, with nested groups.
//!
//! Each [`Rule`] is `{field, op, value}`; [`FIELDS`] lists the fields with their kind, which
//! decides the operators ([`ops_for`]). Text compares case-insensitively; dates are ISO strings
//! compared by prefix; "in the last N days/weeks/months/years" is relative to [`now`].
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{Catalog, Photo};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Match {
#[default]
All,
Any,
None,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct RuleSet {
#[serde(default, rename = "match")]
pub mode: Match,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Rule {
/// A nested group with its own match mode.
Group { group: RuleSet },
Field {
field: String,
op: String,
#[serde(default)]
value: Value,
},
}
/// How a field's value is compared.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Text,
/// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none).
Keywords,
Number,
Date,
/// One of a fixed set (`choices`).
Choice(&'static [&'static str]),
Bool,
}
/// The fields rules can test: (id, label, kind).
pub const FIELDS: &[(&str, &str, Kind)] = &[
("rating", "Rating", Kind::Number),
("flag", "Pick Flag", Kind::Choice(&["pick", "reject", "none"])),
("label", "Color Label", Kind::Choice(&["red", "yellow", "green", "blue", "purple", "none"])),
("kind", "File Type", Kind::Choice(&["image", "raw", "video"])),
("edited", "Has Edits", Kind::Bool),
("keywords", "Keywords", Kind::Keywords),
("text", "Any Searchable Text", Kind::Text),
("fileName", "Filename", Kind::Text),
("format", "File Format", Kind::Text),
("title", "Title", Kind::Text),
("caption", "Caption", Kind::Text),
("camera", "Camera", Kind::Text),
("lens", "Lens", Kind::Text),
("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),
("iso", "ISO Speed", Kind::Number),
("aperture", "Aperture", Kind::Number),
("focalLength", "Focal Length", Kind::Number),
("megapixels", "Megapixels", Kind::Number),
("hasGps", "Has GPS", Kind::Bool),
("virtualCopy", "Virtual Copy", Kind::Bool),
("album", "Album", Kind::Number),
("sharpness", "Focus (assisted culling)", Kind::Number),
("bestOfGroup", "Best of Similar Shots", Kind::Bool),
];
/// The operators for a field kind: (id, label).
pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] {
match kind {
Kind::Text => &[
("contains", "contains"),
("notContains", "doesn't contain"),
("is", "is"),
("isNot", "isn't"),
("startsWith", "starts with"),
("endsWith", "ends with"),
("isEmpty", "is empty"),
("isNotEmpty", "isn't empty"),
],
Kind::Keywords => &[
("contains", "contains"),
("notContains", "doesn't contain"),
("is", "is"),
("startsWith", "starts with"),
("isEmpty", "are empty"),
("isNotEmpty", "aren't empty"),
],
Kind::Number => {
&[("is", "is"), ("isNot", "isn't"), ("gte", "is ≥"), ("lte", "is ≤"), ("gt", "is >"), ("lt", "is <"), ("between", "is between")]
}
Kind::Date => &[
("is", "is"),
("after", "is after"),
("before", "is before"),
("between", "is between"),
("inLast", "is in the last"),
("notInLast", "isn't in the last"),
("isEmpty", "is unknown"),
],
Kind::Choice(_) => &[("is", "is"), ("isNot", "isn't")],
Kind::Bool => &[("is", "is")],
}
}
pub fn field_kind(field: &str) -> Option<Kind> {
FIELDS.iter().find(|f| f.0 == field).map(|f| f.2)
}
thread_local! {
static NOW: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
}
/// Set the time "in the last…" rules count back from (the engine's clock; tests).
pub fn set_now(iso: Option<String>) {
NOW.with(|n| *n.borrow_mut() = iso);
}
/// The current time as ISO 8601: [`set_now`]'s, else the system clock (UTC).
pub fn now() -> String {
if let Some(n) = NOW.with(|n| n.borrow().clone()) {
return n;
}
#[cfg(not(target_arch = "wasm32"))]
{
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
crate::dates::civil(secs)
}
#[cfg(target_arch = "wasm32")]
{
"2026-01-01T00:00:00".to_string()
}
}
fn lower(v: &Value) -> String {
match v {
Value::String(s) => s.trim().to_lowercase(),
Value::Null => String::new(),
other => other.to_string().to_lowercase(),
}
}
fn text_op(op: &str, have: &str, want: &str) -> bool {
let have = have.trim().to_lowercase();
match op {
"contains" => want.split_whitespace().all(|w| have.contains(w)),
"notContains" => !want.split_whitespace().any(|w| have.contains(w)),
"is" => have == want,
"isNot" => have != want,
"startsWith" => have.starts_with(want),
"endsWith" => have.ends_with(want),
"isEmpty" => have.is_empty(),
"isNotEmpty" => !have.is_empty(),
_ => false,
}
}
fn number(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().trim_start_matches("f/").trim_end_matches("mm").trim().parse().ok(),
_ => None,
}
}
fn num_op(op: &str, have: Option<f64>, value: &Value) -> bool {
if op == "between" {
let (a, b) = match value {
Value::Array(v) if v.len() == 2 => (number(&v[0]), number(&v[1])),
_ => (None, None),
};
return matches!((have, a, b), (Some(h), Some(a), Some(b)) if h >= a.min(b) && h <= a.max(b));
}
let (Some(h), Some(w)) = (have, number(value)) else { return op == "isNot" && have.is_none() };
match op {
"is" => (h - w).abs() < 1e-6,
"isNot" => (h - w).abs() >= 1e-6,
"gte" => h >= w - 1e-9,
"lte" => h <= w + 1e-9,
"gt" => h > w,
"lt" => h < w,
_ => false,
}
}
/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days.
fn last_secs(value: &Value) -> Option<i64> {
let (n, unit) = match value {
Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")),
v => (number(v)?, "days"),
};
let day = 86_400.0;
let per = match unit {
"hours" => 3600.0,
"weeks" => 7.0 * day,
"months" => 30.4375 * day,
"years" => 365.25 * day,
_ => day,
};
Some((n * per) as i64)
}
fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
if op == "isEmpty" {
return have.is_none_or(str::is_empty);
}
let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" };
let s = |v: &Value| v.as_str().map(str::trim).unwrap_or("").to_string();
match op {
// a prefix: 2026, 2026-04, 2026-04-12
"is" => {
let w = s(value);
!w.is_empty() && h.starts_with(&w)
}
"after" => {
let w = s(value);
!w.is_empty() && h > w.as_str() && !h.starts_with(&w)
}
"before" => {
let w = s(value);
!w.is_empty() && h < w.as_str()
}
"between" => match value {
Value::Array(v) if v.len() == 2 => {
let (a, b) = (s(&v[0]), s(&v[1]));
h >= a.as_str() && (h <= b.as_str() || h.starts_with(&b))
}
_ => false,
},
"inLast" | "notInLast" => {
let Some(secs) = last_secs(value) else { return false };
let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default();
(h >= from.as_str()) == (op == "inLast")
}
_ => false,
}
}
impl Rule {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
let (field, op, value) = match self {
Rule::Group { group } => return group.matches(p, cat),
Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value),
};
let m = &p.meta;
let want = lower(value);
let text = |have: &str| text_op(op, have, &want);
match field {
"rating" => num_op(op, Some(p.rating as f64), value),
"flag" => {
let f = format!("{:?}", p.flag).to_lowercase();
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
}
"label" => {
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
// custom label names count too
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
(l == want || named) == (op == "is")
}
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
"edited" => p.is_edited() == value.as_bool().unwrap_or(true),
"hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true),
"virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true),
"keywords" => match op {
"isEmpty" => m.keywords.is_empty(),
"isNotEmpty" => !m.keywords.is_empty(),
"notContains" => !m.keywords.iter().any(|k| text_op("contains", k, &want)),
_ => m.keywords.iter().any(|k| text_op(op, k, &want) || (op == "is" && k.to_lowercase().split('|').any(|part| part == want))),
},
"text" => {
let all = [
p.file_name.as_str(),
&m.title,
&m.caption,
&m.camera,
&m.lens,
&m.location,
&m.city,
&m.country,
&p.format,
&m.keywords.join(" "),
]
.join(" ");
text(&all)
}
"fileName" => text(&p.file_name),
"format" => text(&p.format),
"title" => text(&m.title),
"caption" => text(&m.caption),
"camera" => text(&m.camera),
"lens" => text(&m.lens),
"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),
"iso" => num_op(op, m.iso.map(|v| v as f64), value),
"aperture" => num_op(op, m.aperture.map(|v| v as f64), value),
"focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value),
"megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value),
"sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value),
"bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true),
"album" => {
let id = number(value).map(|v| crate::AlbumId(v as u64));
id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is")
}
_ => false,
}
}
}
impl RuleSet {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
if self.rules.is_empty() {
return self.mode != Match::Any;
}
match self.mode {
Match::All => self.rules.iter().all(|r| r.matches(p, cat)),
Match::Any => self.rules.iter().any(|r| r.matches(p, cat)),
Match::None => !self.rules.iter().any(|r| r.matches(p, cat)),
}
}
/// 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();
for r in &self.rules {
match r {
Rule::Group { group } => out.extend(group.problems()),
Rule::Field { field, op, .. } => match field_kind(field) {
None => out.push(format!("unknown field `{field}`")),
Some(k) if !ops_for(k).iter().any(|o| o.0 == op) => out.push(format!("`{field}` has no operator `{op}`")),
_ => {}
},
}
}
out
}
/// A short readable summary ("rating is ≥ 3 and keywords contains travel").
pub fn describe(&self) -> String {
let join = match self.mode {
Match::All => " and ",
Match::Any => " or ",
Match::None => " nor ",
};
let parts: Vec<String> = self
.rules
.iter()
.map(|r| match r {
Rule::Group { group } => format!("({})", group.describe()),
Rule::Field { field, op, value } => {
let label = FIELDS.iter().find(|f| f.0 == field).map_or(field.as_str(), |f| f.1).to_lowercase();
let op = field_kind(field).and_then(|k| ops_for(k).iter().find(|o| o.0 == op)).map_or(op.as_str(), |o| o.1);
let v = match value {
Value::String(s) => s.clone(),
Value::Null => String::new(),
Value::Object(o) => format!(
"{} {}",
o.get("n").map(|n| n.to_string()).unwrap_or_default(),
o.get("unit").and_then(Value::as_str).unwrap_or("days")
),
Value::Array(a) => a.iter().map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_string)).collect::<Vec<_>>().join(" – "),
other => other.to_string(),
};
format!("{label} {op} {v}").trim().to_string()
}
})
.collect();
let s = parts.join(join);
if self.mode == Match::None { format!("none of: {s}") } else { s }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ColorLabel, Flag, PhotoId, Source};
use serde_json::json;
fn photo(id: u64) -> Photo {
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00");
p.rating = 4;
p.flag = Flag::Pick;
p.label = Some(ColorLabel::Red);
p.captured = Some("2026-08-14T18:30:00".into());
p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()];
p.meta.camera = "Model X2".into();
p.meta.iso = Some(1600);
p.meta.aperture = Some(2.8);
p
}
fn rs(v: serde_json::Value) -> RuleSet {
serde_json::from_value(v).unwrap()
}
#[test]
fn fields_ops_and_groups() {
let cat = Catalog::new();
let p = photo(1);
let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}");
let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}");
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]}));
no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]}));
yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]}));
no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]}));
yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]}));
yes(
json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}),
);
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
yes(
json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}),
);
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"}]}}]}));
// in the last N days, relative to now
set_now(Some("2026-09-01T00:00:00".into()));
yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]}));
no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]}));
yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]}));
set_now(None);
// an empty rule list: all → everything, any → nothing
yes(json!({"rules": []}));
no(json!({"match": "any", "rules": []}));
}
#[test]
fn problems_and_description() {
let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]}));
assert_eq!(r.problems(), vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]);
let r = rs(
json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}),
);
assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks");
// every field has a kind with operators
for (f, _, k) in FIELDS {
assert!(!ops_for(*k).is_empty(), "{f}");
}
}
}
+317
View File
@@ -0,0 +1,317 @@
//! Crash-safe writes of user-visible files: exports, renders, DNG conversions, smart previews and
//! XMP sidecars all go through here (the catalog's own files use [`crate::store::FsStore`]).
//!
//! - [`write_atomic`] replaces a file so that after a crash, a full disk or an unplugged drive
//! either the old or the complete new content is there — never a truncated mix. The content
//! goes to a new temp file in the same folder (unique name, `create_new`), is synced to disk and
//! renamed over the target; then the folder is synced (POSIX). Any failure removes the temp
//! file and leaves the target as it was.
//! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file
//! and sync, plus a read-back check, and the file then appears under its final name **without
//! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the
//! volume has no hard links).
//! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix,
//! resolved paths elsewhere), for "is this target an original?" checks.
use std::cell::Cell;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
thread_local! {
static FAIL_AFTER: Cell<Option<u64>> = const { Cell::new(None) };
}
/// Fault injection for tests (in any crate): while the returned guard lives, every write made by
/// this module **on the calling thread** fails once `bytes` bytes of a file were written — as a
/// full disk or an unplugged drive would.
#[doc(hidden)]
pub fn fail_writes_after(bytes: u64) -> FaultGuard {
FAIL_AFTER.with(|c| c.set(Some(bytes)));
FaultGuard(())
}
/// Ends a [`fail_writes_after`] when dropped.
#[doc(hidden)]
pub struct FaultGuard(());
impl Drop for FaultGuard {
fn drop(&mut self) {
FAIL_AFTER.with(|c| c.set(None));
}
}
/// Counts what is written; fails past an injected limit.
struct Limited<W> {
inner: W,
n: u64,
limit: Option<u64>,
}
impl<W: Write> Write for Limited<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let buf = match self.limit {
Some(l) => {
let room = usize::try_from(l.saturating_sub(self.n)).unwrap_or(usize::MAX);
if room == 0 && !buf.is_empty() {
return Err(io::Error::other("write failed (injected fault)"));
}
buf.get(..room.min(buf.len())).unwrap_or(buf)
}
None => buf,
};
let k = self.inner.write(buf)?;
self.n += k as u64;
Ok(k)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
fn parent_of(path: &Path) -> &Path {
path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."))
}
/// Make a rename or a new name in `dir` durable (POSIX). Opening a directory fails on Windows,
/// where NTFS journals the rename itself, so errors are ignored.
pub fn sync_dir(dir: &Path) {
if let Ok(d) = File::open(dir) {
let _ = d.sync_all();
}
}
/// A new, empty temp file next to `path` (hidden, unique in this process and folder).
fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> {
static N: AtomicU64 = AtomicU64::new(0);
if path.file_name().is_none() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{} is not a file path", path.display())));
}
let dir = parent_of(path);
for _ in 0..1000 {
let tmp = dir.join(format!(".lightcraft-{}-{}.tmp", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
match OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(f) => return Ok((tmp, f)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("no free temp file name in {}", dir.display())))
}
/// Write a temp file next to `path` with what `fill` writes, synced to disk. On failure the temp
/// file is removed. Returns its path and length.
fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> {
let (tmp, file) = create_temp(path)?;
let r = (|| {
let limit = FAIL_AFTER.with(Cell::get);
let mut w = Limited { inner: io::BufWriter::with_capacity(1 << 20, file), n: 0, limit };
fill(&mut w)?;
w.flush()?;
let n = w.n;
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
f.sync_all()?;
Ok(n)
})();
match r {
Ok(n) => Ok((tmp, n)),
Err(e) => {
let _ = fs::remove_file(&tmp);
Err(e)
}
}
}
/// Replace (or create) `path` with `data` atomically and durably; see the module docs. The
/// folder must exist.
pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
write_atomic_with(path, &mut |w| w.write_all(data)).map(|_| ())
}
/// [`write_atomic`] with streamed content: `fill` writes it (an error aborts and leaves the old
/// file). Returns the length written.
pub fn write_atomic_with(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<u64> {
let (tmp, n) = write_temp(path, fill)?;
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
sync_dir(parent_of(path));
Ok(n)
}
/// Write `data` as the new file `path`: never replaces an existing file (`AlreadyExists`), and
/// the file only appears once it is complete, synced and read back identical.
pub fn write_new(path: &Path, data: &[u8]) -> io::Result<()> {
write_new_unique(&mut std::iter::once(path.to_path_buf()), data).map(|_| ())
}
/// [`write_new`] under the first free name of `candidates` (all in the same folder, e.g.
/// `IMG_1.dng`, `IMG_1-2.dng`, …): the file is written once and then given the first name that
/// is not taken. Returns that name; `AlreadyExists` when every candidate is taken.
pub fn write_new_unique(candidates: &mut dyn Iterator<Item = PathBuf>, data: &[u8]) -> io::Result<PathBuf> {
let mut candidates = candidates.peekable();
let Some(first) = candidates.peek().cloned() else {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "no file name to write"));
};
let (tmp, _) = write_temp(&first, &mut |w| w.write_all(data))?;
let r = (|| {
if !same_content(&tmp, data)? {
return Err(io::Error::other("the written file reads back different (failing drive or connection?)"));
}
for path in candidates.by_ref().take(100_000) {
match publish_new(&tmp, &path) {
Ok(()) => {
sync_dir(parent_of(&path));
return Ok(path);
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: no free file name", first.display())))
})();
// after a hard link the temp name is a second name of the new file; after a rename it's gone
let _ = fs::remove_file(&tmp);
r
}
/// Give the complete temp file `tmp` the name `path`, unless that is taken.
fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
match fs::hard_link(tmp, path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
// no hard links on this volume (FAT/exFAT, some shares): check, then rename
Err(_) => {
if fs::symlink_metadata(path).is_ok() {
return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} exists", path.display())));
}
fs::rename(tmp, path)
}
}
}
/// Does the file at `path` hold exactly `data`?
fn same_content(path: &Path, data: &[u8]) -> io::Result<bool> {
use std::io::Read;
let mut f = File::open(path)?;
if f.metadata()?.len() != data.len() as u64 {
return Ok(false);
}
let mut buf = vec![0u8; 1 << 20];
let mut at = 0usize;
loop {
let k = match f.read(&mut buf) {
Ok(0) => return Ok(at == data.len()),
Ok(k) => k,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
};
if data.get(at..at + k) != buf.get(..k) {
return Ok(false);
}
at += k;
}
}
/// Do `a` and `b` name the same existing file (through links, `..`, case-insensitive names)?
pub fn same_file(a: &Path, b: &Path) -> bool {
let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false };
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
ma.dev() == mb.dev() && ma.ino() == mb.ino()
}
#[cfg(not(unix))]
{
if ma.len() != mb.len() {
return false;
}
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(x), Ok(y)) => x == y || x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(),
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-safe-file-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn names(dir: &Path) -> Vec<String> {
let mut v: Vec<String> = fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
v.sort();
v
}
#[test]
fn atomic_write_replaces_and_leaves_no_temp() {
let d = temp("replace");
let p = d.join("out.jpg");
write_atomic(&p, b"one").unwrap();
write_atomic(&p, b"two two").unwrap();
assert_eq!(fs::read(&p).unwrap(), b"two two");
assert_eq!(names(&d), vec!["out.jpg"]);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_failure_mid_write_keeps_the_old_file() {
let d = temp("midwrite");
let p = d.join("photo.jpg");
fs::write(&p, b"the original bytes").unwrap();
{
let _f = fail_writes_after(4);
let e = write_atomic(&p, &[7u8; 10_000]).unwrap_err();
assert!(e.to_string().contains("injected"), "{e}");
assert!(write_new(&d.join("new.dng"), &[1u8; 100]).is_err());
}
assert_eq!(fs::read(&p).unwrap(), b"the original bytes", "the target is untouched");
assert_eq!(names(&d), vec!["photo.jpg"], "no temp file and no partial new file");
// a fill error aborts the same way
let r = write_atomic_with(&p, &mut |w| {
w.write_all(b"half")?;
Err(io::Error::other("encoder failed"))
});
assert!(r.is_err());
assert_eq!(fs::read(&p).unwrap(), b"the original bytes");
assert_eq!(names(&d), vec!["photo.jpg"]);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn new_files_never_replace_anything() {
let d = temp("new");
let p = d.join("a.dng");
fs::write(&p, b"keep").unwrap();
assert_eq!(write_new(&p, b"other").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&p).unwrap(), b"keep");
let mut c = (1..).map(|k| if k == 1 { d.join("a.dng") } else { d.join(format!("a-{k}.dng")) });
let got = write_new_unique(&mut c, b"fresh").unwrap();
assert_eq!(got, d.join("a-2.dng"));
assert_eq!(fs::read(&got).unwrap(), b"fresh");
assert_eq!(names(&d), vec!["a-2.dng", "a.dng"], "the temp file is gone");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn same_file_sees_through_other_spellings() {
let d = temp("same");
let p = d.join("x.jpg");
fs::write(&p, b"x").unwrap();
fs::write(d.join("y.jpg"), b"x").unwrap();
fs::create_dir_all(d.join("sub")).unwrap();
assert!(same_file(&p, &d.join("sub/../x.jpg")));
assert!(!same_file(&p, &d.join("y.jpg")));
assert!(!same_file(&p, &d.join("missing.jpg")));
let _ = fs::remove_dir_all(&d);
}
}
+37 -1
View File
@@ -93,6 +93,42 @@ impl Catalog {
self.group_ops(result, sources, true)
}
/// Move `photo` `delta` places within its stack (towards the top for negative; index 0 is the
/// top). `None` when it isn't stacked or can't move.
pub fn move_in_stack_ops(&self, photo: PhotoId, delta: isize) -> Option<Op> {
let st = self.stack_of(photo)?;
let i = st.photos.iter().position(|p| *p == photo)?;
let to = (i as isize + delta).clamp(0, st.photos.len() as isize - 1) as usize;
if to == i {
return None;
}
let mut photos = st.photos.clone();
let p = photos.remove(i);
photos.insert(to, p);
Some(Op::SetStack { id: st.id, photos, collapsed: st.collapsed })
}
/// Split `photo`'s stack in two: the photos before it stay, it and the ones after it form a
/// new stack (topped by it). A part with a single photo is no longer stacked.
pub fn split_stack_ops(&mut self, photo: PhotoId) -> Option<Op> {
let st = self.stack_of(photo)?.clone();
let i = st.photos.iter().position(|p| *p == photo)?;
if i == 0 {
return None;
}
let (head, tail) = st.photos.split_at(i);
let mut ops = vec![if head.len() >= 2 {
Op::SetStack { id: st.id, photos: head.to_vec(), collapsed: st.collapsed }
} else {
Op::RemoveStack { id: st.id }
}];
if tail.len() >= 2 {
let id = self.alloc_stack_id();
ops.push(Op::AddStack { stack: Stack { id, photos: tail.to_vec(), collapsed: st.collapsed } });
}
Some(Op::Batch { ops })
}
/// Ops that dissolve every stack containing one of `photos`.
pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec<Op> {
self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect()
@@ -241,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();
+91 -6
View File
@@ -16,12 +16,33 @@ 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<()>;
/// Append and make durable (fsync) before returning.
/// [`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. On an error, part of `data` may have
/// been written (a full disk, a dropped network share): the journal cuts the file back to its
/// previous length with [`Store::truncate`] before appending again.
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
/// Cut a file to `len` bytes (drop a torn tail before appending again).
/// Cut a file to `len` bytes (drop a torn tail before appending again). A missing file is
/// left missing.
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 +98,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,16 +134,27 @@ impl Store for FsStore {
}
self.appender = Some((name.to_string(), f));
}
let (_, f) = self.appender.as_mut().expect("appender just opened");
f.write_all(data)?;
f.sync_data()
let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) };
let written = f.write_all(data).and_then(|()| f.sync_data());
if written.is_err() {
// The handle may be dead (a network share that reconnected, a removed volume): the next
// attempt reopens the file. Part of `data` may be in the file; the caller cuts it back
// ([`Store::truncate`]) before appending again.
self.appender = None;
}
written
}
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
self.appender = None;
}
let f = std::fs::OpenOptions::new().write(true).open(self.path(name))?;
let f = match std::fs::OpenOptions::new().write(true).open(self.path(name)) {
Ok(f) => f,
// nothing to cut (an append that failed before creating the file)
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
f.set_len(len)?;
f.sync_all()
}
@@ -104,12 +162,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 +227,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>)
}
}
+85 -4
View File
@@ -57,11 +57,15 @@ fn albums_and_folders() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-04-01");
let f = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None } })
.unwrap();
c.apply(Op::AddAlbum {
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
})
.unwrap();
let al = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None } })
.unwrap();
c.apply(Op::AddAlbum {
album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None, quick: false },
})
.unwrap();
assert_eq!(c.albums_of(a), vec![al]);
assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder");
assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos");
@@ -216,3 +220,80 @@ fn folder_paths() {
assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix");
assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false));
}
#[test]
fn merge_results_are_recognised() {
use crate::query::merged_kind;
assert_eq!(merged_kind("IMG_1-HDR.dng"), Some("hdr"));
assert_eq!(merged_kind("IMG_1-Pano.dng"), Some("panorama"));
assert_eq!(merged_kind("IMG_1-HDR-Pano.dng"), Some("hdrPanorama"));
assert_eq!(merged_kind("IMG_1-HDR-2.dng"), Some("hdr"), "a second merge of the same photo");
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:\\"));
}
+339
View File
@@ -0,0 +1,339 @@
//! 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));
// still the (empty) snapshot written when the library was created
assert_eq!(g.snapshot_seq_on_disk(), Some(0));
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());
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Issue #102: catalog format versions. Older formats load and are upgraded; a newer format is
//! refused with a clear error and left untouched (never read in part, truncated or set aside).
use crate::journal::{LOG, SNAPSHOT, VERSION, encode_record};
use crate::*;
fn photo(c: &mut Catalog) -> Op {
let id = c.alloc_photo_id();
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
}
/// A catalog with two photos and the log line for a third op on top of it.
fn legacy_parts() -> (Catalog, String, Catalog) {
let mut c = Catalog::new();
for _ in 0..2 {
let op = photo(&mut c);
c.apply(op).unwrap();
}
let base = c.clone();
let id = c.photos().next().unwrap().id;
let op = Op::SetRating { id, rating: 4 };
c.apply(op.clone()).unwrap();
(base, format!("{}\n", encode_record(3, &op)), c)
}
fn snapshot_version(m: &MemStore) -> u64 {
let v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
v["version"].as_u64().unwrap()
}
#[test]
fn v1_library_loads_and_is_upgraded() {
let (base, log, full) = legacy_parts();
let m = MemStore::new();
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
m.set(LOG, log.into_bytes());
let (mut j, c, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(c.to_snapshot(), full.to_snapshot());
assert_eq!((r.replayed, r.upgraded_from), (1, Some(1)));
// rewritten in this format before anything is appended
assert_eq!(snapshot_version(&m), u64::from(VERSION));
assert!(m.get(LOG).unwrap().is_empty());
assert_eq!(j.seq(), 3);
j.append(&[Op::SetLabelName { label: ColorLabel::ALL[0], name: Some("x".into()) }]).unwrap();
drop(j);
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
assert_eq!((r.replayed, r.upgraded_from), (1, None));
}
#[test]
fn versionless_log_only_library_is_upgraded() {
let mut c = Catalog::new();
let op = photo(&mut c);
c.apply(op.clone()).unwrap();
let m = MemStore::new();
m.set(LOG, format!("{}\n", encode_record(1, &op)).into_bytes());
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
assert_eq!(r.upgraded_from, Some(0));
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
/// A new library gets a snapshot in this format at once, so an older build never sees a bare log
/// it would read in part.
#[test]
fn new_library_is_written_in_this_format() {
let m = MemStore::new();
let (_, _, r) = Journal::open(Box::new(m.clone())).unwrap();
assert!(r.created);
assert_eq!(r.upgraded_from, None);
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
fn assert_refused_untouched(m: &MemStore) {
let before = m.files.lock().unwrap().clone();
let e = Journal::open(Box::new(m.clone())).err().expect("refused");
assert!(matches!(e, CatalogError::Newer(_)), "{e:?}");
let msg = e.to_string();
assert!(msg.contains("newer version of LightCraft") && msg.contains("left unchanged"), "{msg}");
assert_eq!(*m.files.lock().unwrap(), before, "nothing modified, nothing renamed");
}
#[test]
fn newer_snapshot_is_refused_untouched() {
let (base, log, _) = legacy_parts();
let m = MemStore::new();
m.set(
SNAPSHOT,
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{}}}\n", VERSION + 1, base.to_snapshot()).into_bytes(),
);
m.set(LOG, log.into_bytes());
assert_refused_untouched(&m);
// even when its catalog doesn't parse as this version's
let m = MemStore::new();
m.set(
SNAPSHOT,
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{{\"photos\":7}}}}\n", VERSION + 1).into_bytes(),
);
assert_refused_untouched(&m);
let e = Journal::open(Box::new(m)).err().unwrap().to_string();
assert!(e.contains(&format!("v{}", VERSION + 1)), "{e}");
}
/// A log record with a valid CRC whose op this version doesn't know: refused, not taken for a
/// torn tail (truncated) or damage (renamed) — wherever it is in the log.
#[test]
fn unknown_op_in_the_log_is_refused_untouched() {
let (base, log, _) = legacy_parts();
let future = |seq: u64| {
let body = r#"{"op":"fromTheFuture","id":1,"what":"something new"}"#;
format!("{{\"seq\":{seq},\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes()))
};
let snap = format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot());
let id = base.photos().next().unwrap().id;
let after = format!("{}\n", encode_record(5, &Op::SetRating { id, rating: 1 }));
for log in [format!("{log}{}", future(4)), format!("{log}{}{after}", future(4)), format!("{}{log}", future(3))] {
let m = MemStore::new();
m.set(SNAPSHOT, snap.clone().into_bytes());
m.set(LOG, log.into_bytes());
assert_refused_untouched(&m);
}
// a known op with a field of an unknown shape is "newer" too
let body = r#"{"op":"setRating","id":1,"rating":"five stars"}"#;
let m = MemStore::new();
m.set(SNAPSHOT, snap.into_bytes());
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes());
assert_refused_untouched(&m);
}
/// A CRC mismatch is still damage / a torn tail (not "newer").
#[test]
fn bad_crc_is_still_a_torn_tail() {
let (base, log, _) = legacy_parts();
let m = MemStore::new();
m.set(
SNAPSHOT,
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes(),
);
let body = r#"{"op":"fromTheFuture"}"#;
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":1,\"op\":{body}}}\n").into_bytes());
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
assert!(r.torn_bytes > 0 && r.damaged.is_none(), "{r:?}");
}
/// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks
/// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the
/// variant here under the new version.
#[test]
fn op_variants_are_versioned() {
fn since(op: &Op) -> u32 {
match op {
Op::AddPhoto { .. }
| Op::RemovePhoto { .. }
| Op::SetRating { .. }
| Op::SetFlag { .. }
| Op::SetLabel { .. }
| Op::SetDevelop { .. }
| Op::SetMeta { .. }
| Op::SetDeleted { .. }
| Op::SetLocal { .. }
| Op::SetVersions { .. }
| Op::SetHistory { .. }
| Op::PushHistory { .. }
| Op::AddAlbum { .. }
| Op::RemoveAlbum { .. }
| Op::RenameAlbum { .. }
| Op::MoveAlbum { .. }
| Op::SetAlbumPhotos { .. }
| Op::SetAlbumCover { .. }
| Op::SetAlbumRules { .. }
| Op::AddStack { .. }
| Op::RemoveStack { .. }
| Op::SetStack { .. }
| Op::SetCaptured { .. }
| Op::SetAnalysis { .. }
| Op::SetFile { .. }
| Op::Relink { .. }
| Op::SetContent { .. }
| Op::SetLabelName { .. }
| Op::Batch { .. } => 1,
Op::SetBrowsed { .. } => 2,
}
}
let newest = since(&Op::SetBrowsed { folder: String::new(), at: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
}
+60 -1
View File
@@ -50,7 +50,16 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
5 => {
let id = c.alloc_album_id();
Op::AddAlbum {
album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None, smart: None },
album: Album {
id,
name: format!("A{b}"),
parent: None,
folder: b.is_multiple_of(4),
photos: vec![],
cover: None,
smart: None,
quick: false,
},
}
}
6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() },
@@ -265,3 +274,53 @@ 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\":{},\"seq\":{},\"catalog\":{}}}\n",
crate::journal::VERSION,
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);
}
+267
View File
@@ -0,0 +1,267 @@
//! Forgetting untouched Local browse records (see [`crate::local`]).
use std::sync::Arc;
use lightcraft_develop::DevelopSettings;
use crate::*;
const OLD: &str = "2026-08-01T10:00:00";
const NOW: &str = "2026-10-05T10:00:00";
/// A Local record of `/pics/<folder>/IMG_<i>.JPG` as a browse catalogues it (with its baseline).
fn local(c: &mut Catalog, folder: &str, i: u32) -> PhotoId {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder}/IMG_{i}.JPG") }, &format!("IMG_{i}.JPG"), "JPEG", 60, 40, OLD);
p.meta.camera = "Cam".into();
p.meta.creator = "From EXIF".into(); // file metadata is part of the baseline, not a user change
p.focal_and_wb_for_test();
p.local = true;
p.set_local_baseline();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
}
impl Photo {
fn focal_and_wb_for_test(&mut self) {
self.meta.focal_mm = Some(35.3);
self.as_shot_wb = Some((5123.4, 3.1));
}
}
fn browsed(c: &mut Catalog, folder: &str, at: &str) {
c.apply(Op::SetBrowsed { folder: format!("/pics/{folder}"), at: Some(at.into()) }).unwrap();
}
fn plan(c: &Catalog, days: u32) -> ForgetPlan {
c.forget_local_plan(NOW, days, &|_| false)
}
fn run(c: &mut Catalog, days: u32) -> ForgetPlan {
let p = plan(c, days);
for op in p.ops() {
c.apply(op).unwrap();
}
p
}
#[test]
fn untouched_records_of_old_folders_are_forgotten() {
let mut c = Catalog::new();
let old: Vec<_> = (0..5).map(|i| local(&mut c, "old", i)).collect();
let recent: Vec<_> = (0..3).map(|i| local(&mut c, "recent", i)).collect();
browsed(&mut c, "old", OLD);
browsed(&mut c, "recent", "2026-10-01T09:00:00");
// a library photo in the old folder is never a candidate
let lib = c.alloc_photo_id();
let mut p = Photo::new(lib, Source::File { path: "/pics/old/LIB.JPG".into() }, "LIB.JPG", "JPEG", 6, 4, OLD);
p.set_local_baseline();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let dry = plan(&c, 30);
assert_eq!((dry.local, dry.evict.len(), dry.kept_recent), (8, 5, 3), "{dry:?}");
assert_eq!(dry.evict, old);
assert_eq!(c.len(), 9, "a plan changes nothing");
let done = run(&mut c, 30);
assert_eq!(done.evict.len(), 5);
assert!(old.iter().all(|id| c.photo(*id).is_none()));
assert!(recent.iter().all(|id| c.photo(*id).is_some()));
assert!(c.photo(lib).is_some());
// the folder has no Local records left: its time goes
assert_eq!(done.unstamp, vec!["/pics/old".to_string()]);
assert_eq!(c.last_browsed("/pics/old"), None);
assert_eq!(c.last_browsed("/pics/recent/"), Some("2026-10-01T09:00:00"));
// 0 days = never
browsed(&mut c, "recent", OLD);
assert!(plan(&c, 0).evict.is_empty());
assert_eq!(plan(&c, 30).evict.len(), 3);
}
#[test]
fn any_user_change_keeps_a_record() {
type Change = Box<dyn Fn(&mut Catalog, PhotoId)>;
let set = |f: fn(&mut Photo)| -> Change {
Box::new(move |c: &mut Catalog, id: PhotoId| {
let mut p = (**c.photo(id).unwrap()).clone();
f(&mut p);
c.apply(Op::RemovePhoto { id }).unwrap();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
})
};
let op = |f: fn(PhotoId) -> Op| -> Change { Box::new(move |c: &mut Catalog, id: PhotoId| drop(c.apply(f(id)).unwrap())) };
let edited = || {
let mut d = DevelopSettings::default();
d.light.exposure = 0.3;
Arc::new(d)
};
let changes: Vec<(&str, Change)> = vec![
("rating", op(|id| Op::SetRating { id, rating: 3 })),
("flag", op(|id| Op::SetFlag { id, flag: Flag::Reject })),
("label", op(|id| Op::SetLabel { id, label: Some(ColorLabel::Red) })),
("develop", Box::new(move |c, id| drop(c.apply(Op::SetDevelop { id, settings: edited(), label: "Exposure".into(), edited: None }).unwrap()))),
("crop", set(|p| Arc::make_mut(&mut p.develop).crop.flip_h = true)),
(
"history only",
Box::new(|c, id| {
let step = HistoryStep { label: "Reset".into(), settings: Arc::new(DevelopSettings::default()) };
drop(c.apply(Op::PushHistory { id, step }).unwrap())
}),
),
("versions", set(|p| p.versions.push(Version { name: "v".into(), created: NOW.into(), settings: p.develop.clone(), auto: false }))),
("title", set(|p| p.meta.title = "T".into())),
("caption", set(|p| p.meta.caption = "C".into())),
("keywords", set(|p| p.meta.keywords.push("k".into()))),
("location", set(|p| p.meta.city = "Oslo".into())),
("gps", set(|p| p.meta.gps = Some((1.0, 2.0)))),
("copyright", set(|p| p.meta.copyright = "© me".into())),
("creator edit", set(|p| p.meta.creator = "Me".into())),
("capture time", op(|id| Op::SetCaptured { id, captured: Some("2020-01-01T00:00:00".into()) })),
("rename", set(|p| p.file_name = "renamed.JPG".into())),
("culling", op(|id| Op::SetAnalysis { id, analysis: Some(Analysis::default()) })),
("deleted", op(|id| Op::SetDeleted { id, deleted: true })),
("pick", op(|id| Op::SetFlag { id, flag: Flag::Pick })),
(
"album",
Box::new(|c, id| {
let a = c.alloc_album_id();
let mut al = Album::new(a, "A");
al.photos.push(id);
drop(c.apply(Op::AddAlbum { album: al }).unwrap())
}),
),
(
"stack",
Box::new(|c, id| {
let other = local(c, "elsewhere", 99);
let s = c.alloc_stack_id();
drop(c.apply(Op::AddStack { stack: Stack { id: s, photos: vec![id, other], collapsed: false } }).unwrap())
}),
),
(
"virtual copy",
Box::new(|c, id| {
let mut v = (**c.photo(id).unwrap()).clone();
v.id = c.alloc_photo_id();
v.copy_of = Some(id);
v.copy_name = Some("Copy 1".into());
v.local = false;
drop(c.apply(Op::AddPhoto { photo: Box::new(v) }).unwrap())
}),
),
];
for (what, change) in changes {
let mut c = Catalog::new();
let id = local(&mut c, "old", 1);
let control = local(&mut c, "old", 2);
browsed(&mut c, "old", OLD);
change(&mut c, id);
let p = plan(&c, 30);
assert_eq!(p.evict, vec![control], "{what}: {p:?}");
assert_eq!(p.kept_touched + p.kept_in_use, 1, "{what}: {p:?}");
// the stamp stays: a record in the folder remains
assert!(p.unstamp.is_empty(), "{what}");
}
// a change undone (back to the browse state) is untouched again
let mut c = Catalog::new();
let id = local(&mut c, "old", 1);
browsed(&mut c, "old", OLD);
let inv = c.apply(Op::SetRating { id, rating: 2 }).unwrap();
assert!(plan(&c, 30).evict.is_empty());
c.apply(inv).unwrap();
assert_eq!(plan(&c, 30).evict, vec![id]);
}
/// Catalogs from before last-browsed times (and baselines): nothing is forgotten on the first
/// run — the folders are stamped now — and baseline-less records are judged by their data.
#[test]
fn upgrade_without_times_forgets_nothing_then_counts_from_then() {
let mut c = Catalog::new();
let a = local(&mut c, "a", 1);
// legacy records: no baseline
let legacy = |c: &mut Catalog, i: u32, f: fn(&mut Photo)| {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/pics/a/L{i}.JPG") }, "L.JPG", "JPEG", 6, 4, OLD);
p.meta.camera = "Cam".into();
p.local = true;
f(&mut p);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let clean = legacy(&mut c, 1, |_| {});
let rated = legacy(&mut c, 2, |p| p.rating = 1);
let with_creator = legacy(&mut c, 3, |p| p.meta.creator = "maybe the user".into());
let p = run(&mut c, 30);
assert!(p.evict.is_empty(), "{p:?}");
assert_eq!(p.stamp, vec!["/pics/a".to_string()]);
assert_eq!(c.last_browsed("/pics/a"), Some(NOW));
// 30+ days later
let later = c.forget_local_plan("2026-11-06T10:00:00", 30, &|_| false);
let mut evict = later.evict.clone();
evict.sort();
assert_eq!(evict, vec![a, clean]);
assert_eq!(later.kept_touched, 2, "{rated:?} {with_creator:?}");
}
#[test]
fn in_use_records_are_kept() {
let mut c = Catalog::new();
let a = local(&mut c, "old", 1);
let b = local(&mut c, "old", 2);
browsed(&mut c, "old", OLD);
let p = c.forget_local_plan(NOW, 30, &|p| p.id == a);
assert_eq!((p.evict.clone(), p.kept_in_use), (vec![b], 1));
}
/// The ops are journaled: a crash right after forgetting reloads the same catalog, and a
/// fingerprint survives the snapshot round trip (a reloaded record is still untouched).
#[test]
fn forgetting_is_journaled_and_replays() {
let m = MemStore::new();
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
let mut ops = Vec::new();
for i in 0..10 {
local(&mut c, if i % 2 == 0 { "old" } else { "new" }, i);
}
// log what was built so far: the ops are the records as added
for p in c.photos() {
ops.push(Op::AddPhoto { photo: Box::new((**p).clone()) });
}
ops.push(Op::SetBrowsed { folder: "/pics/old".into(), at: Some(OLD.into()) });
ops.push(Op::SetBrowsed { folder: "/pics/new".into(), at: Some(NOW.into()) });
browsed(&mut c, "old", OLD);
browsed(&mut c, "new", NOW);
j.append(&ops).unwrap();
j.snapshot(&c).unwrap();
drop(j);
// reload from the snapshot: baselines still match
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
let p = c.forget_local_plan(NOW, 30, &|_| false);
assert_eq!(p.evict.len(), 5, "{p:?}");
let ops = p.ops();
for op in &ops {
c.apply(op.clone()).unwrap();
}
j.append(&ops).unwrap();
let expect = c.to_snapshot();
drop(j); // crash: replay the log
let (_, c2, r) = Journal::open(Box::new(m)).unwrap();
assert_eq!(r.failed, 0);
assert_eq!(c2.to_snapshot(), expect);
assert_eq!(c2.len(), 5);
// nothing left to forget
assert!(c2.forget_local_plan(NOW, 30, &|_| false).evict.is_empty());
}
#[test]
fn op_photo_ids_cover_nested_ops() {
let op = Op::Batch {
ops: vec![
Op::SetRating { id: PhotoId(1), rating: 1 },
Op::SetAlbumPhotos { id: AlbumId(1), photos: vec![PhotoId(2), PhotoId(3)] },
Op::Batch { ops: vec![Op::RemovePhoto { id: PhotoId(4) }] },
],
};
let mut ids = Vec::new();
op.photo_ids(&mut |id| ids.push(id.0));
assert_eq!(ids, vec![1, 2, 3, 4]);
}
+93
View File
@@ -0,0 +1,93 @@
//! Issue #99: one process per library.
use std::path::PathBuf;
use crate::lock::{LOCK, OWNER};
use crate::*;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn second_opener_is_refused_until_the_first_lets_go() {
let dir = temp_dir("twice");
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
assert!(first.held());
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
assert_eq!((owner.pid, owner.program.as_str()), (std::process::id(), "LightCraft"));
let e = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap_err();
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
assert_eq!(who.pid, std::process::id());
let msg = e.to_string();
assert!(msg.contains("already open in LightCraft") && msg.contains(&format!("process {}", std::process::id())), "{msg}");
drop(first);
assert!(!dir.join(OWNER).exists(), "the owner note goes with the lock");
assert!(dir.join(LOCK).exists(), "the lock file itself may stay: only a held lock refuses");
let again = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap();
assert!(again.held());
drop(again);
let _ = std::fs::remove_dir_all(&dir);
}
/// Files left behind by a crash (lock file + owner note) don't lock anyone out.
#[test]
fn leftover_lock_files_are_not_a_lock() {
let dir = temp_dir("stale");
std::fs::write(dir.join(LOCK), b"").unwrap();
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
let l = LibraryLock::acquire(&dir, "LightCraft").unwrap();
assert!(l.held());
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
assert_eq!(owner.pid, std::process::id(), "the note now names this process");
drop(l);
let _ = std::fs::remove_dir_all(&dir);
}
/// Another process holds the lock; when it is killed (a crash), the OS releases it.
#[test]
fn lock_held_by_another_process_is_released_when_it_dies() {
let dir = temp_dir("child");
let exe = std::env::current_exe().unwrap();
let mut child = std::process::Command::new(exe)
.args(["--exact", "tests_lock::lock_holder_child", "--ignored", "--test-threads=1"])
.env("LC_LOCK_CHILD_DIR", &dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while !dir.join("child-locked").exists() {
assert!(child.try_wait().unwrap().is_none(), "child exited before locking");
assert!(std::time::Instant::now() < deadline, "child never locked");
std::thread::sleep(std::time::Duration::from_millis(20));
}
let e = LibraryLock::acquire(&dir, "LightCraft").unwrap_err();
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
assert_eq!(who.pid, child.id());
assert!(e.to_string().contains("on this computer") || who.host.is_empty(), "{e}");
child.kill().unwrap(); // SIGKILL: no clean-up runs, like a crash
child.wait().unwrap();
assert!(dir.join(OWNER).exists(), "the crashed holder's note is left behind");
let l = LibraryLock::acquire(&dir, "LightCraft").expect("the OS released the dead process's lock");
assert!(l.held());
drop(l);
let _ = std::fs::remove_dir_all(&dir);
}
/// Run by [`lock_held_by_another_process_is_released_when_it_dies`] in a child process: take the
/// lock, say so, and wait to be killed.
#[test]
#[ignore = "helper process for lock_held_by_another_process_is_released_when_it_dies"]
fn lock_holder_child() {
let Some(dir) = std::env::var_os("LC_LOCK_CHILD_DIR") else { return };
let _l = LibraryLock::acquire(std::path::Path::new(&dir), "lightcraft-cli").unwrap();
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
std::thread::sleep(std::time::Duration::from_secs(120));
}
+223
View File
@@ -0,0 +1,223 @@
//! Issue #101: an append that fails part-way (disk full, a dropped network share) and is then
//! retried must not read back as log damage, and must lose nothing.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use crate::journal::LOG;
use crate::*;
/// A store whose next append writes only a prefix of its data, then fails.
#[derive(Clone, Default)]
struct Torn {
files: MemStore,
/// The next append writes this many bytes, then returns an error.
torn_at: Arc<Mutex<Option<usize>>>,
/// `truncate` fails (the cut-back after a failed append can't be done right away).
truncate_fails: Arc<AtomicBool>,
}
impl Torn {
fn tear_next_append(&self, bytes: usize) {
*self.torn_at.lock().unwrap() = Some(bytes);
}
}
impl Store for Torn {
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 append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
if let Some(n) = self.torn_at.lock().unwrap().take() {
self.files.append(name, &data[..n.min(data.len())])?;
return Err(std::io::Error::other("disk full"));
}
self.files.append(name, data)
}
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
if self.truncate_fails.load(Ordering::SeqCst) {
return Err(std::io::Error::other("share unavailable"));
}
self.files.truncate(name, len)
}
fn describe(&self) -> String {
"torn".into()
}
}
fn add_photo(c: &mut Catalog) -> Op {
let id = c.alloc_photo_id();
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
}
/// Apply `ops` to the live catalog (like a command) and return them for the journal.
fn apply(c: &mut Catalog, ops: Vec<Op>) -> Vec<Op> {
for op in &ops {
c.apply(op.clone()).unwrap();
}
ops
}
/// Every tear position of a batch, then the retry (same ops, same seqs, like the engine's queue)
/// and more appends: reloading replays everything, reports no damage and no torn bytes.
#[test]
fn partial_append_then_retry_loads_cleanly() {
let probe = MemStore::new();
let (mut j, mut c, _) = Journal::open(Box::new(probe.clone())).unwrap();
let add = add_photo(&mut c);
let first = apply(&mut c, vec![add]);
j.append(&first).unwrap();
let id = c.photos().next().unwrap().id;
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 3 }, Op::SetFlag { id, flag: Flag::Pick }, Op::SetRating { id, rating: 4 }]);
let before = probe.get(LOG).unwrap().len();
j.append(&batch).unwrap();
let batch_len = probe.get(LOG).unwrap().len() - before;
for tear in 1..batch_len {
let t = Torn::default();
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
let add = add_photo(&mut c);
let first = apply(&mut c, vec![add]);
j.append(&first).unwrap();
let good_len = t.files.get(LOG).unwrap().len();
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 3 }, Op::SetFlag { id, flag: Flag::Pick }, Op::SetRating { id, rating: 4 }]);
t.tear_next_append(tear);
assert!(j.append(&batch).is_err(), "tear at {tear}");
// cut back to the last whole record
assert_eq!(t.files.get(LOG).unwrap().len(), good_len, "tear at {tear}");
j.append(&batch).unwrap();
let more = apply(&mut c, vec![Op::SetLabel { id, label: Some(ColorLabel::ALL[0]) }]);
j.append(&more).unwrap();
drop(j);
let (_, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
assert!(r.damaged.is_none(), "tear at {tear}: {r:?}");
assert_eq!((r.torn_bytes, r.torn_fragments, r.replayed), (0, 0, 5), "tear at {tear}");
assert_eq!(loaded.to_snapshot(), c.to_snapshot(), "tear at {tear}");
}
}
/// When cutting back fails too (the share is gone), the next append cuts back first; until that
/// works nothing more is appended after the fragment.
#[test]
fn failed_cut_back_is_done_before_the_next_append() {
let t = Torn::default();
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
let add = add_photo(&mut c);
let first = apply(&mut c, vec![add]);
j.append(&first).unwrap();
let good_len = t.files.get(LOG).unwrap().len();
let id = c.photos().next().unwrap().id;
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 2 }, Op::SetRating { id, rating: 5 }]);
t.truncate_fails.store(true, Ordering::SeqCst);
t.tear_next_append(30);
assert!(j.append(&batch).is_err());
assert_eq!(t.files.get(LOG).unwrap().len(), good_len + 30, "the fragment is still there");
// still unreachable: the retry fails without writing anything after the fragment
assert!(j.append(&batch).is_err());
assert_eq!(t.files.get(LOG).unwrap().len(), good_len + 30);
t.truncate_fails.store(false, Ordering::SeqCst);
j.append(&batch).unwrap();
drop(j);
let (_, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
assert!(r.damaged.is_none(), "{r:?}");
assert_eq!(r.replayed, 3);
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
}
/// Logs written before the fix: the fragment of the failed append is glued to the first retried
/// record on one line, followed by the rest of the retry. They load in full (the fragment is
/// skipped), instead of stopping at the fragment and setting the later records aside as damage.
#[test]
fn legacy_fragment_glued_to_the_retry_is_skipped() {
let m = MemStore::new();
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
let add = add_photo(&mut c);
let first = apply(&mut c, vec![add]);
j.append(&first).unwrap();
let good = m.get(LOG).unwrap();
let id = c.photos().next().unwrap().id;
let batch = apply(
&mut c,
vec![
Op::SetRating { id, rating: 3 },
Op::SetFlag { id, flag: Flag::Pick },
Op::SetLabelName { label: ColorLabel::ALL[1], name: Some("Größe ✓".into()) },
],
);
j.append(&batch).unwrap();
let full = m.get(LOG).unwrap();
let batch_bytes = &full[good.len()..];
let later = apply(&mut c, vec![Op::SetRating { id, rating: 1 }]);
j.append(&later).unwrap();
let later_bytes = m.get(LOG).unwrap()[full.len()..].to_vec();
drop(j);
// every tear (inside the first record, after it, inside a multi-byte character…)
for tear in 1..batch_bytes.len() {
if batch_bytes[tear - 1] == b'\n' {
continue; // a tear on a record boundary leaves no fragment
}
let mut log = good.clone();
log.extend_from_slice(&batch_bytes[..tear]);
log.extend_from_slice(batch_bytes);
log.extend_from_slice(&later_bytes);
let t = MemStore::new();
t.set(LOG, log);
let (_, loaded, r) = Journal::open(Box::new(t)).unwrap();
assert!(r.damaged.is_none(), "tear at {tear}: {r:?}");
assert_eq!(r.torn_fragments, 1, "tear at {tear}");
assert_eq!(loaded.to_snapshot(), c.to_snapshot(), "tear at {tear}");
}
// a damaged record whose tail happens to be a whole record from the future is still damage
let mut log = good.clone();
log.extend_from_slice(b"garbage");
log.extend_from_slice(&later_bytes);
log.extend_from_slice(&later_bytes);
let t = MemStore::new();
t.set(LOG, log);
let (_, _, r) = Journal::open(Box::new(t)).unwrap();
assert!(r.damaged.is_some(), "{r:?}");
}
/// A snapshot can absorb ops whose append failed: it records them as saved (`seq` moves past
/// them), and appending after it continues from there.
#[test]
fn snapshot_with_unlogged_ops_saves_them() {
let t = Torn::default();
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
let add = add_photo(&mut c);
let first = apply(&mut c, vec![add]);
j.append(&first).unwrap();
let id = c.photos().next().unwrap().id;
let queued = apply(&mut c, vec![Op::SetRating { id, rating: 2 }, Op::SetFlag { id, flag: Flag::Reject }]);
t.tear_next_append(10);
assert!(j.append(&queued).is_err());
j.snapshot_with_unlogged(&c, queued.len() as u64).unwrap();
assert_eq!(j.seq(), 3);
let after = apply(&mut c, vec![Op::SetRating { id, rating: 5 }]);
j.append(&after).unwrap();
drop(j);
let (j, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
assert_eq!((r.snapshot_seq, r.replayed), (3, 1));
assert_eq!(j.seq(), 4);
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
}
/// `FsStore::truncate` on a file that was never created (an append that failed first) is a no-op.
#[test]
fn fs_store_truncates_a_missing_file_quietly() {
let dir = std::env::temp_dir().join(format!("lc-torn-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = FsStore::open(&dir).unwrap();
s.truncate(LOG, 0).unwrap();
assert!(!dir.join(LOG).exists());
s.append(LOG, b"abc\n").unwrap();
s.truncate(LOG, 0).unwrap();
assert_eq!(std::fs::read(dir.join(LOG)).unwrap(), b"");
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(),
}
})
}
+2
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;
@@ -105,6 +106,7 @@ mod tests {
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Radial {
+22 -1
View File
@@ -208,8 +208,29 @@ impl Preset {
pub fn from_settings(id: &str, name: &str, group: &str, s: &DevelopSettings, groups: &[SettingsGroup]) -> Preset {
Preset { id: id.into(), name: name.into(), group: group.into(), settings: extract_groups(s, groups), favorite: false, builtin: false }
}
/// Apply at `amount` (1.0 = 100 %). Masks in the preset are added to the photo's own (ones it
/// already has are skipped), their effect scaled by the amount.
pub fn apply(&self, s: &DevelopSettings, amount: f64) -> DevelopSettings {
apply_partial(s, &self.settings, amount)
let Some(masks) = self.settings.get("masks").and_then(Value::as_array).filter(|m| !m.is_empty()) else {
return apply_partial(s, &self.settings, amount);
};
let mut rest = self.settings.clone();
if let Some(o) = rest.as_object_mut() {
o.remove("masks");
}
let mut out = apply_partial(s, &rest, amount);
let mut next = out.masks.iter().map(|m| m.id).max().unwrap_or(0);
for m in masks {
let Ok(mut m) = serde_json::from_value::<crate::Mask>(m.clone()) else { continue };
if out.masks.iter().any(|q| q.name == m.name && q.components == m.components) {
continue;
}
next += 1;
m.id = next;
m.adjust.amount = (m.adjust.amount * amount).clamp(0.0, 200.0);
out.masks.push(m);
}
out
}
}
+11 -1
View File
@@ -786,6 +786,9 @@ pub enum MaskShape {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MaskComponent {
/// A name given in the Masking panel (Rename); `None` shows the shape's kind.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub op: MaskOp,
#[serde(default)]
@@ -802,11 +805,18 @@ pub struct Mask {
pub invert: bool,
pub components: Vec<MaskComponent>,
pub adjust: LocalAdjustments,
/// Refine Edges 0..100: the mask's edges snap to the photo's (guided filter).
#[serde(skip_serializing_if = "is_zero")]
pub refine: f64,
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
impl Default for Mask {
fn default() -> Self {
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default() }
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default(), refine: 0.0 }
}
}
+2
View File
@@ -17,10 +17,12 @@ lightcraft-scenes = { workspace = true }
lightcraft-pipeline = { workspace = true }
lightcraft-gpu = { workspace = true }
lightcraft-catalog = { workspace = true }
web-time = { workspace = true }
lightcraft-preview = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raw = { workspace = true }
lightcraft-meta = { workspace = true }
miniz_oxide = { workspace = true }
lightcraft-merge = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
+205
View File
@@ -2,6 +2,11 @@
//! The folder's photos are probed in place as `local` photos — only folder views list them —
//! and edits go to the files' XMP sidecars like any photo added in place. "Add to My Photos"
//! makes them ordinary library photos.
//!
//! Each browse records when the folders it listed were browsed. Untouched records of folders
//! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with
//! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files
//! and sidecars on disk are never touched, and browsing the folder again brings them back.
use std::path::Path;
@@ -35,12 +40,113 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
v
};
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
stamp_browsed(s, &dir_s, &files);
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
s.source = LibrarySource::Folder;
let shown = s.visible().len();
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
}
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
/// growing the log; far finer than the days the eviction counts in).
const RESTAMP_SECS: i64 = 3600;
/// Record that `root` and the folders of `files` were browsed now (journaled, not an undo step).
fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
let now = (s.clock)();
let now_s = lightcraft_catalog::stacks::iso_seconds(&now);
let mut folders: std::collections::BTreeSet<String> =
files.iter().filter_map(|f| Path::new(f).parent().map(|d| d.to_string_lossy().trim_end_matches(['/', '\\']).to_string())).collect();
folders.insert(root.to_string());
let ops: Vec<Op> = folders
.into_iter()
.filter(|f| {
let at = s.catalog.last_browsed(f).and_then(lightcraft_catalog::stacks::iso_seconds);
match (at, now_s) {
(Some(at), Some(n)) => n - at >= RESTAMP_SECS || n < at,
_ => true,
}
})
.map(|folder| Op::SetBrowsed { folder, at: Some(now.clone()) })
.collect();
if !ops.is_empty() {
let op = Op::Batch { ops };
if s.catalog.apply(op.clone()).is_ok() {
s.pending_log.push(op);
}
}
}
/// Photos the session still needs, whatever their state: shown, selected, being edited, or
/// referred to by an undo / redo step.
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect();
keep.extend(s.selection.ids.iter().copied());
keep.extend(s.selection.active);
keep.extend(s.previous_active);
keep.extend(s.interaction.as_ref().map(|i| i.photo));
keep.extend(s.before.keys().copied());
for e in s.undo.iter().chain(s.redo.iter()) {
e.op.photo_ids(&mut |id| {
keep.insert(id);
});
}
keep
}
impl Session {
/// Forget untouched Local records whose folder wasn't browsed for `days` (default
/// [`Session::forget_local_days`]; 0 = never). With `dry_run`, only report what would go.
/// Journaled (durable, replayed after a crash) but not an undo step; nothing on disk changes.
pub fn forget_local(&mut self, dry_run: bool, days: Option<u32>) -> lightcraft_catalog::ForgetPlan {
let days = days.unwrap_or(self.forget_local_days);
let now = (self.clock)();
let keep = in_use(self);
let smart = self.media.smart_dir.clone();
let plan = self
.catalog
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| d.join(crate::smart::file_name(p)).exists()));
if dry_run {
return plan;
}
for op in plan.ops() {
match self.catalog.apply(op.clone()) {
Ok(_) => self.pending_log.push(op),
Err(e) => log::warn!("forget Local records: {e}"),
}
}
if !plan.evict.is_empty() {
log::info!("forgot {} untouched Local record(s) of folders not browsed for {days} days", plan.evict.len());
}
plan
}
}
fn forget_local(s: &mut Session, p: &Value) -> Result<Value> {
let dry_run = bool_or(p, "dryRun", false);
let days = match p.get("days") {
None | Some(Value::Null) => None,
Some(v) => {
Some(v.as_u64().ok_or_else(|| bad("library.forgetLocal", "days must be a whole number (0 = never)"))?.min(u64::from(u32::MAX)) as u32)
}
};
let used = days.unwrap_or(s.forget_local_days);
let plan = s.forget_local(dry_run, days);
if !dry_run && !plan.evict.is_empty() {
s.compact_soon();
}
Ok(json!({
"dryRun": dry_run,
"days": used,
"local": plan.local,
"forgotten": plan.evict.len(),
"keptRecent": plan.kept_recent,
"keptTouched": plan.kept_touched,
"keptInUse": plan.kept_in_use,
"foldersStamped": plan.stamp.len(),
}))
}
/// The browsed photos among `ids` (default: the selection) become library photos.
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
@@ -55,6 +161,40 @@ fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"folder.rename",
"Rename Folder",
[],
None,
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; not an undo step → {path, relinked}",
always,
|s, p| {
let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
.ok_or_else(|| bad("folder.rename", "give a folder name (no slashes)"))?;
let to = Path::new(&from).with_file_name(name).to_string_lossy().to_string();
let n = move_folder(s, &from, &to).map_err(|e| bad("folder.rename", e))?;
Ok(json!({"path": to, "relinked": n}))
}
),
cmd!(
"folder.move",
"Move Folder",
[],
None,
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; not an undo step → {path, relinked}",
always,
|s, p| {
let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
let into = str_param(p, "into").ok_or_else(|| bad("folder.move", "missing into"))?;
let name = Path::new(&from).file_name().ok_or_else(|| bad("folder.move", "bad path"))?;
let to = Path::new(into).join(name).to_string_lossy().to_string();
let n = move_folder(s, &from, &to).map_err(|e| bad("folder.move", e))?;
Ok(json!({"path": to, "relinked": n}))
}
),
cmd!(
"library.browse",
"Browse Folder",
@@ -64,6 +204,71 @@ pub fn specs() -> Vec<CommandSpec> {
always,
browse
),
cmd!(
"library.forgetLocal",
"Forget Unchanged Local Photos",
[],
None,
"{dryRun?: bool, days?: n (default: the library preference, 0 = never)} — remove Local (browsed, not added) photos nobody changed, of folders not browsed for `days`, from the catalog; files and sidecars stay, browsing the folder again brings them back; not an undo step → {local, forgotten, keptRecent, keptTouched, keptInUse, foldersStamped, days, dryRun}",
always,
forget_local
),
cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library),
]
}
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
/// the photos inside. Like a rename in the file manager it is not an undo step: undoing would
/// point the photos back at a folder that no longer exists.
pub(crate) fn move_folder(s: &mut crate::Session, from: &str, to: &str) -> std::result::Result<usize, String> {
use std::path::Path;
let (src, dst) = (Path::new(from), Path::new(to));
if !src.is_dir() {
return Err(format!("{from} is not a folder"));
}
if dst.exists() {
return Err(format!("{to} already exists"));
}
if dst.starts_with(src) {
return Err("a folder can't move into itself".into());
}
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
return Err("the library's own folders can't be moved here".into());
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))?;
let mut n = 0;
let ops: Vec<lightcraft_catalog::Op> = s
.catalog
.photos()
.filter_map(|p| match &p.source {
lightcraft_catalog::Source::File { path } => Path::new(path).strip_prefix(src).ok().map(|rel| (p.id, p.file_name.clone(), dst.join(rel))),
_ => None,
})
.map(|(id, file_name, np)| {
n += 1;
lightcraft_catalog::Op::Relink {
id,
file_name,
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
format: None,
}
})
.collect();
for op in ops {
if s.catalog.apply(op.clone()).is_ok() {
s.pending_log.push(op);
}
}
// a browsed folder at or below it follows
if let Some(b) = &mut s.browse
&& let Ok(rel) = Path::new(&b.path).strip_prefix(src)
{
b.path = dst.join(rel).to_string_lossy().to_string();
let f = b.path.clone();
s.filter.folder = Some(f);
}
Ok(n)
}
+290
View File
@@ -0,0 +1,290 @@
//! Convert to DNG: a raw photo's original is re-encoded as a lossless DNG (its develop settings
//! embedded as XMP) next to it, and the photo is relinked to the DNG. The original stays on
//! disk; undo relinks the photo to it.
use std::path::Path;
use lightcraft_catalog::{MediaKind, Op, Source};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_active, has_selection};
use crate::{Result, Session};
/// `<stem>.dng`, then `<stem>-2.dng`, `<stem>-3.dng`… next to `path`.
fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
let p = Path::new(path);
let dir = p.parent().unwrap_or(Path::new("")).to_path_buf();
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
(1u64..).map(move |n| if n == 1 { dir.join(format!("{stem}.dng")) } else { dir.join(format!("{stem}-{n}.dng")) })
}
/// Write the DNG for raw file `path` (develop settings in `packet`) next to it under a free name;
/// returns the new path. An empty `packet` writes no XMP.
///
/// The DNG is checked before anyone relies on it (Convert to DNG relinks the photo to it, Copy as
/// DNG deletes the raw copy): the encoded file must decode to the same raw data, and it is
/// written to a temp file, synced, read back identical and only then given its name — never
/// replacing an existing file. On any failure no DNG is left behind and the raw stays in use.
pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result<String, String> {
let bytes = match &s.media.file_bytes {
Some(r) => r(path)?,
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
};
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
drop(bytes);
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
.map_err(|e| e.to_string())?;
let back = lightcraft_raw::decode(&dng).map_err(|e| format!("{path}: the DNG written for it doesn't decode ({e}); the raw is kept"))?;
let same = match (&back.data, &raw.data) {
(lightcraft_raw::RawData::U16(a), lightcraft_raw::RawData::U16(b)) => a == b,
(lightcraft_raw::RawData::F32(a), lightcraft_raw::RawData::F32(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits())
}
_ => false,
};
if (back.width, back.height, back.cpp) != (raw.width, raw.height, raw.cpp) || !same {
return Err(format!("{path}: the DNG written for it doesn't hold the same raw data; the raw is kept"));
}
drop((raw, back));
let out = lightcraft_catalog::safe_file::write_new_unique(&mut dng_names(path), &dng)
.map_err(|e| format!("{path}: could not write its DNG ({e}); the raw is kept"))?;
Ok(out.to_string_lossy().to_string())
}
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.convertToDng";
let ids = s.targets(p);
let mut ops = Vec::new();
let mut converted = Vec::new();
let mut skipped = Vec::new();
for id in ids {
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
let Source::File { path } = &ph.source else {
skipped.push(json!([id.0, "not a file"]));
continue;
};
if ph.kind != MediaKind::Raw || ph.format.eq_ignore_ascii_case("DNG") || ph.copy_of.is_some() {
skipped.push(json!([
id.0,
if ph.kind != MediaKind::Raw {
"not a raw photo"
} else if ph.copy_of.is_some() {
"a virtual copy"
} else {
"already a DNG"
}
]));
continue;
}
let packet = crate::sidecar::sidecar_packet(&ph, &s.catalog);
match write_dng_for(s, path, packet) {
Ok(out) => {
let file_name = Path::new(&out).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
// virtual copies of this photo follow it to the DNG
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
ops.push(Op::Relink {
id: c.id,
file_name: file_name.clone(),
source: Source::File { path: out.clone() },
format: Some("DNG".into()),
});
}
ops.push(Op::Relink { id, file_name, source: Source::File { path: out.clone() }, format: Some("DNG".into()) });
converted.push(json!({"id": id.0, "path": out, "original": path}));
}
Err(e) => skipped.push(json!([id.0, e])),
}
}
if !ops.is_empty() {
let n = converted.len();
s.commit(&format!("Convert {n} Photo{} to DNG", if n == 1 { "" } else { "s" }), Op::Batch { ops }).map_err(|e| bad(C, e.to_string()))?;
}
Ok(json!({"converted": converted, "skipped": skipped}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"photo.convertToDng",
"Convert to DNG",
["Photo"],
None,
"{ids?} — write each raw photo as a lossless DNG next to it (settings embedded) and relink the photo to it; originals are kept → {converted: [{id, path, original}], skipped}",
has_selection,
convert
)]
}
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
/// TIFF next to the original (`<name>-Edit.tif`, never overwriting), add it to the library and
/// stack it on top of the original. The host opens the file in the editor.
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.editExternal";
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?;
let dir = match &ph.source {
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
Source::Demo { .. } => match super::str_param(p, "dir") {
Some(d) => d.to_string(),
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
},
};
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
};
let opts = crate::export::ExportOptions {
format: crate::export::ExportFormat::Tiff,
bit_depth: Some(16),
color_space: space,
naming: "{name}-Edit".into(),
conflict: crate::export::Conflict::Unique,
..Default::default()
};
let mut written = Vec::new();
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
crate::export::write_file(path, bytes)?;
written.push(path.to_string());
Ok(())
};
let to = crate::export::Destination { dir, exact: None };
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?;
let r = s.execute("library.import", &json!({"paths": [out]}))?;
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
// stack: the edit on top of the original, expanded so both show
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new));
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> {
let ids = s.targets(p);
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
.iter()
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
Some(Source::File { path }) => Some((*id, path)),
_ => None,
})
.collect();
let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
let mut ops = Vec::new();
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 };
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);
}
for id in &reloaded {
s.media.forget(*id);
}
if !ops.is_empty() {
s.commit("Reload Changed Files", Op::Batch { ops })?;
}
Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// Duplicate: copy the file next to itself (`<name>-copy`, never overwriting) and add it with the
/// same settings, metadata, rating, flag, label and albums. → {ids}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.duplicate";
let mut made = Vec::new();
for id in s.targets(p) {
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
let src = Path::new(path);
let stem = src.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
let dir = src.parent().unwrap_or(Path::new(""));
let mut dst = dir.join(format!("{stem}-copy{ext}"));
let mut n = 2;
while dst.exists() {
dst = dir.join(format!("{stem}-copy-{n}{ext}"));
n += 1;
}
std::fs::copy(src, &dst).map_err(|e| bad(C, format!("{path}: {e}")))?;
let new = s.catalog.alloc_photo_id();
let mut q = ph.clone();
q.id = new;
q.source = Source::File { path: dst.to_string_lossy().to_string() };
q.file_name = dst.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
// the same bytes, but its own photo: no duplicate-content clash, no shared history
q.content_hash = q.content_hash.map(|h| format!("{h}:dup{}", new.0));
q.copy_of = None;
q.copy_name = None;
q.history.clear();
q.versions.retain(|v| !v.auto);
let mut ops = vec![Op::AddPhoto { photo: Box::new(q) }];
for a in s.catalog.albums().filter(|a| !a.is_smart() && !a.folder && a.photos.contains(&id)) {
let mut photos = a.photos.clone();
photos.push(new);
ops.push(Op::SetAlbumPhotos { id: a.id, photos });
}
s.commit("Duplicate", Op::Batch { ops })?;
made.push(new);
}
s.merge_undo(made.len(), "Duplicate");
if let Some(last) = made.last() {
s.selection = crate::Selection { ids: made.clone(), active: Some(*last) };
}
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
pub fn edit_specs() -> Vec<CommandSpec> {
vec![
cmd!(
"photo.duplicate",
"Duplicate",
["Photo"],
None,
"{ids?} — copy each photo's file next to it (`-copy`) and add it with the same settings, metadata and albums (a real file, unlike a virtual copy) → {ids}",
has_selection,
duplicate
),
cmd!(
"photo.reload",
"Reload from Disk",
[],
None,
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}",
has_selection,
reload
),
cmd!(
"photo.editExternal",
"Edit Copy for External Editor",
[],
None,
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `<name>-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
has_active,
edit_external
),
]
}
+141
View File
@@ -0,0 +1,141 @@
//! Assisted culling: score photos for focus and clipping, group similar shots taken close
//! together (bursts) and mark the sharpest of each group; optionally reject blurry ones and pick
//! each group's best. Scores are kept on the photos (rules / smart albums: `sharpness`,
//! `bestOfGroup`).
use lightcraft_catalog::{Analysis, Flag, Op, PhotoId};
use serde_json::{Value, json};
use super::{CommandSpec, always, cmd};
use crate::{Result, Session};
/// Seconds between shots of the same burst at most.
const BURST_GAP: i64 = 10;
/// Signature similarity for "the same scene".
const SAME: f32 = 0.93;
fn secs(iso: &str) -> Option<i64> {
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
}
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
let mut rows: Vec<(PhotoId, Option<i64>, f32, f32, [f32; 64])> = Vec::new();
let mut failed = Vec::new();
for id in ids {
match s.source_now(id, crate::media::SourceLevel::Thumb) {
Ok(src) => {
let t = s.catalog.photo(id).and_then(|p| p.captured.as_deref().and_then(secs));
use lightcraft_pipeline::cull;
rows.push((id, t, cull::sharpness(&src), cull::clipped(&src), cull::signature(&src)));
}
Err(e) => failed.push(json!([id.0, e])),
}
}
// bursts: consecutive (by capture time) look-alike shots
rows.sort_by_key(|r| (r.1.unwrap_or(i64::MAX), r.0));
let mut group_of: Vec<Option<u32>> = vec![None; rows.len()];
let mut next = 1u32;
for i in 1..rows.len() {
let (a, b) = (&rows[i - 1], &rows[i]);
let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP);
if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME {
let g = *group_of[i - 1].get_or_insert_with(|| {
next += 1;
next - 1
});
group_of[i] = Some(g);
}
}
let mut best: std::collections::HashMap<u32, (usize, f32)> = Default::default();
for (i, g) in group_of.iter().enumerate() {
if let Some(g) = g {
let e = best.entry(*g).or_insert((i, -1.0));
if rows[i].2 > e.1 {
*e = (i, rows[i].2);
}
}
}
let mut ops = Vec::new();
let (mut rejected, mut picked) = (0, 0);
let mut out = Vec::new();
for (i, (id, _, sharp, clip, _)) in rows.iter().enumerate() {
let g = group_of[i];
let is_best = g.is_some_and(|g| best.get(&g).is_some_and(|b| b.0 == i));
ops.push(Op::SetAnalysis { id: *id, analysis: Some(Analysis { sharpness: *sharp, clipped: *clip, group: g, best: is_best }) });
let flag = s.catalog.photo(*id).map(|p| p.flag).unwrap_or_default();
if reject_below.is_some_and(|t| *sharp < t) && flag != Flag::Reject {
ops.push(Op::SetFlag { id: *id, flag: Flag::Reject });
rejected += 1;
} else if pick_best && is_best && flag == Flag::None {
ops.push(Op::SetFlag { id: *id, flag: Flag::Pick });
picked += 1;
}
out.push(json!({"id": id.0, "sharpness": (sharp * 10.0).round() / 10.0, "clipped": clip, "group": g, "best": is_best}));
}
if !ops.is_empty() {
s.commit("Assisted Culling", Op::Batch { ops })?;
}
Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed}))
}
/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by
/// content), most similar first; the view is filtered to them.
fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.findSimilar";
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
let all: Vec<PhotoId> = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect();
let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> {
let key = s.catalog.photo(id).map(|p| crate::media::content_key(p))?;
if let Some(v) = s.signatures.get(&key) {
return Some(*v);
}
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
let v = lightcraft_pipeline::cull::signature(&src);
s.signatures.insert(key, v);
Some(v)
};
let me = sig(s, id).ok_or_else(|| super::bad(C, "can't read the photo"))?;
let mut hits: Vec<(PhotoId, f32)> = Vec::new();
for q in all {
if let Some(v) = sig(s, q) {
let sim = lightcraft_pipeline::cull::similarity(&me, &v);
if sim >= min {
hits.push((q, sim));
}
}
}
hits.sort_by(|a, b| b.1.total_cmp(&a.1));
if p.get("filter").and_then(Value::as_bool).unwrap_or(true) {
s.filter.only = hits.iter().map(|h| h.0).collect();
}
Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::<Vec<_>>()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"library.findSimilar",
"Find Similar Photos",
["Photo"],
None,
"{id?, similarity?: 0..1 (0.8), filter?: bool (true)} — photos that look like the active one (composition and tones), most similar first; filters the view to them (Clear Filters to go back) → {photos: [{id, similarity}]}",
always,
find_similar
),
cmd!(
"photo.analyze",
"Assisted Culling",
[],
None,
"{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}",
always,
analyze
),
]
}
+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()}))
}
+110 -2
View File
@@ -193,6 +193,28 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
}),
cmd!(
"develop.autoBwMix",
"Auto B&W Mix",
[],
None,
"{} — sets the black & white mix from the photo's colours (and switches to B&W)",
has_active,
|s, _| {
let id = active(s, "develop.autoBwMix")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let d = s.develop_of(id).unwrap_or_default();
let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d);
edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| {
d.treatment = Treatment::Bw;
let b = &mut d.bw_mix;
[b.red, b.orange, b.yellow, b.green, b.aqua, b.blue, b.purple, b.magenta] = m;
Ok(())
})?;
Ok(json!({"mix": m}))
}
),
cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| {
let id = active(s, "develop.auto")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?;
@@ -288,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("develop.profile", "Set Profile", [], None, "{id: profile id (see profiles.list), amount?: 0..200}", has_active, |s, p| {
let id = str_param(p, "id").ok_or_else(|| bad("develop.profile", "missing id"))?.to_string();
if !crate::presets::PROFILES.iter().any(|x| x.id == id) {
if !crate::presets::PROFILES.iter().any(|x| x.id == id) && !s.lut_profiles.iter().any(|x| x.id == id) {
return Err(bad("develop.profile", format!("unknown profile `{id}`")));
}
edit(s, "develop.profile", "Profile", |d| {
@@ -301,7 +323,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("profile.favorite", "Favorite Profile", [], None, "{id, favorite?: bool} (toggles when omitted)", always, |s, p| {
let id = str_param(p, "id").ok_or_else(|| bad("profile.favorite", "missing id"))?.to_string();
if crate::presets::profile(&id).is_none() {
if crate::presets::profile(&id).is_none() && !s.lut_profiles.iter().any(|x| x.id == id) {
return Err(bad("profile.favorite", format!("unknown profile `{id}`")));
}
let on = s.profile_favorites.contains(&id);
@@ -616,6 +638,92 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"changed": n}))
}
),
cmd!(
"develop.quickAdjust",
"Quick Develop",
[],
None,
"{control: develop control id, delta, ids?} — add `delta` to the control on every target photo (each from its own value; Quick Develop) in one undo step → {changed}",
has_selection,
|s, p| {
let c = "develop.quickAdjust";
let ctl = str_param(p, "control").ok_or_else(|| bad(c, "missing control"))?.to_string();
let spec = controls::find(&ctl).ok_or_else(|| bad(c, format!("unknown control `{ctl}`")))?;
let delta = f64_req(p, "delta", c)?;
let label = format!("Quick Develop: {}", spec.label);
let ops: Vec<Op> = s
.targets(p)
.into_iter()
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter_map(|(id, d)| {
let mut nd = (*d).clone();
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
controls::set(&mut nd, &ctl, v + delta);
if ctl == "wb.temp" || ctl == "wb.tint" {
nd.wb.mode = WbMode::Custom;
}
s.develop_op(id, nd, &label)
})
.collect();
let n = ops.len();
if n > 0 {
s.commit(&label, Op::Batch { ops })?;
}
Ok(json!({"changed": n}))
}
),
cmd!(
"develop.matchExposure",
"Match Total Exposures",
["Photo"],
None,
"{ids?} — set each selected photo's Exposure so its total exposure (shutter × ISO ÷ aperture², plus the slider) equals the active photo's; photos without exposure data are skipped → {changed, skipped}",
has_selection,
|s, p| {
let c = "develop.matchExposure";
let reference = active(s, c)?;
// log2 of the light the photo gathered (shutter s × ISO / f-number²)
let gathered = |s: &Session, id: PhotoId| -> Option<f64> {
let m = &s.catalog.photo(id)?.meta;
let t = match m.shutter.split_once('/') {
Some((a, b)) => a.trim().parse::<f64>().ok()? / b.trim().parse::<f64>().ok()?,
None => m.shutter.trim().trim_end_matches('s').trim().parse::<f64>().ok()?,
};
let (n, iso) = (m.aperture? as f64, m.iso? as f64);
(t > 0.0 && n > 0.0 && iso > 0.0).then(|| (t * iso / (n * n)).log2())
};
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
for id in ids {
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
skipped.push(id.0);
continue;
};
let mut nd = (*d).clone();
nd.light.exposure = (r_slider + r_ev - ev).clamp(-5.0, 5.0);
ops.extend(s.develop_op(id, nd, "Match Total Exposures"));
}
let n = ops.len();
if n > 0 {
s.commit("Match Total Exposures", Op::Batch { ops })?;
}
Ok(json!({"changed": n, "skipped": skipped}))
}
),
cmd!(
"develop.autoSync",
"Auto Sync",
["Edit"],
Some("Cmd+Alt+Shift+A"),
"{on?: bool} (toggles when omitted) — edits to the active photo also change the other selected photos (only the settings that changed; not spot removal / red eye) → {on}",
always,
|s, p| {
s.auto_sync = p.get("on").and_then(Value::as_bool).unwrap_or(!s.auto_sync);
Ok(json!({"on": s.auto_sync}))
}
),
// ---- presets
cmd!("preset.apply", "Apply Preset", [], None, "{id: presetId, amount?: 0..200 (percent), ids?}", has_selection, |s, p| {
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
+13
View File
@@ -96,5 +96,18 @@ pub fn specs() -> Vec<CommandSpec> {
save
),
cmd!("export.deletePreset", "Delete Export Preset", [], None, "{name} — removes a user preset → presets", always, delete),
cmd!(
query "export.checkTarget",
"Check Output Path",
[],
None,
"{path} — fails when writing `path` would replace a photo's original (or its XMP sidecar) in the library; front ends call it before saving a render or screenshot to a user-given path → {path}",
always,
|s, p| {
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| bad("export.checkTarget", "missing `path`"))?;
s.check_write_target(path).map_err(|e| bad("export.checkTarget", e))?;
Ok(json!({"path": path}))
}
),
]
}
+71
View File
@@ -0,0 +1,71 @@
//! Filter presets: the filter bar's settings saved under a name ("Five stars", "Unedited raws")
//! and applied in one step. Saved with the library.
use lightcraft_catalog::Filter;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FilterPreset {
pub name: String,
pub filter: Filter,
}
fn list(s: &Session) -> Value {
json!(s.filter_presets)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.savePreset";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
if s.filter == Filter::default() {
return Err(bad(C, "the filter is empty: set some filters first"));
}
let preset = FilterPreset { name: name.clone(), filter: s.filter.clone() };
match s.filter_presets.iter_mut().find(|f| f.name.eq_ignore_ascii_case(&name)) {
Some(f) => *f = preset,
None => s.filter_presets.push(preset),
}
s.save_prefs()?;
Ok(list(s))
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.applyPreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let f = s.filter_presets.iter().find(|f| f.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no filter preset `{name}`")))?;
s.filter = f.filter.clone();
Ok(json!({"photos": s.visible().len()}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.deletePreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let n = s.filter_presets.len();
s.filter_presets.retain(|f| !f.name.eq_ignore_ascii_case(name));
if n == s.filter_presets.len() {
return Err(bad(C, format!("no filter preset `{name}`")));
}
s.save_prefs()?;
Ok(list(s))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "filter.presets", "Filter Presets", [], None, "{} → [{name, filter}]", always, |s, _| Ok(list(s))),
cmd!(
"filter.savePreset",
"Save Filter Preset",
[],
None,
"{name} — the current filter (library.filter), saved with the library",
always,
save
),
cmd!("filter.applyPreset", "Apply Filter Preset", [], None, "{name} — replaces the current filter → {photos}", always, apply),
cmd!("filter.deletePreset", "Delete Filter Preset", [], None, "{name}", always, delete),
]
}
+133 -1
View File
@@ -1,4 +1,5 @@
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge.
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
use lightcraft_catalog::keywords::{clean, is_under};
use serde_json::{Value, json};
@@ -30,8 +31,139 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol
Ok(json!({"changed": n}))
}
/// A named set of up to nine keywords.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct KeywordSet {
pub name: String,
pub keywords: Vec<String>,
}
/// The set name meaning "the nine most recently added keywords".
pub const RECENT: &str = "Recent Keywords";
/// Remember `added` as the most recent keywords (newest first, nine kept).
pub fn note_recent(s: &mut Session, added: &[String]) {
for k in added.iter().rev() {
let k = clean(k);
if k.is_empty() {
continue;
}
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
s.recent_keywords.insert(0, k);
}
s.recent_keywords.truncate(9);
let _ = s.save_prefs();
}
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
pub fn current_keywords(s: &Session) -> Vec<String> {
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
v.truncate(9);
v
}
/// `{sets: [{name, keywords}], current, keywords}` (Recent Keywords first).
pub fn keyword_sets_json(s: &Session) -> Value {
let mut sets = vec![json!({"name": RECENT, "keywords": s.recent_keywords})];
sets.extend(s.keyword_sets.iter().map(|x| json!({"name": x.name, "keywords": x.keywords})));
json!({"sets": sets, "current": s.keyword_set.clone().unwrap_or_else(|| RECENT.into()), "keywords": current_keywords(s)})
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
None
} else {
Some(
s.keyword_sets
.iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
.name
.clone(),
)
};
s.save_prefs()?;
Ok(keyword_sets_json(s))
}),
cmd!(
"keyword.saveSet",
"Save Keyword Set",
[],
None,
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
always,
|s, p| {
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
.to_string();
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
} else {
current_keywords(s)
};
keywords.truncate(9);
let set = KeywordSet { name: name.clone(), keywords };
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = set,
None => s.keyword_sets.push(set),
}
s.keyword_set = Some(name);
s.save_prefs()?;
Ok(keyword_sets_json(s))
}
),
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
let before = s.keyword_sets.len();
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
if s.keyword_sets.len() == before {
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
}
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
s.keyword_set = None;
}
s.save_prefs()?;
Ok(keyword_sets_json(s))
}),
cmd!(
"keyword.toggleFromSet",
"Toggle Keyword from Set",
[],
None,
"{index: 1..9, ids?} — the set's keyword N: added to the target photos, or removed when they all have it",
super::has_selection,
|s, p| {
let i = p
.get("index")
.and_then(Value::as_u64)
.filter(|i| (1..=9).contains(i))
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
return Ok(json!({"changed": 0}));
};
let ids = s.targets(p);
let all = !ids.is_empty()
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
let key = if all { "removeKeywords" } else { "addKeywords" };
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
let recent = s.recent_keywords.clone();
let mut r = s.execute("photo.setMeta", &json!({"ids": ids, key: [k.clone()]}))?;
if s.keyword_set.is_none() {
s.recent_keywords = recent;
}
r["keyword"] = json!(k);
r["added"] = json!(!all);
Ok(r)
}
),
cmd!(query "keyword.list", "Keywords", [], None, "{} → [{name, path, count, children}] keyword tree (`a|b|c` keywords are hierarchical)", always, |s, _| {
Ok(serde_json::to_value(s.catalog.keyword_tree()).unwrap_or_default())
}),
+327 -34
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};
@@ -128,34 +128,43 @@ fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
// ---- view source / filter / sort
cmd!("library.source", "Show Source", [], None, "{kind: all|recentlyAdded|album|recentlyDeleted|picks, id?: albumId}", always, |s, p| {
let kind = str_param(p, "kind").unwrap_or("all");
s.source = match kind {
"all" => LibrarySource::All,
"recentlyAdded" => LibrarySource::RecentlyAdded,
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
"picks" => LibrarySource::Picks,
"album" => {
let a = album_param(p, "id", "library.source")?;
if s.catalog.album(a).is_none_or(|a| a.folder) {
return Err(bad("library.source", "no such album"));
cmd!(
"library.source",
"Show Source",
[],
None,
"{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}",
always,
|s, p| {
let kind = str_param(p, "kind").unwrap_or("all");
s.source = match kind {
"all" => LibrarySource::All,
"recentlyAdded" => LibrarySource::RecentlyAdded,
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
"picks" => LibrarySource::Picks,
"missing" => LibrarySource::Missing,
"album" => {
let a = album_param(p, "id", "library.source")?;
if s.catalog.album(a).is_none_or(|a| a.folder) {
return Err(bad("library.source", "no such album"));
}
LibrarySource::Album(a)
}
LibrarySource::Album(a)
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
};
let vis = s.visible_cloned();
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
}
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
};
let vis = s.visible_cloned();
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
Ok(json!({"count": vis.len()}))
}
Ok(json!({"count": vis.len()}))
}),
),
cmd!(
"library.filter",
"Filter",
[],
None,
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, edited?, date?, keyword?, camera?}",
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, camera?}",
always,
|s, p| {
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
@@ -241,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)
@@ -257,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";
@@ -361,20 +371,49 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Info",
[],
None,
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, 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 =
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
let targets = s.targets(p);
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(t)) if t.trim().is_empty() => Some(None),
Some(Value::String(t)) => Some(Some(
parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?,
)),
Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) {
(Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))),
_ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")),
},
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 };
let mut m = ph.meta.clone();
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),
@@ -402,6 +441,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
let n = ops.len();
s.commit("Edit Info", Op::Batch { ops })?;
// keywords just added become the Recent Keywords set
let added: Vec<String> = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect();
if n > 0 && !added.is_empty() {
crate::cmd::keywords::note_recent(s, &added);
}
Ok(json!({"changed": n}))
}
),
@@ -418,7 +462,7 @@ pub fn specs() -> Vec<CommandSpec> {
let cover = photos.first().copied();
s.commit(
if folder { "New Folder" } else { "New Album" },
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None } },
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false } },
)?;
Ok(json!({"id": id.0}))
}),
@@ -427,7 +471,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Smart Album…",
[],
None,
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)",
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)",
always,
|s, p| {
let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string();
@@ -445,6 +489,27 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
}
),
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
Ok(json!(FIELDS
.iter()
.map(|(id, label, kind)| {
let k = match kind {
Kind::Text => "text",
Kind::Keywords => "keywords",
Kind::Number => "number",
Kind::Date => "date",
Kind::Choice(_) => "choice",
Kind::Bool => "bool",
};
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
if let Kind::Choice(c) = kind {
v["choices"] = json!(c);
}
v
})
.collect::<Vec<_>>()))
}),
cmd!(
"album.setRules",
"Edit Smart Album",
@@ -504,6 +569,145 @@ pub fn specs() -> Vec<CommandSpec> {
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
Ok(json!({"added": added}))
}),
cmd!(
"album.toggleTarget",
"Add to Target Album",
[],
None,
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}",
has_selection,
|s, p| {
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
let id = match target.or_else(|| s.catalog.quick_collection()) {
Some(id) => id,
None => {
let id = s.catalog.alloc_album_id();
let album = Album { quick: true, ..Album::new(id, "Quick Collection") };
s.commit("New Quick Collection", Op::AddAlbum { album })?;
id
}
};
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
let photos: Vec<PhotoId> = if all_in {
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
} else {
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
};
let name = al.name.clone();
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
s.commit(
if all_in { "Remove from Target Album" } else { "Add to Target Album" },
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] },
)?;
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)}))
}
),
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
s.target_album = match p.get("id").and_then(Value::as_u64) {
Some(a) => {
let id = AlbumId(a);
let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.setTarget", "smart albums and folders can't hold photos"));
}
Some(id)
}
None => None,
};
Ok(json!({"target": s.target_album.map(|a| a.0)}))
}),
cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| {
let Some(id) = s.catalog.quick_collection() else { return ok() };
s.commit(
"Clear Quick Collection",
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] },
)?;
ok()
}),
cmd!(
"library.autoImport",
"Auto Import Settings",
[],
None,
"{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings",
always,
|s, p| {
if let Some(f) = p.get("folder") {
s.import_defaults.auto_folder = match f.as_str().map(str::trim).filter(|f| !f.is_empty()) {
Some(f) if std::path::Path::new(f).is_dir() || cfg!(target_arch = "wasm32") => Some(f.to_string()),
Some(f) => return Err(bad("library.autoImport", format!("`{f}` is not a folder"))),
None => None,
};
}
if let Some(c) = p.get("copy").and_then(Value::as_bool) {
s.import_defaults.auto_copy = c;
}
if let Some(a) = p.get("album") {
s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string);
}
s.save_prefs()?;
let d = &s.import_defaults;
Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album}))
}
),
cmd!(
"library.autoImportScan",
"Auto Import Now",
[],
None,
"{} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder}",
always,
|s, _| {
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
// only files that stopped growing: a file still being written is left for later
let known: std::collections::HashSet<String> = s
.catalog
.photos()
.filter_map(|p| match &p.source {
lightcraft_catalog::Source::File { path } => Some(path.clone()),
_ => None,
})
.collect();
let mut fresh = Vec::new();
for e in std::fs::read_dir(&folder).map_err(|e| bad("library.autoImportScan", format!("{folder}: {e}")))?.flatten() {
let path = e.path();
let ps = path.to_string_lossy().to_string();
if !path.is_file() || known.contains(&ps) || path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
continue;
}
// each file is tried once (a non-photo isn't retried every scan)
if s.auto_import_seen.get(&ps) == Some(&u64::MAX) {
continue;
}
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
let seen = s.auto_import_seen.insert(ps.clone(), size);
if size > 0 && seen == Some(size) {
fresh.push(ps);
}
}
if fresh.is_empty() {
return Ok(json!({"imported": [], "folder": folder}));
}
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
let mut params = json!({"paths": fresh, "mode": mode});
if let Some(a) = s.import_defaults.auto_album.clone() {
match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) {
Some(al) => params["album"] = json!(al.id.0),
None => params["albumName"] = json!(a),
}
}
for f in &fresh {
s.auto_import_seen.insert(f.clone(), u64::MAX);
}
let sel = s.selection.clone();
let r = s.execute("library.import", &params)?;
// arriving photos don't take over the selection
s.selection = sel;
Ok(json!({"imported": r["imported"], "folder": folder}))
}
),
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
let id = album_param(p, "id", "album.removePhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
@@ -527,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");
@@ -541,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/), 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");
@@ -554,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) => {
@@ -568,7 +773,38 @@ pub fn specs() -> Vec<CommandSpec> {
{
return Err(bad("library.import", "album must be a regular album"));
}
let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords"), ..Default::default() };
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, 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))
{
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
}
let opts = crate::import::ImportOptions {
mode,
preset,
keywords: strs(p, "keywords"),
destination: str_param(p, "destination").map(str::to_string),
organize,
rename: str_param(p, "rename").map(str::to_string),
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),
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();
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
@@ -582,6 +818,8 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(a) = album
&& !imported.is_empty()
{
// (album.addPhotos wants a selection: the new photos are about to be it)
s.selection = Selection::single(PhotoId(imported[0]));
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
report["album"] = json!(a);
}
@@ -629,6 +867,12 @@ pub fn specs() -> Vec<CommandSpec> {
"logRecords": j.log_records(),
"logBytes": j.log_bytes(),
"lastError": lib.last_error,
// changes applied in memory whose write failed (retried by every save)
"unsavedOps": s.unsaved().map_or(0, |u| u.0),
"unsavedError": s.unsaved().map(|u| u.1),
// untouched Local records forgotten when the library opened
"forgotLocal": lib.forgot_local.as_ref().map(|p| json!({"forgotten": p.evict.len(), "local": p.local, "keptRecent": p.kept_recent, "keptTouched": p.kept_touched, "keptInUse": p.kept_in_use})),
"persistence": j.stats(),
"cache": cache,
"load": {
"created": lib.report.created,
@@ -653,7 +897,11 @@ pub fn specs() -> Vec<CommandSpec> {
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
let mut v = serde_json::to_value(base).unwrap_or_default();
lightcraft_develop::presets::deep_merge(&mut v, patch);
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
return Err(bad(c, problem));
}
Ok(f)
}
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
@@ -697,3 +945,48 @@ impl Session {
fn has_library(s: &Session) -> std::result::Result<(), String> {
if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) }
}
/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W",
/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs).
pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> {
let t = t.trim();
// split into the two coordinates: at a comma, else after the first N/S hemisphere letter
let (a, b) = match t.split_once(',') {
Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
None => {
let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?;
(t[..i].trim().to_string(), t[i..].trim().to_string())
}
};
let one = |s: &str, pos: char, neg: char| -> Option<f64> {
let up = s.to_ascii_uppercase();
let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 };
let nums: Vec<f64> =
up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::<f64>().ok()).collect::<Option<_>>()?;
let v = match nums.as_slice() {
[d] => *d,
[d, m] => d + m / 60.0,
[d, m, s] => d + m / 60.0 + s / 3600.0,
_ => return None,
};
let _ = pos;
Some(sign * v)
};
let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?);
(la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo))
}
#[cfg(test)]
mod gps_tests {
#[test]
fn coordinates_people_type() {
let p = super::parse_gps;
assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276)));
let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap();
assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}");
let (la, lo) = p("33.8688 S, 151.2093 E").unwrap();
assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9);
assert_eq!(p("95, 10"), None);
assert_eq!(p("hello"), None);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Imported profiles: `.cube` 3D LUTs used as creative profiles (Amount 0–200 % like the
//! built-in ones). A library on disk keeps a copy of each file in its `Profiles/` folder; the
//! list lives in the library's prefs and the LUTs are registered with the pipeline when the
//! library opens.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
/// One imported LUT profile.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LutProfile {
/// `lut:<slug>`.
pub id: String,
pub name: String,
pub group: String,
/// The `.cube` file (the library's copy when there is a library on disk).
pub file: String,
}
fn profiles_dir(s: &Session) -> Option<PathBuf> {
s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Profiles"))
}
/// Register every imported profile's LUT with the pipeline (missing files are skipped).
pub fn register_all(s: &Session) {
for p in &s.lut_profiles {
if let Some(l) = std::fs::read_to_string(&p.file).ok().and_then(|t| lightcraft_pipeline::lut::Lut3d::parse_cube(&t).ok()) {
lightcraft_pipeline::lut::register(&p.id, l);
}
}
}
/// `.cube` files in `paths` (folders searched, zip bundles opened).
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
// (name, text, group)
let mut out = Vec::new();
for p in paths {
let path = Path::new(p);
if path.is_dir() {
let mut stack = vec![path.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
let ep = e.path();
if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
continue;
}
if ep.is_dir() {
stack.push(ep);
} else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"))
&& let Ok(t) = std::fs::read_to_string(&ep)
{
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
}
}
}
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
let Ok(b) = std::fs::read(path) else { continue };
for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() {
if inner.to_ascii_lowercase().ends_with(".cube") {
let ip = Path::new(&inner);
let group = ip
.parent()
.and_then(|d| d.file_name())
.map(|n| n.to_string_lossy().to_string())
.or_else(|| path.file_stem().map(|n| n.to_string_lossy().to_string()));
out.push((
ip.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),
String::from_utf8_lossy(&data).to_string(),
group,
));
}
}
} else if let Ok(t) = std::fs::read_to_string(path) {
out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None));
}
}
out
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "profile.import";
let 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 paths.is_empty() {
return Err(bad(C, "no paths"));
}
let dir = profiles_dir(s);
if let Some(d) = &dir {
std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?;
}
let (mut imported, mut failed) = (Vec::new(), Vec::new());
for (name, text, group) in cube_files(&paths) {
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
Ok(l) => l,
Err(e) => {
failed.push(json!([name, e]));
continue;
}
};
let display = lut.title.clone().filter(|t| !t.trim().is_empty()).unwrap_or_else(|| name.clone());
let group = super::super::preset_import::group_from_dir(&group.unwrap_or_default()).unwrap_or_else(|| "Imported".into());
let slug = crate::presets::slug(&format!("{group}-{display}"));
let mut id = format!("lut:{slug}");
let mut n = 2;
while s.lut_profiles.iter().any(|x| x.id == id) {
id = format!("lut:{slug}-{n}");
n += 1;
}
let file = match &dir {
Some(d) => {
let f = d.join(format!("{}.cube", id.trim_start_matches("lut:")));
std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?;
f.to_string_lossy().to_string()
}
None => {
// no library on disk: remember the original (the in-memory session ends with the app)
let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:")));
std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?;
f.to_string_lossy().to_string()
}
};
lightcraft_pipeline::lut::register(&id, lut);
s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file });
imported.push(json!({"id": id, "name": display, "group": group}));
}
s.save_prefs()?;
Ok(json!({"imported": imported, "failed": failed}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"profile.import",
"Import Profiles",
[],
None,
"{paths: [.cube file, folder or .zip]} — 3D LUTs become creative profiles (Amount 0–200 %; rendered on the CPU) in the profile browser's groups (folder / zip name, else Imported) → {imported: [{id, name, group}], failed}",
always,
import
),
cmd!("profile.deleteImported", "Delete Imported Profile", [], None, "{id: lut:…}", always, |s, p| {
let id = str_param(p, "id").ok_or_else(|| bad("profile.deleteImported", "missing id"))?.to_string();
let Some(i) = s.lut_profiles.iter().position(|x| x.id == id) else {
return Err(bad("profile.deleteImported", format!("no imported profile `{id}`")));
};
let gone = s.lut_profiles.remove(i);
if profiles_dir(s).is_some_and(|d| Path::new(&gone.file).starts_with(d)) {
let _ = std::fs::remove_file(&gone.file);
}
lightcraft_pipeline::lut::unregister(&id);
s.save_prefs()?;
Ok(json!({"deleted": id}))
}),
]
}
+198 -3
View File
@@ -1,4 +1,4 @@
//! Photo management commands: batch rename, capture time, colour label names.
//! Photo management commands: batch rename, capture time, colour label names and sets.
use serde_json::{Value, json};
@@ -13,9 +13,149 @@ fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft
Ok((s.targets(p), template, start))
}
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LabelSet {
pub name: String,
pub names: [String; 5],
}
/// Our built-in sets.
pub fn builtin_label_sets() -> Vec<LabelSet> {
let set = |name: &str, n: [&str; 5]| LabelSet { name: name.into(), names: n.map(str::to_string) };
vec![set("Colors", ["", "", "", "", ""]), set("Review", ["Reject", "Needs Work", "Approved", "Retouch", "Print"])]
}
fn all_label_sets(s: &crate::Session) -> Vec<LabelSet> {
builtin_label_sets().into_iter().chain(s.label_sets.iter().cloned()).collect()
}
/// The set whose names are the catalog's current ones.
fn current_label_set(s: &crate::Session) -> Option<String> {
let cur: [String; 5] = ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string());
all_label_sets(s)
.into_iter()
.find(|x| x.names.iter().zip(&cur).all(|(a, b)| a.trim() == b.trim() || (a.trim().is_empty() && b.is_empty())))
.map(|x| x.name)
}
/// Every set (built-ins first) and the one in use: `{sets: [{name, names, builtin}], current}`.
pub fn label_sets_json(s: &crate::Session) -> Value {
let builtin = builtin_label_sets().len();
let sets: Vec<Value> =
all_label_sets(s).iter().enumerate().map(|(i, x)| json!({"name": x.name, "names": x.names, "builtin": i < builtin})).collect();
json!({"sets": sets, "current": current_label_set(s)})
}
fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
ColorLabel::ALL
.iter()
.zip(names)
.filter_map(|(l, n)| {
let n = n.trim();
let name = (!n.is_empty() && !n.eq_ignore_ascii_case(&format!("{l:?}"))).then(|| n.to_string());
(s.catalog.custom_label_name(*l) != name.as_deref()).then_some(Op::SetLabelName { label: *l, name })
})
.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())
}),
@@ -24,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")?;
@@ -81,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()
@@ -112,5 +261,51 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"changed": n}))
}
),
cmd!(query "label.sets", "Color Label Sets", [], None, "{} → {sets: [{name, names: [red, yellow, green, blue, purple], builtin}], current: name|null}", always, |s, _| Ok(label_sets_json(s))),
cmd!("label.applySet", "Apply Color Label Set", [], None, "{name} — use a set's label names (undoable)", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("label.applySet", "missing `name`"))?;
let set = all_label_sets(s)
.into_iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.ok_or_else(|| bad("label.applySet", format!("no label set `{name}`")))?;
let ops = set_names_ops(s, &set.names);
let n = ops.len();
if n > 0 {
s.commit(&format!("Label Set: {}", set.name), Op::Batch { ops })?;
}
Ok(json!({"changed": n}))
}),
cmd!(
"label.saveSet",
"Save Color Label Set",
[],
None,
"{name} — save the current label names as a set (replaces a user set of that name)",
always,
|s, p| {
let name =
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("label.saveSet", "missing `name`"))?.to_string();
if builtin_label_sets().iter().any(|b| b.name.eq_ignore_ascii_case(&name)) {
return Err(bad("label.saveSet", format!("`{name}` is a built-in set")));
}
let set = LabelSet { name: name.clone(), names: ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string()) };
match s.label_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = set,
None => s.label_sets.push(set),
}
s.save_prefs()?;
Ok(json!({"name": name}))
}
),
cmd!("label.deleteSet", "Delete Color Label Set", [], None, "{name} — user sets only", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("label.deleteSet", "missing `name`"))?;
let before = s.label_sets.len();
s.label_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
if s.label_sets.len() == before {
return Err(bad("label.deleteSet", format!("no user label set `{name}`")));
}
s.save_prefs()?;
Ok(json!({"deleted": name}))
}),
]
}
+151 -4
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
masks.push(Mask {
id: next,
name: name.unwrap_or_else(|| format!("Mask {next}")),
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }],
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
..Default::default()
});
*active = Some(next);
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
let mid = mask_id(p, active, "mask.addComponent")?;
masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
let i = find(masks, mid, "mask.addComponent")?;
masks[i].components.push(MaskComponent { op, invert: bool_or(p, "invert", false), shape });
masks[i].components.push(MaskComponent { name: None, op, invert: bool_or(p, "invert", false), shape });
Ok(())
})
}
@@ -144,12 +144,17 @@ pub fn specs() -> Vec<CommandSpec> {
}
};
let m = &mut masks[i];
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
{
strokes.push(stroke);
} else {
m.components.push(MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } });
m.components.push(MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Brush { strokes: vec![stroke] },
});
}
Ok(())
})
@@ -167,6 +172,64 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
}),
cmd!(
"mask.sampleColor",
"Sample Mask Color",
[],
None,
"{x, y: normalized image point, add?: bool (⇧: add to the samples, max 5), id?, component?} — the colour range of the selected mask (its first colour-range component) samples the colour there → {samples}",
has_active,
|s, p| {
let c = "mask.sampleColor";
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let src = s.source_now(id, crate::media::SourceLevel::Thumb).map_err(|e| bad(c, e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let d = s.develop_of(id).unwrap_or_default();
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
let mid = mask_id(p, s.active_mask, c)?;
let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize);
let mut out = Vec::new();
masks_edit(s, c, "Color Range", |masks, _| {
let i = find(masks, mid, c)?;
let k = comp
.or_else(|| masks[i].components.iter().position(|x| matches!(x.shape, MaskShape::ColorRange { .. })))
.ok_or_else(|| bad(c, "the mask has no colour range"))?;
let Some(MaskShape::ColorRange { samples, .. }) = masks[i].components.get_mut(k).map(|x| &mut x.shape) else {
return Err(bad(c, "not a colour range component"));
};
if !add {
samples.clear();
}
if samples.len() < 5 {
samples.push(lab);
}
out = samples.clone();
Ok(())
})?;
Ok(json!({"samples": out}))
}
),
cmd!(
"mask.refine",
"Refine Mask Edges",
[],
None,
"{id?, value: 0..100} — the mask's edges snap to the photo's (guided filter; rendered on the CPU)",
has_active,
|s, p| {
let v = f64_or(p, "value", 0.0).clamp(0.0, 100.0);
let mid = mask_id(p, s.active_mask, "mask.refine")?;
masks_edit(s, "mask.refine", "Refine Edges", |masks, _| {
let i = find(masks, mid, "mask.refine")?;
masks[i].refine = v;
Ok(())
})
}
),
cmd!(
"mask.adjust",
"Set Mask Adjustments",
@@ -258,6 +321,54 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"mask.component",
"Edit Mask Component",
[],
None,
"{id?, component: index, action: invert|duplicate|delete|rename|op, name? (rename; empty clears), op?: add|subtract|intersect} — one component of a mask (deleting the last one deletes the mask)",
has_active,
|s, p| {
let c = "mask.component";
let mid = mask_id(p, s.active_mask, c)?;
let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize;
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
let op: Option<MaskOp> = match p.get("op") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
None => None,
};
let label = match action.as_str() {
"invert" => "Invert Component",
"duplicate" => "Duplicate Component",
"delete" => "Delete Component",
"rename" => "Rename Component",
"op" => "Change Component Mode",
a => return Err(bad(c, format!("unknown action {a:?} (invert|duplicate|delete|rename|op)"))),
};
masks_edit(s, c, label, |masks, active| {
let i = find(masks, mid, c)?;
let comps = &mut masks[i].components;
let comp = comps.get_mut(k).ok_or_else(|| bad(c, "no such component"))?;
match action.as_str() {
"invert" => comp.invert = !comp.invert,
"rename" => comp.name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string),
"op" => comp.op = op.ok_or_else(|| bad(c, "missing op"))?,
"duplicate" => {
let copy = comp.clone();
comps.insert(k + 1, copy);
}
_ => {
comps.remove(k);
if comps.is_empty() {
masks.remove(i);
*active = masks.last().map(|m| m.id);
}
}
}
Ok(())
})
}
),
cmd!("mask.move", "Move Mask", [], None, "{id?, to?: index (0 = top), delta?: ±n} — reorder the masks list", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.move")?;
let mut masks = s.active().and_then(|id| s.develop_of(id)).map(|d| d.masks.clone()).unwrap_or_default();
@@ -279,6 +390,42 @@ pub fn specs() -> Vec<CommandSpec> {
})
}),
// ---- Remove tool (spots)
cmd!(
"spot.findDust",
"Find Dust Spots",
[],
None,
"{sensitivity?: 0..100 (50), add?: bool (true)} — find sensor-dust spots (small soft dark spots on smooth areas) and add a heal spot on each (one undo step) → {spots: [{x, y, size}], added}",
has_active,
|s, p| {
let c = "spot.findDust";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
// the uncropped photo, so positions are the spots' own coordinates
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
let found = lightcraft_pipeline::dust::detect(&img, f64_or(p, "sensitivity", 50.0) as f32);
let out: Vec<Value> = found.iter().map(|d| json!({"x": d.x, "y": d.y, "size": d.radius})).collect();
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
return Ok(json!({"spots": out, "added": 0}));
}
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let base = Spot::default();
for d in &found {
let mut spot = Spot {
mode: SpotMode::Heal,
points: vec![Point::new(d.x, d.y)],
size: d.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
feather: base.feather,
opacity: 100.0,
source_offset: None,
};
spot.source_offset = pick_source(s, id, &dd, &spot, None);
dd.spots.push(spot);
}
s.set_develop(id, dd, "Find Dust Spots")?;
Ok(json!({"spots": out, "added": found.len()}))
}
),
cmd!(
"spot.add",
"Add Remove Spot",
+2 -1
View File
@@ -30,7 +30,8 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
if let (Some(path), Some(img)) = (str_param(p, "previewPath"), &out.preview) {
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
.map_err(|e| bad(id, e.to_string()))?;
std::fs::write(path, png).map_err(|e| bad(id, format!("{path}: {e}")))?;
s.check_write_target(path).map_err(|e| bad(id, e))?;
crate::export::write_file(path, &png).map_err(|e| bad(id, e))?;
info["previewPath"] = json!(path);
}
if let Some(img) = &out.preview {
+183
View File
@@ -0,0 +1,183 @@
//! 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::{CopyrightStatus, Meta};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
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",
"copyrightStatus",
"usageTerms",
"copyrightUrl",
"creator",
"location",
"city",
"state",
"country",
"keywords",
];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MetadataPreset {
pub name: String,
/// photo.setMeta fields; `keywords` are added, not replaced.
pub fields: Value,
}
fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
Some(match k {
"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,
"state" => &mut m.state,
"country" => &mut m.country,
_ => return None,
})
}
/// 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 == "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());
}
}
} else if let (Some(f), Some(t)) = (text_field(m, k), v.as_str()) {
*f = t.to_string();
}
}
}
fn list(s: &Session) -> Value {
json!(s.metadata_presets)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.savePreset";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
let fields = match p.get("fields") {
Some(Value::Object(o)) => {
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")),
// from the active photo: its non-empty fields among `only` (default: copyright, creator, place)
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", "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()));
}
}
Value::Object(o)
}
};
let preset = MetadataPreset { name: name.clone(), fields };
match s.metadata_presets.iter_mut().find(|m| m.name.eq_ignore_ascii_case(&name)) {
Some(m) => *m = preset,
None => s.metadata_presets.push(preset),
}
s.save_prefs()?;
Ok(list(s))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.deletePreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let n = s.metadata_presets.len();
s.metadata_presets.retain(|m| !m.name.eq_ignore_ascii_case(name));
if s.metadata_presets.len() == n {
return Err(bad(C, format!("no metadata preset `{name}`")));
}
if s.import_defaults.metadata_preset.as_deref().is_some_and(|m| m.eq_ignore_ascii_case(name)) {
s.import_defaults.metadata_preset = None;
}
s.save_prefs()?;
Ok(list(s))
}
/// photo.setMeta with the preset's fields (keywords added) on `ids` / the selection.
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.applyPreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let preset = s
.metadata_presets
.iter()
.find(|m| m.name.eq_ignore_ascii_case(name))
.cloned()
.ok_or_else(|| bad(C, format!("no metadata preset `{name}`")))?;
let mut params = Map::new();
for (k, v) in preset.fields.as_object().into_iter().flatten() {
params.insert(if k == "keywords" { "addKeywords".into() } else { k.clone() }, v.clone());
}
if let Some(ids) = p.get("ids") {
params.insert("ids".into(), ids.clone());
}
s.execute("photo.setMeta", &Value::Object(params))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "metadata.presets", "Metadata Presets", [], None, "{} → [{name, fields}]", always, |s, _| Ok(list(s))),
cmd!(
"metadata.savePreset",
"Save Metadata Preset",
[],
None,
"{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
),
cmd!("metadata.deletePreset", "Delete Metadata Preset", [], None, "{name}", always, delete),
cmd!(
"metadata.applyPreset",
"Apply Metadata Preset",
[],
None,
"{name, ids?} — text fields replace, keywords are added; one undo step",
always,
apply
),
]
}
+131 -28
View File
@@ -1,33 +1,58 @@
//! Missing originals: photos whose file is no longer where the library expects it (moved,
//! renamed, on an unplugged drive), and relinking them — one at a time (`photo.relink`) or by
//! searching a folder for files with the same name and size (`library.findMissing`).
//! searching a folder for files with the same name and size, or the same content
//! (`library.findMissing`).
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
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 {
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
Op::Relink { id, file_name, source: Source::File { path: path.to_string() } }
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
@@ -42,13 +67,46 @@ 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}))
}
/// Search `folder` (recursively) for each missing photo's file name — and its size when the
/// library knows it — and relink every match in one undoable step.
/// The stored content hash of a photo, without the `:dupN` suffix duplicates get.
fn stored_hash(p: &Photo) -> Option<&str> {
p.content_hash.as_deref().map(|h| h.split(':').next().unwrap_or(h)).filter(|h| !h.is_empty())
}
/// The content hash of the file at `path` (as import computes it), if it can be read.
fn file_hash(path: &str) -> Option<String> {
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
}
/// Search `folder` (recursively) for each missing photo's file and relink every match in one
/// undoable step.
///
/// Matching, per photo:
/// 1. a file with the same name (any letter case), the same size and the same content hash;
/// 2. renamed: a file of the same size with the same content hash (only same-size files are
/// read, so this stays cheap);
/// 3. without a hash to confirm (or the file was edited in place): the one file with the same
/// name and size. Several such files are ambiguous: the photo is skipped and reported in
/// `ambiguous`, never guessed (two cameras' `IMG_0001.CR3` of one size are different photos).
///
/// Files that are already some photo's original, or that another photo was just matched with,
/// are not used.
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.findMissing";
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
@@ -57,27 +115,72 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
}
let lost = missing(s);
if lost.is_empty() {
return Ok(json!({"found": [], "missing": 0}));
return Ok(json!({"found": [], "missing": 0, "ambiguous": []}));
}
// file name (lower case) → candidate paths
let in_use: HashSet<String> = s.catalog.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect();
// file name (lower case) → candidate paths; size → paths (filled lazily)
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
for f in crate::import::expand(&[folder.to_string()], None) {
if let Some(n) = Path::new(&f).file_name() {
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f);
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f)).collect();
for f in &files {
if let Some(n) = Path::new(f).file_name() {
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f.clone());
}
}
let mut by_size: Option<HashMap<u64, Vec<String>>> = None;
let mut hashes: HashMap<String, Option<String>> = HashMap::new();
let mut hash_of = |f: &str| hashes.entry(f.to_string()).or_insert_with(|| file_hash(f)).clone();
let size_of = |f: &str| std::fs::metadata(f).map(|m| m.len()).ok();
let mut used: HashSet<String> = HashSet::new();
let mut ops = Vec::new();
let mut found = Vec::new();
let mut ambiguous = Vec::new();
for (id, old) in &lost {
let Some(ph) = s.catalog.photo(*id) else { continue };
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let size = s.catalog.photo(*id).map(|p| p.file_size).unwrap_or(0);
let hit = by_name.get(&name).and_then(|c| {
c.iter().find(|f| size == 0 || std::fs::metadata(f).is_ok_and(|m| m.len() == size)).or_else(|| (c.len() == 1 && size == 0).then(|| &c[0]))
});
if let Some(path) = hit {
ops.push(relink_op(*id, path));
found.push(json!({"id": id.0, "from": old, "to": path}));
let size = ph.file_size;
let hash = stored_hash(ph).map(str::to_string);
let named: Vec<&String> = by_name
.get(&name)
.map(|c| c.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
.unwrap_or_default();
// 1. same name and size, confirmed by the content hash
let mut hit: Option<(String, &str)> =
hash.as_deref().and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), "name"));
// 2. renamed: same size and content
if hit.is_none()
&& let Some(h) = &hash
&& size > 0
{
let sizes = by_size.get_or_insert_with(|| {
let mut m: HashMap<u64, Vec<String>> = HashMap::new();
for f in &files {
if let Some(n) = size_of(f) {
m.entry(n).or_default().push(f.clone());
}
}
m
});
hit = sizes
.get(&size)
.and_then(|c| c.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h.as_str())))
.map(|f| (f.clone(), "content"));
}
// 3. same name and size only (no hash, or the file was edited in place): one candidate
// is taken, several are ambiguous and skipped rather than guessed
if hit.is_none() {
match named.len() {
1 => hit = Some((named[0].clone(), "name")),
0 => {}
_ => {
ambiguous.push(json!({"id": id.0, "from": old, "candidates": named}));
continue;
}
}
}
let Some((path, by)) = hit else { continue };
ops.push(relink_op(*id, &path));
found.push(json!({"id": id.0, "from": old, "to": path, "by": by}));
used.insert(path);
}
let still = lost.len() - found.len();
if !ops.is_empty() {
@@ -88,7 +191,7 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(json!({"found": found, "missing": still}))
Ok(json!({"found": found, "missing": still, "ambiguous": ambiguous}))
}
pub fn specs() -> Vec<CommandSpec> {
@@ -110,7 +213,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Missing Photos",
[],
None,
"{folder} — relink every missing photo whose file (same name and size) is somewhere in the folder → {found: [{id, from, to}], missing}",
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
always,
find_missing
),
+18 -3
View File
@@ -7,18 +7,25 @@
mod before;
mod browse;
mod color;
pub(crate) mod convert;
mod cull;
pub mod curves;
mod develop;
mod edit;
mod export;
mod keywords;
pub mod filters;
pub mod keywords;
mod library;
mod manage;
pub mod lut_profiles;
pub mod manage;
mod masks;
mod merge;
mod missing;
pub mod metadata;
pub mod missing;
mod organize;
mod prefs;
mod preset_files;
pub mod previews;
mod query;
mod xmp;
@@ -112,10 +119,16 @@ 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());
v.extend(manage::specs());
v.extend(previews::specs());
v.extend(lut_profiles::specs());
v.extend(cull::specs());
v.extend(convert::specs());
v.extend(convert::edit_specs());
v.extend(merge::specs());
v.extend(query::specs());
v.extend(xmp::specs());
@@ -125,6 +138,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
v.extend(metadata::specs());
v.extend(filters::specs());
v
})
}
+30 -6
View File
@@ -4,7 +4,7 @@
use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection, str_param};
use crate::{Result, Selection, Session};
/// Stacks containing any of `targets`, in first-seen order.
@@ -65,8 +65,8 @@ fn copy_number(name: Option<&str>) -> u32 {
/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and
/// metadata, added to the same albums and stacked right after it (the stack is expanded so the
/// copy is visible).
fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
/// copy is visible). `name` replaces the default "Copy N".
fn virtual_copy(s: &mut Session, src: PhotoId, name: Option<&str>) -> Result<Option<PhotoId>> {
let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) };
let master = orig.copy_of.unwrap_or(src);
let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0);
@@ -74,7 +74,7 @@ fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
let mut c = (*orig).clone();
c.id = id;
c.copy_of = Some(master);
c.copy_name = Some(format!("Copy {n}"));
c.copy_name = Some(name.map_or_else(|| format!("Copy {n}"), str::to_string));
c.versions.clear();
c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }];
c.deleted = false;
@@ -108,13 +108,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Virtual Copy",
["Photo"],
Some("Cmd+'"),
"{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"",
"{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"",
has_selection,
|s, p| {
let targets = s.targets(p);
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
let mut made = Vec::new();
for t in targets {
if let Some(id) = virtual_copy(s, t)? {
if let Some(id) = virtual_copy(s, t, name.as_deref())? {
made.push(id);
}
}
@@ -164,6 +165,29 @@ pub fn specs() -> Vec<CommandSpec> {
let op = s.catalog.set_top_ops(id);
Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?}))
}),
cmd!("stack.moveUp", "Move Up in Stack", ["Photo", "Stack"], None, "{id?} — one place towards the top", in_stack, |s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
let op = s.catalog.move_in_stack_ops(id, -1);
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
}),
cmd!("stack.moveDown", "Move Down in Stack", ["Photo", "Stack"], None, "{id?} — one place away from the top", in_stack, |s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
let op = s.catalog.move_in_stack_ops(id, 1);
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
}),
cmd!(
"stack.split",
"Split Stack",
["Photo", "Stack"],
None,
"{id?} — this photo and the ones after it become their own stack",
in_stack,
|s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
let op = s.catalog.split_stack_ops(id).ok_or_else(|| bad("stack.split", "pick a photo below the top of a stack"))?;
Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?}))
}
),
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
let stacks = stacks_of(s, &s.targets(p));
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
+18 -1
View File
@@ -14,6 +14,7 @@ fn current(s: &Session) -> Value {
"xmp": s.xmp,
"import": s.import_defaults,
"cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) },
"forgetLocalDays": s.forget_local_days,
"persistent": s.library.is_some(),
})
}
@@ -48,6 +49,17 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = i.get("creator").and_then(Value::as_str) {
d.creator = v.trim().to_string();
}
match i.get("metadataPreset") {
Some(Value::String(n)) if n.is_empty() || n == "none" => d.metadata_preset = None,
Some(Value::String(n)) => {
if !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) {
return Err(bad(ID, format!("unknown metadata preset `{n}` (see metadata.presets)")));
}
d.metadata_preset = Some(n.clone());
}
Some(Value::Null) => d.metadata_preset = None,
_ => {}
}
if let Some(a) = i.get("cameras").and_then(Value::as_array) {
d.cameras.clear();
for c in a {
@@ -75,6 +87,11 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
changed = true;
}
s.import_defaults = d;
if let Some(v) = p.get("forgetLocalDays") {
let days = v.as_u64().ok_or_else(|| bad(ID, "forgetLocalDays must be a whole number of days (0 = never)"))?;
s.forget_local_days = days.min(36_500) as u32;
changed = true;
}
if let Some(mb) = p.get("cacheMb") {
let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?;
s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?;
@@ -90,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Library Preferences",
[],
None,
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}",
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}",
always,
prefs
)]
+28 -14
View File
@@ -1,9 +1,10 @@
//! Preset files: import `.lcpreset` / XMP presets, export `.lcpreset`.
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset, Luminar looks and zip bundles; export `.lcpreset`.
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::presets::{LCPRESET_EXT, expand_preset_paths, parse_preset_file, to_lcpreset};
use crate::preset_import::{group_from_dir, read_presets};
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
use crate::{Result, Session};
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
@@ -12,23 +13,36 @@ fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let paths = strings(p, "paths").filter(|v| !v.is_empty()).ok_or_else(|| bad("preset.import", "no paths"))?;
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty()).map(str::to_string);
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let mut imported = Vec::new();
let mut failed = Vec::new();
let mut skipped = 0usize;
for f in expand_preset_paths(&paths) {
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| parse_preset_file(&f, &b));
match parsed {
Ok(list) => {
let n = list.len();
let ids = s.add_presets(list);
skipped += n - ids.len();
for id in ids {
if let Some(pr) = s.presets.iter().find(|x| x.id == id) {
imported.push(json!({"id": pr.id, "name": pr.name, "group": pr.group}));
for top in &paths {
let from_dir = std::path::Path::new(top).is_dir();
for f in expand_preset_paths(std::slice::from_ref(top)) {
// presets in a folder go to a group named after it (unless the file names its own)
// the path from the imported folder (inclusive) to the file's folder
let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new(""));
let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d));
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group));
match parsed {
Ok(list) => {
for mut it in list {
if let Some(g) = &group {
it.preset.group = g.clone();
}
let (name, grp) = (it.preset.name.clone(), it.preset.group.clone());
let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() };
match id {
Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})),
None => skipped += 1,
}
}
}
Err(e) => failed.push(json!([f, e])),
}
Err(e) => failed.push(json!([f, e])),
}
}
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
@@ -69,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Presets",
[],
None,
"{paths: [.lcpreset / .xmp file or folder]} — groups preserved; XMP presets map crs: fields (docs/xmp-interop.md) → {imported: [{id,name,group}], 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
),
+269
View File
@@ -0,0 +1,269 @@
//! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size,
//! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant.
//! Runs on a background thread (the app keeps working; progress / cancel by command), or inline
//! with `wait` (CLI, MCP, tests).
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::media::{RenderJob, THUMB_SIZES};
use crate::{Result, Session};
/// The long edge of a standard-sized preview.
pub const STANDARD_EDGE: usize = 2048;
/// A preview build in progress.
#[derive(Debug, Default)]
pub struct PreviewBuild {
pub total: usize,
pub done: AtomicUsize,
pub failed: AtomicUsize,
pub cancel: AtomicBool,
pub finished: AtomicBool,
}
impl PreviewBuild {
pub fn json(&self) -> Value {
json!({
"total": self.total,
"done": self.done.load(Ordering::Relaxed),
"failed": self.failed.load(Ordering::Relaxed),
"running": !self.finished.load(Ordering::Relaxed),
"cancelled": self.cancel.load(Ordering::Relaxed),
})
}
}
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
/// `edge` (`None` = 1:1, the photo's full size).
fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize>) -> Vec<RenderJob> {
let Some(p) = s.catalog.photo(id) else { return Vec::new() };
let full = p.width.max(p.height) as usize;
let e = edge.unwrap_or(full).min(full.max(1)).max(1);
let mut v = Vec::new();
v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1]));
v.extend(s.loupe_job(id, e, e, true));
v
}
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild) {
for photo_jobs in jobs {
if state.cancel.load(Ordering::Relaxed) {
break;
}
let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok());
if !ok {
state.failed.fetch_add(1, Ordering::Relaxed);
}
state.done.fetch_add(1, Ordering::Relaxed);
}
state.finished.store(true, Ordering::Relaxed);
}
fn build(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.buildPreviews";
let edge = match str_param(p, "size").unwrap_or("standard") {
"standard" => Some(p.get("edge").and_then(Value::as_u64).map_or(STANDARD_EDGE, |e| e.clamp(256, 8192) as usize)),
"full" | "1:1" => None,
other => return Err(bad(C, format!("unknown size `{other}` (standard|full)"))),
};
if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
return Err(bad(C, "a preview build is already running"));
}
// explicit ids, else the selection, else everything in view
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
s.preview_build = Some(state.clone());
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
if wait {
run_all(jobs, &state);
return Ok(state.json());
}
#[cfg(not(target_arch = "wasm32"))]
{
let st = state.clone();
std::thread::Builder::new()
.name("lc-build-previews".into())
.spawn(move || run_all(jobs, &st))
.map_err(|e| bad(C, format!("could not start: {e}")))?;
}
Ok(state.json())
}
/// Smart previews: build (from the original, at preview size) or discard the proxies that keep
/// photos editable while their originals are offline.
#[cfg(not(target_arch = "wasm32"))]
fn smart(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.smartPreviews";
let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let (mut built, mut repaired, mut removed, mut failed) = (0usize, 0usize, 0usize, Vec::new());
if !discard {
// 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 };
if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) {
continue;
}
let path = dir.join(crate::smart::file_name(&ph));
if discard {
if std::fs::remove_file(&path).is_ok() {
removed += 1;
}
continue;
}
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
// drive) is (re)built
let damaged = path.exists();
if damaged && crate::smart::is_valid(&path) {
built += 1;
continue;
}
// atomic: a failed write leaves no partial proxy that would pass for a built one
let r = s
.source_now(id, crate::media::SourceLevel::Preview)
.and_then(|src| crate::smart::encode(&src))
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
match r {
Ok(()) => {
built += 1;
repaired += usize::from(damaged);
}
Err(e) => failed.push(json!([id.0, e])),
}
}
Ok(json!({"built": built, "repaired": repaired, "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)) {
(Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))),
_ => false,
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
#[cfg(not(target_arch = "wasm32"))]
cmd!(
"library.smartPreviews",
"Build Smart Previews",
[],
None,
"{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}",
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()) {
Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(),
_ => true,
};
Ok(json!({"smartPreview": has_smart_preview(s, id), "originalOnline": online}))
}),
cmd!(
"library.buildPreviews",
"Build Previews",
[],
None,
"{size?: standard (2048 px, or `edge`) | full (1:1), ids?, wait?: bool} — render the grid thumbnail and loupe view of the selected photos (else all in view) into the preview cache, in the background unless `wait` → {total, done, failed, running}",
always,
build
),
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, running, cancelled} | null", always, |s, _| {
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
}),
cmd!("library.cancelPreviews", "Cancel Preview Build", [], None, "{}", always, |s, _| {
if let Some(b) = &s.preview_build {
b.cancel.store(true, Ordering::Relaxed);
}
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
}),
]
}
+33 -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,
})
}
@@ -95,6 +97,29 @@ pub fn specs() -> Vec<CommandSpec> {
"clipboard": s.clipboard.is_some(),
}))
}),
cmd!(query "photo.allMetadata", "All Metadata", [], None, "{id?} → {exif: [{group, tag, name, value}], xmp: [{name, value}]} — every EXIF / TIFF / GPS tag of the file and its XMP properties", always, |s, p| {
let id = photo_arg(s, p, "photo.allMetadata")?;
let ph = s.catalog.photo(id).ok_or_else(|| super::bad("photo.allMetadata", "no such photo"))?.clone();
let lightcraft_catalog::Source::File { path } = &ph.source else {
return Ok(json!({"exif": [], "xmp": [], "note": "a generated demo photo has no file"}));
};
let bytes = match &s.media.file_bytes {
Some(r) => r(path),
None => std::fs::read(path).map_err(|e| format!("{path}: {e}")),
}
.map_err(|e| super::bad("photo.allMetadata", e))?;
let exif: Vec<Value> = lightcraft_meta::file_tag_rows(&bytes)
.into_iter()
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
.collect();
// XMP: the sidecar if there is one, else the file's own packet
let packet = crate::sidecar::read_packet(path, ph.kind, s.sidecar_naming(ph.id)).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
let xmp: Vec<Value> = packet
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
.unwrap_or_default();
Ok(json!({"exif": exif, "xmp": xmp}))
}),
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
let all: Vec<Album> = s.catalog.albums().cloned().collect();
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
@@ -161,11 +186,17 @@ pub fn specs() -> Vec<CommandSpec> {
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
}))
}),
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process)", always, |_, p| {
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}", always, |_, p| {
if let Some(on) = p.get("enabled").and_then(Value::as_bool) {
lightcraft_gpu::set_enabled(on);
}
Ok(json!({"enabled": lightcraft_gpu::enabled(), "available": lightcraft_gpu::available(), "adapter": lightcraft_gpu::adapter_name()}))
Ok(json!({
"enabled": lightcraft_gpu::enabled(),
"available": lightcraft_gpu::available(),
"adapter": lightcraft_gpu::adapter_name(),
"reason": lightcraft_gpu::unavailable_reason(),
"lastFallback": lightcraft_gpu::last_fallback(),
}))
}),
cmd!(query "library.memory", "Memory Usage", [], None, "{} — bytes held by each cache (decoded sources, rendered previews, GPU buffers; heap when instrumented)", always, |s, _| {
Ok(serde_json::to_value(s.memory_report()).unwrap_or_default())
+16 -4
View File
@@ -9,14 +9,26 @@ use crate::{Result, Session};
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let mut written = Vec::new();
let mut merged = Vec::new();
let mut backups = Vec::new();
let mut failed = Vec::new();
let owners = crate::sidecar::StemOwners::of(&s.catalog);
for id in s.targets(p) {
match s.save_sidecar(id) {
Ok(path) => written.push(path.display().to_string()),
match s.save_sidecar_with(id, &owners) {
Ok(r) => {
let path = r.path.display().to_string();
if r.merged {
merged.push(path.clone());
}
if let Some(b) = r.backup {
backups.push(json!({"path": path, "backup": b.display().to_string()}));
}
written.push(path);
}
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
}
}
Ok(json!({"written": written, "failed": failed}))
Ok(json!({"written": written, "merged": merged, "backups": backups, "failed": failed}))
}
fn read(s: &mut Session, p: &Value) -> Result<Value> {
@@ -63,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Metadata to File",
["Photo"],
Some("Cmd+S"),
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original → {written: [path], failed}",
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original, merged into an existing one (other apps' data kept; an unreadable one is backed up first) → {written: [path], merged: [path], backups: [{path, backup}], failed}",
has_selection,
save
),
+78
View File
@@ -55,7 +55,21 @@ pub fn has_adjustments(props: &Props) -> bool {
props.keys().any(|k| k.starts_with("crs:") && !NON_ADJUSTMENT.contains(&k.split('/').next().unwrap_or(k)))
}
thread_local! {
/// Keys read by [`to_partial`] while [`to_partial_report`] runs.
static READ: std::cell::RefCell<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
}
fn note(k: &str) {
READ.with(|r| {
if let Some(s) = r.borrow_mut().as_mut() {
s.insert(k.to_string());
}
});
}
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
note(k);
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
}
@@ -95,6 +109,7 @@ pub fn rel_to_kelvin(r: f64) -> f64 {
/// Parse a point curve stored as an `rdf:Seq` of `"x, y"` strings in 0..255.
fn curve(props: &Props, k: &str) -> Option<Value> {
note(k);
let items = props.get(k)?;
let mut pts = Vec::new();
for it in items {
@@ -130,9 +145,30 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
n(o, "Shadows2012", "light.shadows");
n(o, "Whites2012", "light.whites");
n(o, "Blacks2012", "light.blacks");
// older process versions (most `.lrtemplate` presets): approximate the 2012 sliders from the
// earlier ones, relative to their defaults (contrast 25, blacks 5, brightness 50)
let has = |o: &Value, path: &str| path.split('.').try_fold(o, |v, k| v.get(k)).is_some();
// presets written by newer versions keep the old fields at their defaults: ignore them there
let pv2012 = props.keys().any(|k| k.ends_with("2012"));
let old = |o: &mut Value, crs: &str, path: &str, f: &dyn Fn(f64) -> f64| {
let v = num(props, &format!("crs:{crs}"));
if !pv2012
&& !has(o, path)
&& let Some(v) = v
{
put(o, path, json!(f(v).clamp(-100.0, 100.0)));
}
};
old(o, "Exposure", "light.exposure", &|v| v);
old(o, "Contrast", "light.contrast", &|v| v - 25.0);
old(o, "HighlightRecovery", "light.highlights", &|v| -v);
old(o, "FillLight", "light.shadows", &|v| v);
old(o, "Shadows", "light.blacks", &|v| -(v - 5.0) * 4.0);
old(o, "Brightness", "light.whites", &|v| (v - 50.0) * 0.6);
// ---- Presence
n(o, "Texture", "effects.texture");
n(o, "Clarity2012", "effects.clarity");
old(o, "Clarity", "effects.clarity", &|v| v);
n(o, "Dehaze", "effects.dehaze");
n(o, "Vibrance", "color.vibrance");
n(o, "Saturation", "color.saturation");
@@ -191,6 +227,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
n(o, "ParametricMidtoneSplit", "curve.split_mid");
n(o, "ParametricHighlightSplit", "curve.split_highlights");
n(o, "CurveRefineSaturation", "curve.refine_saturation");
if let Some(c) = curve(props, "crs:ToneCurve").filter(|c| c.as_array().is_some_and(|a| !a.is_empty())) {
// the older single curve, used when no 2012 curve is present
if !props.contains_key("crs:ToneCurvePV2012") {
put(o, "curve.master", c);
}
}
for (crs, ch) in
[("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
{
@@ -310,6 +352,42 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
out
}
/// [`to_partial`], plus the adjustments in the packet it could not carry over (field names
/// without the `crs:` prefix, grouped: `CameraProfile`, `Look`, `MaskGroupBasedCorrections`…).
///
/// With `values` (the packet's structured properties), local corrections become masks too
/// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that
/// can't be carried over are reported as `Mask: <kind>`.
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option<bool>, aspect: f64) -> (Value, Vec<String>) {
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
let mut out = to_partial(props, raw);
let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
let mut mask_skips = Vec::new();
if let Some(values) = values {
let (masks, skipped) = crate::crs_masks::masks(values, aspect);
if !masks.is_empty() {
put(&mut out, "masks", Value::Array(masks));
}
if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) {
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
}
}
// fields that only switch a panel on/off or name things: not adjustments by themselves
let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix";
let mut unmapped: Vec<String> = props
.keys()
.filter(|k| k.starts_with("crs:"))
.map(|k| k.split('/').next().unwrap_or(k))
.filter(|k| !NON_ADJUSTMENT.contains(k) && !read.contains(*k))
.map(|k| k.trim_start_matches("crs:").to_string())
.filter(|k| !quiet(k))
.collect();
unmapped.dedup();
unmapped.extend(mask_skips);
(out, unmapped)
}
/// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets.
/// Returns `None` when the packet has no adjustments we understand.
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
+379
View File
@@ -0,0 +1,379 @@
//! Interchange: local corrections (masks) stored as `crs:` structures in XMP sidecars, XMP
//! presets and `.lrtemplate` files → our [`Mask`](lightcraft_develop::Mask)s.
//!
//! Four containers hold them: `MaskGroupBasedCorrections` (current: each correction has a list
//! of mask components) and the older `GradientBasedCorrections`, `CircularGradientBasedCorrections`
//! and `PaintBasedCorrections`. Every correction carries its local sliders (`Local*`, stored as
//! −1..1 fractions; exposure ×4 EV) and its components (`crs:What` = `Mask/Gradient`,
//! `Mask/CircularGradient`, `Mask/Paint`, `Mask/Image`, `Mask/RangeMask`).
//!
//! Implemented from the public XMP data model and black-box observation; best effort — the
//! pipelines differ, and the component combine modes and AI mask kinds are mapped from what the
//! fields evidently mean. Unsupported components are reported, never guessed.
use std::collections::BTreeMap;
use lightcraft_meta::XmpValue;
use serde_json::{Value, json};
/// Top-level properties keyed `prefix:name`, with structure.
pub type Values = BTreeMap<String, XmpValue>;
/// The containers this module reads.
pub const CONTAINERS: [&str; 4] =
["crs:MaskGroupBasedCorrections", "crs:GradientBasedCorrections", "crs:CircularGradientBasedCorrections", "crs:PaintBasedCorrections"];
fn text<'a>(v: &'a XmpValue, k: &str) -> Option<&'a str> {
v.field(k).and_then(XmpValue::text).filter(|s| !s.is_empty())
}
fn num(v: &XmpValue, k: &str) -> Option<f64> {
let s = text(v, k)?;
let s = s.strip_prefix('+').unwrap_or(s);
s.parse::<f64>().ok().filter(|x| x.is_finite())
}
fn flag(v: &XmpValue, k: &str) -> Option<bool> {
text(v, k).map(|s| s.eq_ignore_ascii_case("true") || s == "1")
}
/// Local slider fields → (our `adjust` field, scale from the stored fraction).
const LOCAL: &[(&str, &str, f64)] = &[
("LocalExposure2012", "exposure", 4.0),
("LocalContrast2012", "contrast", 100.0),
("LocalHighlights2012", "highlights", 100.0),
("LocalShadows2012", "shadows", 100.0),
("LocalWhites2012", "whites", 100.0),
("LocalBlacks2012", "blacks", 100.0),
("LocalClarity2012", "clarity", 100.0),
("LocalTexture", "texture", 100.0),
("LocalDehaze", "dehaze", 100.0),
("LocalTemperature", "temp", 100.0),
("LocalTint", "tint", 100.0),
("LocalSaturation", "saturation", 100.0),
("LocalHue", "hue", 100.0),
("LocalSharpness", "sharpness", 100.0),
("LocalLuminanceNoise", "noise", 100.0),
("LocalMoire", "moire", 100.0),
("LocalDefringe", "defringe", 100.0),
("LocalToningSaturation", "color_sat", 100.0),
("LocalToningHue", "color_hue", 1.0),
// older process versions
("LocalExposure", "exposure", 4.0),
("LocalContrast", "contrast", 100.0),
("LocalClarity", "clarity", 100.0),
];
fn adjust(c: &XmpValue) -> Value {
let mut a = serde_json::Map::new();
for (crs, ours, k) in LOCAL {
if a.contains_key(*ours) {
continue;
}
if let Some(v) = num(c, &format!("crs:{crs}")) {
let lim = if *ours == "exposure" {
4.0
} else if *ours == "color_hue" {
360.0
} else {
100.0
};
let v = (v * k).clamp(if *ours == "color_hue" { 0.0 } else { -lim }, lim);
a.insert((*ours).into(), json!(v));
}
}
let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0);
a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0)));
Value::Object(a)
}
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
/// luminance on −8..+4 EV mapped to 0..1.
fn lightness_to_range(l: f64) -> f64 {
let y = l.clamp(1e-4, 1.0).powf(2.2);
((y.log2() + 8.0) / 12.0).clamp(0.0, 1.0)
}
fn pt(x: f64, y: f64) -> Value {
json!({"x": x, "y": y})
}
/// One mask component → our component JSON; `Err(kind)` names an unsupported one.
fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> {
let what = text(m, "crs:What").unwrap_or("");
let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 {
1 => "subtract",
2 => "intersect",
_ => "add",
};
let invert = flag(m, "crs:MaskInverted").unwrap_or(false);
// positions are fractions of the image's width / height; our radii are in long-edge units
let (sx, sy) = if aspect >= 1.0 { (1.0, 1.0 / aspect) } else { (aspect, 1.0) };
let shape = match what {
"Mask/Gradient" => {
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
json!({"kind": "linear", "start": pt(g("FullX", 0.5), g("FullY", 0.0)), "end": pt(g("ZeroX", 0.5), g("ZeroY", 1.0))})
}
"Mask/CircularGradient" => {
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
let (l, r, t, b) = (g("Left", 0.25), g("Right", 0.75), g("Top", 0.25), g("Bottom", 0.75));
json!({
"kind": "radial",
"center": pt((l + r) / 2.0, (t + b) / 2.0),
"rx": ((r - l).abs() / 2.0 * sx).max(1e-3),
"ry": ((b - t).abs() / 2.0 * sy).max(1e-3),
"angle": g("Angle", 0.0),
"feather": g("Feather", 50.0).clamp(0.0, 100.0),
"invert": flag(m, "crs:Flipped").unwrap_or(false),
})
}
"Mask/Paint" => {
let pts: Vec<Value> = m
.field("crs:Dabs")
.map(XmpValue::items)
.unwrap_or_default()
.iter()
.filter_map(XmpValue::text)
.filter_map(|d| {
let mut it = d.split_whitespace().skip(1).filter_map(|v| v.parse::<f64>().ok());
Some(pt(it.next()?, it.next()?))
})
.collect();
if pts.is_empty() {
return Err("empty brush".into());
}
let erase = num(m, "crs:MaskValue").is_some_and(|v| v <= 0.0);
json!({"kind": "brush", "strokes": [{
"points": pts,
"size": num(m, "crs:Radius").unwrap_or(0.03).clamp(0.001, 1.0),
"feather": (num(m, "crs:CenterWeight").unwrap_or(0.5) * 100.0).clamp(0.0, 100.0),
"flow": (num(m, "crs:Flow").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0),
"density": (num(m, "crs:MaskValue").unwrap_or(1.0).abs() * 100.0).clamp(0.0, 100.0),
"erase": erase,
}]})
}
"Mask/Image" => match text(m, "crs:MaskSubType") {
Some("1") => json!({"kind": "subject"}),
Some("2") => json!({"kind": "sky"}),
other => return Err(format!("AI mask ({})", other.unwrap_or("?"))),
},
"Mask/RangeMask" => {
let r = m.field("crs:CorrectionRangeMask").unwrap_or(m);
match text(r, "crs:Type") {
Some("2") => {
let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
}
Some("3") => {
let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, _] = q[..] else { return Err("depth range".into()) };
json!({"kind": "depthRange", "lo": b, "hi": c, "feather": (b - a).max(0.0)})
}
_ => return Err("colour range".into()),
}
}
other => return Err(if other.is_empty() { "unknown mask".into() } else { other.trim_start_matches("Mask/").to_string() }),
};
Ok(json!({"op": op, "invert": invert, "shape": shape}))
}
/// Masks in `values` (JSON for `DevelopSettings::masks`), plus the kinds of component that
/// couldn't be carried over. `aspect` = width / height of the target (presets: assume 3:2).
pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut skipped = Vec::new();
for container in CONTAINERS {
let Some(list) = values.get(container) else { continue };
for c in list.items() {
if flag(c, "crs:CorrectionActive") == Some(false) {
continue;
}
let comps = c.field("crs:CorrectionMasks").map(XmpValue::items).unwrap_or_default();
let mut parts = Vec::new();
for m in comps {
match component(m, aspect) {
Ok(v) => parts.push(v),
Err(kind) => skipped.push(kind),
}
}
if parts.is_empty() {
continue;
}
// brush dabs of one correction are one brush component
let mut merged: Vec<Value> = Vec::new();
for p in parts {
let is_brush = p["shape"]["kind"] == "brush";
if is_brush
&& let Some(prev) = merged.iter_mut().find(|q| q["shape"]["kind"] == "brush" && q["op"] == p["op"])
&& let (Some(a), Some(b)) = (prev["shape"]["strokes"].as_array_mut(), p["shape"]["strokes"].as_array())
{
a.extend(b.iter().cloned());
continue;
}
merged.push(p);
}
let n = out.len() + 1;
let name = text(c, "crs:CorrectionName").map(str::to_string).unwrap_or_else(|| format!("Mask {n}"));
out.push(json!({"id": n, "name": name, "visible": true, "invert": false, "components": merged, "adjust": adjust(c)}));
}
}
skipped.sort();
skipped.dedup();
(out, skipped)
}
/// A `.lrtemplate` Lua value as an XMP value (field names get the `crs:` prefix).
pub fn from_lua(v: &crate::preset_import::Lua) -> XmpValue {
use crate::preset_import::Lua;
match v {
Lua::Nil => XmpValue::Text(String::new()),
Lua::Bool(b) => XmpValue::Text(if *b { "true" } else { "false" }.into()),
Lua::Num(n) => XmpValue::Text(format!("{n}")),
Lua::Str(s) => XmpValue::Text(s.clone()),
Lua::Table(arr, map) if map.is_empty() => XmpValue::Array(arr.iter().map(from_lua).collect()),
Lua::Table(_, map) => XmpValue::Struct(map.iter().map(|(k, v)| (format!("crs:{k}"), from_lua(v))).collect()),
}
}
/// The aspect radial masks are computed for when the target's shape isn't known yet.
pub const DEFAULT_ASPECT: f64 = 1.5;
/// Re-fit radial masks in `partial` (computed for aspect `from`) to a target of aspect `to`.
pub fn refit_radials(partial: &mut Value, from: f64, to: f64) {
let scale = |a: f64| if a >= 1.0 { (1.0, 1.0 / a) } else { (a, 1.0) };
let ((fx, fy), (tx, ty)) = (scale(from), scale(to));
let Some(masks) = partial.get_mut("masks").and_then(Value::as_array_mut) else { return };
for m in masks {
for c in m["components"].as_array_mut().into_iter().flatten() {
let s = &mut c["shape"];
if s["kind"] == "radial" {
if let Some(rx) = s["rx"].as_f64() {
s["rx"] = json!(rx / fx * tx);
}
if let Some(ry) = s["ry"].as_f64() {
s["ry"] = json!(ry / fy * ty);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape};
/// A packet in the shape local corrections take (values written for this test).
const PACKET: &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:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.2">
<crs:MaskGroupBasedCorrections><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:CorrectionActive="true" crs:CorrectionName="Darker sky"
crs:LocalExposure2012="-0.25" crs:LocalSaturation="0.3" crs:LocalTemperature="-0.1">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="2" crs:MaskBlendMode="0" crs:MaskInverted="false"/>
<rdf:li crs:What="Mask/Gradient" crs:MaskBlendMode="2" crs:FullX="0.5" crs:FullY="0" crs:ZeroX="0.5" crs:ZeroY="0.6"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true"/>
<rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionActive="false" crs:LocalExposure2012="1">
<crs:CorrectionMasks><rdf:Seq><rdf:li crs:What="Mask/Image" crs:MaskSubType="1"/></rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:MaskGroupBasedCorrections>
<crs:PaintBasedCorrections><rdf:Seq>
<rdf:li crs:What="Correction" crs:LocalClarity2012="0.4"><crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.05" crs:Flow="0.8" crs:CenterWeight="0.3" crs:MaskValue="1">
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.2</rdf:li><rdf:li>d 0.15 0.22</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.02" crs:MaskValue="1"><crs:Dabs><rdf:Seq><rdf:li>d 0.5 0.5</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
</rdf:Seq></crs:CorrectionMasks></rdf:li>
</rdf:Seq></crs:PaintBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
fn settings() -> (DevelopSettings, Vec<String>) {
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5);
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
}
#[test]
fn corrections_become_masks() {
let (s, unmapped) = settings();
assert_eq!(s.light.exposure, 0.2, "global settings still map");
assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks);
let sky = &s.masks[0];
assert_eq!(sky.name, "Darker sky");
assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0));
assert_eq!(sky.components[0].shape, MaskShape::Sky);
assert_eq!(sky.components[1].op, MaskOp::Intersect);
let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() };
assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point");
let rad = &s.masks[1];
assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0));
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, true));
let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
assert!(lo < hi && *lo_feather > 0.0);
let brush = &s.masks[2];
assert_eq!(brush.adjust.clarity, 40.0);
assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component");
let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() };
assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0));
assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]);
}
#[test]
fn masks_render() {
let (s, _) = settings();
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
let render = |d: &DevelopSettings| {
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
};
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
}
#[test]
fn lrtemplate_corrections() {
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = {
{ What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#;
let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap();
assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped);
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
assert_eq!(s.masks.len(), 1);
assert_eq!(s.masks[0].adjust.exposure, -2.0);
}
#[test]
fn presets_add_masks_once() {
let (s, _) = settings();
let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}});
let p =
lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false };
let mut mine = DevelopSettings::default();
mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() });
let once = p.apply(&mine, 1.0);
assert_eq!(once.masks.len(), 4, "added to the photo's own mask");
assert_eq!(once.masks[1].id, 8, "fresh ids");
assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing");
let half = p.apply(&mine, 0.5);
assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks");
}
#[test]
fn sidecar_radials_fit_the_photo() {
let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]});
refit_radials(&mut v, 1.5, 1.0);
let s = &v["masks"][0]["components"][0]["shape"];
assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
}
}
+2 -2
View File
@@ -62,7 +62,7 @@ pub fn load(s: &mut Session) {
}
let folder = s.catalog.alloc_album_id();
ops.push(Op::AddAlbum {
album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None },
album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
});
let by_kw = |kw: &str| -> Vec<_> { scenes.iter().zip(&ids).filter(|(sc, _)| sc.meta.keywords.contains(&kw)).map(|(_, id)| *id).collect() };
for (name, parent, photos) in [
@@ -75,7 +75,7 @@ pub fn load(s: &mut Session) {
] {
let id = s.catalog.alloc_album_id();
let cover = photos.first().copied();
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None } });
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None, quick: false } });
}
for op in ops {
let _ = s.catalog.apply(op);
+205 -22
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> {
@@ -23,7 +35,9 @@ fn mount_parents() -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
if cfg!(target_os = "macos") {
out.push("/Volumes".into());
} else if cfg!(target_os = "linux") {
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
// Linux desktops mount under /media/$USER or /run/media/$USER; FreeBSD's automounter
// (automount/autofs, or a desktop's) under /media. Missing folders are skipped.
if let Ok(user) = std::env::var("USER") {
out.push(format!("/media/{user}").into());
out.push(format!("/run/media/{user}").into());
@@ -34,21 +48,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 +180,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 +189,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 +213,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);
}
}
+400 -87
View File
@@ -202,6 +202,8 @@ pub enum Anchor {
#[serde(default, rename_all = "camelCase")]
pub struct Watermark {
pub text: String,
/// Basic Japanese vertical lettering: upright glyphs in columns from right to left.
pub vertical: bool,
/// Text height as a fraction of the image's short edge.
pub size: f32,
/// 0..1.
@@ -213,16 +215,38 @@ pub struct Watermark {
pub color: [u8; 3],
/// Soft dark drop shadow for legibility on bright areas.
pub shadow: bool,
/// A graphic (PNG with transparency, JPEG, …) drawn instead of the text (empty = text).
pub image: String,
/// The graphic's width as a fraction of the photo's width.
pub image_width: f32,
/// The colour space the photo is in (set by the encoder; the graphic is converted into it).
#[serde(skip)]
pub target: Option<OutputSpace>,
}
impl Default for Watermark {
fn default() -> Self {
Self { text: String::new(), size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, inset: 0.025, color: [255; 3], shadow: true }
Self {
text: String::new(),
vertical: false,
size: 0.035,
opacity: 0.7,
anchor: Anchor::BottomRight,
inset: 0.025,
color: [255; 3],
shadow: true,
image: String::new(),
image_width: 0.2,
target: None,
}
}
}
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
/// BIZ UDMincho (OFL), Japanese fallback on both native and web.
/// Bundled OFL Japanese font bytes, shared by watermarks and UI glyph fallback.
pub static WATERMARK_JAPANESE_FONT: &[u8] = include_bytes!("../../../assets/fonts/BIZUDMincho-Regular.ttf");
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
@@ -261,28 +285,76 @@ pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
/// every covered pixel (shadow pass first).
fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point};
if !wm.image.trim().is_empty() {
logo_coverage(width, height, wm, blend_px);
return;
}
let text = wm.text.trim();
if text.is_empty() || width == 0 || height == 0 {
return;
}
let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return };
let Ok(japanese) = FontRef::try_from_slice(WATERMARK_JAPANESE_FONT) else { return };
let fonts = [font, japanese];
let short = width.min(height) as f32;
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0);
let sf = font.as_scaled(PxScale::from(px));
// Lay out one line.
let mut glyphs = Vec::new();
let mut x = 0.0f32;
let mut y = 0.0f32;
let mut tw = 0.0f32;
let mut prev = None;
for ch in text.chars() {
let columns = text.split('\n').count();
let mut column = 0usize;
for original in text.chars() {
if original == '\n' {
if wm.vertical {
column = column.saturating_add(1);
tw = tw.max(y);
y = 0.0;
} else {
tw = tw.max(x);
x = 0.0;
y += px;
}
prev = None;
continue;
}
let vertical_form = match original {
'、' => '︑',
'。' => '︒',
'(' => '︵',
')' => '︶',
'「' => '﹁',
'」' => '﹂',
'『' => '﹃',
'』' => '﹄',
'【' => '︻',
'】' => '︼',
'…' => '︙',
other => other,
};
let ch = if wm.vertical && fonts[1].glyph_id(vertical_form).0 != 0 { vertical_form } else { original };
let face = usize::from(fonts[0].glyph_id(ch).0 == 0);
let sf = fonts[face].as_scaled(PxScale::from(px));
let id = sf.glyph_id(ch);
if let Some(p) = prev {
x += sf.kern(p, id);
if wm.vertical {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent());
let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0));
glyphs.push((face, id.with_scale_and_position(px, pen), rotation));
y += px;
} else {
if let Some((previous_face, previous)) = prev
&& previous_face == face
{
x += sf.kern(previous, id);
}
glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None));
x += sf.h_advance(id);
prev = Some((face, id));
}
glyphs.push(id.with_scale_and_position(px, point(x, sf.ascent())));
x += sf.h_advance(id);
prev = Some(id);
}
let (tw, th) = (x, sf.ascent() - sf.descent());
let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) };
let inset = wm.inset.clamp(0.0, 0.4) * short;
let (w, h) = (width as f32, height as f32);
use Anchor::*;
@@ -306,15 +378,90 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px:
let passes: &[(f32, [u8; 3], f32)] =
if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] };
for &(off, col, a) in passes {
for g in &glyphs {
if let Some(o) = font.outline_glyph(g.clone()) {
for (face, g, rotation) in &glyphs {
if let Some(o) = fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) {
let b = o.px_bounds();
o.draw(|gx, gy, cov| blend((ox + off + b.min.x) as i32 + gx as i32, (oy + off + b.min.y) as i32 + gy as i32, cov, col, a * alpha));
o.draw(|gx, gy, cov| {
let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32);
let (sx, sy) = match rotation {
Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)),
None => (sx, sy),
};
blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha);
});
}
}
}
}
/// A watermark graphic, decoded once per file (and again when the file changes).
fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
static CACHE: Mutex<Option<HashMap<String, (std::time::SystemTime, Arc<Rgba8>)>>> = Mutex::new(None);
let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let map = c.get_or_insert_with(HashMap::new);
if let Some((m, img)) = map.get(path)
&& *m == modified
{
return Some(img.clone());
}
let bytes = std::fs::read(path).ok()?;
let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8());
map.insert(path.to_string(), (modified, img.clone()));
Some(img)
}
/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to
/// `image_width` of the photo, placed like the text, blended with its alpha × opacity.
fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
let Some(logo) = watermark_logo(wm.image.trim()) else {
log::warn!("watermark: can't read {}", wm.image);
return;
};
if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 {
return;
}
let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width);
let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height);
let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| {
let p = logo.get(x, y);
let a = p[3] as f32 / 255.0;
[p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a]
});
let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell);
let inset = wm.inset.clamp(0.0, 0.4) * width.min(height) as f32;
let (w, h, lwf, lhf) = (width as f32, height as f32, lw as f32, lh as f32);
use Anchor::*;
let ox = match wm.anchor {
TopLeft | Left | BottomLeft => inset,
Top | Center | Bottom => (w - lwf) / 2.0,
TopRight | Right | BottomRight => w - inset - lwf,
}
.max(0.0) as usize;
let oy = match wm.anchor {
TopLeft | Top | TopRight => inset,
Left | Center | Right => (h - lhf) / 2.0,
BottomLeft | Bottom | BottomRight => h - inset - lhf,
}
.max(0.0) as usize;
let space = wm.target.unwrap_or(OutputSpace::Srgb);
let op = wm.opacity.clamp(0.0, 1.0);
for y in 0..lh {
for x in 0..lw {
let [r, g, b, a] = scaled.get(x, y);
let a = a.clamp(0.0, 1.0);
let (px, py) = (ox + x, oy + y);
if a <= 1e-4 || px >= width || py >= height {
continue;
}
let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8);
blend_px(px, py, a * op, srgb8_in(space, c));
}
}
}
/// Which metadata is embedded in exported files.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -344,8 +491,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,
@@ -355,6 +505,8 @@ pub struct ExportOptions {
pub conflict: Conflict,
/// TIFF compression.
pub tiff_compression: TiffCompression,
/// DNG export: how the raw data is stored.
pub dng_compression: DngCompression,
pub metadata: MetadataPolicy,
/// Strip GPS / location even when the policy would include it.
pub remove_location: bool,
@@ -382,6 +534,7 @@ impl Default for ExportOptions {
subfolder: String::new(),
conflict: Conflict::Unique,
tiff_compression: TiffCompression::Deflate,
dng_compression: DngCompression::Lossless,
metadata: MetadataPolicy::All,
remove_location: false,
watermark: None,
@@ -423,6 +576,7 @@ impl ExportOptions {
Some("zip" | "deflate") => TiffCompression::Deflate,
_ => d.tiff_compression,
},
dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression),
metadata: enm(p, "metadata").unwrap_or(d.metadata),
remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location),
watermark: match p.get("watermark") {
@@ -430,7 +584,7 @@ impl ExportOptions {
Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(),
_ => None,
}
.filter(|w: &Watermark| !w.text.trim().is_empty()),
.filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()),
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
}
@@ -456,10 +610,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)
}
}
})
@@ -524,36 +677,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())
}
}
}
@@ -575,7 +715,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;
@@ -583,7 +723,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);
@@ -648,7 +788,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
let space = o.effective_space();
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() };
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
draw_watermark(&mut img, &wm);
}
let profile = icc::write_named(named_space(space));
@@ -732,7 +872,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
let mut img = img.clone();
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(img.space, wm.color), ..wm.clone() };
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
draw_watermark_deep(&mut img, &wm);
}
let profile = match img.samples {
@@ -770,7 +910,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),
@@ -838,6 +987,8 @@ pub struct PreparedExport {
pub photo: lightcraft_catalog::PhotoId,
pub file_name: String,
work: Work,
/// The library's originals, which [`run_batch`] never writes over (shared by a batch).
guard: std::sync::Arc<crate::originals::OriginalGuard>,
}
struct RenderWork {
@@ -854,21 +1005,39 @@ enum Work {
path: String,
read: Option<crate::merge::ByteReader>,
packet: String,
dng: bool,
dng: Option<DngCompression>,
label: String,
size: (usize, usize),
},
}
/// Set up the export of photo `id` at 1-based position `seq` of a batch.
/// Set up the export of photo `id` at 1-based position `seq` of a batch. (For a whole batch use
/// [`prepare_batch`]: it looks at the library's originals once.)
pub fn prepare_export(
session: &mut crate::Session,
id: lightcraft_catalog::PhotoId,
o: &ExportOptions,
seq: usize,
) -> Result<PreparedExport, String> {
let guard = std::sync::Arc::new(session.original_guard());
prepare_guarded(session, id, o, seq, guard)
}
/// [`prepare_export`] for each of `ids` in order (`{seq}` = position, from 1).
pub fn prepare_batch(session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], o: &ExportOptions) -> Result<Vec<PreparedExport>, String> {
let guard = std::sync::Arc::new(session.original_guard());
ids.iter().enumerate().map(|(i, id)| prepare_guarded(session, *id, o, i + 1, guard.clone())).collect()
}
fn prepare_guarded(
session: &mut crate::Session,
id: lightcraft_catalog::PhotoId,
o: &ExportOptions,
seq: usize,
guard: std::sync::Arc<crate::originals::OriginalGuard>,
) -> 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);
@@ -881,13 +1050,13 @@ pub fn prepare_export(
Work::File {
path: path.clone(),
read: session.media.file_bytes.clone(),
packet: crate::sidecar::sidecar_packet(p),
dng: o.format == ExportFormat::Dng,
packet: crate::sidecar::sidecar_packet(p, &session.catalog),
dng: (o.format == ExportFormat::Dng).then_some(o.dng_compression),
label: p.file_name.clone(),
size: (p.width as usize, p.height as usize),
}
};
Ok(PreparedExport { photo: id, file_name, work })
Ok(PreparedExport { photo: id, file_name, work, guard })
}
impl PreparedExport {
@@ -905,15 +1074,20 @@ impl PreparedExport {
Some(r) => r(&path)?,
None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?,
};
if !dng {
let Some(dng) = dng else {
return Ok(Exported { file_name, bytes, width: size.0, height: size.1, sidecars: vec![("xmp", packet.into_bytes())] });
}
};
if lightcraft_raw::probe(&bytes).is_none() {
return Err(format!("{label}: DNG export needs a raw photo"));
}
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?;
drop(bytes);
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), ..Default::default() })
let compression = match dng {
DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 },
DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false },
DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed,
};
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() })
.map_err(|e| e.to_string())?;
Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() })
}
@@ -921,15 +1095,29 @@ impl PreparedExport {
}
}
/// How an exported DNG stores its raw data.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DngCompression {
/// Lossless JPEG (the usual DNG compression).
#[default]
Lossless,
/// Deflate (zip) tiles with prediction.
Deflate,
Uncompressed,
}
/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy
/// applied), or one exact file path (single photo; overwritten).
/// applied to each file together with its sidecars), or one exact file path (single photo; an
/// ordinary file already there is replaced). Either way a catalogued original (or its sidecar)
/// is never written over: see [`crate::originals`].
#[derive(Clone, Debug, Default)]
pub struct Destination {
pub dir: String,
pub exact: Option<String>,
}
/// Export `ids` in order ([`prepare_export`] + [`run_batch`]), stopping at the first error.
/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error.
pub fn export_batch(
session: &mut crate::Session,
ids: &[lightcraft_catalog::PhotoId],
@@ -938,15 +1126,34 @@ pub fn export_batch(
write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>,
exists: &dyn Fn(&str) -> bool,
) -> Result<Vec<serde_json::Value>, String> {
let items = ids.iter().enumerate().map(|(i, id)| prepare_export(session, *id, o, i + 1)).collect::<Result<Vec<_>, _>>()?;
let items = prepare_batch(session, ids, o)?;
run_batch(items, o, to, write, exists, true, &mut |_, _| true)
}
/// Write an exported or rendered file on disk: its folder is created if needed, and the file is
/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic`]: a temp file, synced, then
/// renamed), so a failure part-way leaves any previous file intact and no truncated one. The
/// native writer behind exports, renders and screenshots (app, CLI, MCP).
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
let p = std::path::Path::new(path);
if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
lightcraft_catalog::safe_file::write_atomic(p, bytes).map_err(|e| format!("{path}: {e}"))
}
/// The path of the sidecar with extension `ext` of the exported file `main`.
fn sidecar_path(main: &str, ext: &str) -> String {
std::path::Path::new(main).with_extension(ext).to_string_lossy().to_string()
}
/// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to
/// `write`. `exists` tells whether a path is taken (conflict policy). `progress(done, next file)`
/// is called before each photo; returning false cancels the rest. Returns one JSON object per
/// photo: `{path, width, height, bytes, sidecars}`, `{skipped: path}` or (unless
/// `stop_on_error`) `{photo, file, error}`.
/// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its
/// sidecars as one: with Unique both get the same free name, with Skip the photo is skipped when
/// either is taken. A path that is a catalogued original (or its sidecar) is refused whatever the
/// policy. `progress(done, next file)` is called before each photo; returning false cancels the
/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`,
/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`.
pub fn run_batch(
items: Vec<PreparedExport>,
o: &ExportOptions,
@@ -966,7 +1173,7 @@ pub fn run_batch(
if !progress(i, &item.file_name) {
break;
}
let (photo, name) = (item.photo, item.file_name.clone());
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
let e = match item.run() {
Ok(e) => e,
Err(err) if stop_on_error => return Err(err),
@@ -975,14 +1182,18 @@ pub fn run_batch(
continue;
}
};
// the exported file and its sidecars
let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::<Vec<_>>();
let path = match to.exact.as_deref().filter(|_| single) {
Some(p) => p.to_string(),
Some(p) => Ok(p.to_string()),
None => {
let mut path = join(&dir, &e.file_name);
let busy = |p: &str, taken: &std::collections::HashSet<String>| taken.contains(p) || exists(p);
if busy(&path, &taken) {
let path = join(&dir, &e.file_name);
let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p));
if !busy(&path) {
Ok(path)
} else {
match o.conflict {
Conflict::Overwrite => {}
Conflict::Overwrite => Ok(path),
Conflict::Skip => {
out.push(json!({"skipped": path}));
continue;
@@ -990,29 +1201,34 @@ pub fn run_batch(
Conflict::Unique => {
let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b)));
let name = |n: usize| join(&dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}")));
path = (2..).map(name).find(|p| !busy(p, &taken)).expect("a free name");
(2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name"))
}
}
}
path
}
};
let written = write(&path, &e.bytes).and_then(|()| {
let mut sidecars = Vec::new();
for (ext, bytes) in &e.sidecars {
let sc = std::path::Path::new(&path).with_extension(ext).to_string_lossy().to_string();
write(&sc, bytes)?;
sidecars.push(sc);
let file = path.clone().unwrap_or_else(|_| name.clone());
let written = path.and_then(|path| {
let files = group(&path);
// never over an original, whatever the conflict policy or the exact path said
for f in &files {
guard.check(std::path::Path::new(f))?;
}
Ok(sidecars)
write(&path, &e.bytes)?;
let mut sidecars = Vec::new();
for ((_, bytes), sc) in e.sidecars.iter().zip(files.iter().skip(1)) {
write(sc, bytes)?;
sidecars.push(sc.clone());
}
Ok((path, files, sidecars))
});
match written {
Ok(sidecars) => {
taken.insert(path.clone());
Ok((path, files, sidecars)) => {
taken.extend(files);
out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}));
}
Err(err) if stop_on_error => return Err(err),
Err(err) => out.push(json!({"photo": photo.0, "file": path, "error": err})),
Err(err) => out.push(json!({"photo": photo.0, "file": file, "error": err})),
}
}
Ok(out)
@@ -1069,6 +1285,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();
@@ -1080,12 +1299,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());
}
@@ -1108,18 +1334,103 @@ mod tests {
assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none());
}
#[test]
fn japanese_watermarks_support_vertical_columns_and_legacy_defaults() {
use ab_glyph::Font;
let font = ab_glyph::FontRef::try_from_slice(WATERMARK_JAPANESE_FONT).unwrap();
for c in "日本語".chars() {
assert_ne!(font.glyph_id(c).0, 0);
}
let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}}));
assert!(!old.watermark.unwrap().vertical);
let options =
ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}}));
let wm = options.watermark.unwrap();
assert!(wm.vertical);
let round: Watermark = serde_json::from_value(serde_json::to_value(&wm).unwrap()).unwrap();
assert!(round.vertical);
let mut img = Rgba8::new(400, 300);
img.data.fill([0, 0, 0, 255]);
draw_watermark(&mut img, &wm);
let lit: Vec<_> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 64).collect();
assert!(lit.len() > 100);
let w = lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap();
let h = lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap();
assert!(h > w * 2, "vertical Japanese must extend down the column");
let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset: 0.0, ..wm };
img.data.fill([0, 0, 0, 255]);
draw_watermark(&mut img, &two);
for (text, dx) in [("本", 0), ("日", 30)] {
let mut single = Rgba8::new(400, 300);
single.data.fill([0, 0, 0, 255]);
draw_watermark(&mut single, &Watermark { text: text.into(), ..two.clone() });
for y in 0..30 {
for x in 0..30 {
assert_eq!(img.get(x + dx, y), single.get(x, y), "newlines move columns left");
}
}
}
}
#[test]
fn graphic_watermark_is_placed_scaled_and_blended() {
let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let logo_path = dir.join("logo.png");
// 40×20: left half opaque red, right half fully transparent
let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 255, 0, 0] });
let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap();
std::fs::write(&logo_path, png).unwrap();
let mut img = Rgba8::new(400, 300);
img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]);
let wm = Watermark { image: logo_path.to_string_lossy().into(), image_width: 0.25, opacity: 1.0, inset: 0.0, ..Default::default() };
draw_watermark(&mut img, &wm);
// 100 px wide × 50 px high in the bottom-right corner
assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280));
assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo");
assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo");
assert_eq!(img.get(10, 10), [0, 0, 0, 255]);
// an image-only watermark survives the params (no text needed)
let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}}));
assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn naming_and_params() {
let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"}));
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]
@@ -1147,7 +1458,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"));
}
}
+375 -82
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.
@@ -52,6 +59,61 @@ pub struct ImportOptions {
/// Browsing a folder: photos come in as `local` (not in the library), and a file with the
/// same content as one already known is still listed.
pub local: bool,
/// Copy: where the copies go (default: the library's `Originals/`).
pub destination: Option<String>,
/// Copy: the folders inside the destination.
pub organize: Organize,
/// Copy: a file-name template for the copies ([`crate::rename::expand`] tokens: `{name}`,
/// `{seq:N}`, `{date:%Y%m%d}`, `{camera}`…), numbered from `rename_start`.
pub rename: Option<String>,
pub rename_start: usize,
/// A metadata preset (by name) applied to every imported photo.
pub metadata_preset: Option<String>,
/// Copy: raws are copied as DNG (Copy as DNG).
pub convert_dng: bool,
}
/// 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]
ByDay,
/// `YYYY/YYYY-MM/`.
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.
@@ -73,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)]
@@ -88,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.
@@ -96,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.
@@ -127,6 +216,14 @@ pub struct ImportDefaults {
pub copyright: String,
/// Creator given to imported photos that don't name one (empty = none).
pub creator: String,
/// Metadata preset applied to every imported photo (`metadata.*`; `None` = none).
pub metadata_preset: Option<String>,
/// Auto import: a watched folder whose new photos are added as they arrive (`None` = off),
/// copied into the library's Originals instead of added in place when `auto_copy`, and an
/// album they go to.
pub auto_folder: Option<String>,
pub auto_copy: bool,
pub auto_album: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -164,8 +261,12 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) {
if p.meta.creator.trim().is_empty() && !d.creator.trim().is_empty() {
p.meta.creator = d.creator.trim().to_string();
}
if let Some(mp) = d.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);
}
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) => {
@@ -213,8 +314,14 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
out
}
fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
let Some(probe) = s.media.file_probe.clone() else {
/// 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.media.file_probe.as_ref(), paths, &ScanProgress::default())
}
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| {
@@ -240,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);
}
});
@@ -249,81 +360,136 @@ 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()
}
/// `Originals/YYYY/YYYY-MM-DD/name`, made unique.
fn copy_into_library(lib: &Path, src: &str, date: &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 = lib.join("Originals").join(year).join(day);
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let name = Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) => (s.to_string(), format!(".{e}")),
None => (name.clone(), String::new()),
};
let mut dst = dir.join(&name);
let mut i = 1;
while dst.exists() {
dst = dir.join(format!("{stem}-{i}{ext}"));
i += 1;
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file,
/// synced and compared byte for byte); a bad one is removed and the photo reported as failed.
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));
let name = name
.map(str::to_string)
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
crate::import_move::copy_new(Path::new(src), &dir, &name).map(|p| p.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())
}
std::fs::copy(src, &dst).map_err(|e| format!("copy {src}: {e}"))?;
Ok(dst.to_string_lossy().to_string())
}
/// 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) => {
@@ -335,14 +501,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.
@@ -355,11 +521,23 @@ 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 lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
if mode == ImportMode::Copy && lib_dir.is_none() {
return Err(crate::EngineError::Other("copying into the library needs an open library".into()));
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
.destination
.as_deref()
.filter(|d| !d.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
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());
let mut report = ImportReport { scanned: files.len(), ..Default::default() };
@@ -388,10 +566,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,
@@ -408,23 +593,92 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
continue;
}
let stored = match (mode, &lib_dir) {
(ImportMode::Copy, Some(lib)) if !Path::new(&path).starts_with(lib) => {
match copy_into_library(lib, &path, info.captured.as_deref().unwrap_or(&now)) {
Ok(p) => p,
Err(e) => {
report.failed.push((path, e));
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 | 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| {
let n = crate::rename::expand(t, &q, seq);
seq += 1;
n
});
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();
if let Some(h) = &info.content_hash {
by_hash.insert(h.clone(), id);
}
let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
let mut stored = stored;
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) => {
let _ = std::fs::remove_file(&stored);
stored = dng;
info.format = "DNG".into();
}
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
}
}
// a copy is catalogued under its new name
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
p.kind = info.kind;
p.file_size = info.file_size;
@@ -433,20 +687,14 @@ 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) {
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);
}
for k in &opts.keywords {
let k = lightcraft_catalog::keywords::clean(k);
@@ -463,14 +711,58 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
}
report.imported.push(id.0);
p.local = opts.local;
if opts.local {
// the state as browsed: a later change keeps the record from being forgotten
p.set_local_baseline();
}
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 {
@@ -503,6 +795,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());
+328
View File
@@ -0,0 +1,328 @@
//! 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)
}
/// Import → **Copy**: copy `src` into `dir` as `name` (-1, -2… when taken) with the same checks as
/// a move's copy — a new file (never overwriting one, even one that appears meanwhile), synced to
/// disk and compared byte for byte with the source; a bad copy is removed and reported, so a card
/// is never wiped on the strength of it. Returns the new path.
pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str) -> Result<PathBuf, 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()),
};
for i in 0..100_000u32 {
let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) };
match copy_verified(src, &d) {
Ok(()) => return Ok(d),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("copy {}: {e} (nothing was imported from it; the original is untouched)", src.display())),
}
}
Err(format!("copy {}: no free name in {}", src.display(), dir.display()))
}
/// 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.
pub(crate) 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)
}
+236 -16
View File
@@ -12,18 +12,25 @@
pub mod cmd;
pub mod crs;
pub mod crs_masks;
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 originals;
pub mod preset_import;
pub mod preset_luminar;
pub mod presets;
pub mod rename;
pub mod sidecar;
pub mod smart;
mod view;
use std::sync::Arc;
@@ -33,7 +40,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)]
@@ -46,6 +53,13 @@ pub enum EngineError {
BadParams { cmd: String, msg: String },
#[error("{0}")]
Catalog(#[from] lightcraft_catalog::CatalogError),
/// The command's change is applied (in memory, undoable) but its journal records could not
/// be written. They stay queued and are written by the next successful save.
#[error("saved in memory but not written to disk: {0}; LightCraft will retry")]
NotSaved(String),
/// Another process (the app, `lightcraft-cli`, another computer) has the library open.
#[error("{0}")]
LibraryInUse(String),
#[error("{0}")]
Other(String),
}
@@ -67,15 +81,27 @@ 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).
pub auto_sync: bool,
pub catalog: Catalog,
pub source: LibrarySource,
pub filter: Filter,
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>,
@@ -114,15 +140,45 @@ pub struct Session {
pub last_export: Option<serde_json::Value>,
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
pub export_presets: Vec<export::ExportPreset>,
/// Metadata presets (`metadata.*`), persisted in prefs.json.
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).
pub signatures: std::collections::HashMap<String, [f32; 64]>,
/// Imported `.cube` LUT profiles.
pub lut_profiles: Vec<cmd::lut_profiles::LutProfile>,
/// The target album B adds to (`None` = the Quick Collection).
pub target_album: Option<lightcraft_catalog::AlbumId>,
/// Auto import: files seen in the watched folder and their size then (a file is imported once
/// its size held between two scans).
pub auto_import_seen: std::collections::HashMap<String, u64>,
/// Keyword sets (⌥1–⌥9 apply the current set's keywords), the one in use (`None` = Recent
/// Keywords) and the recently added keywords, newest first.
pub keyword_sets: Vec<cmd::keywords::KeywordSet>,
pub keyword_set: Option<String>,
pub recent_keywords: Vec<String>,
/// Before/After: the "before" settings chosen per photo (this session; default: the photo's
/// import state). See `cmd/before.rs`.
pub before: std::collections::HashMap<PhotoId, Arc<DevelopSettings>>,
/// File probes from the last import review (`library.importPreview`), reused by the import.
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
/// The last (or running) Build Previews.
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
/// Develop defaults applied on import (persisted in prefs.json).
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>,
/// Untouched Local records of folders not browsed for this many days are forgotten when the
/// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`.
pub forget_local_days: u32,
}
impl Default for Session {
@@ -134,13 +190,16 @@ impl Default for Session {
impl Session {
pub fn new() -> Session {
Session {
auto_sync: false,
catalog: Catalog::new(),
source: LibrarySource::All,
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,
@@ -163,10 +222,24 @@ impl Session {
xmp: sidecar::XmpPrefs::default(),
last_export: None,
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(),
target_album: None,
auto_import_seen: Default::default(),
keyword_sets: Vec::new(),
keyword_set: None,
recent_keywords: Vec::new(),
before: Default::default(),
import_probes: Default::default(),
preview_build: None,
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
smart_previews_dir: None,
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
}
}
@@ -186,10 +259,14 @@ 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;
if let Some(left) = was_active {
self.auto_version(left);
}
}
if r.is_ok() && spec.journal && self.depth == 0 {
self.journal.push((id.to_string(), params.clone()));
@@ -201,9 +278,18 @@ impl Session {
self.auto_write_sidecars(&self.pending_log[log_start..]);
}
if self.depth == 0 && self.library.is_some() {
// Make the command durable before reporting success (on failure the ops stay pending,
// are retried after the next command, and `library.info` reports the error).
let _ = self.persist();
// Make the command durable before reporting success. When the command's own records
// can't be appended, it fails with `NotSaved`: the change stays applied in memory and
// queued, and the next save (any later command, or the frame loop) retries it. Other
// persistence trouble (a failed compaction, an older queued change still unwritten by
// a command that changed nothing) doesn't fail the command; `library.info` reports it.
let produced = self.pending_log.len() > log_start;
if let Err(e @ EngineError::NotSaved(_)) = self.persist()
&& produced
&& r.is_ok()
{
return Err(e);
}
}
r
}
@@ -227,6 +313,30 @@ impl Session {
Ok(())
}
/// Leaving photo `id` after editing it: keep its settings as an automatic version (when they
/// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped).
/// Saved with the library but not an undo step.
pub fn auto_version(&mut self, id: PhotoId) {
let Some(p) = self.catalog.photo(id) else { return };
if !p.is_edited() || p.versions.last().is_some_and(|v| *v.settings == *p.develop) || self.interaction.is_some() {
return;
}
let mut versions = p.versions.clone();
let created = (self.clock)();
let name = lightcraft_catalog::dates::display_time(&created);
versions.push(lightcraft_catalog::Version { name, created, settings: p.develop.clone(), auto: true });
let autos = versions.iter().filter(|v| v.auto).count();
if autos > AUTO_VERSIONS
&& let Some(i) = versions.iter().position(|v| v.auto)
{
versions.remove(i);
}
let op = Op::SetVersions { id, versions };
if self.catalog.apply(op.clone()).is_ok() {
self.pending_log.push(op);
}
}
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
/// depends on the state the previous one left).
pub fn merge_undo(&mut self, n: usize, label: &str) {
@@ -272,15 +382,22 @@ impl Session {
Ok(e.label)
}
/// Apply an undo/redo op, first moving the files its renames imply (all or nothing).
/// Apply an undo/redo op, first moving the files its renames imply (all or nothing). Files
/// that can't be moved back after a failure are reported and the library follows them (a
/// logged change outside the undo history), so none goes missing.
fn apply_with_files(&mut self, op: &Op) -> Result<Op> {
let fs = rename::RealFs;
let moves = self.file_moves(op);
Session::move_files(&moves)?;
if let Err(e) = rename::move_all(&fs, &moves) {
return Err(EngineError::Other(format!("can't move the files back: {}", self.follow_stuck(op, e, false))));
}
match self.catalog.apply(op.clone()) {
Ok(inv) => Ok(inv),
Err(e) => {
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
let _ = Session::move_files(&back);
if let Err(be) = rename::move_all(&fs, &back) {
return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message)));
}
Err(e.into())
}
}
@@ -312,10 +429,46 @@ impl Session {
{
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
}
let op = self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?];
ops.extend(self.auto_sync_ops(id, &settings, label));
let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } };
self.commit(label, op)
}
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
/// other selected photos: only the settings that changed; never spot removal or red eye (they
/// belong to one photo's pixels), nor history / snapshot restores.
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
if !self.auto_sync
|| self.active() != Some(id)
|| self.selection.ids.len() < 2
|| label.starts_with("History:")
|| label.starts_with("Restore ")
{
return Vec::new();
}
let Some(old) = self.develop_of(id) else { return Vec::new() };
let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() };
if let Some(o) = delta.as_object_mut() {
for k in ["spots", "red_eye", "version"] {
o.remove(k);
}
if o.is_empty() {
return Vec::new();
}
}
self.selection
.ids
.iter()
.filter(|x| **x != id)
.filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))
.filter_map(|(x, d)| {
let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
})
.collect()
}
/// The op that sets a photo's develop settings and appends a History entry (for batches).
pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {
self.catalog.photo(id)?;
@@ -361,6 +514,8 @@ impl Session {
pub fn visible(&mut self) -> &[PhotoId] {
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
if self.visible_key.as_ref() != Some(&key) {
// "in the last N days" rules count back from the session's clock
lightcraft_catalog::rules::set_now(Some((self.clock)()));
let mut f = self.source.to_filter(&self.filter, &self.catalog);
if self.source == LibrarySource::Folder {
// no folder chosen: nothing (an empty path matches nothing)
@@ -368,7 +523,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
@@ -377,23 +532,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::RecentlyDeleted {
self.visible = self.catalog.arrange_stacks(&self.visible);
if self.source == LibrarySource::RecentlyAdded {
// newest import first, whatever the sort (the grid groups by import day)
let cat = &self.catalog;
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 {
// 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 {
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) {
@@ -406,6 +604,22 @@ impl Session {
}
}
/// Automatic versions kept per photo.
pub const AUTO_VERSIONS: usize = 20;
/// The parts of `new` that differ from `old` (objects recurse; anything else is taken whole).
pub fn json_delta(old: &Value, new: &Value) -> Option<Value> {
match (old, new) {
(Value::Object(a), Value::Object(b)) => {
let m: serde_json::Map<String, Value> =
b.iter().filter_map(|(k, nv)| json_delta(a.get(k).unwrap_or(&Value::Null), nv).map(|d| (k.clone(), d))).collect();
(!m.is_empty()).then_some(Value::Object(m))
}
(a, b) if a == b => None,
(_, b) => Some(b.clone()),
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
@@ -413,8 +627,12 @@ mod tests_color;
#[cfg(test)]
mod tests_export;
#[cfg(test)]
mod tests_forget_local;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_import_move;
#[cfg(test)]
mod tests_libops;
#[cfg(test)]
mod tests_library;
@@ -423,6 +641,8 @@ mod tests_merge;
#[cfg(test)]
mod tests_organize;
#[cfg(test)]
mod tests_persist;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_spots;
+235 -17
View File
@@ -14,12 +14,16 @@
//! via [`Session::open_library_in`] (the browser build keeps them in OPFS or IndexedDB).
//!
//! 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`].
//! so a crash loses at most the command in flight. When that write fails the command returns
//! [`EngineError::NotSaved`]: its change stays applied in memory and queued, and every later save
//! retries the queue ([`Session::unsaved`] reports it meanwhile), so nothing is lost once the
//! disk is writable again. The log is compacted into a snapshot when it
//! 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};
use lightcraft_catalog::{FsStore, Journal, LoadReport, Store};
use lightcraft_catalog::{FsStore, Journal, LibraryLock, LoadReport, Store};
use lightcraft_develop::Preset;
use serde::{Deserialize, Serialize};
@@ -51,8 +55,18 @@ pub struct Library {
pub report: LoadReport,
/// Last persistence error (shown by the UI; ops stay pending and are retried).
pub last_error: Option<String>,
/// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes
/// exist only in memory ([`Session::unsaved`]).
pub unsaved_error: Option<String>,
/// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off).
retry_at: Option<web_time::Instant>,
/// What forgetting untouched Local records did when the library opened.
pub forgot_local: Option<lightcraft_catalog::ForgetPlan>,
presets_written: String,
view_written: Vec<u8>,
/// Keeps other processes out of this library while it's open (last: released after the
/// journal's background snapshot has landed).
lock: Option<LibraryLock>,
}
/// Where a library's files live (see [`Session::open_library_in`]).
@@ -91,6 +105,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")
}
@@ -108,10 +126,29 @@ struct PrefsFile {
last_export: Option<serde_json::Value>,
/// The user's export presets.
export_presets: Vec<crate::export::ExportPreset>,
/// Metadata presets.
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.
lut_profiles: Vec<crate::cmd::lut_profiles::LutProfile>,
/// Keyword sets, the one in use, recent keywords.
keyword_sets: Vec<crate::cmd::keywords::KeywordSet>,
keyword_set: Option<String>,
recent_keywords: Vec<String>,
/// Develop defaults for imported photos.
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>,
/// Days after which untouched Local records of unbrowsed folders are forgotten (missing =
/// the default, 0 = never).
forget_local_days: Option<u32>,
}
fn presets_json(s: &Session) -> String {
@@ -125,6 +162,35 @@ 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}"));
}
}
/// How long the frame loop waits before retrying a failed append (commands retry at once).
const RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
/// `a` and `b` name the same directory.
fn same_dir(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
/// This program, for the lock owner note ("LightCraft", "lightcraft-cli").
fn program_name() -> String {
let exe = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()));
match exe.as_deref() {
Some("lightcraft") | None => "LightCraft".into(),
Some(other) => other.to_string(),
}
}
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())
}
@@ -132,17 +198,55 @@ fn read_json<T: serde::de::DeserializeOwned>(store: &mut dyn Store, name: &str)
impl Session {
/// Open (or create) the library at `dir` into this session, replacing its catalog. With
/// `seed_demo`, a newly created library starts with the procedural demo photos.
///
/// The library is locked for this session ([`lightcraft_catalog::lock`]): if another process
/// has it open, this fails with [`EngineError::LibraryInUse`] and nothing is read or changed.
/// Reopening the library this session already has open keeps its lock.
pub fn open_library(&mut self, dir: impl AsRef<Path>, seed_demo: bool) -> Result<&LoadReport> {
let dir = dir.as_ref().to_path_buf();
let open = || FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())));
let stores = LibraryStores { catalog: Box::new(open()?), files: Box::new(open()?), on_disk: true, dir: dir.clone() };
self.open_library_in(stores, seed_demo)
// the library open in this session (same directory): hand its lock over
let reused = self.library.as_mut().filter(|l| same_dir(&l.dir, &dir)).and_then(|l| l.lock.take());
let reusing = reused.is_some();
let mut lock = match reused {
Some(l) => Some(l),
None => Some(LibraryLock::acquire(&dir, &program_name()).map_err(|e| EngineError::LibraryInUse(e.to_string()))?),
};
if let Err(e) = self.open_stores(stores, seed_demo, &mut lock) {
if reusing && let Some(lib) = self.library.as_mut() {
lib.lock = lock.take();
}
return Err(e);
}
self.loaded_report()
}
/// Open (or create) a library whose files live in `stores` (e.g. browser storage), replacing
/// this session's catalog.
/// this session's catalog. Not locked: the host guards against a second opener (see
/// [`Session::open_library`] for directories).
pub fn open_library_in(&mut self, stores: LibraryStores, seed_demo: bool) -> Result<&LoadReport> {
self.open_stores(stores, seed_demo, &mut None)?;
self.loaded_report()
}
fn loaded_report(&self) -> Result<&LoadReport> {
match &self.library {
Some(lib) => Ok(&lib.report),
None => Err(EngineError::Other("library closed while opening".into())),
}
}
/// Open `stores`; the new library takes `lock` once nothing can fail any more.
fn open_stores(&mut self, stores: LibraryStores, seed_demo: bool, lock: &mut Option<LibraryLock>) -> Result<()> {
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();
@@ -174,8 +278,22 @@ impl Session {
self.xmp = prefs.xmp;
self.last_export = prefs.last_export;
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);
self.keyword_sets = prefs.keyword_sets;
self.keyword_set = prefs.keyword_set;
self.recent_keywords = prefs.recent_keywords;
self.import_defaults = prefs.import;
self.cache_mb = prefs.cache_mb;
self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS);
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;
@@ -198,26 +316,68 @@ 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)
self.library = Some(Library {
dir,
on_disk,
journal,
files,
report,
last_error: None,
unsaved_error: None,
retry_at: None,
forgot_local: None,
presets_written,
view_written,
lock: lock.take(),
});
// forget untouched Local records of folders not browsed for a while (journaled at once)
if self.forget_local_days > 0 {
let plan = self.forget_local(false, None);
if !plan.evict.is_empty() {
self.compact_soon();
} else if let Err(e) = self.persist() {
log::error!("library: {e}");
}
if let Some(lib) = self.library.as_mut() {
lib.forgot_local = Some(plan);
}
}
Ok(())
}
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.
/// Called after every top-level command; cheap when nothing changed.
/// Called after every top-level command; cheap when nothing changed. Fails (with
/// [`EngineError::NotSaved`]) only when the queued ops couldn't be written; they stay queued.
pub fn persist(&mut self) -> Result<()> {
let Some(lib) = self.library.as_mut() else { return Ok(()) };
if !self.pending_log.is_empty() {
if let Err(e) = lib.journal.append(&self.pending_log) {
lib.last_error = Some(e.to_string());
log::error!("library: {e}");
return Err(e.into());
// the ops stay queued (and applied in memory): the next persist retries them
let reason = e.to_string();
log::error!("library: {} change(s) not written to disk: {reason}", self.pending_log.len());
lib.last_error = Some(reason.clone());
lib.unsaved_error = Some(reason.clone());
lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF);
return Err(EngineError::NotSaved(reason));
}
if lib.unsaved_error.take().is_some() {
log::info!("library: queued changes written to disk");
}
lib.retry_at = None;
self.pending_log.clear();
lib.last_error = None;
}
poll_compaction(lib);
// Never snapshot mid-interaction: the catalog then holds an uncommitted preview value.
if lib.journal.wants_snapshot() && self.interaction.is_none() {
lib.journal.snapshot(&self.catalog)?;
// The snapshot is written by a worker thread (natively); appends go on meanwhile.
// A failed compaction loses nothing (the log is kept whole and compacted later): it is
// reported (`library.info` → `lastError`), not returned.
if lib.journal.wants_snapshot()
&& self.interaction.is_none()
&& let Err(e) = lib.journal.snapshot_in_background(&self.catalog)
{
log::error!("library: compaction: {e}");
lib.last_error = Some(format!("compaction: {e}"));
}
let presets = presets_json(self);
let Some(lib) = self.library.as_mut() else { return Ok(()) };
@@ -234,22 +394,52 @@ impl Session {
/// Persist if commands left ops pending (cheap; frontends call it once per frame for state
/// changed outside [`Session::execute`]).
pub fn persist_if_dirty(&mut self) {
if self.library.is_some() && !self.pending_log.is_empty() {
let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t);
if self.library.is_some() && !self.pending_log.is_empty() && !backing_off {
let _ = self.persist();
} else if let Some(lib) = self.library.as_mut() {
poll_compaction(lib);
}
}
/// Changes applied in memory but not yet written to disk because saving failed: the number of
/// queued ops and why (`None` when everything is saved, or no library is open). Retried by
/// every command and by [`Session::persist_if_dirty`].
pub fn unsaved(&self) -> Option<(usize, &str)> {
let lib = self.library.as_ref()?;
lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e))
}
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
///
/// The snapshot is written even when appending the queued ops fails: it is a fresh file
/// holding everything in memory, so it saves those ops too (the log handle may be the only
/// thing that's broken). Fails only if nothing could be saved; the ops then stay queued.
pub fn close_library(&mut self) -> Result<()> {
if self.library.is_none() {
return Ok(());
}
let _ = self.end_interaction();
self.persist()?;
let persisted = self.persist();
self.save_view();
let unlogged = if persisted.is_err() { self.pending_log.len() as u64 } else { 0 };
let Some(lib) = self.library.as_mut() else { return Ok(()) };
lib.journal.snapshot(&self.catalog)?;
Ok(())
let before = lib.journal.seq();
let snapshot = lib.journal.snapshot_with_unlogged(&self.catalog, unlogged);
if unlogged > 0 && lib.journal.seq() == before + unlogged {
// the snapshot holds the queued ops: they are saved, never append them again
log::info!("library: {unlogged} queued change(s) saved in the closing snapshot");
self.pending_log.clear();
lib.unsaved_error = None;
lib.retry_at = None;
lib.last_error = None;
}
match (persisted, snapshot) {
(_, Ok(())) => Ok(()),
// neither the log nor the snapshot took the queued ops
(Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))),
(_, Err(s)) => Err(s.into()),
}
}
fn view_json(&self) -> Vec<u8> {
@@ -286,8 +476,18 @@ impl Session {
xmp: self.xmp,
last_export: self.last_export.clone(),
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(),
keyword_set: self.keyword_set.clone(),
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()),
forget_local_days: Some(self.forget_local_days),
})
.unwrap_or_default();
let Some(lib) = self.library.as_mut() else { return Ok(()) };
@@ -310,6 +510,24 @@ impl Session {
self.save_prefs()
}
/// Save, then start compacting in the background (natively; synchronously elsewhere): after
/// a change that shrinks the catalog a lot, so the smaller snapshot replaces the old one.
/// Errors are logged and reported like other persistence errors (nothing is lost).
pub fn compact_soon(&mut self) {
if let Err(e) = self.persist() {
log::error!("library: {e}");
return;
}
if self.interaction.is_some() {
return;
}
let Some(lib) = self.library.as_mut() else { return };
if let Err(e) = lib.journal.snapshot_in_background(&self.catalog) {
log::error!("library: compaction: {e}");
lib.last_error = Some(format!("compaction: {e}"));
}
}
/// Compact the log into a snapshot now.
pub fn compact_library(&mut self) -> Result<()> {
self.persist()?;
+62 -10
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];
@@ -74,8 +74,19 @@ pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>
#[derive(Clone)]
pub enum SourceRef {
Loaded(Arc<Rgb32f>),
Demo { scene: Box<lightcraft_scenes::Scene>, max_edge: usize },
File { path: String, max_edge: usize, loader: Option<FileLoader> },
Demo {
scene: Box<lightcraft_scenes::Scene>,
max_edge: usize,
},
File {
path: String,
max_edge: usize,
loader: Option<FileLoader>,
},
/// A smart preview standing in for a missing original.
Smart {
path: std::path::PathBuf,
},
}
impl SourceRef {
@@ -87,6 +98,10 @@ impl SourceRef {
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
None => Err(format!("no decoder available for {path}")),
},
#[cfg(not(target_arch = "wasm32"))]
SourceRef::Smart { path } => crate::smart::load(path),
#[cfg(target_arch = "wasm32")]
SourceRef::Smart { .. } => Err("smart previews are not available here".into()),
}
}
}
@@ -108,6 +123,8 @@ pub struct MediaCache {
pub preview_loader: Option<PreviewLoader>,
/// Reads a photo file's bytes (Photo Merge); `None` = the local file system.
pub file_bytes: Option<crate::merge::ByteReader>,
/// The library's smart previews folder (originals offline: render from the proxy).
pub smart_dir: Option<std::path::PathBuf>,
scenes: Vec<lightcraft_scenes::Scene>,
/// Rendered thumbnails (memory, plus disk once a library is attached).
pub rendered: Arc<PreviewCache>,
@@ -126,6 +143,7 @@ impl Default for MediaCache {
file_probe: None,
preview_loader: None,
file_bytes: None,
smart_dir: None,
scenes: Vec::new(),
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
}
@@ -266,6 +284,16 @@ impl MediaCache {
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
_ => usize::MAX,
});
// the original is offline: its smart preview, when there is one
#[cfg(not(target_arch = "wasm32"))]
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir)
&& !std::path::Path::new(path).exists()
{
let sp = dir.join(crate::smart::file_name(p));
if crate::smart::is_valid(&sp) {
return SourceRef::Smart { path: sp };
}
}
self.origin_ref(&p.source, max_edge)
}
@@ -346,6 +374,20 @@ impl RenderJob {
self
}
/// Soft-proof the result (see [`lightcraft_pipeline::Proof`]). Gets its own result key and is
/// never kept as the photo's cached preview.
pub fn with_proof(mut self, proof: Option<lightcraft_pipeline::Proof>) -> Self {
if self.request.proof != proof {
let k = |p: Option<lightcraft_pipeline::Proof>| p.map_or(0, |p| p.key()).wrapping_mul(0xc2b2_ae3d_27d4_eb4f);
self.key ^= k(self.request.proof) ^ k(proof);
self.request.proof = proof;
}
if proof.is_some() {
self.view_cache = None;
}
self
}
/// Reuse `stages` across this view's renders.
pub fn with_stages(mut self, stages: Arc<StageCache>) -> Self {
self.stages = Some(stages);
@@ -400,7 +442,13 @@ impl RenderJob {
/// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see
/// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU.
pub fn develop(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered {
if gpu && let Some(r) = lightcraft_gpu::render(src, info, s, req, stages) {
// LUT profiles have no GPU stage: they render on the CPU
if gpu
&& !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id)
&& !s.masks.iter().any(|m| m.visible && m.refine > 0.0)
&& req.proof.is_none()
&& let Some(r) = lightcraft_gpu::render(src, info, s, req, stages)
{
return r;
}
match stages {
@@ -475,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 {
@@ -511,12 +557,15 @@ impl crate::Session {
let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) };
// the photo id is part of the key: two photos with the same settings and size must not
// share a result (a view slot showing photo A would otherwise look current for photo B)
// …and so is the file's content: a file changed on disk (Reload) renders afresh
let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64;
let key = settings.hash64()
^ ((max_w as u64) << 40)
^ ((max_h as u64) << 20)
^ (apply_crop as u64)
^ (level as u64) << 60
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15);
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15)
^ content.rotate_left(17);
let cache = thumb_bucket.map(|b| {
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
(self.media.rendered.clone(), k)
@@ -700,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>;
+6 -1
View File
@@ -44,7 +44,12 @@ fn total_ram() -> Option<usize> {
let out = std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
#[cfg(target_os = "freebsd")]
{
let out = std::process::Command::new("/sbin/sysctl").args(["-n", "hw.physmem"]).output().ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
{
None
}
+9 -12
View File
@@ -304,19 +304,13 @@ fn apply_auto(s: &mut DevelopSettings, src: &Rgb32f, info: &SourceInfo) {
s.color.saturation = a.saturation;
}
/// A free file name `dir/<stem>-<suffix>.dng` (`-2`, `-3`… when taken).
fn output_path(first: &str, suffix: &str) -> String {
/// `dir/<stem>-<suffix>.dng`, then `-2`, `-3`… (the candidates for a merge's output).
fn output_names(first: &str, suffix: &str) -> impl Iterator<Item = std::path::PathBuf> {
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 suffix = suffix.to_string();
(1u64..).map(move |k| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") }))
}
/// Parse `merge.*` command parameters into a kind and finishing options.
@@ -371,8 +365,11 @@ impl Session {
/// apply Auto Settings / the auto crop. Returns `{id, path, …info}`.
pub fn finish_merge(&mut self, job: &MergeJob, out: MergeOutput) -> Result<Value> {
let first = &job.sources.first().ok_or_else(|| EngineError::Other("nothing merged".into()))?.1;
let path = output_path(first, job.kind.suffix());
std::fs::write(&path, &out.dng).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
// a new file under a free name (never replacing one), complete and synced before it appears
let path = lightcraft_catalog::safe_file::write_new_unique(&mut output_names(first, job.kind.suffix()), &out.dng)
.map_err(|e| EngineError::Other(format!("could not write the merged DNG next to {first}: {e}")))?
.to_string_lossy()
.to_string();
let report = crate::import::import(self, std::slice::from_ref(&path), crate::import::ImportMode::Add)?;
let id = report
.imported
+102
View File
@@ -0,0 +1,102 @@
//! Never write over an original: exports, renders and other files LightCraft writes for the user
//! must not land on a catalogued photo's own file (or its XMP sidecar), whatever the conflict
//! setting or exact path asked for. [`OriginalGuard`] is a snapshot of the library's originals
//! that answers "would writing here replace one?" by file identity (the same file under another
//! spelling, through `..`, a link or a case-insensitive volume counts), not by comparing strings.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use lightcraft_catalog::safe_file::same_file;
use lightcraft_catalog::{Catalog, Source};
/// The library's originals and their sidecars, looked up by file name.
#[derive(Debug, Default)]
pub struct OriginalGuard {
/// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …)
by_name: HashMap<String, Vec<(PathBuf, String)>>,
}
impl OriginalGuard {
/// The originals of every photo in `catalog`.
pub fn new(catalog: &Catalog) -> Self {
let mut g = Self::default();
for p in catalog.photos() {
if let Source::File { path } = &p.source {
g.add(Path::new(path), &p.file_name);
}
}
g
}
/// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`).
pub fn add(&mut self, path: &Path, label: &str) {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return };
let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what));
put(name.clone(), path.to_path_buf(), format!("the original of {label}"));
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
put(format!("{stem}.xmp"), path.with_extension("xmp"), format!("the XMP sidecar of {label}"));
put(format!("{name}.xmp"), PathBuf::from(format!("{}.xmp", path.to_string_lossy())), format!("the XMP sidecar of {label}"));
}
/// `Err` (a message for the user) when writing `target` would replace a protected file.
pub fn check(&self, target: &Path) -> Result<(), String> {
if std::fs::symlink_metadata(target).is_err() {
return Ok(()); // nothing there to replace
}
let mut names: Vec<String> = target.file_name().map(|n| n.to_string_lossy().to_lowercase()).into_iter().collect();
if let Some(n) = std::fs::canonicalize(target).ok().and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_lowercase())) {
names.push(n);
}
for n in &names {
for (p, what) in self.by_name.get(n).into_iter().flatten() {
if same_file(p, target) {
return Err(format!(
"{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)",
target.display()
));
}
}
}
Ok(())
}
}
impl crate::Session {
/// A guard over this library's originals (see [`OriginalGuard`]).
pub fn original_guard(&self) -> OriginalGuard {
OriginalGuard::new(&self.catalog)
}
/// `Err` when writing `path` would replace a catalogued photo's original or sidecar.
pub fn check_write_target(&self, path: &str) -> Result<(), String> {
self.original_guard().check(Path::new(path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_catalog::{Op, Photo, PhotoId};
#[test]
fn originals_and_their_sidecars_are_protected_by_identity() {
let d = std::env::temp_dir().join(format!("lc-originals-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("sub")).unwrap();
let orig = d.join("IMG_1.jpg");
std::fs::write(&orig, b"original").unwrap();
std::fs::write(d.join("IMG_1.xmp"), b"<x/>").unwrap();
std::fs::write(d.join("other.jpg"), b"other").unwrap();
let mut c = Catalog::default();
let p = Photo::new(PhotoId(1), Source::File { path: orig.to_string_lossy().to_string() }, "IMG_1.jpg", "JPEG", 10, 10, "2026-01-01");
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let g = OriginalGuard::new(&c);
assert!(g.check(&orig).unwrap_err().contains("original of IMG_1.jpg"));
assert!(g.check(&d.join("sub/../IMG_1.jpg")).is_err(), "another spelling of the same file");
assert!(g.check(&d.join("IMG_1.xmp")).unwrap_err().contains("sidecar"));
assert!(g.check(&d.join("other.jpg")).is_ok(), "an ordinary existing file");
assert!(g.check(&d.join("new.jpg")).is_ok(), "a free name");
let _ = std::fs::remove_dir_all(&d);
}
}
+744
View File
@@ -0,0 +1,744 @@
//! 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`]. 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.
use std::path::Path;
use lightcraft_develop::Preset;
use serde_json::{Map, Value};
use crate::crs::Props;
/// One preset read from a file, with the settings we could not carry over.
#[derive(Clone, Debug)]
pub struct Imported {
pub preset: Preset,
/// Names of the other editor's settings that have no counterpart here (local masks, its own
/// profiles…).
pub unmapped: Vec<String>,
}
/// Extensions of files read as presets (beyond our own `.lcpreset`).
pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack"];
// ------------------------------------------------------------------------------------- zip
/// The files of a zip archive: (path inside the archive, bytes). Stored and deflated entries;
/// directories, macOS resource forks and hidden files are skipped.
pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
// end of central directory: the last "PK\x05\x06" within the trailing 64 KiB + 22 bytes
let tail = bytes.len().saturating_sub(65_557);
let eocd = (tail..bytes.len().saturating_sub(21)).rev().find(|&i| bytes[i..].starts_with(b"PK\x05\x06")).ok_or("not a zip file")?;
let count = u16_at(eocd + 10).ok_or("bad zip")?;
let mut at = u32_at(eocd + 16).ok_or("bad zip")?;
let mut out = Vec::new();
for _ in 0..count {
if !bytes.get(at..).is_some_and(|b| b.starts_with(b"PK\x01\x02")) {
return Err("bad zip central directory".into());
}
let method = u16_at(at + 10).ok_or("bad zip")?;
let csize = u32_at(at + 20).ok_or("bad zip")?;
let usize_ = u32_at(at + 24).ok_or("bad zip")?;
let (nlen, xlen, clen) = (u16_at(at + 28).ok_or("bad zip")?, u16_at(at + 30).ok_or("bad zip")?, u16_at(at + 32).ok_or("bad zip")?);
let local = u32_at(at + 42).ok_or("bad zip")?;
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + nlen).ok_or("bad zip")?).replace('\\', "/");
at += 46 + nlen + xlen + clen;
let skip = name.ends_with('/') || name.split('/').any(|p| p.starts_with('.') || p == "__MACOSX");
if skip {
continue;
}
if !bytes.get(local..).is_some_and(|b| b.starts_with(b"PK\x03\x04")) {
return Err(format!("{name}: bad local header"));
}
let data_at = local + 30 + u16_at(local + 26).ok_or("bad zip")? + u16_at(local + 28).ok_or("bad zip")?;
let data = bytes.get(data_at..data_at + csize).ok_or_else(|| format!("{name}: truncated"))?;
let content = match method {
0 => data.to_vec(),
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1) * 2 + 1024).map_err(|e| format!("{name}: {e:?}"))?,
m => return Err(format!("{name}: unsupported compression method {m}")),
};
out.push((name, content));
}
Ok(out)
}
// ------------------------------------------------------------------------------- Lua tables
/// A value of a Lua table literal (what `.lrtemplate` files contain).
#[derive(Clone, Debug, PartialEq)]
pub enum Lua {
Nil,
Bool(bool),
Num(f64),
Str(String),
/// Positional items and named fields, in file order.
Table(Vec<Lua>, Vec<(String, Lua)>),
}
impl Lua {
pub fn get(&self, key: &str) -> Option<&Lua> {
match self {
Lua::Table(_, m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
pub fn str(&self) -> Option<&str> {
match self {
Lua::Str(s) => Some(s),
_ => None,
}
}
}
struct LuaParser<'a> {
s: &'a [u8],
i: usize,
}
impl LuaParser<'_> {
fn err<T>(&self, what: &str) -> Result<T, String> {
Err(format!("{what} at byte {}", self.i))
}
fn skip_ws(&mut self) {
loop {
while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
self.i += 1;
}
if self.s[self.i..].starts_with(b"--") {
self.i += 2;
if let Some(level) = self.long_bracket_level() {
let _ = self.long_string(level);
} else {
while self.i < self.s.len() && self.s[self.i] != b'\n' {
self.i += 1;
}
}
continue;
}
break;
}
}
/// `[[` / `[==[` at the cursor: its level (number of `=`).
fn long_bracket_level(&self) -> Option<usize> {
let r = &self.s[self.i..];
if r.first() != Some(&b'[') {
return None;
}
let eq = r[1..].iter().take_while(|c| **c == b'=').count();
(r.get(1 + eq) == Some(&b'[')).then_some(eq)
}
fn long_string(&mut self, level: usize) -> Result<String, String> {
self.i += level + 2;
let close = format!("]{}]", "=".repeat(level));
let rest = &self.s[self.i..];
let end = rest.windows(close.len()).position(|w| w == close.as_bytes()).ok_or("unterminated long string")?;
let mut text = String::from_utf8_lossy(&rest[..end]).to_string();
if text.starts_with('\n') {
text.remove(0);
}
self.i += end + close.len();
Ok(text)
}
fn string(&mut self) -> Result<String, String> {
let q = self.s[self.i];
self.i += 1;
let mut out = Vec::new();
while self.i < self.s.len() {
let c = self.s[self.i];
self.i += 1;
match c {
c if c == q => return Ok(String::from_utf8_lossy(&out).to_string()),
b'\\' => {
let e = *self.s.get(self.i).ok_or("bad escape")?;
self.i += 1;
match e {
b'n' => out.push(b'\n'),
b't' => out.push(b'\t'),
b'r' => out.push(b'\r'),
b'0'..=b'9' => {
let mut v = (e - b'0') as u32;
for _ in 0..2 {
match self.s.get(self.i) {
Some(d @ b'0'..=b'9') => {
v = v * 10 + (d - b'0') as u32;
self.i += 1;
}
_ => break,
}
}
out.push(v.min(255) as u8);
}
b'\n' => out.push(b'\n'),
other => out.push(other),
}
}
c => out.push(c),
}
}
self.err("unterminated string")
}
fn ident(&mut self) -> Option<String> {
let start = self.i;
while self.i < self.s.len() && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_') {
self.i += 1;
}
(self.i > start && !self.s[start].is_ascii_digit()).then(|| String::from_utf8_lossy(&self.s[start..self.i]).to_string())
}
fn number(&mut self) -> Result<f64, String> {
let start = self.i;
if self.s[self.i..].starts_with(b"0x") || self.s[self.i..].starts_with(b"0X") {
self.i += 2;
let h = self.i;
while self.i < self.s.len() && self.s[self.i].is_ascii_hexdigit() {
self.i += 1;
}
return i64::from_str_radix(std::str::from_utf8(&self.s[h..self.i]).unwrap_or(""), 16).map(|v| v as f64).map_err(|e| e.to_string());
}
while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'e' | b'E' | b'+' | b'-')) {
if matches!(self.s[self.i], b'+' | b'-') && self.i > start && !matches!(self.s[self.i - 1], b'e' | b'E') {
break;
}
self.i += 1;
}
std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok)
}
fn value(&mut self) -> Result<Lua, String> {
self.skip_ws();
let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") };
match c {
b'{' => self.table(),
b'"' | b'\'' => self.string().map(Lua::Str),
b'[' if self.long_bracket_level().is_some() => {
let level = self.long_bracket_level().unwrap_or(0);
self.long_string(level).map(Lua::Str)
}
b'-' => {
self.i += 1;
self.skip_ws();
match self.value()? {
Lua::Num(n) => Ok(Lua::Num(-n)),
_ => self.err("bad negation"),
}
}
b'0'..=b'9' | b'.' => self.number().map(Lua::Num),
_ => match self.ident().as_deref() {
Some("true") => Ok(Lua::Bool(true)),
Some("false") => Ok(Lua::Bool(false)),
Some("nil") => Ok(Lua::Nil),
// a call with one string / table argument, e.g. ZSTR "…" (localisation helpers)
Some(_) => {
self.skip_ws();
match self.s.get(self.i) {
Some(b'"' | b'\'' | b'{') => self.value(),
Some(b'(') => {
self.i += 1;
let v = self.value()?;
self.skip_ws();
if self.s.get(self.i) == Some(&b')') {
self.i += 1;
}
Ok(v)
}
_ => Ok(Lua::Nil),
}
}
None => self.err("unexpected character"),
},
}
}
fn table(&mut self) -> Result<Lua, String> {
self.i += 1; // {
let (mut arr, mut map) = (Vec::new(), Vec::new());
loop {
self.skip_ws();
match self.s.get(self.i) {
None => return self.err("unterminated table"),
Some(b'}') => {
self.i += 1;
return Ok(Lua::Table(arr, map));
}
Some(b',' | b';') => {
self.i += 1;
continue;
}
Some(b'[') if self.long_bracket_level().is_none() => {
self.i += 1;
let k = self.value()?;
self.skip_ws();
if self.s.get(self.i) != Some(&b']') {
return self.err("expected ]");
}
self.i += 1;
self.expect_eq()?;
let v = self.value()?;
match k {
Lua::Str(s) => map.push((s, v)),
Lua::Num(n) => map.push((format!("{n}"), v)),
_ => {}
}
}
_ => {
// `name = value`, or a positional value
let save = self.i;
if let Some(name) = self.ident() {
self.skip_ws();
if self.s.get(self.i) == Some(&b'=') && self.s.get(self.i + 1) != Some(&b'=') {
self.i += 1;
let v = self.value()?;
map.push((name, v));
continue;
}
}
self.i = save;
arr.push(self.value()?);
}
}
}
}
fn expect_eq(&mut self) -> Result<(), String> {
self.skip_ws();
if self.s.get(self.i) == Some(&b'=') {
self.i += 1;
Ok(())
} else {
self.err("expected =")
}
}
}
/// Parse a Lua table literal, optionally preceded by `name =` or `return`.
pub fn parse_lua(text: &str) -> Result<Lua, String> {
let mut p = LuaParser { s: text.as_bytes(), i: 0 };
p.skip_ws();
let save = p.i;
match p.ident().as_deref() {
Some("return") => {}
Some(_) => {
p.skip_ws();
if p.s.get(p.i) == Some(&b'=') {
p.i += 1;
} else {
p.i = save;
}
}
None => {}
}
p.value()
}
/// A localisable string `"$$$/Key/Path=Default text"` → its default text.
fn delocalize(s: &str) -> String {
match s.strip_prefix("$$$/") {
Some(rest) => rest.split_once('=').map_or(rest, |(_, t)| t).to_string(),
None => s.to_string(),
}
}
/// The develop settings of an `.lrtemplate` as `crs:` properties (flat, and structured for local
/// corrections), plus its title.
pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs_masks::Values), String> {
let root = parse_lua(text)?;
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
let mut props = Props::new();
let mut values = crate::crs_masks::Values::new();
for (k, v) in fields {
let key = format!("crs:{k}");
if crate::crs_masks::CONTAINERS.contains(&key.as_str()) {
values.insert(key.clone(), crate::crs_masks::from_lua(v));
}
let vals: Vec<String> = match v {
Lua::Num(n) => vec![format!("{n}")],
Lua::Bool(b) => vec![if *b { "True" } else { "False" }.to_string()],
Lua::Str(s) => vec![delocalize(s)],
// point curves are flat lists x1, y1, x2, y2, …
Lua::Table(arr, map) if map.is_empty() && !arr.is_empty() && arr.iter().all(|a| matches!(a, Lua::Num(_))) => arr
.chunks(2)
.filter_map(|c| match c {
[Lua::Num(x), Lua::Num(y)] => Some(format!("{x}, {y}")),
_ => None,
})
.collect(),
Lua::Table(a, m) if a.is_empty() && m.is_empty() => continue,
// anything else (local corrections, retouch…) is only reported
Lua::Table(..) => vec!["(table)".into()],
Lua::Nil => continue,
};
props.insert(key, vals);
}
Ok((title, props, values))
}
// ------------------------------------------------------------------------------- XMP in files
/// The XMP packet embedded in a file of any container (DNG, JPEG, TIFF…): the text from
/// `<x:xmpmeta` to `</x:xmpmeta>`.
pub fn embedded_xmp(bytes: &[u8]) -> Option<String> {
let start = bytes.windows(10).position(|w| w == b"<x:xmpmeta")?;
let end = bytes[start..].windows(12).position(|w| w == b"</x:xmpmeta>")? + start + 12;
Some(String::from_utf8_lossy(&bytes[start..end]).to_string())
}
// ----------------------------------------------------------------------------------- files
/// A preset group named after the innermost folder of `dir` (a path inside the imported folder or
/// bundle) that isn't a container named for an app or a format (`Lightroom Classic`, `XMP`,
/// `Mobile (DNG)`, `Develop Presets`…).
pub fn group_from_dir(dir: &str) -> Option<String> {
const FILLER: &[&str] = &[
"lightroom",
"lr",
"lrc",
"classic",
"cc",
"camera",
"raw",
"acr",
"photoshop",
"desktop",
"mobile",
"xmp",
"lrtemplate",
"lrtemplates",
"dng",
"dngs",
"preset",
"presets",
"settings",
"lcpreset",
"lightcraft",
"user",
"develop",
"files",
"for",
"and",
"version",
"versions",
"format",
"formats",
"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()))
};
// 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)
}
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
o.remove("crop");
o.remove("geometry");
if o.get("wb").and_then(|w| w.get("mode")).and_then(Value::as_str).is_none_or(|m| m == "custom" || m == "asShot") {
o.remove("wb");
}
}
if settings.as_object().is_none_or(Map::is_empty) {
return None;
}
let group = props
.get("crs:Group")
.and_then(|v| v.first())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or(group)
.unwrap_or_else(|| "Imported Presets".into());
let uuid = props.get("crs:UUID").and_then(|v| v.first()).map(|u| u.to_ascii_lowercase());
let id = format!("user.xmp.{}", uuid.unwrap_or_else(|| crate::presets::slug(&format!("{group}-{name}"))));
Some(Imported { preset: Preset { id, name, group, settings, favorite: false, builtin: false }, unmapped })
}
/// Read the presets in file `name` (`bytes`); `group` names the group when the file doesn't
/// (its folder). Zip bundles are read recursively.
pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<Vec<Imported>, String> {
let path = Path::new(name);
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Preset".into());
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
match ext.as_str() {
"zip" => {
let mut out = Vec::new();
let mut errors = Vec::new();
for (inner, data) in read_zip(bytes)? {
let iext = Path::new(&inner).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
if !(PRESET_EXTS.contains(&iext.as_str()) || iext == crate::presets::LCPRESET_EXT) || iext == "zip" {
continue;
}
let dir_group =
inner.rsplit_once('/').and_then(|(d, _)| group_from_dir(d)).or_else(|| group_from_dir(&stem)).or_else(|| group.clone());
match read_presets(&inner, &data, dir_group) {
Ok(v) => out.extend(v),
Err(e) => errors.push(format!("{inner}: {e}")),
}
}
if out.is_empty() {
return Err(if errors.is_empty() { "no presets in this archive".into() } else { errors.join("; ") });
}
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}'))?;
build(&props, &values, title.unwrap_or(stem), group, false)
.map(|i| vec![i])
.ok_or_else(|| "no develop settings we can use in this template".into())
}
"xmp" => {
let text = String::from_utf8_lossy(bytes);
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
let props = &d.properties;
let name = props.get("crs:Name").and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or(stem);
build(props, &d.values, name, group, false).map(|i| vec![i]).ok_or_else(|| "no develop settings in this XMP file".into())
}
// a photo carrying its edits (mobile "DNG presets", edited JPEG / TIFF)
"dng" | "jpg" | "jpeg" | "tif" | "tiff" => {
let xmp = embedded_xmp(bytes).ok_or("this photo carries no edits (no XMP)")?;
let d = lightcraft_meta::parse_xmp(&xmp).map_err(|e| e.to_string())?;
build(&d.properties, &d.values, stem, group, true).map(|i| vec![i]).ok_or_else(|| "this photo carries no edits we can use".into())
}
_ => crate::presets::parse_preset_file(name, bytes).map(|v| {
v.into_iter()
.map(|mut p| {
if p.group == "Imported Presets"
&& let Some(g) = &group
{
p.group = g.clone();
}
Imported { preset: p, unmapped: Vec::new() }
})
.collect()
}),
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use serde_json::json;
/// A zip archive (`deflate`: compress entries) — enough of a writer for the tests.
pub(crate) fn zip(entries: &[(&str, &[u8])], deflate: bool) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in entries {
let (method, body) = if deflate { (8u16, miniz_oxide::deflate::compress_to_vec(data, 6)) } else { (0, data.to_vec()) };
let off = out.len() as u32;
let head = |sig: &[u8], out: &mut Vec<u8>, central: bool| {
out.extend_from_slice(sig);
if central {
out.extend_from_slice(&20u16.to_le_bytes());
}
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&method.to_le_bytes());
out.extend_from_slice(&[0; 8]); // time, date, crc (not checked)
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
};
head(b"PK\x03\x04", &mut out, false);
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(&body);
head(b"PK\x01\x02", &mut central, true);
central.extend_from_slice(&[0; 10]); // comment length, disk, internal + external attributes
central.extend_from_slice(&off.to_le_bytes());
central.extend_from_slice(name.as_bytes());
}
let cd = out.len() as u32;
out.extend_from_slice(&central);
out.extend_from_slice(b"PK\x05\x06\0\0\0\0");
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
out.extend_from_slice(&cd.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
/// A template in the classic Lua-table form (written for this test).
pub(crate) const TEMPLATE: &str = r#"s = {
id = "0D2F9C1E-TEST",
internalName = "Warm Fade",
title = "$$$/Test/Warm=Warm Fade",
type = "Develop",
value = {
settings = {
Exposure2012 = 0.35,
Contrast2012 = -20,
ConvertToGrayscale = false,
ToneCurvePV2012 = { 0, 20, 128, 128, 255, 240, },
SplitToningShadowHue = 210,
SplitToningShadowSaturation = 15,
HueAdjustmentOrange = -8,
CameraProfile = "Some Profile",
RetouchInfo = {},
ProcessVersion = "11.0",
-- a comment
EnableColorAdjustments = true,
},
uuid = "0D2F9C1E-TEST",
},
version = 0,
}
"#;
#[test]
fn lua_tables() {
let v = parse_lua(
r#"return { a = 1, ["b c"] = 'x\'y', -2.5e1, 0x10, t = { true, nil }, [[long
text]], z = ZSTR "loc" }"#,
)
.unwrap();
assert_eq!(v.get("a"), Some(&Lua::Num(1.0)));
assert_eq!(v.get("b c"), Some(&Lua::Str("x'y".into())));
assert_eq!(v.get("z"), Some(&Lua::Str("loc".into())));
let Lua::Table(arr, _) = &v else { panic!() };
assert_eq!(arr[..2], [Lua::Num(-25.0), Lua::Num(16.0)]);
assert_eq!(arr[2], Lua::Str("long\ntext".into()));
assert!(parse_lua("s = { a = ").is_err());
}
#[test]
fn lrtemplate_maps_settings_and_reports_the_rest() {
let v = read_presets("Warm Fade.lrtemplate", TEMPLATE.as_bytes(), None).unwrap();
assert_eq!(v.len(), 1);
let p = &v[0].preset;
assert_eq!((p.name.as_str(), p.group.as_str()), ("Warm Fade", "Imported Presets"));
assert_eq!(p.settings["light"]["exposure"], json!(0.35));
assert_eq!(p.settings["light"]["contrast"], json!(-20.0));
assert_eq!(p.settings["treatment"], json!("color"));
assert_eq!(p.settings["grading"]["shadows"]["hue"], json!(210.0));
assert_eq!(p.settings["mixer"]["orange"]["hue"], json!(-8.0));
let c = p.settings["curve"]["master"].as_array().unwrap();
assert_eq!(c.len(), 3);
assert!((c[0]["y"].as_f64().unwrap() - 20.0 / 255.0).abs() < 1e-9);
assert_eq!(v[0].unmapped, vec!["CameraProfile".to_string()], "profiles are ours; the rest is mapped or bookkeeping");
// it parses into real develop settings
let d = lightcraft_develop::DevelopSettings::default().merged(&p.settings).expect("valid develop settings");
assert_eq!(d.light.exposure, 0.35);
}
#[test]
fn old_process_version_fields_are_approximated() {
let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }";
let p = &read_presets("Old.lrtemplate", t.as_bytes(), None).unwrap()[0].preset;
assert_eq!(p.settings["light"]["exposure"], json!(0.5));
assert_eq!(p.settings["light"]["contrast"], json!(25.0), "relative to the old default of 25");
assert_eq!(p.settings["light"]["shadows"], json!(20.0));
assert_eq!(p.settings["light"]["highlights"], json!(-30.0));
assert_eq!(p.settings["effects"]["clarity"], json!(10.0));
assert_eq!(p.settings["curve"]["master"].as_array().unwrap().len(), 3);
// newer presets carry the old fields at their defaults: those must not shift anything
let t2 = "s = { value = { settings = { Contrast2012 = 10, Contrast = 25, Shadows = 5, Brightness = 50 } } }";
let p2 = &read_presets("New.lrtemplate", t2.as_bytes(), None).unwrap()[0];
assert_eq!(p2.preset.settings, json!({"light": {"contrast": 10.0}}));
assert!(p2.unmapped.is_empty(), "{:?}", p2.unmapped);
}
const XMP: &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:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:Exposure2012="+0.50" crs:Vibrance="20" crs:WhiteBalance="As Shot" crs:Temperature="5600" crs:HasCrop="True" crs:CropLeft="0.1" crs:CropRight="0.9" crs:CropTop="0" crs:CropBottom="1"/>
</rdf:RDF></x:xmpmeta>"#;
#[test]
fn photos_carrying_edits_become_looks() {
// a "DNG preset": any container with an XMP packet; framing and white balance stay behind
let mut file = b"II*\0 binary junk ".to_vec();
file.extend_from_slice(XMP.as_bytes());
file.extend_from_slice(b" more junk");
let v = read_presets("/x/Moody.dng", &file, None).unwrap();
let p = &v[0].preset;
assert_eq!(p.name, "Moody");
assert_eq!(p.settings, json!({"light": {"exposure": 0.5}, "color": {"vibrance": 20.0}}));
assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err());
}
#[test]
fn zip_bundles_with_folders_as_groups() {
for deflate in [false, true] {
let z = zip(
&[
("Pack/Film/Warm Fade.lrtemplate", TEMPLATE.as_bytes()),
("Pack/Film/Bright.xmp", XMP.as_bytes()),
("__MACOSX/Pack/._Bright.xmp", b"junk"),
("Pack/readme.txt", b"thanks for downloading"),
],
deflate,
);
let v = read_presets("pack.zip", &z, None).unwrap();
let names: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
assert_eq!(names, [("Warm Fade", "Film"), ("Bright", "Film")], "deflate={deflate}");
}
assert!(read_presets("empty.zip", &zip(&[("a.txt", b"x")], true), None).is_err());
assert!(read_presets("bad.zip", b"not a zip", None).is_err());
}
#[test]
fn generic_folder_names_are_not_groups() {
assert_eq!(group_from_dir("Settings"), None);
assert_eq!(group_from_dir("Develop Presets/"), None);
assert_eq!(group_from_dir("Looks/My Film Looks"), Some("My Film Looks".into()));
// commercial packs nest by format: the pack's name wins
assert_eq!(group_from_dir("Film Pack/Lightroom Classic"), Some("Film Pack".into()));
assert_eq!(group_from_dir("Film Pack/Mobile (DNG)/"), Some("Film Pack".into()));
assert_eq!(group_from_dir("Film Pack/XMP Presets for Lightroom CC 7.3+"), Some("Film Pack".into()));
assert_eq!(group_from_dir("Sacred Light"), Some("Sacred Light".into()), "whole words only");
}
#[test]
fn import_command_reads_folders_bundles_and_applies() {
let dir = std::env::temp_dir().join(format!("lc-preset-import-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Looks/Film")).unwrap();
std::fs::write(dir.join("Looks/Film/Warm Fade.lrtemplate"), TEMPLATE).unwrap();
std::fs::write(dir.join("Looks/bundle.zip"), zip(&[("Street/Bright.xmp", XMP.as_bytes())], true)).unwrap();
std::fs::write(dir.join("Looks/notes.txt"), "not a preset").unwrap();
let mut s = crate::Session::with_demo();
let before = s.presets.len();
let paths = json!([dir.join("Looks").to_string_lossy()]);
// a dry run reports without adding
let r = s.execute("preset.import", &json!({"paths": paths, "dryRun": true})).unwrap();
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
assert_eq!(s.presets.len(), before);
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
let got: Vec<(String, String)> =
r["imported"].as_array().unwrap().iter().map(|i| (i["name"].as_str().unwrap().into(), i["group"].as_str().unwrap().into())).collect();
assert_eq!(got, [("Warm Fade".to_string(), "Film".to_string()), ("Bright".into(), "Street".into())]);
assert_eq!(r["imported"][0]["unmapped"], json!(["CameraProfile"]));
assert_eq!(s.presets.len(), before + 2);
// importing again adds nothing
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
assert_eq!((r["imported"].as_array().unwrap().len(), r["skipped"].as_u64()), (0, Some(2)));
// and the imported look applies
let id = s.catalog.photos().next().unwrap().id;
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
let pid = s.presets.iter().find(|p| p.name == "Warm Fade").unwrap().id.clone();
s.execute("preset.apply", &json!({"id": pid})).unwrap();
let d = s.develop_of(id).unwrap();
assert_eq!((d.light.exposure, d.light.contrast), (0.35, -20.0));
let _ = std::fs::remove_dir_all(&dir);
}
}
+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);
}
}
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more